@openwop/openwop-conformance 2.42.2 → 2.42.4
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 +46 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +353 -133
- package/schemas/CORPUS-STAMP.json +10 -10
- 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/inbound-credential-no-passthrough.test.ts +13 -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-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 +71 -13
- package/src/scenarios/v2-effect-seam-manifest.test.ts +3 -9
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +52 -9
- package/src/scenarios/v2-idempotency-in-flight.test.ts +16 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +23 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +33 -6
- package/src/scenarios/v2-min-client-version.test.ts +11 -1
- 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-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-delivery-isolation.test.ts +28 -8
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +24 -0
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +25 -6
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +10 -2
- 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
|
@@ -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';
|
|
@@ -53,24 +54,36 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
53
54
|
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
54
55
|
if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to read`);
|
|
55
56
|
|
|
57
|
+
// Unfailable-leg audit (2026-09-26): the noop fixture issues no effects, so
|
|
58
|
+
// the per-row loop asserted nothing on any host, and the trailing
|
|
59
|
+
// `softSkip('inapplicable')` (called without `return`, after four passing
|
|
60
|
+
// setup asserts) recorded `executed-pass` with a `partial-witness:` detail —
|
|
61
|
+
// a host that mis-keyed every effect passed this row. Now no expect passes
|
|
62
|
+
// before the empty-ledger check: a failing setup read still fails, and an
|
|
63
|
+
// empty but well-formed ledger returns `inapplicable` with zero assertions.
|
|
56
64
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
57
|
-
|
|
65
|
+
if (create.status !== 201) {
|
|
66
|
+
expect(create.status, req('openwop.requirement.0173.effect-identity-business-key', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
67
|
+
}
|
|
58
68
|
const runId = (create.json as { runId: string }).runId;
|
|
59
69
|
await waitTerminal(runId, 10_000);
|
|
60
70
|
|
|
61
71
|
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
|
|
72
|
+
const check = v2Validator('effect-ledger-projection')(res.json);
|
|
73
|
+
const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> } | null;
|
|
74
|
+
const effects = body?.effects ?? [];
|
|
75
|
+
if (res.status === 200 && check.ok && body?.runId === runId && effects.length === 0) {
|
|
76
|
+
return softSkip('inapplicable', 'the noop fixture issued no external effect — the ledger read is well-formed but the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
|
|
77
|
+
}
|
|
62
78
|
expect(
|
|
63
79
|
res.status,
|
|
64
80
|
req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', 'a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)'),
|
|
65
81
|
).toBe(200);
|
|
66
|
-
const check = v2Validator('effect-ledger-projection')(res.json);
|
|
67
82
|
expect(
|
|
68
83
|
check.ok,
|
|
69
84
|
req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json', `the ledger projection MUST validate: ${check.errors}`),
|
|
70
85
|
).toBe(true);
|
|
71
|
-
|
|
72
|
-
expect(body.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
|
|
73
|
-
const effects = body.effects ?? [];
|
|
86
|
+
expect(body?.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
|
|
74
87
|
const ids = new Set<string>();
|
|
75
88
|
for (const e of effects) {
|
|
76
89
|
expect(
|
|
@@ -90,7 +103,6 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
90
103
|
).toBe(false);
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
|
-
if (effects.length === 0) softSkip('inapplicable', 'the noop fixture issued no external effect — the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
|
|
94
106
|
});
|
|
95
107
|
|
|
96
108
|
it('the same provider key is presented across two transport retries', async () => {
|
|
@@ -98,11 +110,19 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
98
110
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
99
111
|
if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
|
|
100
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');
|
|
101
|
-
// The seam is catalogued (api/seams-v2.yaml `forceEffectTransportRetry`)
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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 });
|
|
106
126
|
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
107
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`);
|
|
108
128
|
}
|
|
@@ -117,7 +137,20 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
117
137
|
if (attempts.length < 2) {
|
|
118
138
|
return softSkip('blocked', `the seam produced ${attempts.length} ledger row(s) for effect ${String(body.effectId)} — a cross-retry assertion needs at least two attempts`);
|
|
119
139
|
}
|
|
120
|
-
|
|
140
|
+
// Unfailable-leg audit (2026-09-26): `providerKey` is optional in the ledger
|
|
141
|
+
// schema and `?? ''` folded a missing key to '', so a host that recorded no
|
|
142
|
+
// provider key on any attempt showed "one distinct key" and passed. Every
|
|
143
|
+
// attempt now MUST carry a non-empty key. Remaining limit: the witness is
|
|
144
|
+
// still the host's own ledger; a suite-owned receiver counting the
|
|
145
|
+
// Idempotency-Key header on the wire (lib/effect-receiver.ts) is the
|
|
146
|
+
// stronger follow-up, not done here.
|
|
147
|
+
for (const a of attempts) {
|
|
148
|
+
expect(
|
|
149
|
+
typeof a['providerKey'] === 'string' && (a['providerKey'] as string).length > 0,
|
|
150
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST record the provider key it presented — attempt ${String(a['attempt'])} recorded ${JSON.stringify(a['providerKey'] ?? null)}`),
|
|
151
|
+
).toBe(true);
|
|
152
|
+
}
|
|
153
|
+
const keys = new Set(attempts.map((e) => String(e['providerKey'])));
|
|
121
154
|
expect(
|
|
122
155
|
keys.size,
|
|
123
156
|
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST present the same provider key across a transport retry — ${attempts.length} attempt(s) presented ${keys.size} distinct key(s)`),
|
|
@@ -142,5 +175,30 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
142
175
|
keyings.size,
|
|
143
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(', ')}`),
|
|
144
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
|
+
}
|
|
145
203
|
});
|
|
146
204
|
});
|
|
@@ -95,15 +95,9 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
|
|
|
95
95
|
).toBe(true);
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
for (const kind of declared) {
|
|
102
|
-
expect(
|
|
103
|
-
rows.filter((r) => r.kind === kind).length,
|
|
104
|
-
req('openwop.requirement.0173.effect-seam-manifest', 'RFC 0173 §C.1', `at least one manifest row per declared kind (${kind})`),
|
|
105
|
-
).toBeGreaterThanOrEqual(1);
|
|
106
|
-
}
|
|
98
|
+
// Unfailable-leg audit (2026-09-26): a "one row per declared kind" loop stood
|
|
99
|
+
// here, but it built the declared set FROM the rows, so every host passed it.
|
|
100
|
+
// The manifest has no kind declaration independent of its rows; removed.
|
|
107
101
|
// RFC 0140 G6: a `branch` re-fire is stated as a permission, and a permission
|
|
108
102
|
// for a mode the host does not offer is a claim about nothing.
|
|
109
103
|
const modes = Array.isArray(replay['modes']) ? (replay['modes'] as unknown[]) : [];
|
|
@@ -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
|
});
|
|
@@ -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' });
|
|
@@ -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
|
|
|
@@ -306,7 +307,16 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
306
307
|
const f = await createTask(m.url, 'conformance-failure');
|
|
307
308
|
let terminal: Rpc;
|
|
308
309
|
if (f.result?.['resultType'] === 'task') terminal = await pollTask(m.url, String(f.result['taskId']), (s) => ['completed', 'failed', 'cancelled'].includes(s), 10_000);
|
|
309
|
-
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
|
+
}
|
|
310
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]);
|
|
311
321
|
});
|
|
312
322
|
|
|
@@ -343,9 +353,21 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
343
353
|
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
354
|
const u = await call(m.url, 'tasks/update', { taskId, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } });
|
|
345
355
|
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
|
-
|
|
356
|
+
// Unfailable-leg audit, 2026-09-26: the negative evidence was read once
|
|
357
|
+
// after a fixed 750 ms, so a host applying the non-approver's update
|
|
358
|
+
// asynchronously after that passed. It is now held for a scaled ~3 s: the
|
|
359
|
+
// run must stay waiting-approval with zero interrupt.resolved on every read.
|
|
360
|
+
const holdUntil = Date.now() + scaledTimeoutMs(3_000);
|
|
361
|
+
let status: unknown;
|
|
362
|
+
let resolvedCount = 0;
|
|
363
|
+
for (;;) {
|
|
364
|
+
status = await runStatus(unproject(taskId));
|
|
365
|
+
resolvedCount = (await events(unproject(taskId))).filter((e) => e.type === 'interrupt.resolved').length;
|
|
366
|
+
if (status !== 'waiting-approval' || resolvedCount !== 0 || Date.now() >= holdUntil) break;
|
|
367
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
368
|
+
}
|
|
369
|
+
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');
|
|
370
|
+
expect(resolvedCount, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interrupt.md §Approver enforcement', 'no interrupt.resolved is recorded')).toBe(0);
|
|
349
371
|
await call(m.url, 'tasks/cancel', { taskId });
|
|
350
372
|
});
|
|
351
373
|
|
|
@@ -484,13 +506,18 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
|
|
|
484
506
|
const ac = new AbortController();
|
|
485
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 } }, {})) });
|
|
486
508
|
const runId = await freshRun('conformance-cancellable', before, 3000);
|
|
487
|
-
expect(runId, req(ID_NO_SERVER_CANCELLED, 'interop-map.json mcp.methods tools/call', 'the tools/call started a run listRuns shows')).not.toBeNull();
|
|
488
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.
|
|
489
515
|
if (!(res.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
490
516
|
ac.abort();
|
|
491
|
-
await driver.post(`/runs/${encodeURIComponent(runId
|
|
517
|
+
if (runId !== null) await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {});
|
|
492
518
|
return softSkip('inapplicable', 'the mount answered the blocking call as JSON, not SSE — a JSON response cannot carry a notifications/cancelled frame');
|
|
493
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();
|
|
494
521
|
const cancel = await driver.post(`/runs/${encodeURIComponent(runId!)}/cancel`, {});
|
|
495
522
|
expect(cancel.status, req(ID_NO_SERVER_CANCELLED, 'runs.md §Cancel', `REST cancelRun is accepted (got ${cancel.status})`)).toBeLessThan(300);
|
|
496
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);
|
|
@@ -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) {
|
|
@@ -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 () => {
|