@openwop/openwop-conformance 2.0.0-rc.2 → 2.0.0-rc.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +57 -9
- package/dist/lib/requirement-registry.js +20 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +279 -12
- package/scenario-majors.json +18 -3
- package/schemas/CORPUS-STAMP.json +18 -18
- package/src/cli.ts +55 -10
- package/src/global-setup.ts +11 -0
- package/src/lib/corpus-stamp.ts +24 -2
- package/src/lib/requirement-registry.ts +21 -0
- package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +90 -0
- package/src/scenarios/v2-advertised-path-space-served.test.ts +140 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +12 -3
- package/src/scenarios/v2-era-stamp-universal.test.ts +103 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/v2-version-header-honored.test.ts +124 -0
- package/src/scenarios/version-negotiation.test.ts +25 -3
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
});
|
|
@@ -76,7 +76,35 @@ describe('v2 dual-stack-negotiation (RFC 0172 §A.3–§A.4 — gated on two maj
|
|
|
76
76
|
const read = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, { headers: { 'OpenWOP-Version': '2.0' } }));
|
|
77
77
|
if (read === null) return softSkip('blocked', 'GET /runs/{runId} unreachable (fetch failed)');
|
|
78
78
|
expect(read.status, req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', 'the overlap: a run created through /v1/runs MUST be readable through GET /runs/{runId} with OpenWOP-Version: 2.0')).toBe(200);
|
|
79
|
-
|
|
79
|
+
// The v2 read MUST name the same run BY ITS TENANT-BOUND PROJECTION.
|
|
80
|
+
//
|
|
81
|
+
// This assertion has been wrong twice, in opposite directions, and the pair
|
|
82
|
+
// is the point:
|
|
83
|
+
//
|
|
84
|
+
// until 2026-09-04 `.toBe(runId)` — byte-equality with the v1 id, cited to
|
|
85
|
+
// versioning.md §5, which said nothing about identifiers.
|
|
86
|
+
// TIGHTER than its prose: it failed a host that had
|
|
87
|
+
// implemented identity.md §5 faithfully.
|
|
88
|
+
// rc.28 accepted the bare v1 id OR its projection. LOOSER than
|
|
89
|
+
// its prose: `run-snapshot.schema.json` binds `runId` to
|
|
90
|
+
// `ids.schema.json#/$defs/runId`, whose pattern REQUIRES
|
|
91
|
+
// the `/`. rc.28 passed a response the v2 contract
|
|
92
|
+
// rejects — the same defect it was written to fix,
|
|
93
|
+
// committed in the act of fixing it.
|
|
94
|
+
//
|
|
95
|
+
// §5 now states the rule outright, and the reason it is a MUST is not
|
|
96
|
+
// stylistic: `identity.md` §5 requires a host to refuse a tenant-bound id
|
|
97
|
+
// whose tenant segment is not the caller's (`403 id_tenant_mismatch`). A
|
|
98
|
+
// bare id HAS no tenant segment, so that mandatory check cannot run on it.
|
|
99
|
+
// A legacy unprefixed form would be a class of ids — precisely the ones
|
|
100
|
+
// carried over from v1 — exempt from major 2's tenant isolation.
|
|
101
|
+
//
|
|
102
|
+
// So: the projection, and only the projection. A bare id here is a finding.
|
|
103
|
+
const readId = (read.json as { runId?: unknown } | undefined)?.runId;
|
|
104
|
+
expect(
|
|
105
|
+
readId,
|
|
106
|
+
req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', `a run minted under major 1 MUST be named by its tenant-bound projection <tenantId>/${'${the v1 id}'} when read under major 2 — the bare id is not merely unconventional, it carries no tenant segment for the mandatory 403 id_tenant_mismatch check to read (identity.md §5), and ids.schema.json#/$defs/runId has no legacy branch. Got ${JSON.stringify(readId)} for a run created as ${JSON.stringify(runId)}`),
|
|
107
|
+
).toMatch(new RegExp(`^[A-Za-z0-9._~-]{1,128}/${runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`));
|
|
80
108
|
expect(major(read), req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §1.4', 'the v2 read MUST report the 2.x contract that produced it')).toBe('2');
|
|
81
109
|
});
|
|
82
110
|
|
|
@@ -61,13 +61,22 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
61
61
|
return softSkip('blocked', `GET /runs/${runId}/events/poll answered ${before?.status ?? 'no response'} on the seeded era-2 run — the log cannot be read back`);
|
|
62
62
|
}
|
|
63
63
|
const seedCount = eventsOf(before).length;
|
|
64
|
+
// The seam returning ok is a WRAPPER claim; the readable log is the artifact.
|
|
65
|
+
// A seam that reports success and seeds nothing leaves no era-2 log to append
|
|
66
|
+
// to, so there is nothing here to witness the writer rule with — that is
|
|
67
|
+
// `blocked`, not a writer-rule failure. Asserting against an empty log would
|
|
68
|
+
// charge this requirement for a seam defect, which is the misattribution the
|
|
69
|
+
// suite exists to avoid.
|
|
70
|
+
if (seedCount === 0) {
|
|
71
|
+
return softSkip('blocked', `seedEra2Log reported success but the log reads back empty (0 events) — the seam's return value is not evidence that a log exists, and without a seeded era-2 log the writer rule is unwitnessed here`);
|
|
72
|
+
}
|
|
64
73
|
|
|
65
74
|
// One canonical mutation so the HOST's own writer appends. Cancel is the
|
|
66
75
|
// universally available terminal transition; a host that refuses it on a
|
|
67
76
|
// seeded run records `blocked` rather than a pass.
|
|
68
|
-
const cancelled = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}
|
|
77
|
+
const cancelled = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
69
78
|
if (cancelled === null || (cancelled.status !== 200 && cancelled.status !== 202 && cancelled.status !== 204)) {
|
|
70
|
-
return softSkip('blocked', `POST /runs/{runId}
|
|
79
|
+
return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancelled?.status ?? 'no response'} on a seeded era-2 run — no canonical mutation drove the host's writer, so the append is unwitnessed`);
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
const after = await pollEvents(runId);
|
|
@@ -125,7 +134,7 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
125
134
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
126
135
|
const runId = seeded.runId;
|
|
127
136
|
|
|
128
|
-
await http(() => driver.post(`/runs/${encodeURIComponent(runId)}
|
|
137
|
+
await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
129
138
|
|
|
130
139
|
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
131
140
|
if (snap === null || snap.status !== 200) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A.7 / `spec/v2/core/persistence.md` §The era key — every creation
|
|
3
|
+
* path stamps the same era, and discovery advertises that one value (suite
|
|
4
|
+
* 2.0.0, target major 2; unaided).
|
|
5
|
+
*
|
|
6
|
+
* The era trichotomy (absent ⇒ `2`, `2` = v1 era, `3` = v2 era) is sound only
|
|
7
|
+
* because a v2 host stamps `3` on EVERY run it creates. A host with more than
|
|
8
|
+
* one creation path that leaves one unstamped produces runs indistinguishable
|
|
9
|
+
* from pre-cut runs, and every reader translates them as era `2` — a silent
|
|
10
|
+
* wrong read, not an error. A tier-2 host found exactly this shape in review:
|
|
11
|
+
* one creation path writing `2`, another writing nothing, and discovery
|
|
12
|
+
* advertising `1`, a value no path wrote.
|
|
13
|
+
*
|
|
14
|
+
* This is witnessable unaided because the host's own discovery document states
|
|
15
|
+
* the value it claims to write, and a run created through the canonical path
|
|
16
|
+
* states what it actually wrote. A disagreement between them is the defect.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/persistence.md §The era key
|
|
19
|
+
* @see RFCS/0176-v2-persisted-data-and-coexistence.md §A
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
24
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { req } from '../lib/requirement-ids.js';
|
|
27
|
+
|
|
28
|
+
const ID = 'openwop.requirement.0176.era-stamp-universal';
|
|
29
|
+
const DOC = 'spec/v2/core/persistence.md §The era key';
|
|
30
|
+
|
|
31
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('v2-era-stamp-universal (RFC 0176 §A.7)', () => {
|
|
40
|
+
it('discovery advertises an era the host actually writes, and a new run carries it', async () => {
|
|
41
|
+
const doc = await v2Discovery().catch(() => null);
|
|
42
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
43
|
+
|
|
44
|
+
const advertised = doc['eventLogSchemaVersion'];
|
|
45
|
+
if (advertised === undefined) {
|
|
46
|
+
return softSkip('inapplicable', 'the host advertises no eventLogSchemaVersion — the era axis is undeclared, so there is no claim to falsify here');
|
|
47
|
+
}
|
|
48
|
+
expect(
|
|
49
|
+
typeof advertised === 'number' && Number.isInteger(advertised),
|
|
50
|
+
req(ID, DOC, `eventLogSchemaVersion is the era key and MUST be an integer (got ${JSON.stringify(advertised)})`),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
|
|
53
|
+
const created = await http(() => driver.post('/runs', { workflowId: 'conformance-noop', inputs: {} }));
|
|
54
|
+
if (created === null || created.status !== 201) {
|
|
55
|
+
return softSkip('blocked', `POST /runs answered ${created?.status ?? 'no response'} — a run the host created is what states the era it actually writes`);
|
|
56
|
+
}
|
|
57
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
58
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'POST /runs returned no runId');
|
|
59
|
+
|
|
60
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
61
|
+
if (snap === null || snap.status !== 200) {
|
|
62
|
+
return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} — the snapshot is where the written era is reported`);
|
|
63
|
+
}
|
|
64
|
+
const written = (snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion;
|
|
65
|
+
|
|
66
|
+
// The snapshot field is REQUIRED on the wire and may be synthesized for an
|
|
67
|
+
// era-2 run, but a run this host just created is not era 2 — it is whatever
|
|
68
|
+
// the host writes for new runs, which is the value discovery names.
|
|
69
|
+
expect(
|
|
70
|
+
written,
|
|
71
|
+
req(ID, DOC, `the run snapshot MUST carry the era key — it is required on the wire, synthesized from absent-⇒-2 for historical runs and stated outright for a run the host just created (got ${JSON.stringify(written)})`),
|
|
72
|
+
).not.toBeUndefined();
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
written,
|
|
76
|
+
req(ID, DOC, `discovery MUST advertise the value the host writes for new runs and nothing else: discovery says ${JSON.stringify(advertised)}, the run it just created says ${JSON.stringify(written)}. A host whose creation paths disagree has no single value to advertise, and whatever it publishes is false for some of its own runs`),
|
|
77
|
+
).toBe(advertised);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a v2-era host stamps 3, so its new runs are never read as the v1 era', async () => {
|
|
81
|
+
const doc = await v2Discovery().catch(() => null);
|
|
82
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
83
|
+
const advertised = doc['eventLogSchemaVersion'];
|
|
84
|
+
if (advertised === undefined) return softSkip('inapplicable', 'the host advertises no eventLogSchemaVersion');
|
|
85
|
+
if (advertised !== 3) {
|
|
86
|
+
return softSkip('inapplicable', `the host advertises era ${JSON.stringify(advertised)}, not 3 — this scenario checks that the advertised value AGREES with what the creation paths write, whatever that value is. Whether it MUST be 3 is v2-era-key's assertion, not this one; a host reached under target major 2 is a v2 host and persistence.md requires 3 there`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const created = await http(() => driver.post('/runs', { workflowId: 'conformance-noop', inputs: {} }));
|
|
90
|
+
if (created === null || created.status !== 201) {
|
|
91
|
+
return softSkip('blocked', `POST /runs answered ${created?.status ?? 'no response'}`);
|
|
92
|
+
}
|
|
93
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
94
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'POST /runs returned no runId');
|
|
95
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
96
|
+
if (snap === null || snap.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'}`);
|
|
97
|
+
|
|
98
|
+
expect(
|
|
99
|
+
(snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion,
|
|
100
|
+
req(ID, DOC, 'a host advertising era 3 MUST stamp 3 on every run it creates — an unstamped run made after the cut is indistinguishable from a pre-cut run and every reader translates it as era 2, which is a silent wrong read rather than an error'),
|
|
101
|
+
).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -40,7 +40,7 @@ describe('v2 interrupt-token-scheme (RFC 0170 §E.1)', () => {
|
|
|
40
40
|
if (res === null) return softSkip('blocked', 'GET /interrupts/{token} unreachable (fetch failed)');
|
|
41
41
|
expect([401, 404].includes(res.status), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', DOC, `a token outside the ow2.<alg>.<kid>.<payload>.<mac> grammar MUST NOT resolve — 401 interrupt_token_invalid (or 404 not_found where the signed-token surface is not mounted); got ${res.status}`)).toBe(true);
|
|
42
42
|
const code = readErrorCode(res.json);
|
|
43
|
-
expect(res.status === 401 ? code === 'interrupt_token_invalid' : code === 'not_found', req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry
|
|
43
|
+
expect(res.status === 401 ? code === 'interrupt_token_invalid' : (code === 'not_found' || code === 'interrupt_not_found'), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry a registered code (401 → interrupt_token_invalid; 404 → not_found OR the more precise interrupt_not_found, both registered in spec/v2/errors.json for this state — a scenario narrower than its own registry fails the host that answers more precisely); got ${String(code)}`)).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('a well-formed token under a kid the host does not hold is 401 interrupt_token_invalid', async () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
});
|
|
@@ -7,9 +7,31 @@
|
|
|
7
7
|
*
|
|
8
8
|
* What we CAN test cheaply:
|
|
9
9
|
* 1. Server advertises a `protocolVersion` in `Capabilities`.
|
|
10
|
-
* 2.
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
10
|
+
* 2. `protocolVersion` is advertised, and every event carries the six
|
|
11
|
+
* required `RunEventDoc` fields.
|
|
12
|
+
*
|
|
13
|
+
* This file previously claimed to check "the four version axes
|
|
14
|
+
* (`engineVersion`, `eventLogSchemaVersion`, per-event `schemaVersion`,
|
|
15
|
+
* `pinnedVersions`)". IT DID NOT. `protocolVersion` was the only axis
|
|
16
|
+
* asserted, and across all 444 v1 scenario files the sole occurrence of the
|
|
17
|
+
* identifier `eventLogSchemaVersion` was that sentence — a docstring
|
|
18
|
+
* describing a check that did not exist. A comment claiming coverage is
|
|
19
|
+
* worse than no comment: it answers "is this tested?" for anyone who greps,
|
|
20
|
+
* and answers it wrongly.
|
|
21
|
+
*
|
|
22
|
+
* Current state of the four, stated so this comment can be checked rather
|
|
23
|
+
* than trusted: `eventLogSchemaVersion` and `engineVersion` are witnessed by
|
|
24
|
+
* `era-key-stamped-v1.test.ts` (both are run-document `MUST`s in
|
|
25
|
+
* `version-negotiation.md` §Stamping, and both were unasserted until
|
|
26
|
+
* 2026-09-04). Per-event `schemaVersion` and `pinnedVersions` are **not
|
|
27
|
+
* asserted here and carry no `MUST` in that document** — checked, rather
|
|
28
|
+
* than assumed to be a gap.
|
|
29
|
+
*
|
|
30
|
+
* This paragraph was itself wrong for one release candidate: it said
|
|
31
|
+
* `engineVersion` "remains UNASSERTED" after the leg asserting it had
|
|
32
|
+
* landed. A docstring that describes coverage goes stale the moment
|
|
33
|
+
* coverage changes, which is the argument for stating what can be
|
|
34
|
+
* re-derived rather than what was true once.
|
|
13
35
|
* 3. Forward-compat read: events carrying an UNKNOWN
|
|
14
36
|
* `schemaVersion` SHOULD still be readable via the events/poll
|
|
15
37
|
* endpoint without 5xx (best-effort fold per
|