@openwop/openwop-conformance 1.136.11 → 1.139.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/README.md +268 -3
- package/coverage.md +2 -2
- package/dist/cli.js +8 -1
- package/dist/lib/requirement-ledger.js +8 -0
- package/dist/lib/scenario-disposition.js +30 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +1 -1
- package/schemas/certification-bundle-v2.schema.json +11 -1
- package/src/cli.ts +8 -1
- package/src/global-setup.ts +169 -0
- package/src/lib/fork-availability.test.ts +69 -0
- package/src/lib/fork-availability.ts +61 -0
- package/src/lib/global-setup.test.ts +76 -0
- package/src/lib/polling.test.ts +80 -0
- package/src/lib/polling.ts +39 -2
- package/src/lib/requirement-ledger.test.ts +75 -0
- package/src/lib/requirement-ledger.ts +9 -0
- package/src/lib/risk-disposition.test.ts +91 -0
- package/src/lib/scenario-disposition.ts +30 -0
- package/src/lib/webhook-receiver.test.ts +76 -0
- package/src/lib/webhook-receiver.ts +24 -3
- package/src/scenarios/a2a-task-roundtrip.test.ts +66 -10
- package/src/scenarios/agent-channel-dispatch.test.ts +3 -3
- package/src/scenarios/conformance-execution-witness.test.ts +29 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +10 -6
- package/src/scenarios/durability-poison-exhaustion.test.ts +154 -0
- package/src/scenarios/replay-fanout-suppression.test.ts +328 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +9 -3
- package/src/scenarios/replay-fork.test.ts +10 -2
- package/src/scenarios/replay-side-effect-suppression.test.ts +3 -2
- package/src/scenarios/replayDeterminism.test.ts +8 -6
- package/src/scenarios/webhook-receiver-adversarial.test.ts +9 -3
- package/src/scenarios/webhook-signed-delivery.test.ts +76 -13
- package/src/setup.ts +24 -5
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poison work reaches a bounded, operator-visible terminal state
|
|
3
|
+
* (RFC 0158 §C.8, conformance row `durability/poison-exhaustion`).
|
|
4
|
+
*
|
|
5
|
+
* THE REQUIREMENT: work that fails deterministically **MUST** reach a terminal,
|
|
6
|
+
* operator-visible state within a bounded number of attempts, and **MUST NOT**
|
|
7
|
+
* be redelivered indefinitely.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the obvious assertion proves only half of it ─────────────────────────
|
|
10
|
+
* "The run reached `failed`" is the half everyone writes, and `failure-path.
|
|
11
|
+
* test.ts` already covers it. It says nothing about the second clause: a host
|
|
12
|
+
* that redelivers forever ALSO reports a terminal status at some point, or
|
|
13
|
+
* reports one while the work keeps being re-dispatched behind it. An assertion
|
|
14
|
+
* that stops at the terminal status cannot distinguish "terminated after N
|
|
15
|
+
* attempts" from "still going".
|
|
16
|
+
*
|
|
17
|
+
* So the load-bearing leg here is STABILITY AFTER TERMINAL: count the attempts
|
|
18
|
+
* on the run's own log, wait a scaled quiet window, count again, and assert the
|
|
19
|
+
* number did not move. A host still redelivering shows new attempts; a host that
|
|
20
|
+
* stopped shows the same count. That is the falsifiable form of "MUST NOT be
|
|
21
|
+
* redelivered indefinitely" — and, unlike a ceiling, it needs no number the wire
|
|
22
|
+
* does not carry (see below).
|
|
23
|
+
*
|
|
24
|
+
* ── What this scenario deliberately does NOT assert ──────────────────────────
|
|
25
|
+
* Conformance to a SPECIFIC declared attempt bound. RFC 0158 §E mints no
|
|
26
|
+
* advertised capability field, so a host's declared bound is not on the wire and
|
|
27
|
+
* the suite cannot read it. Asserting against a number the suite invented would
|
|
28
|
+
* be a bound of the suite's own making, which is the inverse of the §B.5
|
|
29
|
+
* discipline ("derived from the mechanism that enforces it"). When
|
|
30
|
+
* `OPENWOP_DECLARED_ATTEMPT_BOUND` is supplied by the operator — the same
|
|
31
|
+
* operator-supplied-input shape as `OPENWOP_OPTED_OUT_PROFILES` — the ceiling leg
|
|
32
|
+
* runs too. Without it, boundedness is still asserted; only the specific bound is
|
|
33
|
+
* not.
|
|
34
|
+
*
|
|
35
|
+
* ── Honest about its gating ──────────────────────────────────────────────────
|
|
36
|
+
* This is NOT a pure black-box scenario. Attempt counts live on the run's event
|
|
37
|
+
* log, which is read through the EXISTING `/v1/host/sample/test/runs/{runId}/
|
|
38
|
+
* events` seam — no new seam, but a seam. A host that has not wired it records
|
|
39
|
+
* `blocked`: unobservable, not unmet. Outside every profile floor, for the same
|
|
40
|
+
* reason.
|
|
41
|
+
*
|
|
42
|
+
* @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §C.8
|
|
43
|
+
* @see spec/v1/host-sample-test-seams.md
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { describe, expect, it } from 'vitest';
|
|
47
|
+
import { driver } from '../lib/driver.js';
|
|
48
|
+
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
49
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
50
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
51
|
+
import { queryTestEvents, requireEvents } from '../lib/event-log-query.js';
|
|
52
|
+
import { recordRequirement } from '../lib/requirement-ledger.js';
|
|
53
|
+
import { requirementIdForFile } from '../lib/scenario-disposition.js';
|
|
54
|
+
|
|
55
|
+
const WORKFLOW_ID = 'conformance-failure';
|
|
56
|
+
const FILE = 'durability-poison-exhaustion.test.ts';
|
|
57
|
+
const REQ = requirementIdForFile(FILE);
|
|
58
|
+
|
|
59
|
+
/** Attempt-bearing event types. A `node.started` is attempt 1; each
|
|
60
|
+
* `node.retried` is one more. Counting BOTH means a host that re-dispatches
|
|
61
|
+
* without emitting `node.retried` is still caught. */
|
|
62
|
+
const ATTEMPT_TYPES = new Set(['node.started', 'node.retried']);
|
|
63
|
+
|
|
64
|
+
/** How long to watch for further attempts after the run reports terminal.
|
|
65
|
+
* Scaled: a longer wait is a STRONGER claim here, because it is a wait for
|
|
66
|
+
* something that must not happen. */
|
|
67
|
+
const QUIET_WINDOW_MS = 4_000;
|
|
68
|
+
|
|
69
|
+
function declaredAttemptBound(): number | null {
|
|
70
|
+
const raw = process.env['OPENWOP_DECLARED_ATTEMPT_BOUND'];
|
|
71
|
+
if (raw === undefined || raw.trim() === '') return null;
|
|
72
|
+
const n = Number(raw);
|
|
73
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
77
|
+
|
|
78
|
+
// Recorded at MODULE scope, not inside the `it`. `describe.skipIf` never runs the
|
|
79
|
+
// callback, so a `recordRequirement` in the test body is dead code on exactly the
|
|
80
|
+
// path it exists to classify — the same shape as the note written after
|
|
81
|
+
// `ctx.skip()` threw, which left seven files carrying notes the ledger never saw.
|
|
82
|
+
// Caught by reading a CI log: this file and `failure-path.test.ts` both skipped
|
|
83
|
+
// against the postgres host, which does not advertise the fixture, and the row
|
|
84
|
+
// would have been recorded `blocked` with the unclassified-return marker instead
|
|
85
|
+
// of `inapplicable` with a reason.
|
|
86
|
+
if (SKIP_NO_FIXTURE) {
|
|
87
|
+
recordRequirement(REQ, 'inapplicable', `fixture ${WORKFLOW_ID} not advertised`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe.skipIf(SKIP_NO_FIXTURE)('RFC 0158 §C.8 — poison work terminates within a bounded number of attempts', () => {
|
|
91
|
+
it('reaches terminal AND stops being retried, asserted on the log rather than on the status alone', async () => {
|
|
92
|
+
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
93
|
+
expect(create.status, driver.describe(
|
|
94
|
+
'rest-endpoints.md',
|
|
95
|
+
'POST /v1/runs MUST return 201 even for work that fails at runtime',
|
|
96
|
+
)).toBe(201);
|
|
97
|
+
const runId = (create.json as { runId: string }).runId;
|
|
98
|
+
|
|
99
|
+
// ── First clause: a terminal, OPERATOR-VISIBLE state ─────────────────────
|
|
100
|
+
const terminal = await pollUntilTerminal(runId);
|
|
101
|
+
expect(terminal.status, driver.describe(
|
|
102
|
+
'RFC 0158 §C.8',
|
|
103
|
+
'deterministically failing work MUST reach a terminal, operator-visible state',
|
|
104
|
+
)).toBe('failed');
|
|
105
|
+
|
|
106
|
+
// ── The seam gate comes AFTER the control above, so a host that wired the
|
|
107
|
+
// fixture but not the log still proves the fixture ran. ───────────────
|
|
108
|
+
const first = await queryTestEvents(runId);
|
|
109
|
+
if (!first.ok) {
|
|
110
|
+
const why = first.reason === 'seam_unavailable'
|
|
111
|
+
? 'event-log seam /v1/host/sample/test/runs/{runId}/events not wired — attempts are unobservable'
|
|
112
|
+
: `event-log seam returned HTTP ${first.status}`;
|
|
113
|
+
recordRequirement(REQ, 'blocked', why);
|
|
114
|
+
return softSkip('blocked', why);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const events = requireEvents(first, 'RFC 0158 §C.8 attempt counting');
|
|
118
|
+
// Non-vacuity: the failure must actually be ON the log. Without this, a host
|
|
119
|
+
// returning an empty array would sail through every count comparison below,
|
|
120
|
+
// since 0 === 0 after any wait.
|
|
121
|
+
expect(events.some((e) => e.type === 'node.failed'), driver.describe(
|
|
122
|
+
'RFC 0158 §C.8',
|
|
123
|
+
'the run log MUST record the deterministic failure — an empty log makes every attempt count vacuous',
|
|
124
|
+
)).toBe(true);
|
|
125
|
+
|
|
126
|
+
const attemptsBefore = events.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
|
|
127
|
+
expect(attemptsBefore, driver.describe(
|
|
128
|
+
'RFC 0158 §C.8',
|
|
129
|
+
'at least one attempt MUST be recorded — zero attempts means nothing was delivered',
|
|
130
|
+
)).toBeGreaterThan(0);
|
|
131
|
+
|
|
132
|
+
// ── Second clause, the load-bearing one: NOT redelivered indefinitely ────
|
|
133
|
+
await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
|
|
134
|
+
const second = await queryTestEvents(runId);
|
|
135
|
+
const after = requireEvents(second, 'RFC 0158 §C.8 attempt counting (post-terminal)');
|
|
136
|
+
const attemptsAfter = after.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
|
|
137
|
+
|
|
138
|
+
expect(attemptsAfter, driver.describe(
|
|
139
|
+
'RFC 0158 §C.8',
|
|
140
|
+
'attempts MUST NOT continue after the run reports terminal — a host still redelivering records more',
|
|
141
|
+
)).toBe(attemptsBefore);
|
|
142
|
+
|
|
143
|
+
// ── Optional ceiling, only when the operator supplies the declared bound ─
|
|
144
|
+
const bound = declaredAttemptBound();
|
|
145
|
+
if (bound !== null) {
|
|
146
|
+
expect(attemptsAfter, driver.describe(
|
|
147
|
+
'RFC 0158 §C.8 + §B.5',
|
|
148
|
+
'total attempts MUST NOT exceed the operator-declared attempt bound',
|
|
149
|
+
)).toBeLessThanOrEqual(bound);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
recordRequirement(REQ, 'executed-pass', undefined, { assertionCount: bound === null ? 5 : 6 });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-initiated fan-out is an external effect (`replay.md` §"Host-initiated
|
|
3
|
+
* fan-out is an external effect").
|
|
4
|
+
*
|
|
5
|
+
* THE MUST NOT: a host that projects its event log outward — webhook delivery,
|
|
6
|
+
* outbound streams, analytics or audit sinks — MUST NOT emit those outbound
|
|
7
|
+
* deliveries for events a `mode: "replay"` fork re-emits as fixed history.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS SCENARIO EXISTS. Until now this was the largest normative MUST NOT on
|
|
10
|
+
* the replay surface with **no conformance scenario and no SECURITY invariant**.
|
|
11
|
+
* Two hosts had it deployed and the interop matrix still had to record it as
|
|
12
|
+
* *not suite-witnessed*, because both rows rested on the hosts' own tests. A
|
|
13
|
+
* host's own test is evidence about that host; it is not a witness of the wire.
|
|
14
|
+
*
|
|
15
|
+
* WHY IT IS OBSERVABLE AT ALL, unlike some other unmeasured invariants: the
|
|
16
|
+
* outbound side has a real probe surface. The suite can BE the subscriber — boot
|
|
17
|
+
* a local receiver, register it via `POST /v1/webhooks`, and read what actually
|
|
18
|
+
* arrives. The property is stated over exactly what a subscriber sees, so this
|
|
19
|
+
* asserts the thing itself rather than an adjacent proxy.
|
|
20
|
+
*
|
|
21
|
+
* ── The positive control is not decoration here; it is the whole test ────────
|
|
22
|
+
* "No delivery arrived" passes identically when delivery never worked at all: a
|
|
23
|
+
* mistyped URL, a subscription that was never created, a host that rejected the
|
|
24
|
+
* receiver, a fixture that emitted nothing. That is a vacuous witness in its
|
|
25
|
+
* purest form. So all three legs run in ONE test against ONE receiver and ONE
|
|
26
|
+
* subscription, and the absence is only ever asserted AFTER presence has been
|
|
27
|
+
* proven on that exact wiring. Do not split this into separate `it` blocks —
|
|
28
|
+
* separate blocks re-register, and a re-registration that silently fails turns
|
|
29
|
+
* the negative leg back into a vacuous pass.
|
|
30
|
+
*
|
|
31
|
+
* ── Why the timeout knob makes this leg STRICTER, not looser ─────────────────
|
|
32
|
+
* Absence has nothing to poll for, so the negative is "nothing arrived within
|
|
33
|
+
* N". `OPENWOP_POLL_TIMEOUT_SCALE` multiplies N. The reflex on seeing a timeout
|
|
34
|
+
* multiplier is that it weakens assertions — here it is the reverse: waiting
|
|
35
|
+
* longer for a delivery that must never come is a STRONGER claim, and an
|
|
36
|
+
* operator raising the scale on a slow host makes this scenario harder to pass,
|
|
37
|
+
* not easier.
|
|
38
|
+
*
|
|
39
|
+
* ── Deliberately NOT in the `openwop-replay-fork` floor ──────────────────────
|
|
40
|
+
* This scenario needs the host to accept a loopback receiver. A host with an
|
|
41
|
+
* SSRF guard on `POST /v1/webhooks` correctly refuses one, and the suite's
|
|
42
|
+
* standing operator contract asks such hosts for an opt-in
|
|
43
|
+
* (`OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` on the SQLite reference). At least one
|
|
44
|
+
* certifying host rejects it today. Putting a receiver-gated row in that
|
|
45
|
+
* profile's floor would make the profile un-certifiable for a host whose
|
|
46
|
+
* SECURITY CONTROL IS CORRECT, and would read in the matrix as a regression that
|
|
47
|
+
* is not one. Capability-gated, outside the floor. Moving it into a floor is a
|
|
48
|
+
* separate decision needing an RFC 0148 §C argument.
|
|
49
|
+
*
|
|
50
|
+
* ── One interpretive call, stated rather than buried ─────────────────────────
|
|
51
|
+
* The subscription below is for `run.completed`. That is a LIFECYCLE event, and
|
|
52
|
+
* `replay.md` notes lifecycle events are "ambiguous noise" next to a
|
|
53
|
+
* recorded-fact event like `memory.written`, which is "a false statement". The
|
|
54
|
+
* reading applied here is that a replay fork reproduces the source's log, so the
|
|
55
|
+
* `run.completed` in the fork's log is RE-EMITTED HISTORY and therefore squarely
|
|
56
|
+
* inside "any event re-emitted as fixed history is in scope". A host that
|
|
57
|
+
* suppressed only recorded-fact events while delivering re-emitted lifecycle
|
|
58
|
+
* events would fail this scenario.
|
|
59
|
+
*
|
|
60
|
+
* That reading is no longer an inference: `replay.md` was clarified on
|
|
61
|
+
* 2026-08-19 to say the contrast RANKS THE HARM and does not narrow the scope,
|
|
62
|
+
* because the requirement above it already decides the question — replay-ness is
|
|
63
|
+
* read from the run, never from the event type, so suppressing by event type is
|
|
64
|
+
* selecting by event type. The paragraph stays here because a host that reads
|
|
65
|
+
* the older text should meet a visible claim rather than a surprise red.
|
|
66
|
+
*
|
|
67
|
+
* @see spec/v1/replay.md §"Host-initiated fan-out is an external effect"
|
|
68
|
+
* @see spec/v1/webhooks.md §"Register"
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
72
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
73
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
74
|
+
import { driver } from '../lib/driver.js';
|
|
75
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
76
|
+
import { discoveryFamilies, readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
77
|
+
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
78
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
79
|
+
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
80
|
+
import { recordRequirement } from '../lib/requirement-ledger.js';
|
|
81
|
+
import { requirementIdForFile } from '../lib/scenario-disposition.js';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* RFC 0148 §A — the MUST NOT gets its OWN disposition, separate from the file's.
|
|
85
|
+
*
|
|
86
|
+
* Without this the file would be recorded `executed-pass` off leg 1's assertions
|
|
87
|
+
* even on a run where the fork returned 501 and the MUST NOT was never
|
|
88
|
+
* exercised. That is a partial witness reading as a full one: the positive
|
|
89
|
+
* control genuinely passed, so the file is not lying, but the matrix cell for
|
|
90
|
+
* this requirement would claim coverage the run did not produce. Recording the
|
|
91
|
+
* requirement explicitly at every exit keeps the cell honest whichever path is
|
|
92
|
+
* taken. `setup.ts` records the file's aggregate under the SAME id and catches
|
|
93
|
+
* the resulting conflict — "a scenario that recorded its own file id first
|
|
94
|
+
* wins" — so this is the sanctioned override, not a second row.
|
|
95
|
+
*
|
|
96
|
+
* It MUST be `requirementIdForFile`, not `requirementIdForScenario`. Those two
|
|
97
|
+
* agree only for files in a certification FLOOR; for a non-floor scenario like
|
|
98
|
+
* this one they differ (`openwop.scenario.<name>` vs the registry id). Recording
|
|
99
|
+
* the registry id here would mint an ORPHAN requirement nothing reads, while the
|
|
100
|
+
* row the bundle actually carries kept the file-level `executed-pass` — a fix
|
|
101
|
+
* that changes nothing and looks like it did.
|
|
102
|
+
*/
|
|
103
|
+
const REQUIREMENT_ID = requirementIdForFile('replay-fanout-suppression.test.ts');
|
|
104
|
+
|
|
105
|
+
interface Delivered {
|
|
106
|
+
readonly body: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function startReceiver(): Promise<{ server: Server; url: string; received: Delivered[] }> {
|
|
110
|
+
const received: Delivered[] = [];
|
|
111
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
112
|
+
const chunks: Buffer[] = [];
|
|
113
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
114
|
+
req.on('end', () => {
|
|
115
|
+
received.push({ body: Buffer.concat(chunks).toString('utf8') });
|
|
116
|
+
res.writeHead(204);
|
|
117
|
+
res.end();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
|
121
|
+
const addr = server.address();
|
|
122
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
123
|
+
return { server, url: `http://127.0.0.1:${addr.port}/`, received };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let activeServer: Server | null = null;
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
if (activeServer) {
|
|
129
|
+
await new Promise<void>((resolve) => activeServer!.close(() => resolve()));
|
|
130
|
+
activeServer = null;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** Deliveries whose body mentions this run id — the fan-out attributable to it. */
|
|
135
|
+
function forRun(received: readonly Delivered[], runId: string): Delivered[] {
|
|
136
|
+
return received.filter((d) => d.body.includes(runId));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wait `ms`, then report what arrived. Used for the NEGATIVE leg, where there is
|
|
141
|
+
* no event to poll for. Scaled, per the note in the file header.
|
|
142
|
+
*/
|
|
143
|
+
async function quietWindow(ms: number): Promise<void> {
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, scaledTimeoutMs(ms)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Grace for fire-and-forget delivery on the POSITIVE legs. */
|
|
148
|
+
const DELIVERY_GRACE_MS = 1_500;
|
|
149
|
+
/**
|
|
150
|
+
* How long the replay fork is watched for a delivery that must never arrive.
|
|
151
|
+
* Deliberately several times the positive grace: a host that merely DELAYS
|
|
152
|
+
* fan-out rather than suppressing it must not pass by being slow.
|
|
153
|
+
*/
|
|
154
|
+
const SUPPRESSION_WINDOW_MS = 6_000;
|
|
155
|
+
|
|
156
|
+
describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted events', () => {
|
|
157
|
+
it('delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork', async (ctx) => {
|
|
158
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
159
|
+
const caps = discoveryFamilies(disco.json) as {
|
|
160
|
+
webhooks?: { supported?: boolean };
|
|
161
|
+
replay?: { fork?: boolean; supported?: boolean };
|
|
162
|
+
};
|
|
163
|
+
if (caps.webhooks?.supported !== true) {
|
|
164
|
+
recordRequirement(REQUIREMENT_ID, 'inapplicable', 'host does not advertise webhooks.supported — outbound fan-out has no probe surface on this host');
|
|
165
|
+
return softSkip('inapplicable', '[replay-fanout-suppression] host does not advertise webhook support');
|
|
166
|
+
}
|
|
167
|
+
if (!isFixtureAdvertised('conformance-noop')) {
|
|
168
|
+
recordRequirement(REQUIREMENT_ID, 'inapplicable', 'conformance-noop fixture not advertised — no run to fan out');
|
|
169
|
+
return softSkip('inapplicable', '[replay-fanout-suppression] conformance-noop not advertised');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const receiver = await startReceiver();
|
|
173
|
+
activeServer = receiver.server;
|
|
174
|
+
|
|
175
|
+
const ownedTenant = await discoverOwnedTenant(driver);
|
|
176
|
+
const reg = await driver.post('/v1/webhooks', {
|
|
177
|
+
url: receiver.url,
|
|
178
|
+
events: ['run.completed'],
|
|
179
|
+
...(ownedTenant ? { tenantId: ownedTenant } : {}),
|
|
180
|
+
});
|
|
181
|
+
if (reg.status === 400 && (reg.json as { error?: string }).error === 'webhook_url_rejected') {
|
|
182
|
+
// The host's SSRF guard refused a loopback destination. That is CORRECT
|
|
183
|
+
// host behaviour, not a failure — see the floor note in the file header.
|
|
184
|
+
recordRequirement(
|
|
185
|
+
REQUIREMENT_ID,
|
|
186
|
+
'blocked',
|
|
187
|
+
"precondition not met — body.error === 'webhook_url_rejected'; the host's SSRF guard refused the loopback "
|
|
188
|
+
+ 'receiver, which is correct host behaviour, so this requirement is unobservable rather than unmet',
|
|
189
|
+
);
|
|
190
|
+
return softSkip(
|
|
191
|
+
'blocked',
|
|
192
|
+
'[replay-fanout-suppression] host SSRF guard rejected the loopback receiver; '
|
|
193
|
+
+ 'set OPENWOP_WEBHOOK_ALLOW_PRIVATE=true on the host (or equivalent) to run',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
expect(reg.status, driver.describe(
|
|
197
|
+
'webhooks.md §"Register"',
|
|
198
|
+
'POST /v1/webhooks MUST return 201 on success',
|
|
199
|
+
)).toBe(201);
|
|
200
|
+
|
|
201
|
+
// ── LEG 1 — POSITIVE CONTROL. Prove this wiring delivers at all. ─────────
|
|
202
|
+
const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
|
|
203
|
+
expect(create.status, 'failed to start conformance-noop').toBe(201);
|
|
204
|
+
const sourceRunId = (create.json as { runId: string }).runId;
|
|
205
|
+
await pollUntilTerminal(sourceRunId, { timeoutMs: 10_000 });
|
|
206
|
+
await quietWindow(DELIVERY_GRACE_MS);
|
|
207
|
+
|
|
208
|
+
expect(
|
|
209
|
+
forRun(receiver.received, sourceRunId).length,
|
|
210
|
+
driver.describe(
|
|
211
|
+
'webhooks.md §"Register"',
|
|
212
|
+
'POSITIVE CONTROL: the host must deliver the source run\'s events to this receiver — '
|
|
213
|
+
+ 'without this, every "no delivery" assertion below is vacuous',
|
|
214
|
+
),
|
|
215
|
+
).toBeGreaterThan(0);
|
|
216
|
+
|
|
217
|
+
// ── CAPABILITY GATE (added 2026-08-25) ──────────────────────────────────
|
|
218
|
+
// This is the ONLY scenario in the replay family that never checked whether
|
|
219
|
+
// the host advertises replay before forking — every sibling reads
|
|
220
|
+
// `replay.supported` first. That omission is why it was the one scenario
|
|
221
|
+
// reaching the fork seam on a host that does not implement it, and why it
|
|
222
|
+
// hard-failed `expected 404 to be 201` on every CI run of `main` while the
|
|
223
|
+
// siblings quietly returned at their capability check.
|
|
224
|
+
//
|
|
225
|
+
// `inapplicable`, not `blocked`: a host that does not advertise replay is
|
|
226
|
+
// outside this MUST NOT's scope entirely, and `blocked` would claim the
|
|
227
|
+
// requirement applies but could not be witnessed — a stronger claim than
|
|
228
|
+
// the evidence supports. Recorded explicitly because LEG 1 above asserted,
|
|
229
|
+
// so the file-level disposition would otherwise be `executed-pass`.
|
|
230
|
+
const replayCap = await readCapabilityFamily<{ supported?: boolean; modes?: unknown }>('replay');
|
|
231
|
+
if (replayCap?.supported !== true) {
|
|
232
|
+
recordRequirement(
|
|
233
|
+
REQUIREMENT_ID,
|
|
234
|
+
'inapplicable',
|
|
235
|
+
'host does not advertise `replay.supported: true`, so a replay fork cannot occur and this MUST NOT '
|
|
236
|
+
+ 'has nothing to constrain on this host',
|
|
237
|
+
);
|
|
238
|
+
ctx.skip();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── LEG 2 — THE MUST NOT. A replay fork re-emits; it must not deliver. ───
|
|
243
|
+
const replay = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
244
|
+
mode: 'replay',
|
|
245
|
+
});
|
|
246
|
+
if (forkDeclined(replay.status, 'fanout-suppression replay fork')) {
|
|
247
|
+
// Leg 1 already asserted, so the FILE is `executed-pass` — but the MUST
|
|
248
|
+
// NOT was never exercised, and that is what this row must say. The
|
|
249
|
+
// explicit record wins over the file-level one (setup.ts).
|
|
250
|
+
//
|
|
251
|
+
// 404 and 403 were NOT handled here until 2026-08-25, only 501. The
|
|
252
|
+
// postgres reference host 404s this route, so this was the one scenario
|
|
253
|
+
// in the replay family that actually reached the seam — and it hard-
|
|
254
|
+
// failed `expected 404 to be 201` on every CI run of `main`, absorbed by
|
|
255
|
+
// the 85% pass-rate floor. The suite required a host to implement the
|
|
256
|
+
// route in order to say it had not implemented the route.
|
|
257
|
+
recordRequirement(
|
|
258
|
+
REQUIREMENT_ID,
|
|
259
|
+
'blocked',
|
|
260
|
+
`replay fork returned ${replay.status} — the re-emission this requirement is stated over never happened, `
|
|
261
|
+
+ 'so the absence of deliveries below would prove nothing',
|
|
262
|
+
);
|
|
263
|
+
ctx.skip();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
expect(replay.status, 'replay fork should be accepted').toBe(201);
|
|
267
|
+
const replayRunId = (replay.json as { runId: string }).runId;
|
|
268
|
+
await pollUntilTerminal(replayRunId, { timeoutMs: 30_000 });
|
|
269
|
+
await quietWindow(SUPPRESSION_WINDOW_MS);
|
|
270
|
+
|
|
271
|
+
expect(
|
|
272
|
+
forRun(receiver.received, replayRunId).map((d) => d.body.slice(0, 200)),
|
|
273
|
+
driver.describe(
|
|
274
|
+
'replay.md §"Host-initiated fan-out is an external effect"',
|
|
275
|
+
'a mode:"replay" fork MUST NOT emit outbound deliveries for the events it re-emits as fixed history — '
|
|
276
|
+
+ 'delivering one asserts to a subscriber that something happened in this run which did not',
|
|
277
|
+
),
|
|
278
|
+
).toEqual([]);
|
|
279
|
+
|
|
280
|
+
// Guard against the OTHER vacuity: a fork that emitted nothing at all would
|
|
281
|
+
// also deliver nothing. The fork's own log MUST still carry the re-emitted
|
|
282
|
+
// events (`replay.md`: suppression is outbound-only, the fork's event log
|
|
283
|
+
// still carries them). Without this, a host that simply failed the fork
|
|
284
|
+
// would pass leg 2.
|
|
285
|
+
const forkEvents = await driver.get(`/v1/runs/${encodeURIComponent(replayRunId)}/events`);
|
|
286
|
+
expect(forkEvents.status, 'fork events must be readable').toBe(200);
|
|
287
|
+
const forkEventList = (forkEvents.json as { events?: { type?: string }[] }).events ?? [];
|
|
288
|
+
expect(
|
|
289
|
+
forkEventList.length,
|
|
290
|
+
driver.describe(
|
|
291
|
+
'replay.md §"Host-initiated fan-out is an external effect"',
|
|
292
|
+
'suppression is OUTBOUND ONLY — the fork\'s own event log MUST still carry the re-emitted events, '
|
|
293
|
+
+ 'so an empty fork log means leg 2 proved nothing',
|
|
294
|
+
),
|
|
295
|
+
).toBeGreaterThan(0);
|
|
296
|
+
|
|
297
|
+
// The MUST NOT has now been exercised against wiring proven to deliver, on a
|
|
298
|
+
// fork proven to have re-emitted. Recorded here rather than after leg 3,
|
|
299
|
+
// because leg 3 pins the boundary and may legitimately not run.
|
|
300
|
+
recordRequirement(REQUIREMENT_ID, 'executed-pass', undefined, { assertionCount: 4 });
|
|
301
|
+
|
|
302
|
+
// ── LEG 3 — the boundary. `branch` is explicitly OUT of scope. ───────────
|
|
303
|
+
// This is what distinguishes "reads replay-ness from the RUN" from "silences
|
|
304
|
+
// anything that is a fork". A host that suppressed both would pass legs 1-2
|
|
305
|
+
// and be wrong: a branch fork's events are new facts and its effects are the
|
|
306
|
+
// ones the operator asked for.
|
|
307
|
+
const branch = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
308
|
+
mode: 'branch',
|
|
309
|
+
});
|
|
310
|
+
if (branch.status === 501 || branch.status === 400) {
|
|
311
|
+
// branch not offered on this range — leg 2 still stands on its own.
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
expect(branch.status, 'branch fork should be accepted').toBe(201);
|
|
315
|
+
const branchRunId = (branch.json as { runId: string }).runId;
|
|
316
|
+
await pollUntilTerminal(branchRunId, { timeoutMs: 30_000 });
|
|
317
|
+
await quietWindow(DELIVERY_GRACE_MS);
|
|
318
|
+
|
|
319
|
+
expect(
|
|
320
|
+
forRun(receiver.received, branchRunId).length,
|
|
321
|
+
driver.describe(
|
|
322
|
+
'replay.md §"Host-initiated fan-out is an external effect"',
|
|
323
|
+
'a branch fork is deliberately OUT of scope — its events are new facts, so suppressing them '
|
|
324
|
+
+ 'means the host keyed on "is a fork" rather than on replay-ness read from the run',
|
|
325
|
+
),
|
|
326
|
+
).toBeGreaterThan(0);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { describe, it, expect } from 'vitest';
|
|
32
32
|
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
33
35
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
34
36
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
35
37
|
|
|
@@ -122,6 +124,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
122
124
|
it('mid-fromSeq replay fork produces a new run that reaches `completed`', async (ctx) => {
|
|
123
125
|
const replay = await fetchReplayCapability();
|
|
124
126
|
if (replay?.supported !== true) {
|
|
127
|
+
softSkip('inapplicable', "host does not advertise `replay.supported: true` — the replay contract does not apply to it");
|
|
125
128
|
ctx.skip();
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
@@ -129,6 +132,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
129
132
|
? replay.modes.filter((m): m is string => typeof m === 'string')
|
|
130
133
|
: [];
|
|
131
134
|
if (!modes.includes('replay')) {
|
|
135
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — this leg's rule has no path to apply");
|
|
132
136
|
ctx.skip();
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
@@ -140,6 +144,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
140
144
|
// Fixture's wire shape doesn't expose node.completed(b) with a
|
|
141
145
|
// numeric sequence — skip rather than fail. Conformant hosts
|
|
142
146
|
// with the standard event shape will hit the assertions below.
|
|
147
|
+
softSkip('blocked', "the advertised fixture's wire shape exposes no numeric sequence for node.completed(b), so there is no mid-run point to fork from");
|
|
143
148
|
ctx.skip();
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
@@ -149,7 +154,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
149
154
|
{ fromSeq, mode: 'replay' },
|
|
150
155
|
);
|
|
151
156
|
|
|
152
|
-
if (fork.status
|
|
157
|
+
if (forkDeclined(fork.status, 'arbitrary-event fork')) {
|
|
153
158
|
ctx.skip();
|
|
154
159
|
return;
|
|
155
160
|
}
|
|
@@ -226,7 +231,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
226
231
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
227
232
|
{ fromSeq, mode: 'replay' },
|
|
228
233
|
);
|
|
229
|
-
if (fork1.status
|
|
234
|
+
if (forkDeclined(fork1.status, 'arbitrary-event fork 1')) {
|
|
230
235
|
ctx.skip();
|
|
231
236
|
return;
|
|
232
237
|
}
|
|
@@ -238,7 +243,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
238
243
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
239
244
|
{ fromSeq, mode: 'replay' },
|
|
240
245
|
);
|
|
241
|
-
if (fork2.status
|
|
246
|
+
if (forkDeclined(fork2.status, 'arbitrary-event fork 2')) {
|
|
242
247
|
ctx.skip();
|
|
243
248
|
return;
|
|
244
249
|
}
|
|
@@ -294,6 +299,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
294
299
|
it('mid-fromSeq branch fork with empty overlay produces a new run that reaches `completed`', async (ctx) => {
|
|
295
300
|
const replay = await fetchReplayCapability();
|
|
296
301
|
if (replay?.supported !== true) {
|
|
302
|
+
softSkip('inapplicable', "host does not advertise `replay.supported: true` — the replay contract does not apply to it");
|
|
297
303
|
ctx.skip();
|
|
298
304
|
return;
|
|
299
305
|
}
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { describe, it, expect } from 'vitest';
|
|
27
27
|
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
28
30
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
29
31
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
30
32
|
|
|
@@ -60,6 +62,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () =
|
|
|
60
62
|
// Visible skip — earlier this was a silent `return` that
|
|
61
63
|
// collapsed to a vacuous pass and made it impossible to tell
|
|
62
64
|
// unexercised tests apart from honest passes.
|
|
65
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — this leg's rule has no path to apply");
|
|
63
66
|
ctx.skip();
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
@@ -70,7 +73,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () =
|
|
|
70
73
|
{ fromSeq: 0, mode: 'replay' },
|
|
71
74
|
);
|
|
72
75
|
|
|
73
|
-
if (fork.status
|
|
76
|
+
if (forkDeclined(fork.status, 'replay fork')) return;
|
|
74
77
|
expect(fork.status, driver.describe(
|
|
75
78
|
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
76
79
|
'fork MUST return 201 on accepted replay',
|
|
@@ -101,6 +104,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with e
|
|
|
101
104
|
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
102
105
|
const modes = await fetchReplayModes();
|
|
103
106
|
if (!modes.includes('branch')) {
|
|
107
|
+
softSkip('inapplicable', "host does not advertise the `branch` fork mode — this leg's rule has no path to apply");
|
|
104
108
|
ctx.skip();
|
|
105
109
|
return;
|
|
106
110
|
}
|
|
@@ -111,7 +115,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with e
|
|
|
111
115
|
{ fromSeq: 0, mode: 'branch', runOptionsOverlay: {} },
|
|
112
116
|
);
|
|
113
117
|
|
|
114
|
-
if (fork.status
|
|
118
|
+
if (forkDeclined(fork.status, 'branch fork')) return;
|
|
115
119
|
expect(fork.status, driver.describe(
|
|
116
120
|
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
117
121
|
'branch fork MUST return 201',
|
|
@@ -137,6 +141,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
137
141
|
it('rejects negative fromSeq with 400', async (ctx) => {
|
|
138
142
|
const modes = await fetchReplayModes();
|
|
139
143
|
if (modes.length === 0) {
|
|
144
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
140
145
|
ctx.skip();
|
|
141
146
|
return;
|
|
142
147
|
}
|
|
@@ -155,6 +160,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
155
160
|
it('rejects fromSeq beyond source event log length with 422', async (ctx) => {
|
|
156
161
|
const modes = await fetchReplayModes();
|
|
157
162
|
if (modes.length === 0) {
|
|
163
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
158
164
|
ctx.skip();
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
@@ -179,6 +185,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
179
185
|
// → 400) only applies on hosts that advertise the `replay` mode.
|
|
180
186
|
// A `branch`-only host has no path to even attempt the request.
|
|
181
187
|
// Visible skip rather than silent vacuous pass.
|
|
188
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — the runOptionsOverlay rejection rule only applies to hosts that do");
|
|
182
189
|
ctx.skip();
|
|
183
190
|
return;
|
|
184
191
|
}
|
|
@@ -200,6 +207,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
200
207
|
it('rejects fork on a non-existent run with 404', async (ctx) => {
|
|
201
208
|
const modes = await fetchReplayModes();
|
|
202
209
|
if (modes.length === 0) {
|
|
210
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
203
211
|
ctx.skip();
|
|
204
212
|
return;
|
|
205
213
|
}
|
|
@@ -48,6 +48,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
48
48
|
import { recordRequirement } from '../lib/requirement-ledger.js';
|
|
49
49
|
import { requirementIdForScenario } from '../lib/requirement-registry.js';
|
|
50
50
|
import { driver } from '../lib/driver.js';
|
|
51
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
51
52
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
52
53
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
53
54
|
import { readFileSync } from 'node:fs';
|
|
@@ -173,8 +174,8 @@ describe.skipIf(SKIP_NO_FIXTURE)('replay-side-effect-suppression: a replay does
|
|
|
173
174
|
const fork = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
174
175
|
mode: 'replay',
|
|
175
176
|
});
|
|
176
|
-
if (fork.status
|
|
177
|
-
ctx.skip();
|
|
177
|
+
if (forkDeclined(fork.status, 'side-effect-suppression replay fork')) {
|
|
178
|
+
ctx.skip();
|
|
178
179
|
return;
|
|
179
180
|
}
|
|
180
181
|
expect(fork.status, 'fork should be accepted').toBe(201);
|