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

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.
@@ -1,156 +0,0 @@
1
- /**
2
- * `spec/v1/version-negotiation.md` §Stamping / §Legacy detection — the two
3
- * run-document stamping MUSTs, and the legacy rule that makes their absence
4
- * actively harmful (suite 2.0.0, target major 1; unaided).
5
- *
6
- * The rule is a v1 `MUST` and has been since the contract was written:
7
- *
8
- * §Stamping "Every persisted run document MUST carry an
9
- * `eventLogSchemaVersion: number` field. The current v1
10
- * value is `2`."
11
- * §Legacy detection "Hosts identify an older run document as legacy when
12
- * `eventLogSchemaVersion` is undefined or `< 2`" … legacy
13
- * runs have "no event subcollection … Readers MUST fall
14
- * back to the snapshot for state."
15
- *
16
- * **Nothing in the suite has ever asserted it.** `version-negotiation.test.ts`
17
- * opens by claiming it checks "the four version axes (`engineVersion`,
18
- * `eventLogSchemaVersion`, per-event `schemaVersion`, `pinnedVersions`) appear
19
- * where the spec says they should" — and `protocolVersion` is the only axis it
20
- * asserts. Across all 444 v1 scenario files the sole occurrence of the
21
- * identifier `eventLogSchemaVersion` was that sentence: a docstring describing
22
- * a check that does not exist. **A comment claiming coverage is worse than no
23
- * comment**, because it answers "is this tested?" for anyone who greps, and
24
- * answers it wrongly.
25
- *
26
- * Both production hosts were measured on 2026-09-04 and neither stamps the
27
- * field on any run it has ever served. Each found it independently, after the
28
- * other published its own greps.
29
- *
30
- * The consequence fails in the direction that punishes correctness. A client
31
- * following §Legacy detection exactly classifies every such run as legacy and
32
- * reads the snapshot — **ignoring the event log the host is in fact serving**.
33
- * The host under-serves the conforming reader and over-serves the careless one.
34
- *
35
- * Why the schema could not catch it: `run-snapshot.schema.json` requires only
36
- * `runId`, `workflowId` and `status`, so a snapshot missing the field validates
37
- * cleanly. The obligation is prose-only, which is exactly the shape that needs
38
- * a scenario rather than a keyword.
39
- *
40
- * @see spec/v1/version-negotiation.md §Stamping
41
- * @see spec/v1/version-negotiation.md §Legacy detection
42
- */
43
-
44
- import { describe, it, expect } from 'vitest';
45
- import { driver } from '../lib/driver.js';
46
- import { softSkip } from '../lib/soft-skip.js';
47
- import { req } from '../lib/requirement-ids.js';
48
-
49
- const ID_STAMPED = 'openwop.requirement.version-negotiation.era-key-stamped';
50
- const ID_NOT_LEGACY = 'openwop.requirement.version-negotiation.era-key-not-legacy';
51
- const ID_ENGINE = 'openwop.requirement.version-negotiation.engine-version-stamped';
52
- const DOC = 'spec/v1/version-negotiation.md §Stamping';
53
-
54
- interface Snapshot { readonly eventLogSchemaVersion?: unknown; readonly engineVersion?: unknown }
55
-
56
- /** A run this host created moments ago — the one case where "legacy" cannot apply. */
57
- async function freshRun(): Promise<{ runId: string } | { skip: string }> {
58
- try {
59
- // v1 path keys are explicit: the driver's unversioned rewrite is a major-2
60
- // behaviour, and `/runs` answers 404 on a v1 host. The first version of this
61
- // file used `/runs` and therefore SOFT-SKIPPED against a host that violates
62
- // the rule — passing vacuously, which is the failure this scenario exists to
63
- // catch, committed by the scenario itself.
64
- const created = await driver.post('/v1/runs', { workflowId: 'conformance-noop', inputs: {} });
65
- if (created.status !== 201) return { skip: `POST /v1/runs answered ${created.status} — no run to inspect` };
66
- const runId = (created.json as { runId?: unknown } | null)?.runId;
67
- if (typeof runId !== 'string') return { skip: 'POST /v1/runs returned no runId' };
68
- return { runId };
69
- } catch {
70
- return { skip: 'POST /v1/runs unreachable' };
71
- }
72
- }
73
-
74
- describe('era-key-stamped-v1 (version-negotiation.md §Stamping)', () => {
75
- it('a run the host just created carries eventLogSchemaVersion', async () => {
76
- const r = await freshRun();
77
- if ('skip' in r) return softSkip('blocked', r.skip);
78
-
79
- let snap;
80
- try {
81
- snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
82
- } catch {
83
- return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
84
- }
85
- if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
86
-
87
- const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
88
- expect(
89
- value,
90
- req(ID_STAMPED, DOC, 'every persisted run document MUST carry an eventLogSchemaVersion — the field is prose-only (run-snapshot.schema.json requires just runId, workflowId and status), so a snapshot without it validates cleanly and only this check can see its absence'),
91
- ).not.toBeUndefined();
92
- expect(
93
- typeof value === 'number',
94
- req(ID_STAMPED, DOC, `eventLogSchemaVersion MUST be a number (got ${JSON.stringify(value)})`),
95
- ).toBe(true);
96
- });
97
-
98
- it('a freshly created run is not classified legacy by the host\'s own rule', async () => {
99
- const r = await freshRun();
100
- if ('skip' in r) return softSkip('blocked', r.skip);
101
-
102
- let snap;
103
- try {
104
- snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
105
- } catch {
106
- return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
107
- }
108
- if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
109
- const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
110
- if (value === undefined) {
111
- return softSkip('blocked', 'the field is absent — the stamping leg above records that; legacy classification cannot be judged separately from it');
112
- }
113
-
114
- // §Legacy detection: "undefined or < 2" is legacy, and a legacy run means
115
- // "no event subcollection … Readers MUST fall back to the snapshot". A host
116
- // that serves an event log while stamping a legacy value is telling a
117
- // conforming client to ignore the log it is serving.
118
- expect(
119
- typeof value === 'number' && value >= 2,
120
- req(ID_NOT_LEGACY, 'spec/v1/version-negotiation.md §Legacy detection', `a run created moments ago MUST NOT be legacy: legacy is "undefined or < 2", and a legacy run is specified to have no event subcollection so readers MUST fall back to the snapshot. Stamping ${JSON.stringify(value)} on a new run instructs a CONFORMING client to ignore the event log this host is serving it — the failure lands on the correct reader and spares the careless one`),
121
- ).toBe(true);
122
- });
123
-
124
- it('a run the host just created carries engineVersion — the legacy escape cannot reach it', async () => {
125
- const r = await freshRun();
126
- if ('skip' in r) return softSkip('blocked', r.skip);
127
-
128
- let snap;
129
- try {
130
- snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
131
- } catch {
132
- return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
133
- }
134
- if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
135
-
136
- // §Stamping: "Every persisted run document MUST carry an `engineVersion:
137
- // number` field … Servers MAY omit this field on legacy runs that predate
138
- // the contract." The escape is scoped to runs that PREDATE the contract, so
139
- // it cannot cover a run created seconds ago — which is why this leg creates
140
- // one rather than inspecting whatever happens to be in the store.
141
- //
142
- // Asserted here because nothing else asserts it ON A RUN: version-fold.test.ts
143
- // reads engineVersion from the DISCOVERY document, and wasm-pack-load.test.ts
144
- // carries it only as a type field. Both mention the identifier, so a grep
145
- // suggests coverage that does not exist for this requirement.
146
- const value = (snap.json as Snapshot | null)?.engineVersion;
147
- expect(
148
- value,
149
- req(ID_ENGINE, DOC, 'every persisted run document MUST carry engineVersion; the "MAY omit" escape applies only to legacy runs that predate the contract, and this run was created moments ago'),
150
- ).not.toBeUndefined();
151
- expect(
152
- typeof value === 'number',
153
- req(ID_ENGINE, DOC, `engineVersion MUST be a number set to the writer engine's CURRENT_ENGINE_VERSION at write time (got ${JSON.stringify(value)})`),
154
- ).toBe(true);
155
- });
156
- });
@@ -1,140 +0,0 @@
1
- /**
2
- * RFC 0172 §A.1 / `spec/v2/core/versioning.md` §1.2 — a host that advertises
3
- * major 2 serves the major-2 PATH SPACE, not just the major-2 discovery
4
- * document (suite 2.0.0, target major 2; unaided).
5
- *
6
- * `v2-version-header-honored` checks that `OpenWOP-Version` is honored or
7
- * refused rather than ignored. It probes `/.well-known/openwop`, because that
8
- * is the one resource whose representation the header selects. **It therefore
9
- * cannot see a host that negotiates correctly on the well-known resource and
10
- * has mounted almost none of the rest of the v2 surface.**
11
- *
12
- * That is not hypothetical. A tier-1 host advertising
13
- * `protocolVersions: ["1.1","2.0"]` was found serving **two of fifteen**
14
- * top-level segments of the v2 path space: its unversioned mount was a
15
- * deliberate allowlist (`['/runs','/interrupts']`, chosen over a blanket
16
- * `/v1`-strip because the host serves a large non-`/v1` surface a blanket
17
- * rewrite would shadow) and the list was simply incomplete. Every probe used to
18
- * call the dual stack live — `protocolVersions`, `preferredVersion`, the
19
- * response header, the two differing representations — hits `/.well-known`, so
20
- * every one of them passed. `POST /webhooks` under major 2 returned `404` while
21
- * `POST /v1/webhooks` returned `201`. The host found this itself by applying
22
- * the artifact rule to a scenario it had first classified as a harness defect.
23
- *
24
- * The discriminator is a PAIR, not a single probe, because "this host does not
25
- * implement webhooks at all" and "this host implements webhooks but did not
26
- * mount them under major 2" are different facts that a lone `404` cannot
27
- * separate:
28
- *
29
- * /v1<path> exists AND <path> is 404 under major 2 ⇒ the advertisement
30
- * overstates: the surface exists and major 2 does not reach it.
31
- *
32
- * both 404 ⇒ the host does not serve that surface in either major. Not this
33
- * scenario's business, and recorded as neither pass nor failure.
34
- *
35
- * Only parameterless GETs from `spec/v2/path-manifest.json` are probed: they
36
- * need no fixture, mutate nothing, and a route that is not mounted answers 404
37
- * regardless of auth, so the check is unaided and safe against a live host.
38
- *
39
- * @see spec/v2/core/versioning.md §1.2
40
- * @see RFCS/0172-v2-versioning-and-release.md §A.1
41
- */
42
-
43
- import { describe, it, expect } from 'vitest';
44
- import { loadEnv } from '../lib/env.js';
45
- import { softSkip } from '../lib/soft-skip.js';
46
- import { req } from '../lib/requirement-ids.js';
47
- import { readFileSync } from 'node:fs';
48
- import { join } from 'node:path';
49
- import { SCHEMAS_DIR } from '../lib/paths.js';
50
-
51
- const ID = 'openwop.requirement.0172.advertised-path-space-served';
52
- const DOC = 'spec/v2/core/versioning.md §1.2';
53
-
54
- /** The well-known resource is the one the HEADER selects; it has no /v1 twin to pair against. */
55
- const NOT_PAIRABLE = new Set(['/.well-known/openwop']);
56
-
57
- interface Probe { readonly status: number | null }
58
-
59
- async function get(path: string, major2: boolean): Promise<Probe> {
60
- const { baseUrl, apiKey } = loadEnv();
61
- const headers: Record<string, string> = { Accept: 'application/json' };
62
- if (major2) headers['OpenWOP-Version'] = '2.0';
63
- if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
64
- try {
65
- const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, { headers });
66
- return { status: res.status };
67
- } catch {
68
- return { status: null };
69
- }
70
- }
71
-
72
- function parameterlessGets(): string[] {
73
- try {
74
- const manifest = JSON.parse(
75
- readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8'),
76
- ) as { operations?: ReadonlyArray<{ method: string; path: string }> };
77
- return (manifest.operations ?? [])
78
- .filter((o) => o.method === 'GET' && !o.path.includes('{') && !NOT_PAIRABLE.has(o.path))
79
- .map((o) => o.path)
80
- .sort();
81
- } catch {
82
- return [];
83
- }
84
- }
85
-
86
- describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
87
- it('a host advertising major 2 reaches the surfaces it already serves under /v1', async () => {
88
- const { baseUrl } = loadEnv();
89
- const bare = await (async () => {
90
- try {
91
- const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: { Accept: 'application/json' } });
92
- return res.status === 200 ? ((await res.json()) as Record<string, unknown>) : null;
93
- } catch {
94
- return null;
95
- }
96
- })();
97
- if (!bare) return softSkip('blocked', 'the discovery document is unreadable, so the advertised majors are unknown');
98
-
99
- const versions = Array.isArray(bare['protocolVersions']) ? (bare['protocolVersions'] as unknown[]).map(String) : [];
100
- if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
101
- return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — it does not claim major 2, so there is no path space to hold it to`);
102
- }
103
-
104
- const paths = parameterlessGets();
105
- if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
106
-
107
- const overstated: string[] = [];
108
- const served: string[] = [];
109
- let pairable = 0;
110
- for (const path of paths) {
111
- const v1 = await get(`/v1${path}`, false);
112
- if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
113
- // The host does not serve this surface in EITHER major. Legitimate, and a
114
- // different question from the one asked here.
115
- if (v1.status === 404) continue;
116
- pairable += 1;
117
-
118
- const v2 = await get(path, true);
119
- if (v2.status === null) return softSkip('blocked', `the host became unreachable while probing ${path}`);
120
- // Any status but 404 means the route is MOUNTED — 401/403/422 all answer
121
- // "this path exists". Only 404 says major 2 cannot reach it.
122
- if (v2.status === 404) overstated.push(`${path} (/v1 → ${v1.status}, major 2 → 404)`);
123
- else served.push(path);
124
- }
125
-
126
- if (pairable === 0) {
127
- return softSkip('inapplicable', 'no parameterless GET in the v2 manifest is served under /v1 either, so there is no pair to compare and the advertisement cannot be checked this way');
128
- }
129
-
130
- expect(
131
- overstated,
132
- req(ID, DOC, `a host advertising major 2 MUST reach, under major 2, the surfaces it already serves under /v1 — advertising the major is a claim about the PATH SPACE and not only about the well-known resource, whose representation the header selects and which therefore passes even when almost nothing else is mounted (${overstated.length} of ${pairable} pairable surface(s) unreachable: ${overstated.slice(0, 6).join('; ')})`),
133
- ).toEqual([]);
134
-
135
- expect(
136
- served.length,
137
- req(ID, DOC, 'at least one non-well-known surface MUST be reachable under major 2, or the advertisement rests entirely on the one resource the header selects'),
138
- ).toBeGreaterThan(0);
139
- });
140
- });
@@ -1,93 +0,0 @@
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
- });
@@ -1,79 +0,0 @@
1
- /**
2
- * `spec/v2/core/events.md` §SSE frames + `identity.md` §5 — every `data:` frame
3
- * on the major-2 run stream names the run by its tenant-bound id (suite 2.0.0,
4
- * target major 2; unaided).
5
- *
6
- * This scenario exists because both production hosts shipped the same defect
7
- * and the suite could not see it. Each built one projection seam for the
8
- * tenant-bound `runId` and each found an emitter outside it on the stream
9
- * path: one host's SSE route had three `res.write` sites and no per-frame
10
- * projector at all; the other's per-frame path projected while its `batch`
11
- * flush wrote the raw array. Sixteen green mount tests on one host all used
12
- * `res.json` handlers. Of the 56 `v2-*` files, none read a stream frame —
13
- * `grep -E '^v2-.*(sse|stream)'` was empty — so both defects were invisible to
14
- * the suite by construction and were found by a live witness and by a peer's
15
- * report. A tier-1 host asked for this file by name.
16
- *
17
- * The assertion is one line: every frame's `runId` is the bound id the create
18
- * returned. It is asserted per frame rather than on the first, because the
19
- * batch-flush variant projects the first frame and not the rest.
20
- *
21
- * @see spec/v2/core/events.md §SSE frames
22
- * @see spec/v2/core/identity.md §5
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 { streamEvents } from '../lib/era2-seed.js';
29
- import { readErrorCode } from '../lib/error-envelope.js';
30
- import { softSkip } from '../lib/soft-skip.js';
31
- import { req } from '../lib/requirement-ids.js';
32
-
33
- const ID = 'openwop.requirement.0171.stream-sse-projection';
34
- const DOC = 'spec/v2/core/events.md §SSE frames';
35
- const NOOP_WORKFLOW_ID = 'conformance-noop';
36
- const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
37
-
38
- async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
39
- try { return await fn(); } catch { return null; }
40
- }
41
-
42
- async function createRun(): Promise<{ runId: string } | { reason: string }> {
43
- try { if (!(await v2Discovery())) return { reason: 'v2 discovery unreachable' }; } catch { return { reason: 'v2 discovery unreachable' }; }
44
- const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
45
- if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
46
- const runId = (res.json as { runId?: unknown } | undefined)?.runId;
47
- 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() };
48
- return { runId };
49
- }
50
-
51
- describe('v2 stream-sse-projection (events.md §SSE frames)', () => {
52
- it('every data: frame on the major-2 stream carries the tenant-bound runId of the run it belongs to', async () => {
53
- const c = await createRun();
54
- if ('reason' in c) return softSkip('blocked', c.reason);
55
- expect(RUN_ID.test(c.runId), req(ID, 'spec/v2/core/identity.md §5', `the created runId MUST be tenant-bound before the stream can be held to it (got ${c.runId})`)).toBe(true);
56
-
57
- const s = await streamEvents(c.runId);
58
- if (s === null) return softSkip('blocked', 'GET /runs/{runId}/events (SSE, OpenWOP-Version: 2.0) unreachable (fetch failed)');
59
- if (s.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events (SSE) answered ${s.status} for the run just created`);
60
- if (s.events.length === 0) return softSkip('blocked', 'the stream delivered no data: frames within the window — nothing to hold to the grammar');
61
-
62
- const validate = v2Validator('run-event');
63
- let i = 0;
64
- for (const ev of s.events) {
65
- i += 1;
66
- const rid = (ev as { runId?: unknown }).runId;
67
- expect(
68
- typeof rid === 'string' && RUN_ID.test(rid),
69
- req(ID, DOC, `frame ${i} (${String((ev as { type?: unknown }).type)}): data.runId MUST be tenant-bound <tenantId>/<opaque> (identity.md §5) — a stream frame is a rendering of the run event and every rendering of a v2 runId uses the same grammar; a bare storage id here is the projection seam missing the stream path (got ${JSON.stringify(rid)})`),
70
- ).toBe(true);
71
- expect(
72
- rid,
73
- req(ID, DOC, `frame ${i}: data.runId MUST be the run's own bound id — asserted on EVERY frame, not the first, because a batch flush that skips the per-frame projector projects frame 1 and not the rest`),
74
- ).toBe(c.runId);
75
- const r = validate(ev);
76
- expect(r.ok, req(ID, DOC, `frame ${i}: data MUST be a valid RunEventDoc (run-event.schema.json) — ${r.errors}`)).toBe(true);
77
- }
78
- });
79
- });
@@ -1,124 +0,0 @@
1
- /**
2
- * RFC 0172 §A.3 — the `OpenWOP-Version` header is HONORED or REFUSED, never
3
- * ignored (suite 2.0.0, target major 2; unaided).
4
- *
5
- * A host that does not implement v2 has two correct answers to
6
- * `OpenWOP-Version: 2`: serve the v2 representation, or refuse with `406
7
- * protocol_version_unsupported`. There is a third thing it can do, and it is
8
- * the dangerous one — return `200` with the v1 document, ignoring the header
9
- * entirely.
10
- *
11
- * That third case is invisible to every presence-gated scenario in the suite.
12
- * A scenario that skips when a v2 shape is absent records `inapplicable`
13
- * whether the host REFUSED major 2 or silently handed back v1; both are
14
- * non-failures, so a bundle looks clean while witnessing nothing. This is not
15
- * hypothetical: it is the shape that let RFC 0165 sit `Accepted` on a host
16
- * that served none of it — the acceptance cited merged PRs, the gated
17
- * scenarios recorded `inapplicable`, and nothing in the chain ever compared
18
- * the two representations. Measured on a live tier-1 host 2026-09-04:
19
- * `OpenWOP-Version: 2` returned `200` with a body byte-identical to the
20
- * header-less fetch. Credit to the openwop-app session for the probe.
21
- *
22
- * The check is cheap and needs no v2 support: fetch the resource twice, once
23
- * with the header and once without. A host advertising two majors MUST NOT
24
- * return the same bytes for both, because the two representations differ by
25
- * construction (`capabilities.md` §1). A host advertising one major MUST
26
- * refuse rather than ignore.
27
- *
28
- * @see spec/v2/core/versioning.md §1.3
29
- * @see RFCS/0172-v2-versioning-and-release.md §A.3
30
- */
31
-
32
- import { describe, it, expect } from 'vitest';
33
- import { loadEnv } from '../lib/env.js';
34
- import { softSkip } from '../lib/soft-skip.js';
35
- import { req } from '../lib/requirement-ids.js';
36
-
37
- const ID = 'openwop.requirement.0172.version-header-honored';
38
- const DOC = 'spec/v2/core/versioning.md §1.3';
39
- const PATH = '/.well-known/openwop';
40
-
41
- interface Fetched {
42
- readonly status: number;
43
- readonly body: string;
44
- readonly version: string | null;
45
- }
46
-
47
- async function fetchRepresentation(header: string | null): Promise<Fetched | null> {
48
- const { baseUrl, apiKey } = loadEnv();
49
- const headers: Record<string, string> = { Accept: 'application/json' };
50
- if (header !== null) headers['OpenWOP-Version'] = header;
51
- if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
52
- try {
53
- const res = await fetch(`${baseUrl.replace(/\/$/, '')}${PATH}`, { headers });
54
- return { status: res.status, body: await res.text(), version: res.headers.get('openwop-version') };
55
- } catch {
56
- return null;
57
- }
58
- }
59
-
60
- describe('v2-version-header-honored (RFC 0172 §A.3)', () => {
61
- it('OpenWOP-Version is honored or refused — never ignored', async () => {
62
- const bare = await fetchRepresentation(null);
63
- const asked = await fetchRepresentation('2.0');
64
- if (bare === null || asked === null) {
65
- return softSkip('blocked', `${PATH} unreachable for one of the two probes — the comparison needs both`);
66
- }
67
- if (bare.status !== 200) {
68
- return softSkip('blocked', `the header-less GET ${PATH} answered ${bare.status}; there is no baseline representation to compare against`);
69
- }
70
-
71
- // A refusal is a correct answer and ends the check: the host has told the
72
- // truth about not serving major 2.
73
- if (asked.status === 406) {
74
- expect(
75
- asked.status,
76
- req(ID, DOC, 'a host that does not serve major 2 MUST refuse with 406 protocol_version_unsupported, which it did'),
77
- ).toBe(406);
78
- return softSkip('inapplicable', 'the host refused major 2 with the specified 406 — a correct answer, and there is no second representation to compare bytes against');
79
- }
80
- if (asked.status !== 200) {
81
- return softSkip('blocked', `GET ${PATH} with OpenWOP-Version: 2.0 answered ${asked.status} — neither the v2 representation (200) nor the specified refusal (406), so this scenario cannot rule on it`);
82
- }
83
-
84
- // 200 means the host claims to have served major 2. The bytes decide
85
- // whether it actually did.
86
- expect(
87
- asked.body === bare.body,
88
- req(ID, DOC, `answering 200 to OpenWOP-Version: 2.0 with a body byte-identical to the header-less fetch means the header was IGNORED, not honored — the host served v1 and called it v2. Refusing with 406 is the correct answer for a host that does not implement major 2; silently returning v1 is the one answer that no presence-gated scenario can detect, because an absent v2 shape records \`inapplicable\` whether the host refused or ignored`),
89
- ).toBe(false);
90
- });
91
-
92
- it('a host advertising two majors serves two different representations', async () => {
93
- const bare = await fetchRepresentation(null);
94
- if (bare === null || bare.status !== 200) {
95
- return softSkip('blocked', `the header-less GET ${PATH} answered ${bare?.status ?? 'no response'}`);
96
- }
97
- let doc: Record<string, unknown>;
98
- try {
99
- doc = JSON.parse(bare.body) as Record<string, unknown>;
100
- } catch {
101
- return softSkip('blocked', `${PATH} did not return a JSON object`);
102
- }
103
- const versions = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).map(String) : [];
104
- const majors = new Set(versions.map((v) => v.split('.')[0]));
105
- if (!(majors.has('1') && majors.has('2'))) {
106
- return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — one major or none, so there is no second representation to differ from`);
107
- }
108
-
109
- const asked = await fetchRepresentation('2.0');
110
- if (asked === null) return softSkip('blocked', `${PATH} unreachable under OpenWOP-Version: 2.0`);
111
- expect(
112
- asked.status,
113
- req(ID, DOC, `a host advertising both majors MUST serve major 2 when asked for it, not refuse (got ${asked.status})`),
114
- ).toBe(200);
115
- expect(
116
- asked.body === bare.body,
117
- req(ID, DOC, 'the v1 document and the closed v2 root differ by construction (capabilities.md §1), so a host advertising both majors returning identical bytes for both has advertised a major it does not actually serve'),
118
- ).toBe(false);
119
- expect(
120
- asked.version?.trim().split('.')[0] ?? null,
121
- req(ID, DOC, `the response to OpenWOP-Version: 2.0 MUST carry OpenWOP-Version naming the major it served (got ${String(asked.version)})`),
122
- ).toBe('2');
123
- });
124
- });