@openwop/openwop-conformance 2.0.0-rc.2 → 2.0.0-rc.28

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,7 +1,7 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.0.0-rc.2",
4
+ "version": "2.0.0-rc.28",
5
5
  "corpusTag": null,
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
@@ -9,9 +9,9 @@
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "7997be82785d963789a452f76455dd91f51a03c227e83706325a8b67d3d16aeb",
13
- "api/v2/asyncapi.yaml": "aeed4cbeb832cfcd4de46639a2b5a8981406d307b73e3a9d11a645eb0726caff",
14
- "api/v2/openapi.yaml": "cbbf5df16473902c3c36b5b048ba80b5b35336c06fd48c8ba32f4b1858dd1807",
12
+ "api/seams-v2.yaml": "d92f0832250f7e2943304c3ebb3b4ac4d94a627a5f275a8cd29f608cae48f920",
13
+ "api/v2/asyncapi.yaml": "917d25c0c03d418b43b778423182de1aaa2f7b4f30a175bdeab3b7d3ffdb894a",
14
+ "api/v2/openapi.yaml": "ccd9e6a5dd8084c6d6c0959157f447da96fc7493cad2479abbf97b69483f9404",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -31,8 +31,8 @@
31
31
  "schemas/artifact-type-pack-manifest.schema.json": "13932ea58e819e01b3a0cc7a65590b70683dd5fe9208fc06c64c339943d3c4ce",
32
32
  "schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
33
33
  "schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
34
- "schemas/capabilities.schema.json": "e672c35d503532d8039b5552115f5ec150d60317f537f13180d830b0fe315947",
35
- "schemas/certification-bundle-v2.schema.json": "3540232676086869ae78d24f3b9c2154f9c4a8a5eeb976512d6a7409f8cf19bb",
34
+ "schemas/capabilities.schema.json": "b413e0235cad412081df09234237e0f322a85903b7411b91aaff54032e946671",
35
+ "schemas/certification-bundle-v2.schema.json": "2f85530dd04824a15810c94e96869362dee1e78396eca21fdd18e5501416bb7d",
36
36
  "schemas/channel-presence-payload.schema.json": "f9d0ef4219814c2b765baf3e68cbe5eea2cb42379f516a93a0459dcd5f7db47e",
37
37
  "schemas/channel-written-payload.schema.json": "54723a56bc5a35232f64544ca686ac1ac959a5fb05b32b2401facca6b3ef51a3",
38
38
  "schemas/chat-card-pack-manifest.schema.json": "402f7e9081cc39857a52e772061f4e034a957dcbc17501fe6e8e0bcb3d337e42",
@@ -86,7 +86,7 @@
86
86
  "schemas/run-event.schema.json": "c70c06932b9831295042836213e617c8e946f20ddd49cf4e889b74d89d2175a8",
87
87
  "schemas/run-options.schema.json": "f8241ece6ddc6527de9160d811d42c231b4f77b935f2e107b3c05608545c59ab",
88
88
  "schemas/run-orchestrator-decided-event.schema.json": "17a43a3c44680a24d411141357a867b3caed6107d56de88fe2e062eaf93fa2c1",
89
- "schemas/run-snapshot.schema.json": "8a45f997beec0a2fa596f4631129a869cfbc37c9ad38583530ed11655301d2ab",
89
+ "schemas/run-snapshot.schema.json": "3343550f6973a0975c61624df569d341e843ac8592a2aa2b3b7cf540d72563ad",
90
90
  "schemas/security-advisory.schema.json": "9c550f0bf10a3193dcca4a127081fb66fe5dc1b6f729c4c17bfaea3ba3ce05fc",
91
91
  "schemas/self-hosted-runner-dispatch-frame.schema.json": "3f1d7edd572c6747657299525d8e85329b93b6fadf670e3a19c74d53e725adec",
92
92
  "schemas/self-hosted-runner-registration.schema.json": "6c781adf1bb915a9e47796494f4a527353cbbf72b480e5a05be4aac65a6d0c6e",
@@ -115,8 +115,8 @@
115
115
  "schemas/v2/artifact-type-pack-manifest.schema.json": "c911757562e521cb4f31a47c097f562573b64087efe60c8acb81fd352367d300",
116
116
  "schemas/v2/audit-verify-result.schema.json": "cb54c4d89ba5dfb73f49aa8959a980be65c2dc2a87a12f71ba36d3e836fe85da",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
- "schemas/v2/capabilities.schema.json": "b6191deddf6bee19470eee9a45bb520de0d17385c195de6e3b8c5cb6d9520238",
119
- "schemas/v2/certification-bundle.schema.json": "02aa15f17644d902a9df06a99cfdaf51273b0a9cca0d0ef515d5da03be01fb09",
118
+ "schemas/v2/capabilities.schema.json": "792669b2cc7b1fd44ce9a4fd3e79696ad1277734e29299a4504b39eda15913ed",
119
+ "schemas/v2/certification-bundle.schema.json": "d441198893c01cd14d84abe9c5bf591b69f47554db749c01899512f36cea11fa",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "9475d46f358d21c19126e78204987a5633979917a2293653354ef5c5e044d54e",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
122
122
  "schemas/v2/chat-card-pack-manifest.schema.json": "566982ce509a96d1619d58a72e383eff0773fe43fc65e4bfc85a4fe158f4eaff",
@@ -149,7 +149,7 @@
149
149
  "schemas/v2/goal.schema.json": "d202ef21ed3dbff87c25fd4b553fa978862a3200999878d25c5918f6794b3060",
150
150
  "schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
151
151
  "schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
152
- "schemas/v2/ids.schema.json": "0ccf5eb3ec0744296b3379f7ced3b46ec51d908705a4f03dc4404d42236ebe19",
152
+ "schemas/v2/ids.schema.json": "dc6c99dea35a3416167b2b883ac08694794da1c7552bbfa48fc489c612d1cf97",
153
153
  "schemas/v2/localized-content-language-settings.schema.json": "92240f19a2b4b2f05ab41a797f703485a28813848035478e72f395edd04c1d35",
154
154
  "schemas/v2/localized-content-page-response.schema.json": "476b5f2775b42e35e679814a4ce846a7be1d5311d3a748c10df4275a78b0fb27",
155
155
  "schemas/v2/localized-content-page.schema.json": "717bb51fbc64d0b34eb0a4b35c8b463d08d505267bf251b4cbc67e0a016a567a",
@@ -169,11 +169,11 @@
169
169
  "schemas/v2/residency.schema.json": "af2658a7104cbcacd0e20a91226271c4a77f91ef46ae97612768fe743e6091d8",
170
170
  "schemas/v2/run-ancestry-response.schema.json": "907e36341ba83a2147a5d76963950b6dcd47506b663ecbccae9dfa553f3724d3",
171
171
  "schemas/v2/run-diff-response.schema.json": "6ab6cd265dc7e0a2086c6659c46aa0e7b43e6302949f0adac5d7deb1c7c0ecd3",
172
- "schemas/v2/run-event-payloads.schema.json": "8eb0e05e1bd3399b0e2654a1ec5a13198ac4ec8459a1fcbfd800ab2d0a0ae8c6",
173
- "schemas/v2/run-event.schema.json": "4ec8eb048a79ca1343e00aa018acbf86814b5eda6768add6b17e8d7b83638fdf",
172
+ "schemas/v2/run-event-payloads.schema.json": "bd5cb6687828a86fb4411d331893ec183dce2c52cb27c22035d4cd7a8bc3c1a2",
173
+ "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
174
174
  "schemas/v2/run-options.schema.json": "6d6052596ab0f17acfb00fdfc89907da7724f952e5ffbe1f81d94ba23d4c77cd",
175
175
  "schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
176
- "schemas/v2/run-snapshot.schema.json": "e77bf491d11c1414e62ed84cbf0c1d7149279b133a2101dc8f5eec89d3e45204",
176
+ "schemas/v2/run-snapshot.schema.json": "01408d8dac93e2d08d8db30f9cd7a4d903dce882a60bc1d48c3e6b921267f2a3",
177
177
  "schemas/v2/security-advisory.schema.json": "6a382f737730a738d634266315e9d335e5760020a0cdd9f30727c2f987a01770",
178
178
  "schemas/v2/self-hosted-runner-dispatch-frame.schema.json": "d8129f11d4e731d17ea951e66faaea2086081d13958b62ceaaa359fd6637dd7d",
179
179
  "schemas/v2/self-hosted-runner-registration.schema.json": "93d60003a668cacb0eeac37699ca2884c6b79332d9494cb9e0a1752ac8f8a16c",
@@ -198,19 +198,19 @@
198
198
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
199
199
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
200
200
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
201
- "spec/v1/core-standard-manifest.json": "6d0dd7cec8085554565060d2b5ed3abdcd3d13d9940404de33cac17633f3c23a",
201
+ "spec/v1/core-standard-manifest.json": "bd06beb20cc52f8558e8b72598becb380e3159bb842f13389bcf92619f1fad23",
202
202
  "spec/v1/deprecations.json": "520d79927a8ed43ccf62c207a7cf6cf7b19b16e00bbbbdf0410667e939c19d85",
203
203
  "spec/v1/deprecations.schema.json": "4cdbbb8eb9c0092913a5704c943880f0c8581bcf0f640a7df5a84105d53c8aee",
204
204
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
205
205
  "spec/v1/event-codemap.schema.json": "d52caf29071e3784d4c20259cdbd5a7b533986548a440bb632a80b82cadd7ea5",
206
- "spec/v1/extensions.json": "4d7ba68bae7c7f50cc655b94646ad25945a5dd63b703d43c1d015470141cd668",
206
+ "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
207
207
  "spec/v1/gaps.json": "96ca051cb20f1a669b355d67a89421c56fef28aa5e6710c9519f592f26af33a3",
208
208
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
209
209
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
210
210
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
211
211
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
212
212
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
213
- "spec/v2/declaration.json": "892b6fae1e87f279ff75451f67ffab2a63ef883203f8066886bd60f165469348",
213
+ "spec/v2/declaration.json": "52b8c24a2dd463d44086129bf792ad108746f5102778cbbf0ff469fa534eca48",
214
214
  "spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
215
215
  "spec/v2/errors.json": "ad1725dc7c11002450e8abc682858eb5c11952a0718ea8ef6bdb2fb5ae6e7e21",
216
216
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -228,7 +228,7 @@
228
228
  "spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
229
229
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
230
230
  "spec/v2/profiles.json": "1f0ee40491131cf561ef19a0394c47ba476b4567514fe16befb6cada4a10a5a4",
231
- "spec/v2/release.json": "ef05b0ec4ef4296dfbbacf37e97612b3c1f8d11b701890caa7b9c8462eab8359"
231
+ "spec/v2/release.json": "20a6cb7efde5fcdbbc2b663976882b6dec93ec52556d422de7290e25906f2748"
232
232
  },
233
- "corpusCommit": "18c32663eb8acfe76d9b5906f69c82ed214987e0"
233
+ "corpusCommit": "e9aa9131f3d64f814b2c0ca16535675968efad76"
234
234
  }
package/src/cli.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  type DiscoveryPayload,
55
55
  PROFILE_FLOOR_SCENARIOS,
56
56
  } from './lib/profiles.js';
57
+ import { setV2ProfileFloors } from './lib/requirement-registry.js';
57
58
 
58
59
  interface ParsedArgs {
59
60
  readonly baseUrl: string | undefined;
@@ -320,14 +321,8 @@ function claimedProfilesFor(doc: DiscoveryPayload): string[] {
320
321
  * no v2 host could ever certify. Falls back to the empty set only when the
321
322
  * registry is genuinely absent from the layout, and says so.
322
323
  */
323
- function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): string[] {
324
- // Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
325
- // this package — instead of guessing directory shapes. Hand-rolled candidates
326
- // found the registry in a repo checkout and missed it in every published
327
- // install, where npm hoists the peer to a SIBLING package dir: the probe that
328
- // walked one level up landed on the `@openwop/` scope directory, not a
329
- // package, so a real host run silently claimed nothing.
330
- const candidates = [];
324
+ function v2RegistryPath(conformanceRoot: string): string | null {
325
+ const candidates: string[] = [];
331
326
  try {
332
327
  const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
333
328
  candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
@@ -337,8 +332,18 @@ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): s
337
332
  resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'),
338
333
  resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'),
339
334
  );
340
- const found = candidates.find((c) => existsSync(c));
341
- if (found === undefined) {
335
+ return candidates.find((c) => existsSync(c)) ?? null;
336
+ }
337
+
338
+ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): string[] {
339
+ // Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
340
+ // this package — instead of guessing directory shapes. Hand-rolled candidates
341
+ // found the registry in a repo checkout and missed it in every published
342
+ // install, where npm hoists the peer to a SIBLING package dir: the probe that
343
+ // walked one level up landed on the `@openwop/` scope directory, not a
344
+ // package, so a real host run silently claimed nothing.
345
+ const found = v2RegistryPath(conformanceRoot);
346
+ if (found === null) {
342
347
  process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
343
348
  return [];
344
349
  }
@@ -364,6 +369,42 @@ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): s
364
369
  return out;
365
370
  }
366
371
 
372
+ /**
373
+ * `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
374
+ * `planned:<name>` entry names a scenario the registry expects but that may not
375
+ * exist yet; it resolves to `v2-<name>.test.ts` when that file is in the
376
+ * manifest and is dropped otherwise, so a planned-but-unwritten floor cannot
377
+ * fail a host for the corpus's own backlog.
378
+ */
379
+ function v2ProfileFloors(conformanceRoot: string): Record<string, readonly string[]> {
380
+ const registryPath = v2RegistryPath(conformanceRoot);
381
+ if (registryPath === null) return {};
382
+ let known: Set<string>;
383
+ try {
384
+ known = new Set(Object.keys((JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')) as { majors: Record<string, number[]> }).majors));
385
+ } catch {
386
+ known = new Set();
387
+ }
388
+ let registry: { profiles?: Array<{ id?: unknown; floorScenarios?: unknown }> };
389
+ try {
390
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
391
+ } catch {
392
+ return {};
393
+ }
394
+ const out: Record<string, readonly string[]> = {};
395
+ for (const p of registry.profiles ?? []) {
396
+ if (typeof p.id !== 'string') continue;
397
+ const raw = Array.isArray(p.floorScenarios) ? (p.floorScenarios as unknown[]).map(String) : [];
398
+ const files: string[] = [];
399
+ for (const entry of raw) {
400
+ const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
401
+ if (known.size === 0 || known.has(name)) files.push(name);
402
+ }
403
+ out[p.id] = files;
404
+ }
405
+ return out;
406
+ }
407
+
367
408
  /** A single scenario test file's terminal state, derived from the vitest JSON report. */
368
409
  type ScenarioState = 'passed' | 'failed' | 'skipped';
369
410
 
@@ -475,6 +516,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
475
516
 
476
517
  // (b) Derive claimedProfiles from the captured document.
477
518
  const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
519
+ // Major-2 floors come from spec/v2/profiles.json. Without this the derivation
520
+ // measures a v2 host against v1 scenario files a major-2 run never executes,
521
+ // and refuses certification for not running them.
522
+ setV2ProfileFloors(target.major === 2 ? v2ProfileFloors(conformanceRoot) : null);
478
523
 
479
524
  // (c) Run the suite, capturing per-scenario terminal state via the vitest
480
525
  // JSON reporter. server-targeted scenarios live under src/scenarios/.
@@ -75,6 +75,17 @@ 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
+ }
78
89
  if (stamp.kind === 'mismatch') {
79
90
  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.');
80
91
  }
@@ -39,7 +39,17 @@ 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[] };
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 };
43
53
 
44
54
  export const STAMP_RELATIVE_PATH = join('schemas', 'CORPUS-STAMP.json');
45
55
 
@@ -94,7 +104,11 @@ export function verifyPeerContract(pkgRoot: string): StampVerdict {
94
104
  if (!existsSync(stampPath)) return { kind: 'mismatch', missing: ['@openwop/spec-artifacts/CORPUS-STAMP.json'], altered: [], extra: [] };
95
105
  const stamp = JSON.parse(readFileSync(stampPath, 'utf8')) as { package: string; version: string; files: Record<string, string> };
96
106
  const digest = createHash('sha256').update(JSON.stringify({ package: stamp.package, version: stamp.version, files: stamp.files })).digest('hex');
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: [] };
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: [] };
98
112
  const missing: string[] = []; const altered: string[] = [];
99
113
  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); }
100
114
  if (missing.length || altered.length) return { kind: 'mismatch', missing, altered, extra: [] };
@@ -143,6 +157,14 @@ export function describeVerdict(v: StampVerdict): string {
143
157
  return `[openwop-conformance] corpus stamp VERIFIED — ${v.files} vendored api/ + schemas/ files match their SHA-256 digests`;
144
158
  case 'not-applicable':
145
159
  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
+ );
146
168
  case 'mismatch':
147
169
  return (
148
170
  `[openwop-conformance] corpus stamp MISMATCH — the vendored contract is not the one this suite shipped ` +
@@ -78,7 +78,28 @@ export function floorFilesFor(profile: string, document?: Readonly<Record<string
78
78
  * discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
79
79
  * it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
80
80
  */
81
+ /**
82
+ * Major-2 floors come from `spec/v2/profiles.json`, not from the v1 table.
83
+ * `PROFILE_FLOOR_SCENARIOS` names v1 scenario FILES (`runs-lifecycle.test.ts`,
84
+ * `discovery.test.ts`, …) that `scenario-majors.json` assigns to major 1 and a
85
+ * major-2 run therefore never executes — so every one of them came back
86
+ * unclassified and a v2 host was refused certification for not running v1
87
+ * scenarios. Set by the runner before deriving; empty means the registry
88
+ * declares no floor for that profile, which is a real "witnesses nothing yet",
89
+ * not an unclassified return.
90
+ */
91
+ let v2Floors: Readonly<Record<string, readonly string[]>> | null = null;
92
+
93
+ export function setV2ProfileFloors(floors: Readonly<Record<string, readonly string[]>> | null): void {
94
+ v2Floors = floors;
95
+ }
96
+
81
97
  export function requirementsFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
98
+ if (v2Floors !== null) {
99
+ const files = v2Floors[profile];
100
+ if (files === undefined) return null;
101
+ return files.map(requirementIdForScenario);
102
+ }
82
103
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
83
104
  if (floor === undefined) return null;
84
105
  if (floor.discoveryOnly === true) return [];
@@ -0,0 +1,156 @@
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
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `spec/v2/core/conformance.md` — an advertised conformance fixture is a claim
3
+ * the host must be able to honour (suite 2.0.0, target major 2; unaided).
4
+ *
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.
11
+ *
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/`".
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.
32
+ *
33
+ * @see spec/v2/core/conformance.md
34
+ * @see conformance/src/scenarios/v2-approver-enforced.test.ts (a host-supplied fixture, by design)
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
39
+ import { v2Discovery } from '../lib/v2.js';
40
+ import { softSkip } from '../lib/soft-skip.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+
43
+ const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
44
+ const DOC = 'spec/v2/core/conformance.md §Fixtures';
45
+ /** Creating one run per advertised fixture would be a load test, not a check. */
46
+ const SAMPLE = 5;
47
+
48
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
49
+ try {
50
+ return await fn();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function advertisedIds(doc: Record<string, unknown>): string[] {
57
+ const raw = doc['fixtures'];
58
+ return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
59
+ }
60
+
61
+ describe('v2-advertised-fixtures-exist (conformance.md §Fixtures)', () => {
62
+ it('a sampled advertised fixture is actually creatable, not just listed', async () => {
63
+ const doc = await v2Discovery().catch(() => null);
64
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
65
+ const ids = advertisedIds(doc);
66
+ if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[]');
67
+
68
+ // Deterministic sample: the first N by sort order, so a failure is
69
+ // reproducible and a host cannot pass by luck of ordering.
70
+ const sample = [...ids].sort().slice(0, SAMPLE);
71
+ const unreachable: string[] = [];
72
+ let attempted = 0;
73
+ for (const id of sample) {
74
+ const res = await http(() => driver.post('/runs', { workflowId: id, inputs: {} }));
75
+ if (res === null) {
76
+ return softSkip('blocked', `POST /runs was unreachable while sampling advertised fixtures (stopped at ${id})`);
77
+ }
78
+ attempted += 1;
79
+ // 201 is the claim honoured. A 4xx that names the workflow as unknown is
80
+ // the drift this leg exists to catch; any other status is a different
81
+ // problem and is not judged here.
82
+ if (res.status !== 201) unreachable.push(`${id} → ${res.status}`);
83
+ }
84
+ if (attempted === 0) return softSkip('blocked', 'no advertised fixture could be attempted');
85
+ expect(
86
+ unreachable,
87
+ req(ID, DOC, `an advertised fixture MUST be creatable: fixtures[] gates whether a scenario runs at all, so a listed-but-unseeded fixture makes some OTHER scenario fail on a run that cannot exist, and the failure is attributed to the wrong requirement (${unreachable.length} of ${attempted} sampled: ${unreachable.join(', ')})`),
88
+ ).toEqual([]);
89
+ });
90
+ });