@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +257 -14
  3. package/dist/lib/certification-bundle-v3.js +142 -0
  4. package/dist/lib/requirement-ids.js +23 -0
  5. package/dist/lib/requirement-ledger.js +1 -0
  6. package/dist/lib/requirement-registry.js +20 -0
  7. package/dist/lib/scenario-disposition.js +5 -1
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +2939 -4
  11. package/scenario-majors.json +164 -2
  12. package/schemas/CORPUS-STAMP.json +21 -21
  13. package/src/cli.ts +235 -19
  14. package/src/lib/certification-bundle-v3.ts +175 -0
  15. package/src/lib/driver.ts +9 -1
  16. package/src/lib/era2-seed.ts +197 -0
  17. package/src/lib/requirement-ids.ts +21 -0
  18. package/src/lib/requirement-ledger.ts +7 -1
  19. package/src/lib/requirement-registry.ts +21 -0
  20. package/src/lib/scenario-disposition.ts +5 -1
  21. package/src/lib/seams.ts +45 -0
  22. package/src/lib/v2.ts +62 -0
  23. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +108 -0
  24. package/src/scenarios/v2-approver-enforced.test.ts +97 -0
  25. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
  26. package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
  27. package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
  28. package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
  29. package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
  30. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
  31. package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
  32. package/src/scenarios/v2-configurable-closed.test.ts +67 -0
  33. package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
  34. package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
  35. package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
  36. package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
  37. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
  38. package/src/scenarios/v2-era-key.test.ts +86 -0
  39. package/src/scenarios/v2-era-stamp-universal.test.ts +103 -0
  40. package/src/scenarios/v2-error-registry.test.ts +89 -0
  41. package/src/scenarios/v2-event-type-closed.test.ts +97 -0
  42. package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
  43. package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
  44. package/src/scenarios/v2-header-scheme.test.ts +67 -0
  45. package/src/scenarios/v2-id-grammar.test.ts +77 -0
  46. package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
  47. package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
  48. package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
  49. package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
  50. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
  51. package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
  52. package/src/scenarios/v2-min-client-version.test.ts +48 -0
  53. package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
  54. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
  55. package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
  56. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
  57. package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
  58. package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
  59. package/src/scenarios/v2-pack-isolation.test.ts +147 -0
  60. package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
  61. package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
  62. package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
  63. package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
  64. package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
  65. package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
  66. package/src/scenarios/v2-provider-conflict.test.ts +115 -0
  67. package/src/scenarios/v2-refresh-sla.test.ts +78 -0
  68. package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
  69. package/src/scenarios/v2-revocation-honored.test.ts +66 -0
  70. package/src/scenarios/v2-subject-link-record.test.ts +102 -0
  71. package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
  72. package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
  73. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
  74. package/src/scenarios/v2-version-header-honored.test.ts +124 -0
  75. package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
  76. package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
  77. package/src/setup.ts +40 -4
package/README.md CHANGED
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
112
112
 
113
113
  ## What's Covered
114
114
 
115
- The current suite has 444 scenario files under `src/scenarios/`.
115
+ The current suite has 498 scenario files under `src/scenarios/`.
116
116
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
117
117
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
118
118
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
457
457
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
458
458
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
459
459
 
460
- Current source tree: 444 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
460
+ Current source tree: 498 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
461
461
 
462
462
  ## Remaining Gaps
463
463
 
package/dist/cli.js CHANGED
@@ -34,8 +34,9 @@
34
34
  import { spawnSync } from 'node:child_process';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { dirname, resolve as resolvePath, join } from 'node:path';
37
+ import { createRequire } from 'node:module';
37
38
  import { createHash } from 'node:crypto';
38
- import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
39
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
39
40
  import { tmpdir } from 'node:os';
40
41
  import Ajv2020 from 'ajv/dist/2020.js';
41
42
  import addFormats from 'ajv-formats';
@@ -43,7 +44,9 @@ import { SCHEMAS_DIR } from './lib/paths.js';
43
44
  import { readLedgerFile } from './lib/requirement-ledger.js';
44
45
  import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
45
46
  import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
46
- import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, } from './lib/profiles.js';
47
+ import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
48
+ import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
49
+ import { setV2ProfileFloors } from './lib/requirement-registry.js';
47
50
  function parseArgs(argv) {
48
51
  let baseUrl;
49
52
  let apiKey;
@@ -56,7 +59,10 @@ function parseArgs(argv) {
56
59
  // Suite 1.152.0: bundle v2 (RFC 0148) is the default. v1 stays reachable via
57
60
  // `--bundle-version 1` through the RFC 0148 migration window (ends
58
61
  // 2026-11-10) and is removed at v2.0 (spec/v1/deprecations.json).
59
- let bundleVersion = '2';
62
+ let bundleVersion = '3';
63
+ let hostBuild;
64
+ let evidenceTier = 'self';
65
+ let signingKeyPath, signingKeyId, verifierKeyPath, verifierKeyId;
60
66
  let targetMajor;
61
67
  let maxWorkers = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
62
68
  for (let i = 0; i < argv.length; i++) {
@@ -110,17 +116,47 @@ function parseArgs(argv) {
110
116
  implVersion = nextValue();
111
117
  break;
112
118
  case '--bundle-version': {
113
- const v = nextValue();
114
- if (v !== '1' && v !== '2') {
115
- process.stderr.write(`--bundle-version must be 1 or 2 (got '${v}')\n`);
119
+ const v = argv[++i];
120
+ if (v !== '2' && v !== '3') {
121
+ process.stderr.write(`openwop-conformance: --bundle-version must be 2 or 3 (got ${String(v)}); v1 is gone in suite 2.0.0 (RFC 0168 §E.3)\n`);
116
122
  process.exit(2);
117
123
  }
124
+ if (v === '2')
125
+ process.stderr.write('openwop-conformance: --bundle-version 2 is DEPRECATED — v2 bundles stop substantiating a certification at v1 end-of-support (RFC 0168 §E.3); the default is 3.\n');
118
126
  bundleVersion = v;
119
- if (v === '1') {
120
- process.stderr.write('openwop-conformance: --bundle-version 1 is DEPRECATED (RFC 0148). A v1 bundle ceases to substantiate a new certification after 2026-11-10 and the format is removed at v2.0; omit the flag to emit bundle v2.\n');
127
+ break;
128
+ }
129
+ case '--host-build': {
130
+ const v = argv[++i] ?? '';
131
+ const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(v);
132
+ if (!mm) {
133
+ process.stderr.write(`openwop-conformance: --host-build must be <image-digest|commit|artifact-sha256>:<id> (got ${JSON.stringify(v)})\n`);
134
+ process.exit(2);
135
+ }
136
+ hostBuild = { kind: mm[1], id: mm[2] };
137
+ break;
138
+ }
139
+ case '--evidence-tier': {
140
+ const v = argv[++i];
141
+ if (v !== 'self' && v !== 'steward' && v !== 'independent') {
142
+ process.stderr.write('openwop-conformance: --evidence-tier must be self, steward or independent\n');
143
+ process.exit(2);
121
144
  }
145
+ evidenceTier = v;
122
146
  break;
123
147
  }
148
+ case '--signing-key':
149
+ signingKeyPath = argv[++i];
150
+ break;
151
+ case '--signing-key-id':
152
+ signingKeyId = argv[++i];
153
+ break;
154
+ case '--verifier-key':
155
+ verifierKeyPath = argv[++i];
156
+ break;
157
+ case '--verifier-key-id':
158
+ verifierKeyId = argv[++i];
159
+ break;
124
160
  case '--certify':
125
161
  certify = nextValue();
126
162
  break;
@@ -144,6 +180,12 @@ function parseArgs(argv) {
144
180
  certify,
145
181
  bundleVersion,
146
182
  targetMajor,
183
+ hostBuild,
184
+ evidenceTier,
185
+ signingKeyPath,
186
+ signingKeyId,
187
+ verifierKeyPath,
188
+ verifierKeyId,
147
189
  maxWorkers,
148
190
  };
149
191
  }
@@ -183,7 +225,11 @@ Certification (RFC 0089):
183
225
  --target-major <1|2> Suite 2.0.0 (RFC 0168 §D.3): run the scenarios for this protocol
184
226
  major. Default: the host's preferredVersion (RFC 0179), else
185
227
  max(protocolVersions[]), else 1. Selection is scenario-majors.json.
186
- --bundle-version <1|2> Certification bundle format. Default 2 (corrected 2026-09-03, RFC 0168 §E.4; the text said 1 while the code set 2). Version 2 (RFC 0148
228
+ --host-build <k>:<id> v3: the build identity (image-digest|commit|artifact-sha256), or OPENWOP_HOST_BUILD.
229
+ --signing-key <pem> v3: Ed25519 private key (PKCS8 PEM) that signs the bundle, or OPENWOP_BUNDLE_SIGNING_KEY.
230
+ --signing-key-id <id> v3: the keyId the host publishes for that key, or OPENWOP_BUNDLE_SIGNING_KEY_ID.
231
+ --evidence-tier <t> v3: self | steward | independent (independent needs --verifier-key/--verifier-key-id).
232
+ --bundle-version <2|3> Certification bundle format. Default 2 (corrected 2026-09-03, RFC 0168 §E.4; the text said 1 while the code set 2). Version 2 (RFC 0148
187
233
  §C) records per-requirement DISPOSITIONS instead of pass/fail/skip
188
234
  file lists, so "we could not check" stops being indistinguishable
189
235
  from "checked and it holds". See the note it prints.
@@ -245,6 +291,101 @@ function claimedProfilesFor(doc) {
245
291
  profiles.push('openwop-agent-platform');
246
292
  return profiles;
247
293
  }
294
+ /**
295
+ * The v2 profile registry is a set of predicates over the DECLARATION
296
+ * (RFC 0169 §C.1): every listed family present as a record, every listed
297
+ * metadata key present. The v1 derivation cannot stand in — `isCore` wants a
298
+ * root `protocolVersion` plus `supportedEnvelopes`/`schemaVersions`/`limits`,
299
+ * shapes a closed v2 root does not have — so a major-2 run claimed NOTHING and
300
+ * no v2 host could ever certify. Falls back to the empty set only when the
301
+ * registry is genuinely absent from the layout, and says so.
302
+ */
303
+ function v2RegistryPath(conformanceRoot) {
304
+ const candidates = [];
305
+ try {
306
+ const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
307
+ candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
308
+ }
309
+ catch { /* not installed as a package; the repo-layout candidates below */ }
310
+ candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'));
311
+ return candidates.find((c) => existsSync(c)) ?? null;
312
+ }
313
+ function claimedProfilesForV2(doc, conformanceRoot) {
314
+ // Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
315
+ // this package — instead of guessing directory shapes. Hand-rolled candidates
316
+ // found the registry in a repo checkout and missed it in every published
317
+ // install, where npm hoists the peer to a SIBLING package dir: the probe that
318
+ // walked one level up landed on the `@openwop/` scope directory, not a
319
+ // package, so a real host run silently claimed nothing.
320
+ const found = v2RegistryPath(conformanceRoot);
321
+ if (found === null) {
322
+ process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
323
+ return [];
324
+ }
325
+ let registry;
326
+ try {
327
+ registry = JSON.parse(readFileSync(found, 'utf8'));
328
+ }
329
+ catch {
330
+ process.stderr.write(`openwop-conformance --certify: ${found} is unreadable; claimedProfiles is empty.\n`);
331
+ return [];
332
+ }
333
+ const root = doc;
334
+ const isRecord = (k) => {
335
+ const v = root[k];
336
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
337
+ };
338
+ const out = [];
339
+ for (const p of registry.profiles ?? []) {
340
+ if (typeof p.id !== 'string')
341
+ continue;
342
+ const families = Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : [];
343
+ const metadata = Array.isArray(p.predicate?.metadata) ? p.predicate.metadata.map(String) : [];
344
+ if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined))
345
+ out.push(p.id);
346
+ }
347
+ return out;
348
+ }
349
+ /**
350
+ * `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
351
+ * `planned:<name>` entry names a scenario the registry expects but that may not
352
+ * exist yet; it resolves to `v2-<name>.test.ts` when that file is in the
353
+ * manifest and is dropped otherwise, so a planned-but-unwritten floor cannot
354
+ * fail a host for the corpus's own backlog.
355
+ */
356
+ function v2ProfileFloors(conformanceRoot) {
357
+ const registryPath = v2RegistryPath(conformanceRoot);
358
+ if (registryPath === null)
359
+ return {};
360
+ let known;
361
+ try {
362
+ known = new Set(Object.keys(JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')).majors));
363
+ }
364
+ catch {
365
+ known = new Set();
366
+ }
367
+ let registry;
368
+ try {
369
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
370
+ }
371
+ catch {
372
+ return {};
373
+ }
374
+ const out = {};
375
+ for (const p of registry.profiles ?? []) {
376
+ if (typeof p.id !== 'string')
377
+ continue;
378
+ const raw = Array.isArray(p.floorScenarios) ? p.floorScenarios.map(String) : [];
379
+ const files = [];
380
+ for (const entry of raw) {
381
+ const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
382
+ if (known.size === 0 || known.has(name))
383
+ files.push(name);
384
+ }
385
+ out[p.id] = files;
386
+ }
387
+ return out;
388
+ }
248
389
  /**
249
390
  * Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
250
391
  * the test-file basename (e.g. `discovery.test.ts`) to align with the basenames
@@ -294,11 +435,14 @@ function scenarioStatesFromReport(report) {
294
435
  async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
295
436
  let major = args.targetMajor;
296
437
  let source = 'flag';
438
+ let dualStack = false;
297
439
  if (major === undefined && baseUrl) {
298
440
  try {
299
441
  const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} });
300
442
  const doc = (await res.json());
301
443
  const pv = doc.preferredVersion ?? (doc.protocolVersions ?? []).map((v) => v).sort((a, b) => Number(b.split('.')[0]) - Number(a.split('.')[0]))[0];
444
+ if ((doc.protocolVersions ?? []).some((v) => v.startsWith('2.')))
445
+ dualStack = true;
302
446
  if (pv) {
303
447
  major = Number(pv.split('.')[0]) >= 2 ? 2 : 1;
304
448
  source = doc.preferredVersion ? 'preferredVersion' : 'max(protocolVersions[])';
@@ -310,6 +454,11 @@ async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
310
454
  major = 1;
311
455
  source = 'default';
312
456
  }
457
+ // Through the overlap `preferredVersion` names the 1.x member (versioning.md
458
+ // §1.1), so auto-detection on a dual-stack host resolves to major 1 by design.
459
+ // Say so, or an operator reads a v1 run as the host's only option.
460
+ if (major === 1 && dualStack)
461
+ source += ' (the host also serves 2.x — pass --target-major 2 to measure it)';
313
462
  const manifest = JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8'));
314
463
  const files = Object.entries(manifest.majors).filter(([, m]) => m.includes(major)).map(([f]) => `src/scenarios/${f}`);
315
464
  return { major, files, source };
@@ -319,10 +468,19 @@ async function runCertify(args, baseUrl, apiKey) {
319
468
  if (outPath === undefined)
320
469
  process.exit(2);
321
470
  // (a) Fetch /.well-known/openwop verbatim + its canonical-JSON SHA-256.
471
+ // The target is resolved FIRST: `/.well-known/openwop` is one resource whose
472
+ // representation the `OpenWOP-Version` header selects, and through the overlap
473
+ // the header-less representation is the v1 document (spec/v2/core/versioning.md
474
+ // §1.1). Fetching it header-less on a major-2 run captured the v1 rendering, so
475
+ // `claimedProfiles` came out as the v1 set and the bundle described a contract
476
+ // the run never measured.
477
+ const here = dirname(fileURLToPath(import.meta.url));
478
+ const conformanceRoot = resolvePath(here, '..');
479
+ const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
322
480
  const discoveryUrl = `${baseUrl.replace(/\/$/, '')}/.well-known/openwop`;
323
481
  let document;
324
482
  try {
325
- const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json' } });
483
+ const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json', ...(target.major === 2 ? { 'OpenWOP-Version': '2.0' } : {}) } });
326
484
  if (!resp.ok) {
327
485
  process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
328
486
  process.exit(2);
@@ -335,11 +493,13 @@ async function runCertify(args, baseUrl, apiKey) {
335
493
  }
336
494
  const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
337
495
  // (b) Derive claimedProfiles from the captured document.
338
- const claimedProfiles = claimedProfilesFor(document);
496
+ const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
497
+ // Major-2 floors come from spec/v2/profiles.json. Without this the derivation
498
+ // measures a v2 host against v1 scenario files a major-2 run never executes,
499
+ // and refuses certification for not running them.
500
+ setV2ProfileFloors(target.major === 2 ? v2ProfileFloors(conformanceRoot) : null);
339
501
  // (c) Run the suite, capturing per-scenario terminal state via the vitest
340
502
  // JSON reporter. server-targeted scenarios live under src/scenarios/.
341
- const here = dirname(fileURLToPath(import.meta.url));
342
- const conformanceRoot = resolvePath(here, '..');
343
503
  const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
344
504
  const reportFile = join(reportDir, 'vitest-report.json');
345
505
  // RFC 0148 §A ledger sink (S6): every scenario file records its disposition
@@ -354,7 +514,6 @@ async function runCertify(args, baseUrl, apiKey) {
354
514
  env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
355
515
  if (args.implVersion)
356
516
  env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
357
- const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
358
517
  env.OPENWOP_TARGET_MAJOR = String(target.major);
359
518
  process.stderr.write(`openwop-conformance --certify: target major ${target.major} (${target.source}); ${target.files.length} scenario file(s)\n`);
360
519
  const vitestArgs = [
@@ -460,6 +619,90 @@ async function runCertify(args, baseUrl, apiKey) {
460
619
  ' evidence is inspectable, but it does not certify and this process exits 3.\n' +
461
620
  rejectedProfiles.map((v) => ` - ${v.profile}: ${v.unclassified.join(', ')}\n`).join(''));
462
621
  }
622
+ if (args.bundleVersion === '3') {
623
+ // ── Bundle v3 (RFC 0168 §E) ─────────────────────────────────────────────
624
+ const build = args.hostBuild ?? (() => {
625
+ const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(process.env['OPENWOP_HOST_BUILD'] ?? '');
626
+ return mm ? { kind: mm[1], id: mm[2] } : undefined;
627
+ })();
628
+ const signingKeyPem = args.signingKeyPath ? readFileSync(args.signingKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_SIGNING_KEY'];
629
+ const keyId = args.signingKeyId ?? process.env['OPENWOP_BUNDLE_SIGNING_KEY_ID'];
630
+ if (!build || !signingKeyPem || !keyId) {
631
+ process.stderr.write('openwop-conformance --certify: a v3 bundle needs --host-build <kind>:<id> (or OPENWOP_HOST_BUILD), --signing-key <pem> (or OPENWOP_BUNDLE_SIGNING_KEY) and --signing-key-id (or OPENWOP_BUNDLE_SIGNING_KEY_ID) — an unsigned bundle does not exist in v3 (RFC 0168 §E.2).\n');
632
+ process.exit(2);
633
+ }
634
+ const rows3 = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition, ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
635
+ const totals3 = derived.totals;
636
+ const doc3 = document;
637
+ const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
638
+ const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? doc3['preferredVersion'] : (protocolVersions[0] ?? '');
639
+ // witnessCount: executed-pass rows on the profile's floor (PROFILE_FLOOR_SCENARIOS required + requiredAnyPrefix).
640
+ const witnessCountFor = (profile) => {
641
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
642
+ if (!floor)
643
+ return 0;
644
+ const onFloor = (scenario) => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
645
+ return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
646
+ };
647
+ const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
648
+ let relaxations;
649
+ if (process.env['OPENWOP_HOST_RELAXATIONS']) {
650
+ try {
651
+ relaxations = JSON.parse(process.env['OPENWOP_HOST_RELAXATIONS']);
652
+ }
653
+ catch {
654
+ process.stderr.write('openwop-conformance --certify: OPENWOP_HOST_RELAXATIONS is not JSON\n');
655
+ process.exit(2);
656
+ }
657
+ }
658
+ const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
659
+ const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : undefined;
660
+ const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
661
+ const unsigned = {
662
+ bundleVersion: '3',
663
+ generatedAt: new Date().toISOString(),
664
+ suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
665
+ host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
666
+ discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion },
667
+ claimedProfiles: claimed3,
668
+ results: { totals: totals3, requirements: rows3 },
669
+ witnessSha256: witnessDigest(rows3),
670
+ assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
671
+ ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
672
+ };
673
+ const signature = signBundleV3(unsigned, signingKeyPem, keyId);
674
+ const v3 = { ...unsigned, signature };
675
+ if (args.evidenceTier === 'independent') {
676
+ const vk = args.verifierKeyPath ? readFileSync(args.verifierKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_VERIFIER_KEY'];
677
+ const vkId = args.verifierKeyId ?? process.env['OPENWOP_BUNDLE_VERIFIER_KEY_ID'];
678
+ if (!vk || !vkId) {
679
+ process.stderr.write('openwop-conformance --certify: --evidence-tier independent needs --verifier-key and --verifier-key-id (RFC 0168 §E.2)\n');
680
+ process.exit(2);
681
+ }
682
+ v3.verifierSignature = verifierSign(unsigned, vk, vkId);
683
+ }
684
+ const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem]);
685
+ const scrubbed3 = scrubEvidence(v3, secrets3);
686
+ const v3Out = scrubbed3.value;
687
+ const audit3 = verifyBundleV3(v3Out, { hostPublicKeyPem: publicKeyFromPrivate(signingKeyPem) });
688
+ const emitterDefects = audit3.rejections.filter((r) => !['blocked-certified', 'relaxed-profile-certified', 'independent-unverifiable'].includes(r.kind));
689
+ if (emitterDefects.length > 0) {
690
+ process.stderr.write('openwop-conformance --certify: assembled v3 bundle FAILED self-verification (emitter defect):\n' + emitterDefects.map((r) => ` - [${r.kind}] ${r.detail}`).join('\n') + '\n');
691
+ process.exit(2);
692
+ }
693
+ const v3Schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'certification-bundle.schema.json'), 'utf8'));
694
+ const v3Ajv = new Ajv2020({ allErrors: true, strict: false });
695
+ addFormats(v3Ajv);
696
+ v3Ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'ids.schema.json'), 'utf8')));
697
+ const v3Validate = v3Ajv.compile(v3Schema);
698
+ if (!v3Validate(v3Out)) {
699
+ process.stderr.write('openwop-conformance --certify: assembled v3 bundle FAILED schema validation:\n' + `${JSON.stringify(v3Validate.errors, null, 2)}\n`);
700
+ process.exit(2);
701
+ }
702
+ writeFileSync(outPath, `${JSON.stringify(v3Out, null, 2)}\n`);
703
+ process.stdout.write(`openwop-conformance --certify: wrote bundle v3 → ${outPath} (${rows3.length} requirement rows, ${v3Out.assertionCount} assertions, witness ${v3Out.witnessSha256.slice(0, 12)}, signed by ${keyId}; certified: ${audit3.certifiedProfiles.join(', ') || 'none'})\n`);
704
+ process.exit(rejectedProfiles.length > 0 ? 3 : failed.length > 0 ? 1 : 0);
705
+ }
463
706
  if (args.bundleVersion === '2') {
464
707
  const scenarioIds = [...passed, ...failed, ...skipped].sort();
465
708
  const manifestSha = createHash('sha256').update(scenarioIds.join('\n'), 'utf8').digest('hex');
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Certification bundle v3 (RFC 0168 §E; schemas/v2/certification-bundle.schema.json).
3
+ *
4
+ * What v3 adds over v2 and why:
5
+ * - a closed root and `witnessSha256` over the reporter record (RFC 0148 §C
6
+ * asked for it; the v2 schema listed it optional and no emitter wrote it);
7
+ * - `assertionCount`, `host.build`, per-profile `evidenceTier`/`witnessCount`,
8
+ * `detail` (required when any result is not executed-pass);
9
+ * - an Ed25519 attestation over the canonical JSON of
10
+ * { witnessSha256, host.build, suite.version, discovery.sha256 } — the same
11
+ * commit rebuilt once scored 283/22 and 303/2 and nothing in the bundle could
12
+ * tell them apart; the build id plus the signature can;
13
+ * - `evidenceTier: independent` REQUIRES a verifier signature under a key id
14
+ * distinct from the host's (RFC 0148 R5: a malicious generator fabricates
15
+ * witnesses — a self-signed "independent" claim is refused, not warned).
16
+ *
17
+ * This module is schema-agnostic on purpose (the schema check is a separate step
18
+ * in cli.ts) and pure: it never reads the environment.
19
+ */
20
+ import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
21
+ export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
22
+ /** Deterministic JSON: keys sorted at every level, no whitespace. */
23
+ export function canonicalJSON(value) {
24
+ if (Array.isArray(value))
25
+ return `[${value.map(canonicalJSON).join(',')}]`;
26
+ if (value !== null && typeof value === 'object') {
27
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(',')}}`;
28
+ }
29
+ return JSON.stringify(value);
30
+ }
31
+ /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
32
+ export function witnessDigest(rows) {
33
+ const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
34
+ return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
35
+ }
36
+ /** The bytes the attestation covers. */
37
+ export function attestationPayload(b) {
38
+ return Buffer.from(canonicalJSON({ witnessSha256: b.witnessSha256, 'host.build': b.host.build, 'suite.version': b.suite.version, 'discovery.sha256': b.discovery.sha256 }), 'utf8');
39
+ }
40
+ function toBase64url(buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
41
+ function fromBase64url(s) { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
42
+ export function signBundleV3(bundle, privateKeyPem, keyId) {
43
+ const key = createPrivateKey(privateKeyPem);
44
+ if (key.asymmetricKeyType !== 'ed25519')
45
+ throw new Error(`bundle v3 signing key must be Ed25519, got ${String(key.asymmetricKeyType)}`);
46
+ const sig = edSign(null, attestationPayload(bundle), key);
47
+ return { alg: 'ed25519', keyId, sig: toBase64url(sig), over: [...SIGNATURE_OVER] };
48
+ }
49
+ export function verifierSign(bundle, privateKeyPem, keyId) {
50
+ const key = createPrivateKey(privateKeyPem);
51
+ return { alg: 'ed25519', keyId, sig: toBase64url(edSign(null, attestationPayload(bundle), key)) };
52
+ }
53
+ export function publicKeyFromPrivate(privateKeyPem) {
54
+ return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
55
+ }
56
+ export function verifyBundleV3(bundle, opts = {}) {
57
+ const rejections = [];
58
+ if (bundle.bundleVersion !== '3')
59
+ rejections.push({ kind: 'not-v3', detail: `bundleVersion is ${JSON.stringify(bundle.bundleVersion)}, expected "3"` });
60
+ const rows = bundle.results?.requirements ?? [];
61
+ const seen = new Set();
62
+ for (const r of rows) {
63
+ if (seen.has(r.id))
64
+ rejections.push({ kind: 'duplicate-requirement', detail: `${r.id}: more than one row (RFC 0148 §A)` });
65
+ seen.add(r.id);
66
+ if (r.result !== 'executed-pass' && !(r.detail && r.detail.trim()))
67
+ rejections.push({ kind: 'reason-missing', detail: `${r.id}: '${r.result}' recorded without a reason` });
68
+ if (r.result === 'executed-pass' && !(r.assertions && r.assertions > 0))
69
+ rejections.push({ kind: 'vacuous-pass', detail: `${r.id}: executed-pass with no assertions is a witness of nothing (RFC 0148 §C)` });
70
+ }
71
+ const count = (d) => rows.filter((r) => r.result === d).length;
72
+ const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
73
+ for (const k of Object.keys(expected))
74
+ if (bundle.results?.totals?.[k] !== expected[k])
75
+ rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
76
+ const digest = witnessDigest(rows);
77
+ if (bundle.witnessSha256 !== digest)
78
+ rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
79
+ const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
80
+ if (bundle.assertionCount !== assertions)
81
+ rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
82
+ const nonPass = rows.filter((r) => r.result !== 'executed-pass');
83
+ if (nonPass.length > 0 && !bundle.detail)
84
+ rejections.push({ kind: 'detail-missing', detail: `${nonPass.length} non-pass row(s) and no detail block` });
85
+ if (!bundle.host?.build?.kind || !bundle.host?.build?.id)
86
+ rejections.push({ kind: 'build-missing', detail: 'host.build.{kind,id} is required — a bundle attributes to a BUILD, not a commit' });
87
+ // Signature
88
+ let signatureVerified = false;
89
+ const sig = bundle.signature;
90
+ if (!sig || sig.alg !== 'ed25519' || !sig.keyId || !sig.sig)
91
+ rejections.push({ kind: 'signature-missing', detail: 'signature.{alg: ed25519, keyId, sig} is required in v3' });
92
+ else if (JSON.stringify(sig.over) !== JSON.stringify(SIGNATURE_OVER))
93
+ rejections.push({ kind: 'signature-over', detail: `signature.over must be ${JSON.stringify(SIGNATURE_OVER)}` });
94
+ else if (opts.hostPublicKeyPem) {
95
+ const key = createPublicKey(opts.hostPublicKeyPem);
96
+ signatureVerified = edVerify(null, attestationPayload(bundle), key, fromBase64url(sig.sig));
97
+ if (!signatureVerified)
98
+ rejections.push({ kind: 'signature-invalid', detail: 'the attestation does not verify under the host key' });
99
+ }
100
+ // Independent tier
101
+ let verifierSignatureVerified = false;
102
+ const claimsIndependent = (bundle.claimedProfiles ?? []).some((p) => p.evidenceTier === 'independent');
103
+ if (claimsIndependent) {
104
+ const vs = bundle.verifierSignature;
105
+ if (!vs || !vs.keyId || !vs.sig)
106
+ rejections.push({ kind: 'independent-unsigned', detail: 'evidenceTier independent requires a verifierSignature (RFC 0168 §E.2)' });
107
+ else if (vs.keyId === sig?.keyId || vs.keyId === bundle.host?.signingKeyId)
108
+ rejections.push({ kind: 'independent-self-signed', detail: 'the verifier key must be distinct from the host key (RFC 0148 R5)' });
109
+ else if (opts.verifierPublicKeyPem) {
110
+ verifierSignatureVerified = edVerify(null, attestationPayload(bundle), createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
111
+ if (!verifierSignatureVerified)
112
+ rejections.push({ kind: 'verifier-signature-invalid', detail: 'the verifier attestation does not verify' });
113
+ }
114
+ else
115
+ rejections.push({ kind: 'independent-unverifiable', detail: 'an independent claim needs the verifier public key to verify; refused, not assumed' });
116
+ }
117
+ // Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
118
+ const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
119
+ const certifiedProfiles = [];
120
+ for (const p of bundle.claimedProfiles ?? []) {
121
+ if (p.certified && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o)))
122
+ rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
123
+ else if (p.certified)
124
+ certifiedProfiles.push(p.id);
125
+ }
126
+ if (expected.blocked > 0 && certifiedProfiles.length > 0)
127
+ rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
128
+ // RFC 0168 §E.2: the verifier REFUSES, it does not warn — but a refusal is
129
+ // scoped to what it names. A rejection carrying a `profile` removes that
130
+ // profile only (a relaxation on one obligation does not poison an unrelated
131
+ // one, RFC 0173 §A.2); a rejection that names no profile is a statement about
132
+ // the bundle — a bad witness digest, an unverifiable signature, a self-signed
133
+ // `independent` claim, a blocked row — and nothing in it certifies.
134
+ const bundleWide = rejections.filter((r) => !('profile' in r) || !r.profile);
135
+ const scoped = new Set(rejections.map((r) => r.profile).filter((p) => typeof p === 'string'));
136
+ return {
137
+ rejections,
138
+ signatureVerified,
139
+ verifierSignatureVerified,
140
+ certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
141
+ };
142
+ }
@@ -69,6 +69,29 @@ export function scenarioFileOfItId(requirementId) {
69
69
  return null;
70
70
  return `${rest.slice(0, dot)}.test.ts`;
71
71
  }
72
+ /**
73
+ * Every FILE-derived id back to its file: the per-test `openwop.it.<stem>.<title>`
74
+ * plus the per-file rows the ledger also carries, `openwop.scenario.<stem>` and
75
+ * `openwop.floor.<stem>`. Gate rows (`openwop.profile.*`, `openwop.family.*`),
76
+ * explicit `req()` ids and the `openwop.floor.any.<prefix>` GROUP name no single
77
+ * file and return null. A caller that understood only `it` ids read a per-file
78
+ * row as an un-cited requirement and demanded a `req()` literal that, by
79
+ * construction, never exists.
80
+ */
81
+ export function scenarioFileOfId(requirementId) {
82
+ const it = scenarioFileOfItId(requirementId);
83
+ if (it !== null)
84
+ return it;
85
+ for (const prefix of ['openwop.scenario.', 'openwop.floor.']) {
86
+ if (!requirementId.startsWith(prefix))
87
+ continue;
88
+ const rest = requirementId.slice(prefix.length);
89
+ if (rest.length === 0 || rest.startsWith('any.'))
90
+ return null;
91
+ return `${rest}.test.ts`;
92
+ }
93
+ return null;
94
+ }
72
95
  /**
73
96
  * Collision suffixing: the first occurrence keeps the bare id, later ones get
74
97
  * `~2`, `~3`, … Keyed per file so a worker running many files never crosses
@@ -63,6 +63,7 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
63
63
  disposition,
64
64
  ...(detail === undefined ? {} : { detail }),
65
65
  ...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
66
+ ...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
66
67
  };
67
68
  ledger.set(requirementId, entry);
68
69
  journal.push(entry);
@@ -76,7 +76,27 @@ export function floorFilesFor(profile, document) {
76
76
  * discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
77
77
  * it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
78
78
  */
79
+ /**
80
+ * Major-2 floors come from `spec/v2/profiles.json`, not from the v1 table.
81
+ * `PROFILE_FLOOR_SCENARIOS` names v1 scenario FILES (`runs-lifecycle.test.ts`,
82
+ * `discovery.test.ts`, …) that `scenario-majors.json` assigns to major 1 and a
83
+ * major-2 run therefore never executes — so every one of them came back
84
+ * unclassified and a v2 host was refused certification for not running v1
85
+ * scenarios. Set by the runner before deriving; empty means the registry
86
+ * declares no floor for that profile, which is a real "witnesses nothing yet",
87
+ * not an unclassified return.
88
+ */
89
+ let v2Floors = null;
90
+ export function setV2ProfileFloors(floors) {
91
+ v2Floors = floors;
92
+ }
79
93
  export function requirementsFor(profile, document) {
94
+ if (v2Floors !== null) {
95
+ const files = v2Floors[profile];
96
+ if (files === undefined)
97
+ return null;
98
+ return files.map(requirementIdForScenario);
99
+ }
80
100
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
81
101
  if (floor === undefined)
82
102
  return null;
@@ -241,7 +241,11 @@ document) {
241
241
  // them. This is the granularity RFC 0148 §A describes and the G8 fix.
242
242
  const emitted = new Set(rows.map((r) => r.requirementId));
243
243
  for (const e of [...ledger].sort((a, b) => a.requirementId.localeCompare(b.requirementId))) {
244
- const file = scenarioFileOfItId(e.requirementId);
244
+ // Prefer the file the entry recorded: an explicit `req()` id
245
+ // (`openwop.requirement.…`) is authored, not file-derived, so deriving a
246
+ // file from the id alone returned null and the row was dropped — every
247
+ // explicit requirement id was missing from bundle v3 for that reason.
248
+ const file = e.scenarioFile ?? scenarioFileOfItId(e.requirementId);
245
249
  // Attribute only to files this run reported on: a worker's ledger can carry
246
250
  // rows from files outside the certified set (the suite's own lib tests, or
247
251
  // a filtered run), and those are not evidence about the host.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.0-rc.0",
4
- "stampSha256": "9596838d163c3c9bc51311776e20216fa6abcd9d315077af05f5301bab4fdcd9"
3
+ "version": "2.0.0-rc.10",
4
+ "stampSha256": "9bd46582060eb1408c0bcac781805f0cee84cd4b2955251bed4a5b328e19e4af"
5
5
  }