@openwop/openwop-conformance 2.42.1 → 2.42.3
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 +37 -0
- package/README.md +1 -1
- package/coverage.md +1 -0
- package/dist/lib/certification-bundle-verify.js +13 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +91 -55
- package/schemas/CORPUS-STAMP.json +15 -15
- package/src/lib/carries-payload.ts +42 -0
- package/src/lib/certification-bundle-verify.ts +13 -0
- package/src/lib/effect-refire.ts +34 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +1 -1
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +13 -2
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +8 -12
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +17 -3
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +8 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +13 -4
- package/src/scenarios/v2-effect-identity-business-key.test.ts +48 -10
- package/src/scenarios/v2-effect-seam-manifest.test.ts +3 -9
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +18 -5
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +52 -9
- package/src/scenarios/v2-interop-trace-context.test.ts +23 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +16 -3
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +11 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +28 -5
- package/src/scenarios/v2-profiles-derived-only.test.ts +9 -2
- package/src/scenarios/v2-webhook-delivery-isolation.test.ts +28 -8
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +16 -0
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +25 -6
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +10 -2
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { describe, it, expect } from 'vitest';
|
|
29
29
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
30
30
|
import { v2Discovery } from '../lib/v2.js';
|
|
31
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
32
32
|
import { req } from '../lib/requirement-ids.js';
|
|
33
33
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
34
|
import { codemapV1toV2, era2Gate, eventsOf, pollEvents, seedEra2Log, v1FixtureLog, type ReadEvent } from '../lib/era2-seed.js';
|
|
@@ -76,12 +76,31 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
76
76
|
return softSkip('blocked', `seedEra2Log reported success but the log reads back empty (0 events) — the seam's return value is not evidence that a log exists, and without a seeded era-2 log the writer rule is unwitnessed here`);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// unfailable-leg audit, 2026-09-26: cancel was the ONLY write driven, and
|
|
80
|
+
// it appends run.cancelled (and node.cancelled) — names the codemap spells
|
|
81
|
+
// identically in v1 and v2. A host writing v2 names into an era-2 log
|
|
82
|
+
// therefore produced the same rows as one writing v1 names, and the leg
|
|
83
|
+
// recorded executed-pass either way. The discriminator needs a type the
|
|
84
|
+
// codemap RENAMES. Best effort first: pause then resume the seeded run
|
|
85
|
+
// through the core :pause / :resume operations (runs.md §Pause and
|
|
86
|
+
// resume), which gives a host that records the resume transition a chance
|
|
87
|
+
// to append `run.resuming` (v1) — read back as `run.resume-started` (v2).
|
|
88
|
+
// Neither answer is asserted: the resume transition's event set is not
|
|
89
|
+
// pinned to a renamed type, and a seeded run's pause is host latitude. If
|
|
90
|
+
// no renamed type is appended, the leg records `blocked` below, not a pass.
|
|
91
|
+
const enc = encodeURIComponent(runId);
|
|
92
|
+
const paused = await http(() => driver.post(`/runs/${enc}:pause`, { reason: 'conformance', drainPolicy: 'immediate' }));
|
|
93
|
+
const resumed = paused?.status === 202 ? await http(() => driver.post(`/runs/${enc}:resume`, { reason: 'conformance' })) : null;
|
|
94
|
+
|
|
79
95
|
// One canonical mutation so the HOST's own writer appends. Cancel is the
|
|
80
96
|
// universally available terminal transition; a host that refuses it on a
|
|
81
|
-
// seeded run records `blocked` rather than a pass
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
// seeded run records `blocked` rather than a pass — unless the pause and
|
|
98
|
+
// resume above were both accepted, which already drove the host's writer.
|
|
99
|
+
const cancelled = await http(() => driver.post(`/runs/${enc}/cancel`, {}));
|
|
100
|
+
const cancelOk = cancelled !== null && (cancelled.status === 200 || cancelled.status === 202 || cancelled.status === 204);
|
|
101
|
+
const pauseResumeOk = paused?.status === 202 && resumed?.status === 202;
|
|
102
|
+
if (!cancelOk && !pauseResumeOk) {
|
|
103
|
+
return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancelled?.status ?? 'no response'} on a seeded era-2 run (pause ${paused?.status ?? 'no response'}, resume ${resumed?.status ?? 'not attempted'}) — no canonical mutation drove the host's writer, so the append is unwitnessed`);
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
const after = await pollEvents(runId);
|
|
@@ -128,6 +147,19 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
128
147
|
req(ID, DOC, `the appended rows continue the seeded sequence space without a gap or restart (expected ${i}, got ${String(seqs[i])})`),
|
|
129
148
|
).toBe(i);
|
|
130
149
|
}
|
|
150
|
+
|
|
151
|
+
// unfailable-leg audit, 2026-09-26: the vocabulary is only observable
|
|
152
|
+
// through a type the codemap renames. An appended row that reads back under
|
|
153
|
+
// a RENAMED v2 name (v2 ≠ v1) through a successful translated read is the
|
|
154
|
+
// positive witness: the stored spelling was the v1 one the reader maps
|
|
155
|
+
// from. When every appended type is spelled the same in both vocabularies,
|
|
156
|
+
// the rows above hold for a v1-writing and a v2-writing host alike, so the
|
|
157
|
+
// writer rule is unobserved — `blocked`, not a pass.
|
|
158
|
+
const renamedV2 = new Set([...codemapV1toV2()].filter(([v1, v2]) => v1 !== v2).map(([, v2]) => v2));
|
|
159
|
+
const appendedTypes = rows.slice(seedCount).map((r) => String(r.type ?? ''));
|
|
160
|
+
if (!appendedTypes.some((t) => renamedV2.has(t))) {
|
|
161
|
+
return blockedDespiteAssertions(`every type the host appended to the era-2 log (${appendedTypes.join(', ') || 'none'}) is spelled identically in v1 and v2 (spec/v2/event-codemap.json), so a host writing v2 names reads back the same as one writing v1 names — the writer rule is unobserved. The leg needs a canonical mutation that appends a renamed type (e.g. run.resuming → run.resume-started); pause ${paused?.status ?? 'no response'} / resume ${resumed?.status ?? 'not attempted'} on the seeded run appended none, and the seed seam (api/seams-v2.yaml seedEra2EventLog) cannot seed a paused or interrupt-suspended era-2 run to resume.`);
|
|
162
|
+
}
|
|
131
163
|
});
|
|
132
164
|
|
|
133
165
|
it('the run keeps the era it was created with; an append does not promote it to era 3', async () => {
|
|
@@ -139,16 +171,27 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
139
171
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
140
172
|
const runId = seeded.runId;
|
|
141
173
|
|
|
142
|
-
|
|
174
|
+
// unfailable-leg audit, 2026-09-26: the cancel's answer was ignored, so a
|
|
175
|
+
// host that refused it (no append at all) still had its unappended run's
|
|
176
|
+
// era read as the witness of "an append does not promote".
|
|
177
|
+
const cancelled = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
178
|
+
if (cancelled === null || (cancelled.status !== 200 && cancelled.status !== 202 && cancelled.status !== 204)) {
|
|
179
|
+
return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancelled?.status ?? 'no response'} on a seeded era-2 run — nothing was appended, so whether an append promotes the era is unwitnessed`);
|
|
180
|
+
}
|
|
143
181
|
|
|
144
182
|
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
145
183
|
if (snap === null || snap.status !== 200) {
|
|
146
184
|
return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} — the era key cannot be read back`);
|
|
147
185
|
}
|
|
148
186
|
const era = (snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion;
|
|
187
|
+
// unfailable-leg audit, 2026-09-26: `era === undefined` was accepted, so a
|
|
188
|
+
// host that dropped the era key from the snapshot passed. runs.md §getRun
|
|
189
|
+
// and persistence.md make eventLogSchemaVersion REQUIRED on every v2 run
|
|
190
|
+
// snapshot (schemas/v2/run-snapshot.schema.json `required`); absent ⇒ 2 is
|
|
191
|
+
// the STORAGE rule, not a wire latitude.
|
|
149
192
|
expect(
|
|
150
|
-
era
|
|
151
|
-
req(ID, DOC, `the era key is fixed at run creation: a run seeded in era 2 and then appended to MUST still read as era 2 (got ${String(era)}) — promoting it to 3 would strand the seeded rows in a vocabulary the reader no longer translates`),
|
|
152
|
-
).toBe(
|
|
193
|
+
era,
|
|
194
|
+
req(ID, DOC, `the era key is fixed at run creation: a run seeded in era 2 and then appended to MUST still read as era 2 (got ${String(era)}) — promoting it to 3 would strand the seeded rows in a vocabulary the reader no longer translates, and the snapshot MUST carry eventLogSchemaVersion (runs.md §getRun)`),
|
|
195
|
+
).toBe(2);
|
|
153
196
|
});
|
|
154
197
|
});
|
|
@@ -327,6 +327,29 @@ describe('RFC 0207 — the receiver rule (host as MCP server / A2A server)', ()
|
|
|
327
327
|
try {
|
|
328
328
|
expect(r.error, req(A2A_MALFORMED, DOC, `a malformed traceparent MUST be ignored, never a request failure — SendMessage answered error ${JSON.stringify(r.error)}`)).toBeUndefined();
|
|
329
329
|
expect(r.result, req(A2A_MALFORMED, DOC, 'SendMessage MUST answer a normal result')).toBeDefined();
|
|
330
|
+
// unfailable-leg audit, 2026-09-26: only the JSON-RPC envelope was held,
|
|
331
|
+
// so a host that accepted SendMessage but failed (or rejected) the task
|
|
332
|
+
// because it could not parse the traceparent passed. "Ignored" means the
|
|
333
|
+
// task proceeds as if no traceparent were sent — the A2A sibling of the
|
|
334
|
+
// MCP leg's isError check. Read the returned Task (result.task or a bare
|
|
335
|
+
// Task) and, when it names an id, GetTask once more after a beat so a
|
|
336
|
+
// failure recorded just after the response is seen too. A Message result
|
|
337
|
+
// carries no task state and is not a failure.
|
|
338
|
+
const FAILED = new Set(['TASK_STATE_FAILED', 'TASK_STATE_REJECTED', 'failed', 'rejected']);
|
|
339
|
+
const stateOf = (res: Record<string, unknown> | undefined): string | undefined => {
|
|
340
|
+
const task = (res?.['task'] ?? res) as { status?: { state?: unknown } } | undefined;
|
|
341
|
+
const st = task?.status?.state;
|
|
342
|
+
return typeof st === 'string' ? st : undefined;
|
|
343
|
+
};
|
|
344
|
+
const first = stateOf(r.result);
|
|
345
|
+
expect(first !== undefined && FAILED.has(first), req(A2A_MALFORMED, DOC, `a malformed traceparent MUST be ignored — the task SendMessage returned is ${String(first)}; the malformed value failed the task instead of starting a new trace`)).toBe(false);
|
|
346
|
+
const id = ((r.result?.['task'] as { id?: unknown } | undefined)?.id ?? r.result?.['id']) as unknown;
|
|
347
|
+
if (typeof id === 'string') {
|
|
348
|
+
await sleep(500);
|
|
349
|
+
const again = await a2aCall(t.url, 'GetTask', { id }, {}).catch(() => null);
|
|
350
|
+
const later = again?.error === undefined ? stateOf(again?.result) : undefined;
|
|
351
|
+
expect(later !== undefined && FAILED.has(later), req(A2A_MALFORMED, DOC, `a malformed traceparent MUST be ignored — GetTask shows the task ${String(later)} after the SendMessage that carried it`)).toBe(false);
|
|
352
|
+
}
|
|
330
353
|
} finally {
|
|
331
354
|
await cancelTask(t.url, r);
|
|
332
355
|
}
|
|
@@ -37,6 +37,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
37
37
|
import { loadEnv } from '../lib/env.js';
|
|
38
38
|
import { softSkip } from '../lib/soft-skip.js';
|
|
39
39
|
import { req } from '../lib/requirement-ids.js';
|
|
40
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
40
41
|
|
|
41
42
|
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the MCP client: every leg POSTs JSON-RPC to the mount the host advertises in mcp.serverUrls; nothing harness-hosted is handed to the host';
|
|
42
43
|
|
|
@@ -343,9 +344,21 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
343
344
|
expect(key, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interop-map.json mcp.tasks.status', `the approvers task is input_required (got ${JSON.stringify(t.error ?? t.result)})`)).toBeDefined();
|
|
344
345
|
const u = await call(m.url, 'tasks/update', { taskId, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } });
|
|
345
346
|
expect(u.error === undefined || u.error.code === INVALID_PARAMS, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interop-map.json mcp.tasks.methods tasks/update', `a non-approver's update is acknowledged or -32602, never an internal error (got ${JSON.stringify(u.error)})`)).toBe(true);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
// Unfailable-leg audit, 2026-09-26: the negative evidence was read once
|
|
348
|
+
// after a fixed 750 ms, so a host applying the non-approver's update
|
|
349
|
+
// asynchronously after that passed. It is now held for a scaled ~3 s: the
|
|
350
|
+
// run must stay waiting-approval with zero interrupt.resolved on every read.
|
|
351
|
+
const holdUntil = Date.now() + scaledTimeoutMs(3_000);
|
|
352
|
+
let status: unknown;
|
|
353
|
+
let resolvedCount = 0;
|
|
354
|
+
for (;;) {
|
|
355
|
+
status = await runStatus(unproject(taskId));
|
|
356
|
+
resolvedCount = (await events(unproject(taskId))).filter((e) => e.type === 'interrupt.resolved').length;
|
|
357
|
+
if (status !== 'waiting-approval' || resolvedCount !== 0 || Date.now() >= holdUntil) break;
|
|
358
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
359
|
+
}
|
|
360
|
+
expect(status, req(ID_TASK_UPDATE_APPROVER_CHECKED, `${DOC}; interrupt.md §Approver enforcement; RFC 0198 §E.7`, 'the interrupt stays open: the run is still waiting-approval')).toBe('waiting-approval');
|
|
361
|
+
expect(resolvedCount, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interrupt.md §Approver enforcement', 'no interrupt.resolved is recorded')).toBe(0);
|
|
349
362
|
await call(m.url, 'tasks/cancel', { taskId });
|
|
350
363
|
});
|
|
351
364
|
|
|
@@ -97,5 +97,16 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
|
|
|
97
97
|
served.length,
|
|
98
98
|
req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `the host MUST NOT issue a retry beyond the ceiling — the server saw ${served.length} tools/call for a ceiling of ${String(maxRounds)}`),
|
|
99
99
|
).toBeLessThanOrEqual((maxRounds as number) + 1);
|
|
100
|
+
// unfailable-leg audit, 2026-09-26: only the upper bound was enforced, so a
|
|
101
|
+
// host that answered 422 mcp_mrtr_rounds_exceeded at round 1 (or refused
|
|
102
|
+
// every MRTR tool result outright) passed. Under the fake's counting, call
|
|
103
|
+
// k (k = 1..maxRounds+1) answers the k-th input_required; a host honouring
|
|
104
|
+
// the ceiling answers the first maxRounds of them (maxRounds retries) and
|
|
105
|
+
// refuses the (maxRounds + 1)-th — exactly maxRounds + 1 tools/call. Fewer
|
|
106
|
+
// means the refusal came BEFORE the ceiling.
|
|
107
|
+
expect(
|
|
108
|
+
served.length,
|
|
109
|
+
req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `the refusal MUST come at the ceiling, not before: rounds 1..${String(maxRounds)} are within maxRounds and MUST be answered, so the server MUST see ${rounds} tools/call (the initial call + ${String(maxRounds)} retries) — it saw ${served.length}`),
|
|
110
|
+
).toBeGreaterThanOrEqual(rounds);
|
|
100
111
|
});
|
|
101
112
|
});
|
|
@@ -14,7 +14,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
14
14
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
15
15
|
import { v2Discovery } from '../lib/v2.js';
|
|
16
16
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
17
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
17
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
18
18
|
import { req } from '../lib/requirement-ids.js';
|
|
19
19
|
|
|
20
20
|
const DOC = 'spec/v2/core/events.md §Poll';
|
|
@@ -32,19 +32,24 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
|
|
|
32
32
|
try { return await fn(); } catch { return null; }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
async function terminalRun(): Promise<{ runId: string } | { reason: string }> {
|
|
35
|
+
async function terminalRun(): Promise<{ runId: string; terminal: boolean } | { reason: string }> {
|
|
36
36
|
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
37
37
|
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
38
38
|
if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
39
39
|
const runId = (created.json as { runId?: unknown } | undefined)?.runId;
|
|
40
40
|
if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
41
|
+
// unfailable-leg audit, 2026-09-26: the helper used to return the run even
|
|
42
|
+
// when the deadline lapsed with it still running, so the shape leg could not
|
|
43
|
+
// hold a host to isTerminal:true. It now reports whether it actually saw a
|
|
44
|
+
// terminal snapshot status.
|
|
41
45
|
const deadline = Date.now() + TIMEOUT_MS;
|
|
46
|
+
let terminal = false;
|
|
42
47
|
while (Date.now() < deadline) {
|
|
43
48
|
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
44
|
-
if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) break;
|
|
49
|
+
if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) { terminal = true; break; }
|
|
45
50
|
await new Promise((r) => setTimeout(r, 250));
|
|
46
51
|
}
|
|
47
|
-
return { runId };
|
|
52
|
+
return { runId, terminal };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
async function poll(runId: string, query: string): Promise<OpenWOPResponse | null> {
|
|
@@ -60,6 +65,7 @@ describe('v2 poll-cursor-v2 (RFC 0171 §E.2)', () => {
|
|
|
60
65
|
it('the response is the closed { runId, events, lastSequence, status, isTerminal }', async () => {
|
|
61
66
|
const r = await terminalRun();
|
|
62
67
|
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
68
|
+
if (!r.terminal) return softSkip('blocked', `the ${NOOP_WORKFLOW_ID} run did not reach a terminal snapshot status within ${TIMEOUT_MS} ms — isTerminal/status/lastSequence cannot be held to a settled log`);
|
|
63
69
|
const res = await poll(r.runId, '');
|
|
64
70
|
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll unreachable (fetch failed)');
|
|
65
71
|
expect(res.status, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'pollRunEvents MUST answer 200')).toBe(200);
|
|
@@ -70,8 +76,25 @@ describe('v2 poll-cursor-v2 (RFC 0171 §E.2)', () => {
|
|
|
70
76
|
expect(Number.isInteger(body?.lastSequence) && (body?.lastSequence as number) >= -1, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'lastSequence is the highest sequence in the log (−1 when empty)')).toBe(true);
|
|
71
77
|
expect(typeof body?.status, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'status is the snapshot status')).toBe('string');
|
|
72
78
|
expect(typeof body?.isTerminal, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'isTerminal is a boolean')).toBe('boolean');
|
|
79
|
+
// unfailable-leg audit, 2026-09-26: status and isTerminal were checked only
|
|
80
|
+
// for type, so a host answering isTerminal:false (or status "running") for
|
|
81
|
+
// a run whose snapshot is terminal passed. The run was observed terminal
|
|
82
|
+
// above, so the poll MUST say so.
|
|
83
|
+
expect(TERMINAL.has(String(body?.status)), req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, `status is the snapshot status: the run was observed terminal, so status MUST be one of ${[...TERMINAL].join('/')}; got ${String(body?.status)}`)).toBe(true);
|
|
84
|
+
expect(body?.isTerminal, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'isTerminal is whether the run is terminal: the run was observed terminal, so isTerminal MUST be true')).toBe(true);
|
|
85
|
+
// unfailable-leg audit, 2026-09-26: the old check was max(seqs) ≤
|
|
86
|
+
// lastSequence, so a host answering max+1 (or any larger number) passed.
|
|
87
|
+
// lastSequence is the highest sequence IN THE LOG; a cursor-less poll
|
|
88
|
+
// carries the whole log, so once the log is confirmed quiescent (a second
|
|
89
|
+
// whole-log read returns the same sequences) lastSequence MUST equal the
|
|
90
|
+
// highest returned sequence, or −1 when the log is empty.
|
|
73
91
|
const seqs = sequences(body);
|
|
74
|
-
|
|
92
|
+
const settled = await poll(r.runId, '');
|
|
93
|
+
const seqsAfter = settled !== null && settled.status === 200 ? sequences(settled.json) : null;
|
|
94
|
+
if (seqsAfter === null || seqsAfter.join(',') !== seqs.join(',')) {
|
|
95
|
+
return blockedDespiteAssertions(`the event log grew between two whole-log reads (before: [${seqs.join(',')}], after: [${seqsAfter?.join(',') ?? 'unreadable'}]) — a run at terminal STATUS is still appending, so lastSequence cannot be compared to a settled log. Re-run against a quiescent log.`);
|
|
96
|
+
}
|
|
97
|
+
expect(body?.lastSequence, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, `lastSequence MUST equal the highest sequence in the log (−1 when empty); the whole-log poll returned [${seqs.join(', ')}]`)).toBe(seqs.length > 0 ? Math.max(...seqs) : -1);
|
|
75
98
|
});
|
|
76
99
|
|
|
77
100
|
it('omitting the cursor returns from the first event (sequence 0)', async () => {
|
|
@@ -28,8 +28,13 @@ async function discovery(): Promise<Record<string, unknown> | null> {
|
|
|
28
28
|
try { return await v2Discovery(); } catch { return null; }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Unfailable-leg audit, 2026-09-26: this checked `status` alone, so a bare
|
|
32
|
+
// `{status}` object passed as a capability record. capabilities.md §2 makes
|
|
33
|
+
// `status`, `since` and `witness` all REQUIRED, so all three are checked.
|
|
31
34
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
32
|
-
|
|
35
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
36
|
+
const r = v as Record<string, unknown>;
|
|
37
|
+
return typeof r['status'] === 'string' && typeof r['since'] === 'string' && typeof r['witness'] === 'string';
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/** RFC 0169 §C.1 — the predicate, evaluated over the document alone. */
|
|
@@ -60,7 +65,9 @@ describe('v2 profiles-derived-only (RFC 0169 §C.1, §C.3)', () => {
|
|
|
60
65
|
expect(isRecord(doc[f]), req('openwop.requirement.0169.profiles-derived-only.derivable', DOC, `profile ${p.id} lists family ${f}; the host advertises it, so it MUST be one capability record {status, since, witness, …}`)).toBe(true);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
|
-
|
|
68
|
+
// Unfailable-leg audit, 2026-09-26: a `typeof derive(...) === 'boolean'`
|
|
69
|
+
// assertion stood here; `derive` always returns a boolean, so it passed
|
|
70
|
+
// every host. Decidability is carried by the record check above.
|
|
64
71
|
}
|
|
65
72
|
const core = rows.find((p) => p.id === 'openwop-discovery-core');
|
|
66
73
|
if (core !== undefined) {
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
* host released one to make room), or never within the window, AND the held
|
|
27
27
|
* attempts had stayed open until the run was terminal: the contention the
|
|
28
28
|
* floor names existed when the healthy delivery fell due, and it waited.
|
|
29
|
-
* -
|
|
30
|
-
* before the run was terminal.
|
|
31
|
-
* healthy delivery fell due, so this
|
|
32
|
-
* timeout is the host's to choose; a
|
|
33
|
-
* DELAY_MS is simply not measured by this
|
|
29
|
+
* - blocked (unjudged) — the host had all 8 held attempts open at once, but
|
|
30
|
+
* its own delivery timeout closed them before the run was terminal.
|
|
31
|
+
* Contention was not sustained when the healthy delivery fell due, so this
|
|
32
|
+
* run of the leg cannot judge §A. The timeout is the host's to choose; a
|
|
33
|
+
* host with a timeout shorter than DELAY_MS is simply not measured by this
|
|
34
|
+
* instrument. If fewer than 8 were EVER open at once (a serial or small
|
|
35
|
+
* pool), it fails instead (2026-09-26, unfailable-leg audit).
|
|
34
36
|
* - blocked — fewer than 8 held attempts ever arrived AND the healthy one
|
|
35
37
|
* never did either: nothing reached the suite, which says nothing about
|
|
36
38
|
* isolation (`noDeliveryCause`).
|
|
@@ -121,6 +123,10 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
121
123
|
let healthyId: string | null = null;
|
|
122
124
|
let healthyAt: number | null = null;
|
|
123
125
|
let openAtHealthy = -1;
|
|
126
|
+
// The most held attempts ever open at once. Sampled at each held arrival:
|
|
127
|
+
// the open count only rises there (closes only lower it), so the max over
|
|
128
|
+
// arrivals is the true peak.
|
|
129
|
+
let peakOpen = 0;
|
|
124
130
|
const openCount = (): number => [...held.values()].filter((h) => h.closedAt === null).length;
|
|
125
131
|
|
|
126
132
|
const rx = await startScopedReceiver((hit, res) => {
|
|
@@ -137,6 +143,7 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
137
143
|
}
|
|
138
144
|
const h: Held = { webhookId, res, openedAt: Date.now(), closedAt: null };
|
|
139
145
|
held.set(webhookId, h);
|
|
146
|
+
peakOpen = Math.max(peakOpen, openCount());
|
|
140
147
|
// The HOST closing the socket before we answer is the host abandoning the attempt.
|
|
141
148
|
res.on('close', () => { if (h.closedAt === null) h.closedAt = Date.now(); });
|
|
142
149
|
});
|
|
@@ -175,7 +182,7 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
175
182
|
const everOpened = held.size;
|
|
176
183
|
await waitFor(() => healthyAt !== null, WINDOW_MS);
|
|
177
184
|
const earliestClose = Math.min(...[...held.values()].map((h) => h.closedAt ?? Infinity));
|
|
178
|
-
const detail = `${everOpened} held attempt(s) arrived; ${openAtTerminal} were still open when the run was seen terminal; `
|
|
185
|
+
const detail = `${everOpened} held attempt(s) arrived (at most ${peakOpen} open at once); ${openAtTerminal} were still open when the run was seen terminal; `
|
|
179
186
|
+ (healthyAt === null ? `the healthy attempt did not arrive within ${WINDOW_MS}ms of that` : `the healthy attempt arrived ${healthyAt - terminalAt}ms after it, with ${openAtHealthy} held open`);
|
|
180
187
|
|
|
181
188
|
if (healthyAt === null && everOpened === 0) {
|
|
@@ -193,8 +200,21 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
193
200
|
// delivery timeout released the held attempts early (its choice), or it
|
|
194
201
|
// never opened FLOOR of them at once. The second is the defect, the first
|
|
195
202
|
// is not, and they differ in WHEN the first held attempt closed.
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
//
|
|
204
|
+
// Unfailable-leg audit (2026-09-26): this branch asserted everOpened > 0
|
|
205
|
+
// and then `softSkip('skipped')`, which records `executed-pass` with a
|
|
206
|
+
// `partial-witness:` detail. A serial dispatcher (pool of 1) with a
|
|
207
|
+
// sub-2s delivery timeout landed here and passed — every held attempt
|
|
208
|
+
// arrived eventually (everOpened reached FLOOR), one at a time, while the
|
|
209
|
+
// healthy attempt queued behind them: head-of-line blocking. peakOpen
|
|
210
|
+
// separates the two cases the comment above names: below FLOOR the host
|
|
211
|
+
// never had FLOOR attempts outstanding at once, which is the defect; at
|
|
212
|
+
// FLOOR the host's timeout released them early, which is unjudged.
|
|
213
|
+
expect(
|
|
214
|
+
peakOpen,
|
|
215
|
+
req(ID, DOC, `a host MUST sustain ${FLOOR} subscriptions' attempts outstanding at once — at most ${peakOpen} were ever open together, so the held attempts were dispatched below the floor — ${detail}`),
|
|
216
|
+
).toBeGreaterThanOrEqual(FLOOR);
|
|
217
|
+
return blockedDespiteAssertions(`unjudged: ${FLOOR} held attempts were open at once, but the host closed held attempts ${Number.isFinite(earliestClose) ? `${terminalAt - earliestClose}ms ` : ''}before the run was terminal (its delivery timeout is shorter than this leg's ${DELAY_MS}ms delay), so ${FLOOR} attempts were not outstanding when the healthy delivery fell due — ${detail}`);
|
|
198
218
|
} else {
|
|
199
219
|
expect.fail(req(ID, DOC, `the host never had ${FLOOR} subscriptions' attempts outstanding at once, and the healthy attempt waited — a bounded dispatcher below the floor — ${detail}`));
|
|
200
220
|
}
|
|
@@ -348,6 +348,22 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
348
348
|
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST be spaced by the advertised ${policy.backoff} backoff (attempt ${i + 1} followed attempt ${i} after ${times[i]! - times[i - 1]!}ms)`),
|
|
349
349
|
).toBeGreaterThan(0);
|
|
350
350
|
}
|
|
351
|
+
// Unfailable-leg audit (2026-09-26): `> 0 ms` alone let a host advertising
|
|
352
|
+
// `backoff: exponential` that retried in a tight loop (any constant
|
|
353
|
+
// spacing, even a few ms) pass. An exponential backoff MUST widen, so with
|
|
354
|
+
// three or more attempts the second gap is at least 1.5x the first (not
|
|
355
|
+
// 2x: jitter tolerance). Only the first two gaps are compared, so a host
|
|
356
|
+
// whose schedule hits its cap later is not convicted. `fixed` gets no
|
|
357
|
+
// equivalent: its base interval is not on the wire (see the comment above
|
|
358
|
+
// the `!retried` branch), so there is no floor to measure against.
|
|
359
|
+
if (policy.backoff === 'exponential' && times.length >= 3) {
|
|
360
|
+
const gap1 = times[1]! - times[0]!;
|
|
361
|
+
const gap2 = times[2]! - times[1]!;
|
|
362
|
+
expect(
|
|
363
|
+
gap2,
|
|
364
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST follow the advertised exponential backoff — the second gap (${gap2}ms) MUST be at least 1.5x the first (${gap1}ms); a constant spacing is not exponential`),
|
|
365
|
+
).toBeGreaterThanOrEqual(1.5 * gap1);
|
|
366
|
+
}
|
|
351
367
|
}
|
|
352
368
|
} else {
|
|
353
369
|
softSkip('inapplicable', 'no advertised retryPolicy.backoff other than none — the spacing leg is not asserted');
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
39
39
|
import { req } from '../lib/requirement-ids.js';
|
|
40
40
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
41
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
41
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
42
42
|
import { v2Validator } from '../lib/v2.js';
|
|
43
|
-
import {
|
|
43
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
44
|
+
import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks, type ModalHit } from '../lib/webhook-receiver.js';
|
|
44
45
|
import {
|
|
45
46
|
STANDARD_WEBHOOKS_ALG,
|
|
46
47
|
deliveriesFor,
|
|
@@ -53,6 +54,9 @@ import {
|
|
|
53
54
|
} from '../lib/standard-webhooks.js';
|
|
54
55
|
|
|
55
56
|
const REFUSED = ['no-echo', 'wrong-echo', 'redirect'] as const;
|
|
57
|
+
/** A second echo subscription, registered AFTER the refused modes (routed as `echo`, told apart by this trailing segment). */
|
|
58
|
+
const LATE_CONTROL = 'late-control';
|
|
59
|
+
const isLateControl = (h: ModalHit): boolean => h.path.split('?')[0]!.endsWith(`/echo/${LATE_CONTROL}`);
|
|
56
60
|
|
|
57
61
|
let closeReceiver: (() => Promise<void>) | null = null;
|
|
58
62
|
afterEach(async () => {
|
|
@@ -80,10 +84,18 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
80
84
|
const res = await registerSw(body(mode));
|
|
81
85
|
refused.set(mode, { status: res.status, code: readErrorCode(res.json), id: (res.json as { webhookId?: unknown } | null)?.webhookId });
|
|
82
86
|
}
|
|
87
|
+
// Unfailable-leg audit, 2026-09-26: the persisted-nothing check synced only
|
|
88
|
+
// on the FIRST echo subscription plus a 1 s grace, so a host that answered
|
|
89
|
+
// 400 but kept the rows, delivering serially in registration order, could
|
|
90
|
+
// land those deliveries after the window. A second echo control registered
|
|
91
|
+
// AFTER the refused modes sits behind them in that order.
|
|
92
|
+
const late = await registerSw({ ...body('echo'), url: `${rx.urlFor('echo').url}/${LATE_CONTROL}` });
|
|
83
93
|
|
|
84
94
|
const ID = 'openwop.requirement.0201.endpoint-verification';
|
|
85
95
|
expect(ok.status, req(ID, 'RFC 0201 §D.14', `an endpoint that echoes the challenge MUST be registered 201 (got ${ok.status} ${readErrorCode(ok.json) ?? ''})`)).toBe(201);
|
|
86
96
|
const echoId = (ok.json as { webhookId: string }).webhookId;
|
|
97
|
+
expect(late.status, req(ID, 'RFC 0201 §D.14', `a second echoing endpoint (the late control) MUST be registered 201 (got ${late.status} ${readErrorCode(late.json) ?? ''})`)).toBe(201);
|
|
98
|
+
const lateId = (late.json as { webhookId: string }).webhookId;
|
|
87
99
|
for (const [mode, r] of refused) {
|
|
88
100
|
expect(r.status, req(ID, 'RFC 0201 §D.14', `a ${mode} endpoint MUST be refused 400 before any 201 (got ${r.status})`)).toBe(400);
|
|
89
101
|
expect(r.code, req(ID, 'RFC 0201 §D.14', `a ${mode} refusal MUST carry webhook_endpoint_unverified`)).toBe('webhook_endpoint_unverified');
|
|
@@ -91,7 +103,7 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
91
103
|
|
|
92
104
|
const validate = v2Validator('webhook-verification');
|
|
93
105
|
for (const mode of ['echo', ...REFUSED]) {
|
|
94
|
-
const vr = rx.hits.filter((h) => h.mode === mode && h.verification);
|
|
106
|
+
const vr = rx.hits.filter((h) => h.mode === mode && h.verification && !isLateControl(h));
|
|
95
107
|
expect(vr.length, req(ID, 'RFC 0201 §D.13–§D.14', `the host MUST send exactly one verification request to the ${mode} endpoint and MUST NOT retry it (saw ${vr.length})`)).toBe(1);
|
|
96
108
|
const h = vr[0]!;
|
|
97
109
|
expect(h.method, req(ID, 'RFC 0201 §D.13', 'the verification request MUST be a POST')).toBe('POST');
|
|
@@ -113,12 +125,19 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
113
125
|
// delivery; by then any persisted refused subscription would have one too.
|
|
114
126
|
const run = await driveRun();
|
|
115
127
|
expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
116
|
-
await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0, 15_000);
|
|
128
|
+
await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0 && deliveriesFor(rx.hits, lateId, run.runId).length > 0, 15_000);
|
|
117
129
|
expect(deliveriesFor(rx.hits, echoId, run.runId).length, req(ID, 'webhooks.md §Durability', 'the verified subscription MUST receive run.completed — the sync point for the persisted-nothing check')).toBeGreaterThan(0);
|
|
118
|
-
|
|
130
|
+
// The late control is a SYNC POINT, not a requirement: a public receiver
|
|
131
|
+
// front that drops its extra path segment would starve it through no fault
|
|
132
|
+
// of the host. Unobserved ⇒ the persisted-nothing check is unjudged, never
|
|
133
|
+
// failed and never passed.
|
|
134
|
+
if (deliveriesFor(rx.hits, lateId, run.runId).length === 0) {
|
|
135
|
+
return blockedDespiteAssertions(`the late-control subscription (${lateId}) received no run.completed within the wait — the second sync point for the persisted-nothing check is missing, so that check is unjudged`);
|
|
136
|
+
}
|
|
137
|
+
await new Promise((r) => setTimeout(r, scaledTimeoutMs(3_000)));
|
|
119
138
|
for (const mode of REFUSED) {
|
|
120
139
|
const late = rx.hits.filter((h) => h.mode === mode && !h.verification);
|
|
121
140
|
expect(late.length, req(ID, 'RFC 0201 §D.14', `a refused (${mode}) registration MUST persist no subscription — it received ${late.length} later request(s)`)).toBe(0);
|
|
122
141
|
}
|
|
123
|
-
},
|
|
142
|
+
}, 75_000);
|
|
124
143
|
});
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
32
32
|
import { req } from '../lib/requirement-ids.js';
|
|
33
33
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
35
35
|
import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
|
|
36
36
|
import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
|
|
37
37
|
import {
|
|
@@ -108,11 +108,19 @@ describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct acro
|
|
|
108
108
|
req(ID, 'RFC 0201 §C.10', `webhook-id MUST be identical on every attempt of one (webhookId, runId, sequence) — ${attempts.length} attempts of ${key} carried ${ids.size} value(s)`),
|
|
109
109
|
).toHaveLength(1);
|
|
110
110
|
}
|
|
111
|
+
// Unfailable-leg audit, 2026-09-26: the distinctness half asserted only
|
|
112
|
+
// `keys.size >= 2`, so a host coalescing the two subscriptions (or keeping a
|
|
113
|
+
// per-subscription constant id with one delivery each) passed on a partial
|
|
114
|
+
// set. Distinctness is judged only over the full expected set; fewer keys
|
|
115
|
+
// is unmeasured, and the retry-stability assertions above already ran.
|
|
116
|
+
if (keys.size < expectedKeys) {
|
|
117
|
+
return blockedDespiteAssertions(`only ${keys.size} of ${expectedKeys} expected deliveries (${subs.length} subscriptions × ${EVENTS.join(' + ')}) arrived inside the ${window}ms window — distinctness across deliveries cannot be judged on a partial set`);
|
|
118
|
+
}
|
|
111
119
|
const perKey = [...keys.entries()].map(([key, a]) => ({ key, id: hitHeader(a[0]!, 'webhook-id') ?? '' }));
|
|
112
120
|
for (const { key, id } of perKey) {
|
|
113
121
|
expect(STANDARD_WEBHOOKS_ID.test(id), req(ID, 'RFC 0201 §C.10', `webhook-id MUST match ^[A-Za-z0-9_-]{16,128}$ (${key}: ${JSON.stringify(id)})`)).toBe(true);
|
|
114
122
|
}
|
|
115
|
-
expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).
|
|
123
|
+
expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).toBe(expectedKeys);
|
|
116
124
|
expect(
|
|
117
125
|
new Set(perKey.map((k) => k.id)).size,
|
|
118
126
|
req(ID, 'RFC 0201 §C.10', `webhook-id MUST differ between distinct deliveries, by event and by subscription — ${perKey.length} deliveries carried ${new Set(perKey.map((k) => k.id)).size} distinct id(s)`),
|