@openwop/openwop-conformance 2.42.3 → 2.42.5
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 +22 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +292 -103
- package/schemas/CORPUS-STAMP.json +9 -9
- package/src/lib/agentOrgChart.ts +21 -4
- package/src/lib/effect-receiver.ts +23 -2
- package/src/lib/toolCatalog.ts +29 -5
- package/src/scenarios/agent-manifest-runtime.test.ts +17 -4
- package/src/scenarios/agent-org-chart-scoping.test.ts +27 -4
- package/src/scenarios/agent-roster-attribution.test.ts +28 -4
- package/src/scenarios/agentPackExport.test.ts +33 -0
- package/src/scenarios/agentPackInstall.test.ts +32 -0
- package/src/scenarios/agentReasoningEvents.test.ts +22 -13
- package/src/scenarios/agents-run-tool-allowlist.test.ts +6 -2
- package/src/scenarios/ai-envelope-shape.test.ts +9 -8
- package/src/scenarios/anonymous-actor-write-gated.test.ts +12 -6
- package/src/scenarios/audit-log-integrity.test.ts +37 -0
- package/src/scenarios/context-budget-transcript-bound.test.ts +14 -4
- package/src/scenarios/context-summarization-replay.test.ts +17 -8
- package/src/scenarios/conversationLifecycle.test.ts +12 -1
- package/src/scenarios/conversationReplayDeterminism.test.ts +19 -3
- package/src/scenarios/cost-attribution.test.ts +12 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +16 -3
- package/src/scenarios/distillation-shape.test.ts +30 -0
- package/src/scenarios/envelope-truncated.test.ts +6 -2
- package/src/scenarios/feedback-fork-not-copied.test.ts +28 -7
- package/src/scenarios/idempotency-concurrent-claim.test.ts +27 -9
- package/src/scenarios/idempotency-key-determinism.test.ts +6 -2
- package/src/scenarios/mcp-server-untrusted-args.test.ts +20 -5
- package/src/scenarios/mcp-tool-roundtrip.test.ts +7 -21
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +11 -3
- package/src/scenarios/orchestratorDispatch.test.ts +18 -2
- package/src/scenarios/pack-registry.test.ts +8 -1
- package/src/scenarios/production-backpressure.test.ts +17 -11
- package/src/scenarios/redaction.test.ts +8 -2
- package/src/scenarios/replay-fanout-suppression.test.ts +18 -2
- package/src/scenarios/runner-ledger.test.ts +7 -2
- package/src/scenarios/stream-modes.test.ts +27 -0
- package/src/scenarios/tool-catalog-projection.test.ts +35 -12
- package/src/scenarios/v2-effect-identity-business-key.test.ts +40 -6
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +12 -3
- package/src/scenarios/v2-idempotency-in-flight.test.ts +16 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +17 -3
- package/src/scenarios/v2-min-client-version.test.ts +11 -1
- package/src/scenarios/v2-run-diff-identical.test.ts +13 -5
- package/src/scenarios/v2-run-pause-resume.test.ts +4 -1
- package/src/scenarios/v2-sse-last-event-id.test.ts +7 -0
- package/src/scenarios/v2-version-header-honored.test.ts +22 -3
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +8 -0
- package/src/scenarios/v2-webhook-unregister-stops-delivery.test.ts +6 -1
- package/src/scenarios/version-negotiation.test.ts +12 -1
- package/src/scenarios/webhook-signed-delivery.test.ts +18 -3
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { describe, it, expect } from 'vitest';
|
|
22
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
23
23
|
import { driver } from '../lib/driver.js';
|
|
24
24
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
25
25
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -90,7 +90,11 @@ describe('memory-compaction-provenance-tag: compacted-from:<id> tag follows §C
|
|
|
90
90
|
if (listRes.status === 404) {
|
|
91
91
|
// eslint-disable-next-line no-console
|
|
92
92
|
console.warn('[rfc0012-tag] host does not expose memory:list at /v1/memory/{ref}; skipping tag inspection (canonical provenance signal remains the memory.compacted event itself)');
|
|
93
|
-
|
|
93
|
+
// unfailable-leg audit wave 2, 2026-09-27: this return follows the seed/
|
|
94
|
+
// compact asserts, so a plain softSkip recorded a partial-witness
|
|
95
|
+
// `executed-pass` — a host whose compacted entry was never inspectable
|
|
96
|
+
// passed the tag requirement unobserved. Now `blocked`.
|
|
97
|
+
return blockedDespiteAssertions('[rfc0012-tag] host does not expose memory:list at /v1/memory/{ref}; skipping tag inspection (canonical provenance signal remains the memory.compacted event itself)');
|
|
94
98
|
}
|
|
95
99
|
expect(listRes.status, req('openwop.it.memory-compaction-provenance-tag.compacted-entry-carries-a-well-formed-compacted-from-tag-or-omits-it-cleanly-no', 'RFC 0012 §C', 'memory:list MUST return 200 when reachable')).toBe(200);
|
|
96
100
|
|
|
@@ -100,7 +104,11 @@ describe('memory-compaction-provenance-tag: compacted-from:<id> tag follows §C
|
|
|
100
104
|
if (!output) {
|
|
101
105
|
// eslint-disable-next-line no-console
|
|
102
106
|
console.warn(`[rfc0012-tag] outputId ${outputId} not visible via memory:list; cannot inspect tags`);
|
|
103
|
-
|
|
107
|
+
// unfailable-leg audit wave 2, 2026-09-27: this return follows the seed/
|
|
108
|
+
// compact asserts, so a plain softSkip recorded a partial-witness
|
|
109
|
+
// `executed-pass` — a host whose compacted entry was never inspectable
|
|
110
|
+
// passed the tag requirement unobserved. Now `blocked`.
|
|
111
|
+
return blockedDespiteAssertions('[rfc0012-tag] outputId … not visible via memory:list; cannot inspect tags');
|
|
104
112
|
}
|
|
105
113
|
const tags = output.tags ?? [];
|
|
106
114
|
|
|
@@ -45,8 +45,8 @@ describe.skipIf(SKIP)('orchestratorDispatch: supervisor → dispatch → next-wo
|
|
|
45
45
|
expect(terminal.status).toBe('completed');
|
|
46
46
|
|
|
47
47
|
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
48
|
-
const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
|
|
49
|
-
.events ?? [];
|
|
48
|
+
const list = ((events.json as { events?: Array<{ type: string; sequence?: number; nodeId?: string; payload?: Record<string, unknown> }> })
|
|
49
|
+
.events ?? []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
|
50
50
|
|
|
51
51
|
const decisions = list.filter((e) => e.type === 'runOrchestrator.decided');
|
|
52
52
|
expect(decisions.length).toBeGreaterThan(0);
|
|
@@ -64,5 +64,21 @@ describe.skipIf(SKIP)('orchestratorDispatch: supervisor → dispatch → next-wo
|
|
|
64
64
|
expect(decision.kind).toBe('next-worker');
|
|
65
65
|
expect(Array.isArray(decision.nextWorkerIds)).toBe(true);
|
|
66
66
|
expect(decision.nextWorkerIds.length).toBeGreaterThanOrEqual(1);
|
|
67
|
+
|
|
68
|
+
// unfailable-leg audit wave 2, 2026-09-27: the leg is named "decided
|
|
69
|
+
// {next-worker} BETWEEN supervisor + dispatch" but never read an order —
|
|
70
|
+
// a host that emitted the decision AFTER the dispatch node had already
|
|
71
|
+
// started (or never started the dispatch node at all) passed. The
|
|
72
|
+
// next-worker decision MUST precede the dispatch node's first node.started.
|
|
73
|
+
const nodeIdOf = (e: { nodeId?: string; payload?: Record<string, unknown> }): unknown => e.nodeId ?? e.payload?.nodeId;
|
|
74
|
+
const dispatchStarted = list.find((e) => e.type === 'node.started' && nodeIdOf(e) === 'dispatch');
|
|
75
|
+
expect(
|
|
76
|
+
dispatchStarted,
|
|
77
|
+
req('openwop.it.orchestratorDispatch.emits-runorchestrator-decided-next-worker-between-supervisor-dispatch', 'RFCS/0006-orchestrator.md', 'the dispatch node MUST emit node.started after the supervisor decides next-worker'),
|
|
78
|
+
).toBeDefined();
|
|
79
|
+
expect(
|
|
80
|
+
typeof nextWorker!.sequence === 'number' && typeof dispatchStarted?.sequence === 'number' && nextWorker!.sequence < dispatchStarted.sequence,
|
|
81
|
+
req('openwop.it.orchestratorDispatch.emits-runorchestrator-decided-next-worker-between-supervisor-dispatch', 'RFCS/0006-orchestrator.md', 'runOrchestrator.decided{next-worker} MUST precede node.started{dispatch} in event-log sequence'),
|
|
82
|
+
).toBe(true);
|
|
67
83
|
});
|
|
68
84
|
});
|
|
@@ -208,7 +208,14 @@ describe('pack-registry: read-endpoint shape contracts', () => {
|
|
|
208
208
|
'node-packs.md §"GET /v1/packs/{name}/-/{version}.sig"',
|
|
209
209
|
'404 response MUST carry a string `error` field — `signature_not_available` is the canonical code',
|
|
210
210
|
)).toBe('string');
|
|
211
|
-
|
|
211
|
+
// unfailable-leg audit wave 2, 2026-09-27: any non-empty `error` string
|
|
212
|
+
// (e.g. `not_found`) previously passed; node-packs.md names
|
|
213
|
+
// `404 signature_not_available` for all four cases (missing / yanked /
|
|
214
|
+
// unsigned / storage-unwired), intentionally indistinguishable.
|
|
215
|
+
expect(body?.error, req('openwop.it.pack-registry.get-v1-packs-name-version-sig-returns-404-signature-not-available-for-nonexisten',
|
|
216
|
+
'node-packs.md §"GET /v1/packs/{name}/-/{version}.sig"',
|
|
217
|
+
'nonexistent (name, version) MUST return 404 `signature_not_available`',
|
|
218
|
+
)).toBe('signature_not_available');
|
|
212
219
|
});
|
|
213
220
|
|
|
214
221
|
it('GET /v1/packs/{bad-name}/-/{version}.json returns 400 invalid_pack_name', async () => {
|
|
@@ -42,7 +42,7 @@ import { behaviorGate } from '../lib/behavior-gate.js';
|
|
|
42
42
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
43
43
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
44
44
|
import { req } from '../lib/requirement-ids.js';
|
|
45
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
45
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
46
46
|
|
|
47
47
|
interface BackpressureCaps {
|
|
48
48
|
supported?: boolean;
|
|
@@ -212,7 +212,10 @@ describe('production-backpressure: 503 envelope under saturation', () => {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
await Promise.allSettled(slotPromises);
|
|
215
|
-
|
|
215
|
+
// unfailable-leg audit wave 2, 2026-09-27: this plain softSkip came
|
|
216
|
+
// AFTER up to cap-1 `create.status === 201` assertions, so the row was
|
|
217
|
+
// a partial-witness PASS although no 503 envelope was ever observed.
|
|
218
|
+
return blockedDespiteAssertions('inflight cap already saturated by parallel runs before the cap+1 probe (run with --no-file-parallelism) — 503 envelope not observed');
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
// Let SSE connections register.
|
|
@@ -233,7 +236,10 @@ describe('production-backpressure: 503 envelope under saturation', () => {
|
|
|
233
236
|
console.warn(
|
|
234
237
|
`[production-backpressure] expected 503 at cap+1=${cap + 1}, got ${blocked.status}; saturation may need tuning`,
|
|
235
238
|
);
|
|
236
|
-
|
|
239
|
+
// unfailable-leg audit wave 2, 2026-09-27: after `cap` × 201 asserts a
|
|
240
|
+
// plain softSkip recorded a partial-witness PASS — a host that never
|
|
241
|
+
// 503s at cap+1 passed the 503-envelope leg. Now `blocked`.
|
|
242
|
+
return blockedDespiteAssertions(`expected 503 at cap+1=${cap + 1}, got ${blocked.status} — saturation not reached (SSE slots may not have registered); 503 envelope not observed`);
|
|
237
243
|
}
|
|
238
244
|
|
|
239
245
|
const retryAfterHeader = blocked.headers.get('retry-after');
|
|
@@ -318,14 +324,14 @@ describe('production-backpressure: discovery exempt from cap', () => {
|
|
|
318
324
|
|
|
319
325
|
const cap = prod?.backpressure?.inflightCap;
|
|
320
326
|
if (cap === undefined) {
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return softSkip('inapplicable', '
|
|
327
|
+
// unfailable-leg audit wave 2, 2026-09-27: this used to take one UNLOADED
|
|
328
|
+
// discovery probe and then soft-skip, which recorded a partial-witness PASS
|
|
329
|
+
// for "returns 200 even when inflight is saturated", although nothing was
|
|
330
|
+
// saturated. The cap is OPTIONAL, so a host without one has nothing to
|
|
331
|
+
// saturate and the requirement does not bind it: `inapplicable`, before
|
|
332
|
+
// any assertion. It is not `blocked`, which would deny certification to a
|
|
333
|
+
// conforming host for a surface it may decline.
|
|
334
|
+
return softSkip('inapplicable', 'host does not advertise production.backpressure.inflightCap — there is no cap to saturate, so discovery-under-saturation does not bind');
|
|
329
335
|
}
|
|
330
336
|
|
|
331
337
|
// Issue many concurrent discovery probes; all MUST succeed.
|
|
@@ -187,6 +187,9 @@ describe('redaction: 401 response MUST NOT echo invalid Bearer token (NFR-7)', (
|
|
|
187
187
|
|
|
188
188
|
describe.skipIf(SKIP_NO_NOOP)('redaction: credentialRef value MUST NOT appear in event payloads (gated on secrets.supported)', () => {
|
|
189
189
|
it('skips when host does NOT advertise secrets.supported', async () => {
|
|
190
|
+
// Names the row id first (the registry takes the FIRST req() in a body as
|
|
191
|
+
// the explicitId) now that the tautological assert below is gone.
|
|
192
|
+
req('openwop.it.redaction.skips-when-host-does-not-advertise-secrets-supported', 'capabilities.md', 'a credentialRef value MUST NOT appear in any RunEvent payload');
|
|
190
193
|
const cap = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
191
194
|
const supported =
|
|
192
195
|
(cap.json as { secrets?: { supported?: boolean } } | undefined)?.secrets
|
|
@@ -194,8 +197,11 @@ describe.skipIf(SKIP_NO_NOOP)('redaction: credentialRef value MUST NOT appear in
|
|
|
194
197
|
|
|
195
198
|
if (supported !== true) {
|
|
196
199
|
// Spec-allowed — this scenario only applies to hosts that opt
|
|
197
|
-
// into BYOK.
|
|
198
|
-
expect(supported
|
|
200
|
+
// into BYOK. unfailable-leg audit wave 2, 2026-09-27: a tautological
|
|
201
|
+
// `expect(supported).not.toBe(true)` inside `if (supported !== true)`
|
|
202
|
+
// turned every non-BYOK host's row into an executed PASS of the
|
|
203
|
+
// credentialRef no-leak requirement; with zero assertions the row now
|
|
204
|
+
// records `inapplicable`.
|
|
199
205
|
return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `supported !== true` returned early');
|
|
200
206
|
}
|
|
201
207
|
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
|
|
89
89
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
90
90
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
91
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
91
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
92
92
|
import { driver } from '../lib/driver.js';
|
|
93
93
|
import { forkDeclined } from '../lib/fork-availability.js';
|
|
94
94
|
import { discoveryFamilies, readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
@@ -313,7 +313,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
313
313
|
}
|
|
314
314
|
expect(replay.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'webhooks.md §"Register"', 'replay fork should be accepted')).toBe(201);
|
|
315
315
|
const replayRunId = (replay.json as { runId: string }).runId;
|
|
316
|
-
await pollUntilTerminal(replayRunId, { timeoutMs: FORK_POLL_MS });
|
|
316
|
+
const replayTerminal = await pollUntilTerminal(replayRunId, { timeoutMs: FORK_POLL_MS });
|
|
317
317
|
await quietWindow(SUPPRESSION_WINDOW_MS);
|
|
318
318
|
|
|
319
319
|
expect(
|
|
@@ -342,6 +342,22 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
342
342
|
),
|
|
343
343
|
).toBeGreaterThan(0);
|
|
344
344
|
|
|
345
|
+
// unfailable-leg audit wave 2, 2026-09-27: a non-empty fork log was the
|
|
346
|
+
// only re-emission check, so a replay fork that FAILED (or never reached
|
|
347
|
+
// `run.completed`, the one event the webhook subscribes to) delivered
|
|
348
|
+
// nothing because it never re-emitted the subscribed event — and was
|
|
349
|
+
// recorded `executed-pass` for the suppression MUST NOT. The fork must
|
|
350
|
+
// terminate `completed` with `run.completed` in its own log before the
|
|
351
|
+
// absence of deliveries is evidence; otherwise `blocked`.
|
|
352
|
+
const forkReemittedCompleted = forkEventList.some((e) => e.type === 'run.completed');
|
|
353
|
+
if (replayTerminal.status !== 'completed' || !forkReemittedCompleted) {
|
|
354
|
+
const why = `replay fork ${replayRunId} terminated ${String(replayTerminal.status)}`
|
|
355
|
+
+ `${forkReemittedCompleted ? '' : ' without run.completed in its event log'} — the subscribed event was never re-emitted, `
|
|
356
|
+
+ 'so the absence of deliveries proves nothing';
|
|
357
|
+
recordRequirement(REQUIREMENT_ID, 'blocked', why);
|
|
358
|
+
return blockedDespiteAssertions(why);
|
|
359
|
+
}
|
|
360
|
+
|
|
345
361
|
// The MUST NOT has now been exercised against wiring proven to deliver, on a
|
|
346
362
|
// fork proven to have re-emitted. Recorded here rather than after leg 3,
|
|
347
363
|
// because leg 3 pins the boundary and may legitimately not run.
|
|
@@ -270,11 +270,16 @@ describe('RFC 0148 §A (S6) — the live sink, when this run was given one', ()
|
|
|
270
270
|
it('files that finished before this one appear in the ledger file with an assertion count', () => {
|
|
271
271
|
const path = process.env['OPENWOP_LEDGER_PATH'];
|
|
272
272
|
if (!path || !existsSync(path)) return softSkip('blocked', 'precondition not met — `!path || !existsSync(path)` returned early (not a --certify run; nothing to inspect) (seam, prior step, or fixture unavailable)'); // not a --certify run; nothing to inspect
|
|
273
|
-
const entries = readLedgerFile(path);
|
|
274
273
|
// At least the in-memory ledger of THIS worker has file-level entries for
|
|
275
274
|
// earlier files (setup.ts afterAll), and each carries assertionCount.
|
|
275
|
+
//
|
|
276
|
+
// unfailable-leg audit wave 2, 2026-09-27: the leg ended with
|
|
277
|
+
// `expect(entries.length).toBeGreaterThanOrEqual(0)` — a tautology (and a
|
|
278
|
+
// bare expect with no req()) that made the leg an `executed-pass` even when
|
|
279
|
+
// `fileEntries` was empty and the loop asserted nothing. Removed; an empty
|
|
280
|
+
// set now says so instead of passing vacuously.
|
|
276
281
|
const fileEntries = snapshot().filter((e) => e.requirementId.startsWith('openwop.scenario.') || e.requirementId.startsWith('openwop.floor.'));
|
|
282
|
+
if (fileEntries.length === 0) return softSkip('inapplicable', 'no file-level ledger entries recorded in this worker before this leg — nothing to inspect');
|
|
277
283
|
for (const e of fileEntries) expect(typeof e.assertionCount, req('openwop.it.runner-ledger.files-that-finished-before-this-one-appear-in-the-ledger-file-with-an-assertion', 'RFC 0148 §A', 'files that finished before this one appear in the ledger file with an assertion count')).toBe('number');
|
|
278
|
-
expect(entries.length).toBeGreaterThanOrEqual(0);
|
|
279
284
|
});
|
|
280
285
|
});
|
|
@@ -132,6 +132,33 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes: debug emits at least as many eve
|
|
|
132
132
|
'debug stream event count MUST be >= updates stream event count',
|
|
133
133
|
)).toBeGreaterThanOrEqual(updatesResult.events.length);
|
|
134
134
|
|
|
135
|
+
// unfailable-leg audit wave 2, 2026-09-27: superset-by-COUNT alone passed a
|
|
136
|
+
// host whose debug stream dropped canonical types (e.g. no run.completed)
|
|
137
|
+
// as long as it padded the count with other frames (log.appended,
|
|
138
|
+
// keep-alive-ish events). The mapping table is per TYPE: every type the
|
|
139
|
+
// updates stream carried MUST appear in debug, and debug MUST carry
|
|
140
|
+
// node.started (✅ debug / — updates) — the conformance-delay fixture has
|
|
141
|
+
// one node (`wait`), so a debug stream without node.started is filtering.
|
|
142
|
+
const typeOf = (e: SseEvent): string => {
|
|
143
|
+
if (e.event !== 'message') return e.event;
|
|
144
|
+
try {
|
|
145
|
+
const t = (JSON.parse(e.data) as { type?: unknown }).type;
|
|
146
|
+
return typeof t === 'string' ? t : e.event;
|
|
147
|
+
} catch {
|
|
148
|
+
return e.event;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const debugTypes = new Set(debugResult.events.map(typeOf));
|
|
152
|
+
const missingFromDebug = [...new Set(updatesResult.events.map(typeOf))].filter((t) => !debugTypes.has(t));
|
|
153
|
+
expect(missingFromDebug, req('openwop.it.stream-modes.debug-stream-is-a-superset-of-updates-per-stream-modes-md-mode-mapping',
|
|
154
|
+
'stream-modes.md §Mode-to-event mapping',
|
|
155
|
+
'every event type emitted in updates mode is also ✅ in debug mode — debug MUST carry each of them',
|
|
156
|
+
)).toEqual([]);
|
|
157
|
+
expect([...debugTypes], req('openwop.it.stream-modes.debug-stream-is-a-superset-of-updates-per-stream-modes-md-mode-mapping',
|
|
158
|
+
'stream-modes.md §Mode-to-event mapping',
|
|
159
|
+
'debug mode MUST emit node.started (✅ debug) for the fixture\'s node',
|
|
160
|
+
)).toContain('node.started');
|
|
161
|
+
|
|
135
162
|
expect(debugResult.closedBy, req('openwop.it.stream-modes.debug-stream-is-a-superset-of-updates-per-stream-modes-md-mode-mapping',
|
|
136
163
|
'stream-modes.md §debug',
|
|
137
164
|
'debug stream MUST close on terminal event',
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
TOOL_CONTENT_FORBIDDEN,
|
|
56
56
|
} from '../lib/toolCatalog.js';
|
|
57
57
|
import { req } from '../lib/requirement-ids.js';
|
|
58
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
58
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
59
59
|
|
|
60
60
|
function loadSchema(name: string): Record<string, unknown> {
|
|
61
61
|
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
@@ -92,8 +92,23 @@ describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
|
|
|
92
92
|
).toBe(true);
|
|
93
93
|
|
|
94
94
|
// ---- Leg 1: the list (§B) -------------------------------------------
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// unfailable-leg audit wave 2, 2026-09-27: a host that ADVERTISES the
|
|
96
|
+
// catalog but 404s the list (or answers 500 / a non-list 200, which the
|
|
97
|
+
// old `listTools` read as `[]`) previously recorded a partial-witness pass
|
|
98
|
+
// after the 401 assertion. An unserved read is now `seamAbsent` (blocked;
|
|
99
|
+
// fails under OPENWOP_REQUIRE_BEHAVIOR=true), and any other non-200 or
|
|
100
|
+
// non-list body FAILS.
|
|
101
|
+
const read = await listTools();
|
|
102
|
+
if (read.unserved) return seamAbsent(`host advertises toolCatalog but ${toolsPath()} answered ${read.status}`);
|
|
103
|
+
expect(
|
|
104
|
+
read.status,
|
|
105
|
+
req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', `GET ${toolsPath()} MUST return 200 to an authenticated caller on a host advertising toolCatalog`),
|
|
106
|
+
).toBe(200);
|
|
107
|
+
expect(
|
|
108
|
+
read.tools !== null,
|
|
109
|
+
req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', `GET ${toolsPath()} MUST return a ToolDescriptor[]`),
|
|
110
|
+
).toBe(true);
|
|
111
|
+
const tools = read.tools ?? [];
|
|
97
112
|
|
|
98
113
|
for (const t of tools) {
|
|
99
114
|
const v = validate(t);
|
|
@@ -116,12 +131,18 @@ describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
|
|
|
116
131
|
if (tools.length > 0 && typeof tools[0]!.toolId === 'string') {
|
|
117
132
|
const id = tools[0]!.toolId as string;
|
|
118
133
|
const one = await getTool(id);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
// unfailable-leg audit wave 2, 2026-09-27: the by-id check used to run
|
|
135
|
+
// only `if (one.status === 200)`, so a host that 404'd (or 500'd) the
|
|
136
|
+
// by-id read of a tool its own list returned passed. A listed tool is
|
|
137
|
+
// one the caller may see; its by-id read MUST serve it.
|
|
138
|
+
expect(
|
|
139
|
+
one.status,
|
|
140
|
+
req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', 'GET /v1/tools/{toolId} MUST return 200 for a toolId the caller\'s own list returned'),
|
|
141
|
+
).toBe(200);
|
|
142
|
+
expect(
|
|
143
|
+
one.descriptor?.toolId === id,
|
|
144
|
+
req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', 'GET /v1/tools/{toolId} MUST return the requested descriptor'),
|
|
145
|
+
).toBe(true);
|
|
125
146
|
}
|
|
126
147
|
const unknown = await getTool('__conformance_nonexistent_tool__');
|
|
127
148
|
expect(
|
|
@@ -142,9 +163,11 @@ describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
|
|
|
142
163
|
|
|
143
164
|
it('advisory (SHOULD): an unchanged catalog reads identically, sorted by toolId', async () => {
|
|
144
165
|
if (!(await toolCatalogGate('openwop-tool-catalog'))) return;
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
166
|
+
const ra = await listTools();
|
|
167
|
+
const rb = await listTools();
|
|
168
|
+
const a = ra.tools;
|
|
169
|
+
const b = rb.tools;
|
|
170
|
+
if (a === null || b === null) return softSkip('inapplicable', `the list read did not return a ToolDescriptor[] (status ${ra.status}/${rb.status}); the normative leg above records that — this advisory leg is never failed`);
|
|
148
171
|
const ids = (l: ReadonlyArray<{ toolId?: unknown }>) => l.map((t) => String(t.toolId));
|
|
149
172
|
const first = ids(a); const second = ids(b);
|
|
150
173
|
const sorted = [...first].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
|
|
@@ -25,7 +25,8 @@ import { describe, it, expect } from 'vitest';
|
|
|
25
25
|
import { driver } from '../lib/driver.js';
|
|
26
26
|
import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
|
|
27
27
|
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
28
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
28
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
29
|
+
import { startEffectReceiver } from '../lib/effect-receiver.js';
|
|
29
30
|
import { req } from '../lib/requirement-ids.js';
|
|
30
31
|
|
|
31
32
|
const FIXTURE = 'conformance-noop';
|
|
@@ -109,11 +110,19 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
109
110
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
110
111
|
if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
|
|
111
112
|
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the retry leg is driven through the suite fixture provider (RFC 0173 §D.2 G4) under the seams profile — seams profile not advertised');
|
|
112
|
-
// The seam is catalogued (api/seams-v2.yaml `forceEffectTransportRetry`)
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
113
|
+
// The seam is catalogued (api/seams-v2.yaml `forceEffectTransportRetry`), and
|
|
114
|
+
// its contract names the witness: "the suite fixture provider records the
|
|
115
|
+
// idempotency key of each attempt". Until 2.42.4 this leg handed the host an
|
|
116
|
+
// address nothing listened on (`http://127.0.0.1:1/`), so no suite-owned
|
|
117
|
+
// party ever saw a key, and the only witness was the host's own ledger — a
|
|
118
|
+
// host could record one constant providerKey while sending a fresh key on
|
|
119
|
+
// every attempt. The suite's own receiver now cuts the FIRST attempt's
|
|
120
|
+
// connection (a genuine transport failure, after recording its
|
|
121
|
+
// Idempotency-Key) and answers the second, so the keys compared below are
|
|
122
|
+
// the ones that crossed the wire.
|
|
123
|
+
const rx = await startEffectReceiver({ failFirst: 1 });
|
|
124
|
+
try {
|
|
125
|
+
const fired = await driver.post(`${SEAMS_PREFIX}/sample/test/idempotency/effect-retry`, { providerUrl: rx.url });
|
|
117
126
|
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
118
127
|
return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/test/idempotency/effect-retry (answered ${fired.status}) — the cross-retry keying leg cannot be driven`);
|
|
119
128
|
}
|
|
@@ -166,5 +175,30 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
166
175
|
keyings.size,
|
|
167
176
|
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST declare the same keying — ${attempts.length} attempt(s) declared ${[...keyings].join(', ')}`),
|
|
168
177
|
).toBe(1);
|
|
178
|
+
// The wire half: what the provider actually received.
|
|
179
|
+
const deadline = Date.now() + 10_000;
|
|
180
|
+
while (rx.arrivals() < 2 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
|
|
181
|
+
const wire = rx.all().filter((a) => a.mine);
|
|
182
|
+
if (wire.length < 2) {
|
|
183
|
+
return blockedDespiteAssertions(`the suite's fixture provider saw ${wire.length} attempt(s) at ${rx.url} (local ${rx.localUrl}${rx.tunnelled ? ', tunnelled' : ''}) while the ledger records ${attempts.length} — the host's attempts did not reach the suite's receiver, so the keys on the wire are unobserved`);
|
|
184
|
+
}
|
|
185
|
+
for (const a of wire) {
|
|
186
|
+
expect(
|
|
187
|
+
typeof a.idempotencyKey === 'string' && a.idempotencyKey.length > 0,
|
|
188
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt MUST present an Idempotency-Key to the provider — an attempt at ${a.path} carried ${JSON.stringify(a.idempotencyKey ?? null)}`),
|
|
189
|
+
).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
const wireKeys = new Set(wire.map((a) => a.idempotencyKey));
|
|
192
|
+
expect(
|
|
193
|
+
wireKeys.size,
|
|
194
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `the provider MUST see the same Idempotency-Key on every attempt of one effect — ${wire.length} attempt(s) on the wire carried ${wireKeys.size} distinct key(s)`),
|
|
195
|
+
).toBe(1);
|
|
196
|
+
expect(
|
|
197
|
+
[...wireKeys][0],
|
|
198
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', 'the key the ledger records MUST be the key the provider received — a ledger that disagrees with the wire is not a witness'),
|
|
199
|
+
).toBe([...keys][0]);
|
|
200
|
+
} finally {
|
|
201
|
+
await rx.close();
|
|
202
|
+
}
|
|
169
203
|
});
|
|
170
204
|
});
|
|
@@ -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 {
|
|
31
|
+
import { 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';
|
|
@@ -154,11 +154,20 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
154
154
|
// positive witness: the stored spelling was the v1 one the reader maps
|
|
155
155
|
// from. When every appended type is spelled the same in both vocabularies,
|
|
156
156
|
// the rows above hold for a v1-writing and a v2-writing host alike, so the
|
|
157
|
-
// writer rule is unobserved
|
|
157
|
+
// writer rule is unobserved.
|
|
158
158
|
const renamedV2 = new Set([...codemapV1toV2()].filter(([v1, v2]) => v1 !== v2).map(([, v2]) => v2));
|
|
159
159
|
const appendedTypes = rows.slice(seedCount).map((r) => String(r.type ?? ''));
|
|
160
160
|
if (!appendedTypes.some((t) => renamedV2.has(t))) {
|
|
161
|
-
|
|
161
|
+
// partial-witness-ok: HOTFIX 2.42.5 (2026-09-27). 2.42.3 and 2.42.4 made this
|
|
162
|
+
// `blockedDespiteAssertions`, which is bundle-fatal (RFC 0168 §E.1). No catalogued
|
|
163
|
+
// seam lets ANY host make a seeded era-2 run append a type the codemap renames,
|
|
164
|
+
// so the row blocked on every host and no host could certify. That is the suite's
|
|
165
|
+
// gap, not a host's. The append, the read-back and the sequence rules above ARE
|
|
166
|
+
// observed. The codemap-renaming writer rule is NOT, and this partial witness says
|
|
167
|
+
// so in its detail; the acceptance predicate refuses a partial-witness row. The
|
|
168
|
+
// seams-contract addition that makes the rule witnessable is in progress
|
|
169
|
+
// (openwop-1f); when it lands, this becomes a real assertion again.
|
|
170
|
+
return softSkip('inapplicable', `partial witness — the codemap-renaming writer rule is UNWITNESSED: 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. A seams-contract addition to seed one is in progress.`);
|
|
162
171
|
}
|
|
163
172
|
});
|
|
164
173
|
|
|
@@ -106,6 +106,12 @@ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)',
|
|
|
106
106
|
const r = await theRace();
|
|
107
107
|
if (r.kind === 'blocked') return softSkip('blocked', r.reason);
|
|
108
108
|
expect(r.successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${r.all.map((x) => x.status).join(',')})`)).toBeGreaterThan(0);
|
|
109
|
+
// unfailable-leg audit wave 2, 2026-09-27: non-string runIds were FILTERED
|
|
110
|
+
// before the distinct-count, so a host answering the winner `201 {runId}`
|
|
111
|
+
// and every loser `201 {}` (a success that names no run — possibly a second
|
|
112
|
+
// one) passed. Every success MUST name the run it created or replayed.
|
|
113
|
+
const withoutRunId = r.successes.filter((x) => typeof (x.json as { runId?: unknown } | null)?.runId !== 'string');
|
|
114
|
+
expect(withoutRunId.length, req(ID_ONE, DOC, `every same-key success MUST carry the string runId of the one run processed (${withoutRunId.length} of ${r.successes.length} did not; statuses ${withoutRunId.map((x) => x.status).join(',')})`)).toBe(0);
|
|
109
115
|
const runIds = new Set(r.successes.map((x) => (x.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
|
|
110
116
|
expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
|
|
111
117
|
}, 60_000);
|
|
@@ -115,6 +121,16 @@ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)',
|
|
|
115
121
|
if (r.kind === 'blocked') return softSkip('blocked', r.reason);
|
|
116
122
|
const unmarked = r.successes.filter((x) => x.headers.get('openwop-idempotent-replay') !== 'true');
|
|
117
123
|
expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${r.successes.length} unmarked)`)).toBe(1);
|
|
124
|
+
// unfailable-leg audit wave 2, 2026-09-27: a marked replay was never
|
|
125
|
+
// compared to the winner, so a loser answered `201` + the replay header
|
|
126
|
+
// over a DIFFERENT (or no) runId passed. §Concurrency: the loser receives
|
|
127
|
+
// "the winner's response" — the same runId.
|
|
128
|
+
const winnerRunId = (unmarked[0]?.json as { runId?: unknown } | null)?.runId;
|
|
129
|
+
const replayRunIds = r.successes.filter((x) => x !== unmarked[0]).map((x) => (x.json as { runId?: unknown } | null)?.runId);
|
|
130
|
+
expect(
|
|
131
|
+
{ winnerIsString: typeof winnerRunId === 'string', mismatched: replayRunIds.filter((id) => id !== winnerRunId).map(String) },
|
|
132
|
+
req(ID_LOSER, DOC, `a loser that waits MUST receive the winner's response — every marked replay MUST carry the winner's runId (${String(winnerRunId)})`),
|
|
133
|
+
).toEqual({ winnerIsString: true, mismatched: [] });
|
|
118
134
|
for (const x of r.refusals) {
|
|
119
135
|
const code = readErrorCode(x.json);
|
|
120
136
|
expect({ status: x.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${x.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
|
|
@@ -307,7 +307,16 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
307
307
|
const f = await createTask(m.url, 'conformance-failure');
|
|
308
308
|
let terminal: Rpc;
|
|
309
309
|
if (f.result?.['resultType'] === 'task') terminal = await pollTask(m.url, String(f.result['taskId']), (s) => ['completed', 'failed', 'cancelled'].includes(s), 10_000);
|
|
310
|
-
else
|
|
310
|
+
else {
|
|
311
|
+
// unfailable-leg audit wave 2, 2026-09-27: this branch returned
|
|
312
|
+
// softSkip('inapplicable') after the input_required asserts — a
|
|
313
|
+
// partial-witness PASS at both majors even when the synchronous answer
|
|
314
|
+
// was NOT a tool-error outcome. The synchronous CallToolResult of a
|
|
315
|
+
// failed run MUST still carry isError true; the task projection itself
|
|
316
|
+
// was not observed, so the row is `blocked` (stands at major 2).
|
|
317
|
+
expect((f.result as { isError?: unknown } | undefined)?.isError, req(ID_TASK_STATUS_PROJECTION, 'RFC 0198 §B.4; ext-tasks §Task Execution Errors', `a failed run answered synchronously is a CallToolResult with isError true (got ${JSON.stringify(f.error ?? f.result)})`)).toBe(true);
|
|
318
|
+
return softSkip('blocked', 'conformance-failure finished before the host answered, so it was answered CallToolResult (RFC 0198 §B.4) — the failed-run task-status projection was not observed');
|
|
319
|
+
}
|
|
311
320
|
expect([terminal.result?.['status'], (terminal.result?.['result'] as { isError?: unknown } | undefined)?.isError], req(ID_TASK_STATUS_PROJECTION, 'interop-map.json mcp.tasks.status failed; ext-tasks §Task Execution Errors', `a failed run is a tool outcome: completed with result.isError true, never failed (got ${JSON.stringify(terminal.result)})`)).toEqual(['completed', true]);
|
|
312
321
|
});
|
|
313
322
|
|
|
@@ -497,13 +506,18 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
497
506
|
const ac = new AbortController();
|
|
498
507
|
const resP = fetch(m.url, { method: 'POST', signal: ac.signal, headers: headersFor('tools/call', 'conformance-cancellable', undefined, 'text/event-stream, application/json'), body: JSON.stringify(envelope('tools/call', { name: 'conformance-cancellable', arguments: { delayMs: 20_000 } }, {})) });
|
|
499
508
|
const runId = await freshRun('conformance-cancellable', before, 3000);
|
|
500
|
-
expect(runId, req(ID_NO_SERVER_CANCELLED, 'interop-map.json mcp.methods tools/call', 'the tools/call started a run listRuns shows')).not.toBeNull();
|
|
501
509
|
const res = await resP;
|
|
510
|
+
// unfailable-leg audit wave 2, 2026-09-27: the runId assert used to run
|
|
511
|
+
// BEFORE this content-type gate, so a JSON-answering mount (which cannot
|
|
512
|
+
// carry notifications/cancelled at all) recorded a partial-witness PASS
|
|
513
|
+
// for "carries no notifications/cancelled". The gate now precedes any
|
|
514
|
+
// assertion, so the JSON mount is cleanly inapplicable.
|
|
502
515
|
if (!(res.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
503
516
|
ac.abort();
|
|
504
|
-
await driver.post(`/runs/${encodeURIComponent(runId
|
|
517
|
+
if (runId !== null) await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {});
|
|
505
518
|
return softSkip('inapplicable', 'the mount answered the blocking call as JSON, not SSE — a JSON response cannot carry a notifications/cancelled frame');
|
|
506
519
|
}
|
|
520
|
+
expect(runId, req(ID_NO_SERVER_CANCELLED, 'interop-map.json mcp.methods tools/call', 'the tools/call started a run listRuns shows')).not.toBeNull();
|
|
507
521
|
const cancel = await driver.post(`/runs/${encodeURIComponent(runId!)}/cancel`, {});
|
|
508
522
|
expect(cancel.status, req(ID_NO_SERVER_CANCELLED, 'runs.md §Cancel', `REST cancelRun is accepted (got ${cancel.status})`)).toBeLessThan(300);
|
|
509
523
|
const msgs: Array<Record<string, unknown>> = [];
|
|
@@ -18,7 +18,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
18
18
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
19
19
|
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
20
20
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
21
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
21
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
22
22
|
import { req } from '../lib/requirement-ids.js';
|
|
23
23
|
|
|
24
24
|
const DOC = 'spec/v2/core/versioning.md §1.5';
|
|
@@ -40,6 +40,16 @@ describe('v2 min-client-version (RFC 0172 §A.5 — gated on minClientVersion)',
|
|
|
40
40
|
expect(typeof floor === 'string' && VERSION.test(floor), req('openwop.requirement.0172.min-client-version', DOC, 'minClientVersion MUST use the <major>.<minor> grammar (axis 15, as axis 1)')).toBe(true);
|
|
41
41
|
const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Client-Version': '0.0.1' } }));
|
|
42
42
|
if (res === null) return softSkip('blocked', 'GET /.well-known/openwop with OpenWOP-Client-Version unreachable (fetch failed)');
|
|
43
|
+
// unfailable-leg audit wave 2, 2026-09-27: every non-426 answer — including
|
|
44
|
+
// a 4xx/5xx that refused the client in an unregistered form — recorded
|
|
45
|
+
// `inapplicable` AFTER the grammar assert, i.e. a partial-witness PASS of
|
|
46
|
+
// the 426 refusal row. Only a SERVED request (2xx) is the MAY exercised; any
|
|
47
|
+
// other non-426 answer is `blocked`. Not asserted as a failure:
|
|
48
|
+
// `OpenWOP-Client-Version` is not yet declared in api/v2/openapi.yaml /
|
|
49
|
+
// headers.md, so a non-2xx here is not provably a version refusal.
|
|
50
|
+
if (res.status !== 426 && (res.status < 200 || res.status >= 300)) {
|
|
51
|
+
return blockedDespiteAssertions(`the host advertises minClientVersion ${String(floor)} and answered ${res.status} to a client announcing 0.0.1 — neither served (the MAY) nor refused with the registered 426 client_version_unsupported; the refusal form was not observed`);
|
|
52
|
+
}
|
|
43
53
|
if (res.status !== 426) return softSkip('inapplicable', `the host advertises minClientVersion ${String(floor)} but served a client announcing 0.0.1 (${res.status}) — refusal is a MAY; nothing further is observable`);
|
|
44
54
|
expect(readErrorCode(res.json), req('openwop.requirement.0172.min-client-version', DOC, 'a 426 refusal MUST carry client_version_unsupported')).toBe('client_version_unsupported');
|
|
45
55
|
const r = v2Validator('error-envelope')(res.json);
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* `schemas/v2/run-diff-response.schema.json` and echo both ids in `a` / `b`
|
|
13
13
|
* (a host answering `{}` to any pair fails there); and, when
|
|
14
14
|
* `conformance-failure` is advertised, a noop-vs-failure diff MUST diverge.
|
|
15
|
-
* Without that fixture the divergence control records
|
|
15
|
+
* Without that fixture the divergence control cannot run and the leg records
|
|
16
|
+
* `blocked` (`blockedDespiteAssertions`): a host answering a constant
|
|
17
|
+
* `{a, b, divergedAtSeq: null, eventDiffs: []}` to every pair would otherwise
|
|
18
|
+
* pass on the identical-logs assertions alone.
|
|
16
19
|
*
|
|
17
20
|
* @see spec/v2/core/runs.md §Diff and ancestry
|
|
18
21
|
* @see schemas/v2/run-diff-response.schema.json
|
|
@@ -23,7 +26,7 @@ import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
|
23
26
|
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
24
27
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
25
28
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
26
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
27
30
|
import { req } from '../lib/requirement-ids.js';
|
|
28
31
|
|
|
29
32
|
const ID = 'openwop.requirement.0170.run-diff-identical';
|
|
@@ -65,10 +68,15 @@ describe('v2 run-diff-identical (runs.md §Diff and ancestry)', () => {
|
|
|
65
68
|
expect([d.a, d.b], req(ID, DOC, 'the response MUST name the two runs it compared')).toEqual([a.runId, b.runId]);
|
|
66
69
|
expect(d.divergedAtSeq, req(ID, DOC, `identical logs MUST yield divergedAtSeq: null (got ${String(d.divergedAtSeq)}) — eventId, runId, timestamp and other run-scoped fields MUST be excluded from the comparison`)).toBeNull();
|
|
67
70
|
expect(d.eventDiffs, req(ID, DOC, 'identical logs MUST yield empty eventDiffs')).toEqual([]);
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// unfailable-leg audit wave 2, 2026-09-27: without the divergence control,
|
|
72
|
+
// these returns were plain softSkips AFTER the identical-logs asserts, so a
|
|
73
|
+
// host answering a constant `divergedAtSeq: null, eventDiffs: []` to every
|
|
74
|
+
// pair recorded a (partial-witness) pass. The identical-logs result is only
|
|
75
|
+
// evidence once a different pair is seen to diverge — else `blocked`.
|
|
76
|
+
if (!isFixtureAdvertised(FAILURE)) return blockedDespiteAssertions(`${FAILURE} fixture not advertised — the divergence control (a noop-vs-failure diff MUST diverge) cannot run, so the identical-logs result is not evidence`);
|
|
77
|
+
const f = await createSettled(FAILURE); if ('reason' in f) return blockedDespiteAssertions(f.reason);
|
|
70
78
|
const dv = await http(() => driver.get(`/runs/${enc(a.runId)}:diff?against=${enc(f.runId)}`));
|
|
71
|
-
if (dv === null || dv.status !== 200) return
|
|
79
|
+
if (dv === null || dv.status !== 200) return blockedDespiteAssertions(`the divergence control diff answered ${dv?.status ?? 'no response'}`);
|
|
72
80
|
const x = dv.json as Diff;
|
|
73
81
|
expect(typeof x.divergedAtSeq === 'number' && Array.isArray(x.eventDiffs) && x.eventDiffs.length > 0, req(ID, DOC, `a noop log and a failure log MUST diverge (divergedAtSeq ${String(x.divergedAtSeq)}, ${Array.isArray(x.eventDiffs) ? x.eventDiffs.length : 0} diff(s)) — the identical-logs pass above is only evidence if a different pair does not also read identical`)).toBe(true);
|
|
74
82
|
}, 45_000);
|
|
@@ -71,7 +71,10 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
|
|
|
71
71
|
if (resume === null) return softSkip('blocked', 'POST /runs/{runId}:resume unreachable (fetch failed)');
|
|
72
72
|
expect(resume.status, req(ID, DOC, `resume on a run that is not paused MUST answer 409 — got ${resume.status} ${readErrorCode(resume.json) ?? ''}`.trim())).toBe(409);
|
|
73
73
|
expect(readErrorCode(resume.json), req(ID, DOC, `a resume refused because the run is terminal MUST carry run_terminal (got ${String(readErrorCode(resume.json))})`)).toBe('run_terminal');
|
|
74
|
-
|
|
74
|
+
// unfailable-leg audit wave 2, 2026-09-27: 'inapplicable' after the 409
|
|
75
|
+
// asserts resolved to a partial-witness PASS — a host answering 409 to
|
|
76
|
+
// every pause/resume passed. `blocked` stands at major 2.
|
|
77
|
+
if (!isFixtureAdvertised(DELAY)) return softSkip('blocked', `${DELAY} fixture not advertised — the positive pause/resume leg cannot run, so the two 409s above are witnessed without their control (a host answering 409 to everything would pass them)`);
|
|
75
78
|
});
|
|
76
79
|
|
|
77
80
|
it('a running run pauses (202 paused), refuses a second pause (409), resumes (202 running), and the log carries run.paused then run.resumed', async () => {
|