@openwop/openwop-conformance 2.0.0-rc.6 → 2.0.0-rc.61

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.
Files changed (49) hide show
  1. package/README.md +8 -3
  2. package/dist/cli.js +24 -34
  3. package/dist/lib/requirement-registry.js +69 -0
  4. package/dist/lib/scenario-disposition.js +84 -11
  5. package/dist/lib/seams.js +42 -0
  6. package/dist/lib/soft-skip.js +39 -8
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirement-aliases.json +4 -1
  10. package/requirements.json +1129 -51
  11. package/scenario-majors.json +60 -3
  12. package/schemas/CORPUS-STAMP.json +40 -37
  13. package/src/cli.ts +23 -30
  14. package/src/global-setup.ts +11 -0
  15. package/src/lib/corpus-stamp.ts +24 -2
  16. package/src/lib/fixtures.ts +31 -0
  17. package/src/lib/requirement-registry.ts +68 -0
  18. package/src/lib/scenario-disposition.ts +88 -9
  19. package/src/lib/soft-skip.ts +42 -7
  20. package/src/lib/sse.ts +8 -0
  21. package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
  22. package/src/scenarios/pause-resume.test.ts +159 -75
  23. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
  24. package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
  25. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
  26. package/src/scenarios/v2-created-run-readable.test.ts +98 -0
  27. package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
  28. package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
  29. package/src/scenarios/v2-effect-seam-no-refire.test.ts +88 -0
  30. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
  31. package/src/scenarios/v2-era-stamp-universal.test.ts +1 -1
  32. package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
  33. package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
  34. package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
  35. package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
  36. package/src/scenarios/v2-run-cancel.test.ts +97 -0
  37. package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
  38. package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
  39. package/src/scenarios/v2-run-fork-prefix.test.ts +140 -0
  40. package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
  41. package/src/scenarios/v2-run-options-limits.test.ts +69 -0
  42. package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
  43. package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
  44. package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
  45. package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
  46. package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
  47. package/src/scenarios/v2-version-header-honored.test.ts +124 -0
  48. package/src/scenarios/version-negotiation.test.ts +25 -3
  49. package/src/setup.ts +104 -50
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Cancel — `bulkCancelRuns` (suite 2.0.0, target major 2;
3
+ * unaided).
4
+ *
5
+ * Legs:
6
+ * 1. over the cap: 101 syntactically valid tenant-bound ids MUST be refused
7
+ * `400 validation_error` with `details.maxRunIds` (creates nothing);
8
+ * 2. `[own, foreign-tenant, own]` MUST answer `200 { results[] }` in request
9
+ * order even when every entry failed; the foreign entry is `ok: false` with
10
+ * an error envelope in the entry — never a top-level 403. The prose names
11
+ * `run_forbidden` for a run the caller cannot see while identity.md §5 names
12
+ * `id_tenant_mismatch` / `not_found` for a foreign tenant segment; the leg
13
+ * accepts any of the three and the disagreement is filed (finding 3). An
14
+ * own entry on an already-terminal noop is `ok: true` with
15
+ * `cancelling | cancelled` or `ok: false` with `run_terminal` (finding 1).
16
+ *
17
+ * The foreign id matches the runId grammar, so a 400 on leg 2 is a host defect,
18
+ * not body validation.
19
+ *
20
+ * @see spec/v2/core/runs.md §Cancel
21
+ * @see spec/v2/core/identity.md §5
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
26
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
27
+ import { readErrorCode } from '../lib/error-envelope.js';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { req } from '../lib/requirement-ids.js';
30
+
31
+ const ID = 'openwop.requirement.0170.run-bulk-cancel';
32
+ const DOC = 'spec/v2/core/runs.md §Cancel';
33
+ const NOOP = 'conformance-noop';
34
+ const FOREIGN = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
35
+
36
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
37
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
38
+ async function create(): Promise<{ runId: string } | { reason: string }> {
39
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
40
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
41
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
42
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
43
+ return { runId };
44
+ }
45
+ interface Entry { runId?: unknown; ok?: unknown; status?: unknown; error?: unknown }
46
+
47
+ describe('v2 run-bulk-cancel (runs.md §Cancel)', () => {
48
+ it('101 ids are refused 400 validation_error with details.maxRunIds', async () => {
49
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
50
+ const runIds = Array.from({ length: 101 }, (_, i) => `openwop-conformance-bulk-cap/opaque${String(i).padStart(3, '0')}0123456789abcdef`);
51
+ const res = await http(() => driver.post('/runs:bulk-cancel', { runIds }));
52
+ if (res === null) return softSkip('blocked', 'POST /runs:bulk-cancel unreachable (fetch failed)');
53
+ if (res.status === 404) return softSkip('blocked', 'POST /runs:bulk-cancel answered 404 — bulkCancelRuns is a core operation (runs.md §Surface) and is not mounted');
54
+ expect(res.status, req(ID, DOC, `over the cap (RECOMMENDED 100) the host MUST answer 400 — got ${res.status}`)).toBe(400);
55
+ expect(readErrorCode(res.json), req(ID, DOC, 'the refusal MUST be validation_error')).toBe('validation_error');
56
+ // The v2 envelope is { error: <code>, message, details? } — `details` at the ROOT
57
+ // (schemas/v2/error-envelope.schema.json). rc.48/rc.49 read `error.details`
58
+ // and reported a host defect that was this reader's; retracted in rc.50.
59
+ const max = (res.json as { details?: { maxRunIds?: unknown } } | null)?.details?.maxRunIds;
60
+ expect(typeof max === 'number' && max >= 1 && max <= 100, req(ID, DOC, `details.maxRunIds MUST state the cap (got ${String(max)})`)).toBe(true);
61
+ });
62
+
63
+ it('results[] come back in request order, each id processed independently, a foreign id refused in its entry and never as a top-level 403', async () => {
64
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
65
+ const a = await create(); if ('reason' in a) return softSkip('blocked', a.reason);
66
+ const b = await create(); if ('reason' in b) return softSkip('blocked', b.reason);
67
+ const runIds = [a.runId, FOREIGN, b.runId];
68
+ const res = await http(() => driver.post('/runs:bulk-cancel', { runIds, reason: 'conformance' }));
69
+ if (res === null) return softSkip('blocked', 'POST /runs:bulk-cancel unreachable (fetch failed)');
70
+ if (res.status === 404) return softSkip('blocked', 'POST /runs:bulk-cancel answered 404 — not mounted');
71
+ expect(res.status, req(ID, DOC, `bulkCancelRuns MUST answer 200 even when entries fail — a foreign id MUST NOT surface as a top-level ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
72
+ const results = (res.json as { results?: unknown } | null)?.results;
73
+ expect(Array.isArray(results) && results.length === 3, req(ID, DOC, `results[] MUST carry one entry per requested id (got ${Array.isArray(results) ? results.length : typeof results})`)).toBe(true);
74
+ const entries = results as Entry[];
75
+ expect(entries.map((e) => e.runId), req(ID, DOC, 'results[] MUST be in request order')).toEqual(runIds);
76
+ const foreign = entries[1]!;
77
+ expect(foreign.ok, req(ID, DOC, 'the foreign-tenant entry MUST be ok: false')).toBe(false);
78
+ // An entry's `error` IS the error envelope (api/v2/openapi.yaml: `$ref error-envelope`):
79
+ // { error: <code>, message, details? } nested under the entry — so the code is
80
+ // `entry.error.error`, read the same way as a top-level envelope.
81
+ const env = v2Validator('error-envelope')(foreign.error);
82
+ expect(env.ok, req(ID, 'api/v2/openapi.yaml bulkCancelRuns results[].error', `an ok: false entry's error MUST be the error envelope { error: <code>, message, details? } (schemas/v2/error-envelope.schema.json; the OpenAPI $refs it for results[].error) — got ${JSON.stringify(foreign.error)}: ${env.errors}`)).toBe(true);
83
+ const fcode = String(readErrorCode(foreign.error));
84
+ expect(['id_tenant_mismatch', 'not_found'].includes(fcode), req(ID, 'spec/v2/core/identity.md §5', `an id whose tenant segment is not the caller's MUST be refused inside the entry with id_tenant_mismatch (or not_found where existence is not leaked) — identity.md §5 applies inside a bulk entry exactly as on a path; run_forbidden is for a same-tenant run the caller may not cancel — got ${fcode}`)).toBe(true);
85
+ for (const own of [entries[0]!, entries[2]!]) {
86
+ const okShape = own.ok === true && ['cancelling', 'cancelled'].includes(String(own.status));
87
+ const terminalShape = own.ok === false && readErrorCode(own.error) === 'run_terminal';
88
+ expect(okShape || terminalShape, req(ID, DOC, `an own entry MUST be ok: true with cancelling|cancelled, or ok: false run_terminal when the noop already completed — got ${JSON.stringify(own)}`)).toBe(true);
89
+ }
90
+ });
91
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Cancel — `cancelRun` (suite 2.0.0, target major 2).
3
+ *
4
+ * Witness class: witnessable — the positive leg is gated on the
5
+ * `conformance-cancellable` fixture (a run that stays `running` for `delayMs`);
6
+ * the terminal-run leg is unaided.
7
+ *
8
+ * Legs:
9
+ * 1. cancelling a run that is already terminal — `runs.md` §Cancel is SILENT
10
+ * on this case; the registry has `run_terminal` (409) and 200-idempotent is
11
+ * the other defensible answer. The leg accepts either, records which, and
12
+ * the ambiguity is filed for the prose (rc.48 CHANGELOG, finding 1).
13
+ * 2. on `conformance-cancellable`: `POST /runs/{runId}/cancel` on a running
14
+ * run answers `200 { runId, status: cancelling | cancelled }`; `run.cancelled`
15
+ * is in the log within 5 s; the snapshot reads `cancelled`.
16
+ *
17
+ * Until rc.48 nothing at major 2 witnessed §Cancel at all — runs.md carried 61
18
+ * MUSTs and 12 witness sites, every one under §Create.
19
+ *
20
+ * @see spec/v2/core/runs.md §Cancel
21
+ * @see conformance/fixtures.md §conformance-cancellable
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
26
+ import { v2Discovery } from '../lib/v2.js';
27
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
28
+ import { readErrorCode } from '../lib/error-envelope.js';
29
+ import { softSkip } from '../lib/soft-skip.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+
32
+ const ID = 'openwop.requirement.0170.run-cancel';
33
+ const DOC = 'spec/v2/core/runs.md §Cancel';
34
+ const NOOP = 'conformance-noop';
35
+ const CANCELLABLE = 'conformance-cancellable';
36
+
37
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
38
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
39
+ const enc = (id: string): string => encodeURIComponent(id);
40
+
41
+ async function create(body: Record<string, unknown>): Promise<{ runId: string } | { reason: string }> {
42
+ const res = await http(() => driver.post('/runs', body));
43
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
44
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
45
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
46
+ return { runId };
47
+ }
48
+ async function statusOf(runId: string): Promise<string | null> {
49
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
50
+ return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
51
+ }
52
+ async function waitFor(runId: string, pred: (s: string) => boolean, ms: number): Promise<string | null> {
53
+ const t0 = Date.now(); let last: string | null = null;
54
+ while (Date.now() - t0 < ms) { last = await statusOf(runId); if (last !== null && pred(last)) return last; await new Promise((r) => setTimeout(r, 250)); }
55
+ return last;
56
+ }
57
+ async function eventTypes(runId: string): Promise<string[] | null> {
58
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
59
+ const events = (res?.json as { events?: unknown } | null)?.events;
60
+ return res?.status === 200 && Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : null;
61
+ }
62
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
63
+
64
+ describe('v2 run-cancel (runs.md §Cancel)', () => {
65
+ it('cancelling a terminal run is refused 409 run_terminal', async () => {
66
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
67
+ const c = await create({ workflowId: NOOP });
68
+ if ('reason' in c) return softSkip('blocked', c.reason);
69
+ const s = await waitFor(c.runId, (x) => TERMINAL.has(x), 10_000);
70
+ if (s === null || !TERMINAL.has(s)) return softSkip('blocked', `the noop run did not reach a terminal status within 10 s (last: ${s ?? 'unreadable'})`);
71
+ const res = await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, {}));
72
+ if (res === null) return softSkip('blocked', 'POST /runs/{runId}/cancel unreachable (fetch failed)');
73
+ const code = readErrorCode(res.json);
74
+ const status = (res.json as { status?: unknown } | null)?.status;
75
+ expect(res.status, req(ID, DOC, `a cancel on a run that is already terminal (${s}) MUST be refused 409 run_terminal — got ${res.status} ${code ?? String(status)}; a 200 echoing the terminal state is outside §Cancel's 200 grammar { runId, status: cancelling | cancelled }`)).toBe(409);
76
+ expect(code, req(ID, DOC, 'the refusal MUST be run_terminal')).toBe('run_terminal');
77
+ });
78
+
79
+ it('cancelling a running run answers 200 with cancelling|cancelled, emits run.cancelled within 5 s, and the snapshot reads cancelled', async () => {
80
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
81
+ if (!isFixtureAdvertised(CANCELLABLE)) return softSkip('inapplicable', `${CANCELLABLE} fixture not advertised — no run stays running long enough to cancel`);
82
+ const c = await create({ workflowId: CANCELLABLE, inputs: { delayMs: 15_000 } });
83
+ if ('reason' in c) return softSkip('blocked', c.reason);
84
+ const running = await waitFor(c.runId, (x) => x === 'running' || TERMINAL.has(x), 5_000);
85
+ if (running !== 'running') return softSkip('blocked', `${CANCELLABLE} did not reach running within 5 s (last: ${running ?? 'unreadable'})`);
86
+ const res = await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance' }));
87
+ if (res === null) return softSkip('blocked', 'POST /runs/{runId}/cancel unreachable (fetch failed)');
88
+ expect(res.status, req(ID, DOC, `cancelRun on a running run MUST answer 200 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
89
+ const body = res.json as { runId?: unknown; status?: unknown } | null;
90
+ expect(body?.runId, req(ID, DOC, 'the 200 body MUST carry the run\'s runId')).toBe(c.runId);
91
+ expect(['cancelling', 'cancelled'].includes(String(body?.status)), req(ID, DOC, `status MUST be cancelling or cancelled (got ${String(body?.status)})`)).toBe(true);
92
+ const final = await waitFor(c.runId, (x) => x === 'cancelled', 5_000);
93
+ expect(final, req(ID, DOC, `the snapshot MUST read cancelled within 5 s of an accepted cancel (last: ${final ?? 'unreadable'})`)).toBe('cancelled');
94
+ const types = await eventTypes(c.runId);
95
+ expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/events.md §run.cancelled', `the log MUST carry run.cancelled once the cascade completes (types: ${(types ?? []).join(', ')})`)).toBe(true);
96
+ }, 30_000);
97
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `spec/v2/core/events.md` §Payloads — `run.completed` MUST carry `outputs`
3
+ * (suite 2.0.0, target major 2; unaided).
4
+ *
5
+ * This requirement is one day old, and the reason it needed writing is the
6
+ * reason this file exists. Both majors' payload schemas NAMED `outputs` on
7
+ * `runCompleted` and REQUIRED nothing. v1 also left the object open. So a
8
+ * tier-1 host emitted the singular `output` for its entire life and validated
9
+ * every time; v2 closing the object caught the EXTRA key — and that is the
10
+ * only reason anyone looked. Nothing in either major has ever caught an ABSENT
11
+ * one: a host emitting `run.completed {}` validates in v1 and, until rc.35, in
12
+ * v2. Seventeen scenario files mention `run.completed`; none read its payload.
13
+ *
14
+ * A key that no schema requires and no scenario reads is a requirement that
15
+ * exists only as a property name — the same shape as `eventLogSchemaVersion`,
16
+ * which lived in a schema `description` for the life of v1 while two hosts
17
+ * shipped without it. Asserting the EFFECT (the terminal event of a run that
18
+ * completed carries an outputs object) is the only form of this check a
19
+ * permissive emitter cannot satisfy by accident.
20
+ *
21
+ * @see spec/v2/core/events.md §Payloads
22
+ * @see schemas/v2/run-event-payloads.schema.json $defs/runCompleted
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
27
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
28
+ import { readErrorCode } from '../lib/error-envelope.js';
29
+ import { softSkip } from '../lib/soft-skip.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+
32
+ const ID = 'openwop.requirement.0171.run-completed-outputs';
33
+ const DOC = 'spec/v2/core/events.md §Payloads';
34
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
35
+
36
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
37
+ try { return await fn(); } catch { return null; }
38
+ }
39
+
40
+ async function createRun(): Promise<{ runId: string } | { reason: string }> {
41
+ try { if (!(await v2Discovery())) return { reason: 'v2 discovery unreachable' }; } catch { return { reason: 'v2 discovery unreachable' }; }
42
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
43
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
44
+ const runId = (res.json as { runId?: unknown } | undefined)?.runId;
45
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
46
+ return { runId };
47
+ }
48
+
49
+ interface Ev { readonly type?: unknown; readonly payload?: unknown }
50
+
51
+ /** Poll until the terminal event lands. The noop workflow completes in well under a second; the bound is for a slow host, not a slow workflow. */
52
+ async function terminalEvent(runId: string): Promise<{ ev: Ev } | { reason: string }> {
53
+ let last: string | null = null;
54
+ for (let i = 0; i < 20; i += 1) {
55
+ const res = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
56
+ if (res === null) return { reason: 'GET /runs/{runId}/events/poll unreachable (fetch failed)' };
57
+ if (res.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${res.status}` };
58
+ const events = (res.json as { events?: unknown } | undefined)?.events;
59
+ if (Array.isArray(events)) {
60
+ const hit = events.find((e): e is Ev => typeof (e as Ev)?.type === 'string' && (e as Ev).type === 'run.completed');
61
+ if (hit) return { ev: hit };
62
+ const failed = events.find((e): e is Ev => typeof (e as Ev)?.type === 'string' && /^run\.(failed|cancelled)$/.test(String((e as Ev).type)));
63
+ if (failed) return { reason: `the noop run reached ${String(failed.type)} rather than run.completed — nothing to assert outputs on` };
64
+ last = `${events.length} event(s), none terminal`;
65
+ }
66
+ await new Promise((r) => setTimeout(r, 250));
67
+ }
68
+ return { reason: `run.completed never appeared (${last ?? 'no events'}) — the host may not run the noop workflow to completion` };
69
+ }
70
+
71
+ describe('v2 run-completed-outputs (events.md §Payloads)', () => {
72
+ it('the run.completed event of a run that completed carries an outputs object', async () => {
73
+ const c = await createRun();
74
+ if ('reason' in c) return softSkip('blocked', c.reason);
75
+ const t = await terminalEvent(c.runId);
76
+ if ('reason' in t) return softSkip('blocked', t.reason);
77
+
78
+ const payload = (t.ev.payload ?? null) as { outputs?: unknown; output?: unknown } | null;
79
+ expect(
80
+ payload !== null && typeof payload === 'object',
81
+ req(ID, DOC, 'run.completed MUST carry a payload object'),
82
+ ).toBe(true);
83
+ expect(
84
+ payload !== null && typeof payload.outputs === 'object' && payload.outputs !== null && !Array.isArray(payload.outputs),
85
+ req(ID, DOC, `run.completed MUST carry \`outputs\` as an object (an empty object is valid) — a client cannot tell "no outputs" from "outputs not rendered" when the key is absent, and no schema in either major required it until rc.35, which is how a host emitted the singular \`output\` for its whole life. Got keys ${JSON.stringify(payload ? Object.keys(payload) : null)}`),
86
+ ).toBe(true);
87
+
88
+ // The schema now requires it too; assert through the validator so the two
89
+ // cannot drift apart again without this leg saying which one moved.
90
+ const r = v2Validator('run-event')(t.ev);
91
+ expect(r.ok, req(ID, DOC, `the terminal event MUST validate against run-event.schema.json, whose runCompleted payload now requires outputs (${r.errors})`)).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Diff and ancestry — `diffRun` is a pure function of
3
+ * the two logs (suite 2.0.0, target major 2; unaided; two runs created, a third
4
+ * when `conformance-failure` is advertised).
5
+ *
6
+ * Identical logs MUST yield `divergedAtSeq: null` and empty `eventDiffs`;
7
+ * `eventId`, `runId`, `timestamp` and other run-scoped fields MUST be excluded
8
+ * from the comparison — so two `conformance-noop` runs ARE identical logs.
9
+ * `diffRun` is OPTIONAL (`404` when absent → inapplicable).
10
+ *
11
+ * Controls: the response MUST validate against
12
+ * `schemas/v2/run-diff-response.schema.json` and echo both ids in `a` / `b`
13
+ * (a host answering `{}` to any pair fails there); and, when
14
+ * `conformance-failure` is advertised, a noop-vs-failure diff MUST diverge.
15
+ * Without that fixture the divergence control records `inapplicable`.
16
+ *
17
+ * @see spec/v2/core/runs.md §Diff and ancestry
18
+ * @see schemas/v2/run-diff-response.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
23
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+ import { readErrorCode } from '../lib/error-envelope.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { req } from '../lib/requirement-ids.js';
28
+
29
+ const ID = 'openwop.requirement.0170.run-diff-identical';
30
+ const DOC = 'spec/v2/core/runs.md §Diff and ancestry';
31
+ const NOOP = 'conformance-noop';
32
+ const FAILURE = 'conformance-failure';
33
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
34
+
35
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
36
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
37
+ const enc = (id: string): string => encodeURIComponent(id);
38
+ async function createSettled(workflowId: string): Promise<{ runId: string } | { reason: string }> {
39
+ const res = await http(() => driver.post('/runs', { workflowId }));
40
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
41
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
42
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${workflowId}} answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
43
+ const t0 = Date.now();
44
+ while (Date.now() - t0 < 10_000) {
45
+ const s = await http(() => driver.get(`/runs/${enc(runId)}`));
46
+ if (s?.status === 200 && TERMINAL.has(String((s.json as { status?: unknown }).status))) return { runId };
47
+ await new Promise((r) => setTimeout(r, 250));
48
+ }
49
+ return { reason: `${workflowId} run ${runId} did not settle within 10 s` };
50
+ }
51
+ interface Diff { a?: unknown; b?: unknown; divergedAtSeq?: unknown; eventDiffs?: unknown }
52
+
53
+ describe('v2 run-diff-identical (runs.md §Diff and ancestry)', () => {
54
+ it('two identical logs diff to divergedAtSeq null and empty eventDiffs, in a response that validates and echoes both ids', async () => {
55
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
56
+ const a = await createSettled(NOOP); if ('reason' in a) return softSkip('blocked', a.reason);
57
+ const b = await createSettled(NOOP); if ('reason' in b) return softSkip('blocked', b.reason);
58
+ const res = await http(() => driver.get(`/runs/${enc(a.runId)}:diff?against=${enc(b.runId)}`));
59
+ if (res === null) return softSkip('blocked', 'GET /runs/{runId}:diff unreachable (fetch failed)');
60
+ if (res.status === 404) return softSkip('inapplicable', 'diffRun is OPTIONAL and answered 404 (runs.md §Surface)');
61
+ expect(res.status, req(ID, DOC, `diffRun MUST answer 200 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
62
+ const check = v2Validator('run-diff-response')(res.json);
63
+ expect(check.ok, req(ID, 'schemas/v2/run-diff-response.schema.json', `the response MUST validate: ${check.errors}`)).toBe(true);
64
+ const d = res.json as Diff;
65
+ expect([d.a, d.b], req(ID, DOC, 'the response MUST name the two runs it compared')).toEqual([a.runId, b.runId]);
66
+ 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
+ expect(d.eventDiffs, req(ID, DOC, 'identical logs MUST yield empty eventDiffs')).toEqual([]);
68
+ if (!isFixtureAdvertised(FAILURE)) return softSkip('inapplicable', `${FAILURE} fixture not advertised — the divergence control (a noop-vs-failure diff MUST diverge) cannot run`);
69
+ const f = await createSettled(FAILURE); if ('reason' in f) return softSkip('blocked', f.reason);
70
+ const dv = await http(() => driver.get(`/runs/${enc(a.runId)}:diff?against=${enc(f.runId)}`));
71
+ if (dv === null || dv.status !== 200) return softSkip('blocked', `the divergence control diff answered ${dv?.status ?? 'no response'}`);
72
+ const x = dv.json as Diff;
73
+ 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
+ }, 45_000);
75
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `spec/v2/core/replay.md` §Endpoint — the fork prefix boundary is EXCLUSIVE
3
+ * (suite 2.0.0, target major 2; unaided; gated on `replay`; two runs created).
4
+ *
5
+ * "Events with `sequence < fromSeq` are fixed history; events `>= fromSeq` are
6
+ * re-executed." A host that copies `sequence <= fromSeq` inherits one event too
7
+ * many and re-executes from one event too late.
8
+ *
9
+ * Why this file exists. Until rc.60 the only major-2 witness of that boundary
10
+ * was `v2-fork-a-v1-run`, which is driven through the conformance seam and sits
11
+ * on the seams floor — so a host that does not advertise the seams profile was
12
+ * never measured on it, and `v2-run-fork-refusals` (the unaided fork scenario)
13
+ * asserts only the refusals and never reads the forked log. A tier-1 host
14
+ * shipped `<= fromSeq` and passed every unaided major-2 scenario; it found the
15
+ * defect only when fixing 1-based sequence numbering made the off-by-one
16
+ * visible — two errors cancelling. The v1 scenarios that do check the prefix
17
+ * (`replay-fork`, `replay-fork-arbitrary`, `feedback-fork-not-copied`) are
18
+ * `majors: [1]` and never run here.
19
+ *
20
+ * The falsifiable shape, chosen to be race-free and implementation-agnostic.
21
+ * `fromSeq` is placed on a `node.completed` that is not the run's last, so the
22
+ * source's event AT `fromSeq` is one re-execution cannot honestly reproduce at
23
+ * that same sequence: an exclusive host restarts that node, and its event at
24
+ * `fromSeq` is the fresh `node.started`; an inclusive host inherited the
25
+ * `node.completed` and re-executes from `fromSeq + 1`, so its event at
26
+ * `fromSeq` is the source's, verbatim. Both logs are read after they settle.
27
+ *
28
+ * Measured on the reference host (exclusive) at `fromSeq: 4` of an 8-event
29
+ * `conformance-multi-node` log: source `4 node.completed b`, fork
30
+ * `4 node.started b`, fork 9 events to the source's 8 — the fork is LONGER,
31
+ * because node `b` had started inside the prefix and its completion is
32
+ * re-executed. Length is therefore not the discriminator and this file does not
33
+ * assert on it; the event at the boundary is.
34
+ *
35
+ * What this file deliberately does NOT assert: the CONTENT of the re-executed
36
+ * tail, determinism across two forks, timing, and effect re-fire suppression.
37
+ * Those are `replay.md` §Replay determinism and the seams floor's, witnessed in
38
+ * `replay-fork-arbitrary` (major 1) and `v2-effect-seam-no-refire` (seams).
39
+ *
40
+ * @see spec/v2/core/replay.md §Endpoint
41
+ * @see spec/v2/core/runs.md §Fork
42
+ */
43
+
44
+ import { describe, it, expect } from 'vitest';
45
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
46
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
47
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
48
+ import { readErrorCode } from '../lib/error-envelope.js';
49
+ import { softSkip } from '../lib/soft-skip.js';
50
+ import { req } from '../lib/requirement-ids.js';
51
+
52
+ const ID = 'openwop.requirement.0170.fork-prefix-boundary';
53
+ const DOC = 'spec/v2/core/replay.md §Endpoint';
54
+ const MULTI = 'conformance-multi-node';
55
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
56
+
57
+ interface Ev { readonly sequence?: unknown; readonly type?: unknown }
58
+
59
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
60
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
61
+ const enc = (id: string): string => encodeURIComponent(id);
62
+
63
+ /** Every event of a run, in sequence order, read through the poll surface. */
64
+ async function logOf(runId: string): Promise<Ev[] | null> {
65
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1&streamMode=debug`));
66
+ if (res === null || res.status !== 200) return null;
67
+ const events = (res.json as { events?: unknown } | undefined)?.events;
68
+ if (!Array.isArray(events)) return null;
69
+ return (events as Ev[]).filter((e) => typeof e.sequence === 'number').sort((a, b) => (a.sequence as number) - (b.sequence as number));
70
+ }
71
+
72
+ /** Poll until the run is terminal or the bound elapses; returns the last status seen. */
73
+ async function settle(runId: string, ms = 20_000): Promise<string | null> {
74
+ const deadline = Date.now() + ms;
75
+ let status: string | null = null;
76
+ while (Date.now() < deadline) {
77
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
78
+ status = res?.status === 200 ? String((res.json as { status?: unknown } | undefined)?.status ?? '') : status;
79
+ if (status !== null && TERMINAL.has(status)) return status;
80
+ await new Promise((r) => setTimeout(r, 200));
81
+ }
82
+ return status;
83
+ }
84
+
85
+ describe('v2 run-fork-prefix (replay.md §Endpoint — the boundary is exclusive)', () => {
86
+ it('a replay fork inherits exactly [0, fromSeq): the event at fromSeq is re-executed, never carried over', async () => {
87
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
88
+ if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
89
+ if (!isFixtureAdvertised(MULTI)) return softSkip('inapplicable', `${MULTI} fixture not advertised — no multi-event deterministic run to fork at a mid-log point`);
90
+
91
+ const created = await http(() => driver.post('/runs', { workflowId: MULTI }));
92
+ if (created === null || created.status !== 201) return softSkip('blocked', `POST /runs {workflowId: ${MULTI}} answered ${created?.status ?? 'no response'} ${readErrorCode(created?.json) ?? ''}`.trim());
93
+ const runId = (created.json as { runId?: unknown }).runId;
94
+ if (typeof runId !== 'string') return softSkip('blocked', 'the 201 carried no runId');
95
+
96
+ const sourceStatus = await settle(runId);
97
+ if (sourceStatus === null || !TERMINAL.has(sourceStatus)) return softSkip('blocked', `the source run did not settle within 20 s (last status: ${sourceStatus ?? 'unreadable'}) — a mid-log fork point cannot be chosen from an unfinished log`);
98
+ const source = await logOf(runId);
99
+ if (source === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer 200 with events[] for the source run`);
100
+
101
+ // The fork point: a `node.completed` that is not the run's last event, so
102
+ // the source's event AT fromSeq is one an honest re-execution cannot
103
+ // reproduce at that sequence (it restarts the node instead).
104
+ const seqs = source.map((e) => e.sequence as number);
105
+ const last = Math.max(...seqs);
106
+ const point = source.find((e) => e.type === 'node.completed' && (e.sequence as number) > 0 && (e.sequence as number) < last);
107
+ if (point === undefined) return softSkip('inapplicable', `no node.completed sits strictly inside the ${source.length}-event source log (types: ${[...new Set(source.map((e) => String(e.type)))].join(', ')}) — this host's log shape offers no fork point where an inherited row and a re-executed row are distinguishable`);
108
+ const fromSeq = point.sequence as number;
109
+
110
+ const fork = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', fromSeq }));
111
+ if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
112
+ if (fork.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 — forkRun is served by a host advertising replay (runs.md §Surface)');
113
+ expect(fork.status, req(ID, 'spec/v2/core/runs.md §Fork', `a replay fork at a sequence in the source log MUST answer 201 — got ${fork.status} ${readErrorCode(fork.json) ?? ''}`.trim())).toBe(201);
114
+ const forkId = (fork.json as { runId?: unknown }).runId;
115
+ if (typeof forkId !== 'string') return softSkip('blocked', 'the fork 201 carried no runId');
116
+
117
+ const forkStatus = await settle(forkId);
118
+ if (forkStatus === null || !TERMINAL.has(forkStatus)) return softSkip('blocked', `the fork did not settle within 20 s (last status: ${forkStatus ?? 'unreadable'}) — the inherited-prefix length is only comparable on a finished log`);
119
+ const forked = await logOf(forkId);
120
+ if (forked === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 with events[] for the fork');
121
+
122
+ // The prefix itself: every inherited row matches the source by sequence and
123
+ // type. This is the fixed-history claim, stated positively.
124
+ const inherited = forked.filter((e) => (e.sequence as number) < fromSeq);
125
+ expect(
126
+ inherited.map((e) => `${String(e.sequence)}:${String(e.type)}`),
127
+ req(ID, DOC, `the fork MUST inherit [0, ${fromSeq}) from the source verbatim by sequence and type`),
128
+ ).toEqual(source.filter((e) => (e.sequence as number) < fromSeq).map((e) => `${String(e.sequence)}:${String(e.type)}`));
129
+
130
+ // The boundary. The source's event at `fromSeq` is re-executed, not fixed
131
+ // history, so it MUST NOT appear at its own sequence in the fork. An
132
+ // inclusive host copies [0, fromSeq] and that is exactly what it shows.
133
+ const at = forked.find((e) => (e.sequence as number) === fromSeq);
134
+ if (at === undefined) return softSkip('blocked', `the fork's log has no event at sequence ${fromSeq} (last ${Math.max(...forked.map((e) => e.sequence as number))}) — the boundary is unobservable on this log`);
135
+ expect(
136
+ String(at.type),
137
+ req(ID, DOC, `the event at sequence ${fromSeq} is re-executed, not fixed history: the fork MUST NOT carry the source's ${String(point.type)} at that sequence — seeing it means the prefix was copied inclusively ([0, ${fromSeq}] instead of [0, ${fromSeq}))`),
138
+ ).not.toBe(String(point.type));
139
+ }, 60_000);
140
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Fork — the refusals and the copied owner (suite
3
+ * 2.0.0, target major 2; gated on `replay`; one run created plus one fork).
4
+ *
5
+ * A `replay` fork with a non-empty `runOptionsOverlay` MUST be rejected 400
6
+ * (the overlay is `branch`-only); a `fromSeq` not in the source log MUST be
7
+ * rejected `422 fork_point_invalid`; a valid `replay` fork answers `201 {
8
+ * runId, sourceRunId, mode, status, eventsUrl }` and the child's `owner` is
9
+ * copied verbatim from the parent (RFC 0170 §A.4). The 201 is the control for
10
+ * the two refusals. Determinism and side-effect suppression are replay.md's
11
+ * and witnessed there.
12
+ *
13
+ * @see spec/v2/core/runs.md §Fork
14
+ * @see RFCS/0170 §A.4
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
20
+ import { readErrorCode } from '../lib/error-envelope.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const ID = 'openwop.requirement.0170.run-fork-refusals';
25
+ const DOC = 'spec/v2/core/runs.md §Fork';
26
+ const NOOP = 'conformance-noop';
27
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
28
+
29
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
30
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
31
+ const enc = (id: string): string => encodeURIComponent(id);
32
+ async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
33
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
34
+ return res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
35
+ }
36
+
37
+ describe('v2 run-fork-refusals (runs.md §Fork)', () => {
38
+ it('replay+overlay → 400; a fromSeq outside the log → 422 fork_point_invalid; a valid replay fork → 201 with the parent\'s owner verbatim', async () => {
39
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
40
+ if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
41
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
42
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
43
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
44
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
45
+ const t0 = Date.now(); let parent: Record<string, unknown> | null = null;
46
+ while (Date.now() - t0 < 10_000) { parent = await snapshot(runId); if (parent && TERMINAL.has(String(parent['status']))) break; await new Promise((r) => setTimeout(r, 250)); }
47
+ if (!parent || !TERMINAL.has(String(parent['status']))) return softSkip('blocked', 'the noop run did not settle within 10 s');
48
+
49
+ const overlay = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', runOptionsOverlay: { tags: ['conformance'] } }));
50
+ if (overlay === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
51
+ if (overlay.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 with replay advertised — forkRun is not mounted');
52
+ expect(overlay.status, req(ID, DOC, `a replay fork with a non-empty runOptionsOverlay MUST be rejected 400 (the overlay is branch-only) — got ${overlay.status} ${readErrorCode(overlay.json) ?? ''}`.trim())).toBe(400);
53
+
54
+ const bad = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'branch', fromSeq: 999_999 }));
55
+ if (bad === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
56
+ expect(bad.status, req(ID, DOC, `a fromSeq not in the source log MUST be rejected 422 — got ${bad.status} ${readErrorCode(bad.json) ?? ''}`.trim())).toBe(422);
57
+ expect(readErrorCode(bad.json), req(ID, DOC, 'the refusal MUST be fork_point_invalid')).toBe('fork_point_invalid');
58
+
59
+ const ok = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay' }));
60
+ if (ok === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
61
+ expect(ok.status, req(ID, DOC, `a valid replay fork MUST answer 201 — got ${ok.status} ${readErrorCode(ok.json) ?? ''} (the control for the two refusals above)`.trim())).toBe(201);
62
+ const body = ok.json as { runId?: unknown; sourceRunId?: unknown; mode?: unknown; eventsUrl?: unknown } | null;
63
+ expect(body?.sourceRunId, req(ID, DOC, 'the 201 MUST name the parent as sourceRunId')).toBe(runId);
64
+ expect(body?.mode, req(ID, DOC, 'the 201 MUST echo the mode')).toBe('replay');
65
+ expect(typeof body?.runId === 'string' && typeof body?.eventsUrl === 'string', req(ID, DOC, 'the 201 MUST carry runId and eventsUrl')).toBe(true);
66
+ const child = await snapshot(String(body?.runId));
67
+ if (!child) return softSkip('blocked', 'the child snapshot could not be read');
68
+ expect(child['owner'], req(ID, 'RFCS/0170 §A.4', 'the child\'s owner MUST be copied verbatim from the parent')).toEqual(parent['owner']);
69
+ }, 30_000);
70
+ });