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

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.
@@ -75,17 +75,6 @@ export function setup(): void {
75
75
  // Suite 2.0.0: in the published layout the contract is the spec-artifacts peer (RFC 0168 §D.2).
76
76
  const stamp = LAYOUT === 'published' ? verifyPeerContract(PKG_ROOT_PATH) : verifyCorpusStamp(PKG_ROOT_PATH, LAYOUT);
77
77
  process.stderr.write(`${describeVerdict(stamp)}\n`);
78
- // A version skew and a digest mismatch both have to refuse, but they are
79
- // different faults with different fixes, and reporting them in the same words
80
- // sends the reader to debug a corrupt install when nothing is corrupt.
81
- if (stamp.kind === 'peer-version') {
82
- throw new Error(
83
- `openwop-conformance: refusing to run — this suite was packed against @openwop/spec-artifacts@${stamp.lockVersion}, ` +
84
- `but @openwop/spec-artifacts@${stamp.peerVersion} is installed. They are declared EXACT peers. ` +
85
- `Install both at the same explicit version; do NOT install at a dist-tag such as \`next\`, which moves per package ` +
86
- `and can name a pair that was never published together.`,
87
- );
88
- }
89
78
  if (stamp.kind === 'mismatch') {
90
79
  throw new Error('openwop-conformance: refusing to run — schemas/CORPUS-STAMP.json digests do not match the vendored api/ + schemas/ files. Reinstall the package; do not hand-patch vendored contract files.');
91
80
  }
@@ -39,17 +39,7 @@ export interface CorpusStamp {
39
39
  export type StampVerdict =
40
40
  | { readonly kind: 'verified'; readonly files: number }
41
41
  | { readonly kind: 'not-applicable'; readonly reason: string }
42
- | { readonly kind: 'mismatch'; readonly missing: readonly string[]; readonly altered: readonly string[]; readonly extra: readonly string[] }
43
- /**
44
- * The peer is INSTALLED and INTACT but is a different version than the suite
45
- * was packed against. Its own kind because the remedy is completely different
46
- * from a digest mismatch — nothing is corrupt, two versions are simply out of
47
- * step — and because the generic message sends readers to debug a broken
48
- * install. Reported by a tier-2 host that hit it through the `next` dist-tag:
49
- * the tag moves per package, so `@next` can name an exact-peer PAIR that was
50
- * never published together.
51
- */
52
- | { readonly kind: 'peer-version'; readonly peerVersion: string; readonly lockVersion: string };
42
+ | { readonly kind: 'mismatch'; readonly missing: readonly string[]; readonly altered: readonly string[]; readonly extra: readonly string[] };
53
43
 
54
44
  export const STAMP_RELATIVE_PATH = join('schemas', 'CORPUS-STAMP.json');
55
45
 
@@ -104,11 +94,7 @@ export function verifyPeerContract(pkgRoot: string): StampVerdict {
104
94
  if (!existsSync(stampPath)) return { kind: 'mismatch', missing: ['@openwop/spec-artifacts/CORPUS-STAMP.json'], altered: [], extra: [] };
105
95
  const stamp = JSON.parse(readFileSync(stampPath, 'utf8')) as { package: string; version: string; files: Record<string, string> };
106
96
  const digest = createHash('sha256').update(JSON.stringify({ package: stamp.package, version: stamp.version, files: stamp.files })).digest('hex');
107
- // A plain version difference is NOT corruption; report it as itself so the
108
- // message names the two versions and the fix, instead of sending the reader
109
- // to hunt a damaged install.
110
- if (stamp.version !== lock.version) return { kind: 'peer-version', peerVersion: stamp.version, lockVersion: lock.version };
111
- if (digest !== lock.stampSha256) return { kind: 'mismatch', missing: [], altered: [`@openwop/spec-artifacts ${stamp.version} stamp digest ${digest.slice(0, 12)} ≠ the suite's lock ${lock.stampSha256.slice(0, 12)} — same version, different contents`], extra: [] };
97
+ if (stamp.version !== lock.version || digest !== lock.stampSha256) return { kind: 'mismatch', missing: [], altered: [`@openwop/spec-artifacts ${stamp.version} (digest ${digest.slice(0, 12)}) ≠ the suite's lock ${lock.version} (${lock.stampSha256.slice(0, 12)})`], extra: [] };
112
98
  const missing: string[] = []; const altered: string[] = [];
113
99
  for (const [rel, d] of Object.entries(stamp.files)) { const p = join(peerRoot, ...rel.split('/')); if (!existsSync(p)) missing.push(rel); else if (sha256File(p) !== d) altered.push(rel); }
114
100
  if (missing.length || altered.length) return { kind: 'mismatch', missing, altered, extra: [] };
@@ -157,14 +143,6 @@ export function describeVerdict(v: StampVerdict): string {
157
143
  return `[openwop-conformance] corpus stamp VERIFIED — ${v.files} vendored api/ + schemas/ files match their SHA-256 digests`;
158
144
  case 'not-applicable':
159
145
  return `[openwop-conformance] corpus stamp not checked — ${v.reason}`;
160
- case 'peer-version':
161
- return (
162
- `[openwop-conformance] peer version MISMATCH — this suite was packed against ` +
163
- `@openwop/spec-artifacts@${v.lockVersion} but @openwop/spec-artifacts@${v.peerVersion} is installed. ` +
164
- `Nothing is corrupt: the two are declared EXACT peers and are simply out of step. ` +
165
- `Install both at the same explicit version — never at a dist-tag such as \`next\`, which moves per package ` +
166
- `and can therefore name a pair that was never published together.`
167
- );
168
146
  case 'mismatch':
169
147
  return (
170
148
  `[openwop-conformance] corpus stamp MISMATCH — the vendored contract is not the one this suite shipped ` +
@@ -3,42 +3,40 @@
3
3
  * the host must be able to honour (suite 2.0.0, target major 2; unaided).
4
4
  *
5
5
  * `fixtures[]` in discovery gates scenarios: `isFixtureAdvertised(id)` decides
6
- * whether a scenario runs at all. So a host whose advertised list and seeded set
7
- * drift apart fails somewhere else entirely — the scenario gated on the missing
8
- * fixture attempts, fails on a run that cannot be created, and the failure is
9
- * attributed to that scenario's requirement rather than to the advertisement
10
- * that was wrong. That misattribution is what this scenario exists to catch.
6
+ * whether a scenario runs at all. Nothing checked that an advertised id names a
7
+ * fixture the corpus actually defines, or that the host can serve it. A host
8
+ * whose advertised list and seeded set drift apart therefore fails somewhere
9
+ * else entirely — the scenario gated on the missing fixture attempts, fails on
10
+ * a run that cannot be created, and the failure is attributed to that
11
+ * scenario's requirement rather than to the advertisement that was wrong.
11
12
  *
12
- * **This scenario shipped with a second leg that was wrong, and the correction
13
- * matters more than the check.** That leg asserted the advertised ids are a
14
- * SUBSET of `conformance/fixtures/` — "the vocabulary is closed, so an id the
15
- * corpus does not define is a typo or an invention". The vocabulary is not
16
- * closed. Host-supplied fixtures are the normal case: dozens of ids the
17
- * scenarios gate on are deliberately not shipped, and `v2-approver-enforced`
18
- * says so in its own docstring — it needs an approval fixture whose
19
- * `approversList` names a principal the suite is not, and records `blocked`
20
- * naming it precisely because "no such fixture ships in `conformance/fixtures/`".
13
+ * No host is currently known to exhibit this. A tier-2 host was thought to
14
+ * (46 seeded against 47 advertised) and then verified and retracted it — the
15
+ * count had been eyeballed from an array literal rather than measured, and the
16
+ * two sets are in fact identical. The scenario is kept because the failure mode
17
+ * is a property of the gating mechanism, not of that host: `fixtures[]` decides
18
+ * whether a scenario runs, so a wrong advertisement is charged to whatever runs
19
+ * next. Leg 1 is also the stronger check — an id the corpus catalog does not
20
+ * define is wrong however well a host's own two lists agree with each other.
21
21
  *
22
- * So the leg failed a host for doing exactly what the corpus asks. It was found
23
- * by running the suite against the reference host, which advertised two
24
- * host-supplied fixtures and was marked non-conformant for it. Set membership
25
- * cannot distinguish a typo from a legitimate host fixture, and a check that
26
- * cannot tell those apart is not a check — it is a coin flip that happens to
27
- * land on "fail" for correct hosts.
28
- *
29
- * What survives is the leg that was always sound: an advertised fixture MUST be
30
- * creatable. That holds whoever defines it, and it is the one that catches the
31
- * drift the misattribution comes from.
22
+ * Two legs, both cheap:
23
+ * 1. the advertised ids are a subset of the corpus fixture catalog — the
24
+ * vocabulary is closed, so an id the corpus does not define is a typo or
25
+ * an invention, not a capability;
26
+ * 2. a bounded sample of advertised fixtures is actually creatable, so the
27
+ * list is a claim about reachable state rather than a wish.
32
28
  *
33
29
  * @see spec/v2/core/conformance.md
34
- * @see conformance/src/scenarios/v2-approver-enforced.test.ts (a host-supplied fixture, by design)
30
+ * @see conformance/fixtures.md
35
31
  */
36
32
 
37
33
  import { describe, it, expect } from 'vitest';
34
+ import { existsSync, readdirSync } from 'node:fs';
38
35
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
39
36
  import { v2Discovery } from '../lib/v2.js';
40
37
  import { softSkip } from '../lib/soft-skip.js';
41
38
  import { req } from '../lib/requirement-ids.js';
39
+ import { FIXTURES_DIR } from '../lib/paths.js';
42
40
 
43
41
  const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
44
42
  const DOC = 'spec/v2/core/conformance.md §Fixtures';
@@ -59,6 +57,26 @@ function advertisedIds(doc: Record<string, unknown>): string[] {
59
57
  }
60
58
 
61
59
  describe('v2-advertised-fixtures-exist (conformance.md §Fixtures)', () => {
60
+ it('every advertised fixture id is one the corpus defines', async () => {
61
+ const doc = await v2Discovery().catch(() => null);
62
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
63
+ const ids = advertisedIds(doc);
64
+ if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[] — there is no claim to falsify');
65
+ if (FIXTURES_DIR === null || !existsSync(FIXTURES_DIR)) {
66
+ return softSkip('blocked', 'the fixture catalog is absent from this layout, so an advertised id cannot be checked against it');
67
+ }
68
+ const catalog = new Set(
69
+ readdirSync(FIXTURES_DIR)
70
+ .filter((f) => f.endsWith('.json'))
71
+ .map((f) => f.replace(/\.json$/, '')),
72
+ );
73
+ const unknown = ids.filter((id) => !catalog.has(id));
74
+ expect(
75
+ unknown,
76
+ req(ID, DOC, `every id in fixtures[] MUST name a fixture the corpus defines — the vocabulary is closed, so an id the catalog does not carry is a typo or an invention rather than a capability (${unknown.length} unknown of ${ids.length}: ${unknown.slice(0, 5).join(', ')})`),
77
+ ).toEqual([]);
78
+ });
79
+
62
80
  it('a sampled advertised fixture is actually creatable, not just listed', async () => {
63
81
  const doc = await v2Discovery().catch(() => null);
64
82
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
@@ -76,35 +76,7 @@ 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
- // 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, '\\$&')}$`));
79
+ expect((read.json as { runId?: unknown } | undefined)?.runId, req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', 'the v2 read MUST name the same run')).toBe(runId);
108
80
  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');
109
81
  });
110
82
 
@@ -61,15 +61,6 @@ 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
- }
73
64
 
74
65
  // One canonical mutation so the HOST's own writer appends. Cancel is the
75
66
  // universally available terminal transition; a host that refuses it on a
@@ -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' || 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);
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 its registered code (401 → interrupt_token_invalid, 404 → not_found); 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 () => {
@@ -7,31 +7,9 @@
7
7
  *
8
8
  * What we CAN test cheaply:
9
9
  * 1. Server advertises a `protocolVersion` in `Capabilities`.
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.
10
+ * 2. The four version axes (`engineVersion`,
11
+ * `eventLogSchemaVersion`, per-event `schemaVersion`,
12
+ * `pinnedVersions`) appear where the spec says they should.
35
13
  * 3. Forward-compat read: events carrying an UNKNOWN
36
14
  * `schemaVersion` SHOULD still be readable via the events/poll
37
15
  * endpoint without 5xx (best-effort fold per
package/src/setup.ts CHANGED
@@ -40,8 +40,6 @@ import { softSkipDisposition } from './lib/soft-skip.js';
40
40
  import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
41
41
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
42
42
  import type { DiscoveryPayload } from './lib/profiles.js';
43
- import { targetMajor } from './lib/seams.js';
44
- import { softSkip } from './lib/soft-skip.js';
45
43
 
46
44
  const SUITE_INIT_TIMEOUT_MS = 5_000;
47
45
 
@@ -279,62 +277,6 @@ function _fileOf(task: { file?: { filepath?: string; name?: string } } | undefin
279
277
  const f = task?.file?.filepath ?? task?.file?.name;
280
278
  return typeof f === 'string' && f.length > 0 ? basename(f) : null;
281
279
  }
282
- // ---------------------------------------------------------------------------
283
- // The applicability check and the assertion MUST run under the same contract.
284
- //
285
- // A scenario's registration in scenario-majors.json says which target majors it
286
- // is written for. The driver reads OPENWOP_TARGET_MAJOR to decide which header
287
- // and path space every probe uses. Nothing connected the two: a lane that ran
288
- // vitest directly over all 501 files at the default (major 1) executed every
289
- // major-2 scenario with major-1 requests. The scenarios' own gates call
290
- // v2Discovery(), which sets the header EXPLICITLY, so the gate passed and the
291
- // probe went out as v1 — a check that proved the host speaks v2 with one
292
- // request, then tested a v2 requirement with a request that did not.
293
- //
294
- // Measured on a tier-1 host 2026-09-04: three "host defects" reported from that
295
- // lane, all of which evaporated at major 2; the host was one edit from "fixing"
296
- // correct behaviour. And in the other direction: 4 of 56 v2 files fail on every
297
- // host forever under a major-1 driver, so a gate that runs them that way is red
298
- // by construction and gets reasoned past. Both are the same defect: a scoped
299
- // signal read as unscoped, with the scope nowhere in the output.
300
- //
301
- // So: a file whose registered majors do not include the driver's target major
302
- // is INAPPLICABLE to this lane, recorded as such with the reason, and never
303
- // probes. Files not in the registry (coherence checks, lib tests) are untouched.
304
- // ---------------------------------------------------------------------------
305
- const SCENARIO_MAJORS: Record<string, number[]> = (() => {
306
- try {
307
- const p = join(PKG_ROOT_PATH, 'scenario-majors.json');
308
- if (!existsSync(p)) return {};
309
- return (JSON.parse(readFileSync(p, 'utf8')) as { majors?: Record<string, number[]> }).majors ?? {};
310
- } catch {
311
- return {};
312
- }
313
- })();
314
-
315
- beforeEach((ctx) => {
316
- const p = (expect.getState() as { testPath?: string }).testPath;
317
- if (!p) return;
318
- const file = basename(p);
319
- const majors = SCENARIO_MAJORS[file];
320
- if (!majors) return;
321
- const lane = targetMajor();
322
- if (majors.includes(lane)) return;
323
- const detail = `registered for target major ${majors.join('/')} and this lane runs at major ${lane} (OPENWOP_TARGET_MAJOR) — the probe would go out under a contract the scenario's gate does not use; select files with --target-major or set the variable`;
324
- // Two records, one per resolver. The per-TEST disposition is read from the
325
- // requirement journal (the same entry behaviorGate writes), so the row lands
326
- // as `inapplicable` with this reason rather than `skipped`, which under RFC
327
- // 0148 would claim the operator opted out. The per-FILE note covers the
328
- // all-skipped fallback in resolveFileRecord.
329
- try {
330
- recordRequirement('openwop.family.lane-target-major', 'inapplicable', detail, { scenarioFile: file });
331
- } catch {
332
- /* never fail a test for bookkeeping */
333
- }
334
- softSkip('inapplicable', detail);
335
- ctx.skip();
336
- });
337
-
338
280
  beforeAll(({}, suite) => {
339
281
  // Mark the ledger journal BEFORE the file's tests run, so a behaviorGate
340
282
  // decision made by the very first test is inside the file's window.
@@ -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
- });