@openwop/openwop-conformance 2.0.0-rc.9 → 2.0.1

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 (66) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +8 -3
  3. package/dist/cli.js +24 -34
  4. package/dist/lib/requirement-registry.js +69 -0
  5. package/dist/lib/scenario-disposition.js +84 -11
  6. package/dist/lib/seams.js +72 -0
  7. package/dist/lib/soft-skip.js +39 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +3 -3
  10. package/requirement-aliases.json +4 -1
  11. package/requirements.json +1030 -57
  12. package/scenario-majors.json +57 -3
  13. package/schemas/CORPUS-STAMP.json +40 -37
  14. package/src/cli.ts +23 -30
  15. package/src/global-setup.ts +11 -0
  16. package/src/lib/corpus-stamp.ts +24 -2
  17. package/src/lib/era2-seed.ts +12 -1
  18. package/src/lib/fixtures.ts +31 -0
  19. package/src/lib/requirement-registry.ts +68 -0
  20. package/src/lib/scenario-disposition.ts +88 -9
  21. package/src/lib/seams.ts +31 -1
  22. package/src/lib/soft-skip.ts +42 -7
  23. package/src/lib/sse.ts +8 -0
  24. package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
  25. package/src/scenarios/pause-resume.test.ts +159 -75
  26. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
  27. package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
  28. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
  29. package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
  30. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
  31. package/src/scenarios/v2-created-run-readable.test.ts +98 -0
  32. package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
  33. package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
  34. package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
  35. package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
  36. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
  37. package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
  38. package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
  39. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
  40. package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
  41. package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
  42. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  43. package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
  44. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
  45. package/src/scenarios/v2-pack-isolation.test.ts +1 -1
  46. package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
  47. package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
  48. package/src/scenarios/v2-revocation-honored.test.ts +1 -1
  49. package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
  50. package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
  51. package/src/scenarios/v2-run-cancel.test.ts +97 -0
  52. package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
  53. package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
  54. package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
  55. package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
  56. package/src/scenarios/v2-run-options-limits.test.ts +69 -0
  57. package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
  58. package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
  59. package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
  60. package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
  61. package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
  62. package/src/scenarios/v2-subject-link-record.test.ts +1 -1
  63. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
  64. package/src/scenarios/v2-webhook-durable-delivery.test.ts +58 -7
  65. package/src/scenarios/version-negotiation.test.ts +25 -3
  66. package/src/setup.ts +104 -50
@@ -96,6 +96,24 @@ describe('v2 poll-cursor-v2 (RFC 0171 §E.2)', () => {
96
96
  if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll?afterSequence= unreachable (fetch failed)');
97
97
  expect(res.status, req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, 'afterSequence is an integer ≥ 0 and MUST be accepted')).toBe(200);
98
98
  const after = sequences(res.json);
99
+ // QUIESCENCE (rc.67). The comparison below is between TWO READS of the
100
+ // same log, and a run whose STATUS is terminal has not necessarily
101
+ // finished APPENDING — a host emitting trailing or vendor rows can add an
102
+ // event between the full read above and the cursor read just made. The
103
+ // assertion then reports an array mismatch and blames the host for a
104
+ // cursor defect it does not have.
105
+ //
106
+ // Measured by a peer host: this leg failed TWICE under a full-suite run
107
+ // and passed 6/6 in isolation on the same revision — the signature of a
108
+ // widening window under load, not of a wrong cursor. Re-reading the whole
109
+ // log now settles which it was: if the log is unchanged, the two reads
110
+ // straddled a quiet window and the comparison is decidable; if it grew,
111
+ // the suite could not measure the rule and says so instead of failing.
112
+ const settled = await poll(r.runId, '');
113
+ const seqsAfter = settled !== null && settled.status === 200 ? sequences(settled.json) : null;
114
+ if (seqsAfter === null || seqsAfter.join(',') !== seqs.join(',')) {
115
+ return softSkip('blocked', `the event log grew while the cursor was being read (before: [${seqs.join(',')}], after: [${seqsAfter?.join(',') ?? 'unreadable'}]) — a run at terminal STATUS is still appending, so the two reads this rule compares do not describe the same log. Re-run against a quiescent log; this is not a cursor defect.`);
116
+ }
99
117
  expect(after.every((s) => s > n), req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, `every returned sequence MUST be > afterSequence (${n}); got [${after.join(', ')}]`)).toBe(true);
100
118
  expect(after, req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, 'the cursor is exclusive and the log is not renumbered: afterSequence=first yields exactly the rest of the log')).toEqual(seqs.filter((s) => s > n));
101
119
  });
@@ -38,7 +38,7 @@ describe('v2 revocation-honored (RFC 0170 §B.3 — seam-gated)', () => {
38
38
  it('a revoked next-request credential is refused on the next request with credential_revoked', async () => {
39
39
  const doc = await discovery();
40
40
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
41
- if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — revocation is seam-gated and cannot be observed from the canonical API');
41
+ if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — revocation is seam-gated and cannot be observed from the canonical API');
42
42
  const auth = await familyAdvertised('auth');
43
43
  const lanes = Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
44
44
  const nextRequest = lanes.filter((l) => l['revocation'] === 'next-request').map((l) => String(l['lane']));
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Annotations, artifacts, eval summary — an annotation
3
+ * is a live notification, never a run event (suite 2.0.0, target major 2; gated
4
+ * on `feedback`; one run created).
5
+ *
6
+ * `createAnnotation` answers 201 with `schemas/v2/annotation.schema.json`;
7
+ * `listAnnotations` returns `{ annotations[] }` carrying it (the control — a host
8
+ * that discards annotations would otherwise pass the next assertion for free);
9
+ * the run's event log MUST NOT carry it: no `run.annotated` in the poll, and
10
+ * the event count MUST NOT grow across the create. `runs.md` §Surface says
11
+ * `501` when `feedback` is unadvertised while the registry has no 501 code but
12
+ * `credential_unavailable` (finding 4, filed); 501 or 404 records inapplicable.
13
+ *
14
+ * @see spec/v2/core/runs.md §Annotations, artifacts, eval summary
15
+ * @see schemas/v2/annotation-create.schema.json
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
20
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
21
+ import { readErrorCode } from '../lib/error-envelope.js';
22
+ import { softSkip } from '../lib/soft-skip.js';
23
+ import { req } from '../lib/requirement-ids.js';
24
+
25
+ const ID = 'openwop.requirement.0170.run-annotation-not-event';
26
+ const DOC = 'spec/v2/core/runs.md §Annotations, artifacts, eval summary';
27
+ const NOOP = 'conformance-noop';
28
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
31
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
32
+ const enc = (id: string): string => encodeURIComponent(id);
33
+ async function events(runId: string): Promise<Array<{ type?: unknown }> | null> {
34
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
35
+ const ev = (res?.json as { events?: unknown } | null)?.events;
36
+ return res?.status === 200 && Array.isArray(ev) ? (ev as Array<{ type?: unknown }>) : null;
37
+ }
38
+
39
+ describe('v2 run-annotation-not-event (runs.md §Annotations)', () => {
40
+ it('an annotation is created and listed, and never enters the run\'s event log', async () => {
41
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
42
+ if (!(await gateFamily('feedback'))) return softSkip('inapplicable', 'feedback family not advertised (gate recorded under openwop.family.feedback) — annotations are gated on feedback (runs.md §Surface)');
43
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
44
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
45
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
46
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
47
+ const t0 = Date.now();
48
+ while (Date.now() - t0 < 10_000) { const s = await http(() => driver.get(`/runs/${enc(runId)}`)); if (s?.status === 200 && TERMINAL.has(String((s.json as { status?: unknown }).status))) break; await new Promise((r) => setTimeout(r, 250)); }
49
+ const before = await events(runId);
50
+ if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 before the annotation');
51
+
52
+ const ann = await http(() => driver.post(`/runs/${enc(runId)}/annotations`, { signal: { kind: 'label', label: 'conformance' }, note: 'openwop conformance annotation' }));
53
+ if (ann === null) return softSkip('blocked', 'POST /runs/{runId}/annotations unreachable (fetch failed)');
54
+ if (ann.status === 501 || ann.status === 404) return softSkip('blocked', `POST /runs/{runId}/annotations answered ${ann.status} although feedback is advertised — the surface is not mounted (an unadvertised feedback answers 404 not_found; 501 is not a registered shape)`);
55
+ expect(ann.status, req(ID, DOC, `createAnnotation MUST answer 201 — got ${ann.status} ${readErrorCode(ann.json) ?? ''}`.trim())).toBe(201);
56
+ const check = v2Validator('annotation')(ann.json);
57
+ expect(check.ok, req(ID, 'schemas/v2/annotation.schema.json', `the 201 body MUST validate: ${check.errors}`)).toBe(true);
58
+ const annotationId = (ann.json as { annotationId?: unknown } | null)?.annotationId;
59
+
60
+ const list = await http(() => driver.get(`/runs/${enc(runId)}/annotations`));
61
+ if (list === null) return softSkip('blocked', 'GET /runs/{runId}/annotations unreachable (fetch failed)');
62
+ expect(list.status, req(ID, DOC, `listAnnotations MUST answer 200 — got ${list.status}`)).toBe(200);
63
+ const rows = (list.json as { annotations?: unknown } | null)?.annotations;
64
+ expect(Array.isArray(rows) && rows.some((r) => (r as { annotationId?: unknown }).annotationId === annotationId), req(ID, DOC, 'listAnnotations MUST return the annotation just created (the control: a host that discards annotations must not pass the log assertion for free)')).toBe(true);
65
+
66
+ const after = await events(runId);
67
+ if (after === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 after the annotation');
68
+ expect(after.some((e) => String(e.type) === 'run.annotated'), req(ID, DOC, 'run.annotated is a live notification and MUST NOT enter the event log')).toBe(false);
69
+ expect(after.length, req(ID, DOC, `the event log MUST NOT grow across an annotation (${before.length} → ${after.length})`)).toBe(before.length);
70
+ }, 30_000);
71
+ });
@@ -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
+ });