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

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 (72) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +209 -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/scenario-disposition.js +5 -1
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +2744 -4
  10. package/scenario-majors.json +155 -2
  11. package/schemas/CORPUS-STAMP.json +17 -17
  12. package/src/cli.ts +190 -19
  13. package/src/lib/certification-bundle-v3.ts +175 -0
  14. package/src/lib/driver.ts +9 -1
  15. package/src/lib/era2-seed.ts +197 -0
  16. package/src/lib/requirement-ids.ts +21 -0
  17. package/src/lib/requirement-ledger.ts +7 -1
  18. package/src/lib/scenario-disposition.ts +5 -1
  19. package/src/lib/seams.ts +45 -0
  20. package/src/lib/v2.ts +62 -0
  21. package/src/scenarios/v2-approver-enforced.test.ts +97 -0
  22. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
  23. package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
  24. package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
  25. package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
  26. package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
  27. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
  28. package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
  29. package/src/scenarios/v2-configurable-closed.test.ts +67 -0
  30. package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
  31. package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
  32. package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
  33. package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
  34. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
  35. package/src/scenarios/v2-era-key.test.ts +86 -0
  36. package/src/scenarios/v2-error-registry.test.ts +89 -0
  37. package/src/scenarios/v2-event-type-closed.test.ts +97 -0
  38. package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
  39. package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
  40. package/src/scenarios/v2-header-scheme.test.ts +67 -0
  41. package/src/scenarios/v2-id-grammar.test.ts +77 -0
  42. package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
  43. package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
  44. package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
  45. package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
  46. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
  47. package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
  48. package/src/scenarios/v2-min-client-version.test.ts +48 -0
  49. package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
  50. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
  51. package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
  52. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
  53. package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
  54. package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
  55. package/src/scenarios/v2-pack-isolation.test.ts +147 -0
  56. package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
  57. package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
  58. package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
  59. package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
  60. package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
  61. package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
  62. package/src/scenarios/v2-provider-conflict.test.ts +115 -0
  63. package/src/scenarios/v2-refresh-sla.test.ts +78 -0
  64. package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
  65. package/src/scenarios/v2-revocation-honored.test.ts +66 -0
  66. package/src/scenarios/v2-subject-link-record.test.ts +102 -0
  67. package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
  68. package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
  69. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
  70. package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
  71. package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
  72. 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 495 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: 495 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,8 @@ 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';
47
49
  function parseArgs(argv) {
48
50
  let baseUrl;
49
51
  let apiKey;
@@ -56,7 +58,10 @@ function parseArgs(argv) {
56
58
  // Suite 1.152.0: bundle v2 (RFC 0148) is the default. v1 stays reachable via
57
59
  // `--bundle-version 1` through the RFC 0148 migration window (ends
58
60
  // 2026-11-10) and is removed at v2.0 (spec/v1/deprecations.json).
59
- let bundleVersion = '2';
61
+ let bundleVersion = '3';
62
+ let hostBuild;
63
+ let evidenceTier = 'self';
64
+ let signingKeyPath, signingKeyId, verifierKeyPath, verifierKeyId;
60
65
  let targetMajor;
61
66
  let maxWorkers = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
62
67
  for (let i = 0; i < argv.length; i++) {
@@ -110,17 +115,47 @@ function parseArgs(argv) {
110
115
  implVersion = nextValue();
111
116
  break;
112
117
  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`);
118
+ const v = argv[++i];
119
+ if (v !== '2' && v !== '3') {
120
+ 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
121
  process.exit(2);
117
122
  }
123
+ if (v === '2')
124
+ 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
125
  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');
126
+ break;
127
+ }
128
+ case '--host-build': {
129
+ const v = argv[++i] ?? '';
130
+ const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(v);
131
+ if (!mm) {
132
+ process.stderr.write(`openwop-conformance: --host-build must be <image-digest|commit|artifact-sha256>:<id> (got ${JSON.stringify(v)})\n`);
133
+ process.exit(2);
134
+ }
135
+ hostBuild = { kind: mm[1], id: mm[2] };
136
+ break;
137
+ }
138
+ case '--evidence-tier': {
139
+ const v = argv[++i];
140
+ if (v !== 'self' && v !== 'steward' && v !== 'independent') {
141
+ process.stderr.write('openwop-conformance: --evidence-tier must be self, steward or independent\n');
142
+ process.exit(2);
121
143
  }
144
+ evidenceTier = v;
122
145
  break;
123
146
  }
147
+ case '--signing-key':
148
+ signingKeyPath = argv[++i];
149
+ break;
150
+ case '--signing-key-id':
151
+ signingKeyId = argv[++i];
152
+ break;
153
+ case '--verifier-key':
154
+ verifierKeyPath = argv[++i];
155
+ break;
156
+ case '--verifier-key-id':
157
+ verifierKeyId = argv[++i];
158
+ break;
124
159
  case '--certify':
125
160
  certify = nextValue();
126
161
  break;
@@ -144,6 +179,12 @@ function parseArgs(argv) {
144
179
  certify,
145
180
  bundleVersion,
146
181
  targetMajor,
182
+ hostBuild,
183
+ evidenceTier,
184
+ signingKeyPath,
185
+ signingKeyId,
186
+ verifierKeyPath,
187
+ verifierKeyId,
147
188
  maxWorkers,
148
189
  };
149
190
  }
@@ -183,7 +224,11 @@ Certification (RFC 0089):
183
224
  --target-major <1|2> Suite 2.0.0 (RFC 0168 §D.3): run the scenarios for this protocol
184
225
  major. Default: the host's preferredVersion (RFC 0179), else
185
226
  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
227
+ --host-build <k>:<id> v3: the build identity (image-digest|commit|artifact-sha256), or OPENWOP_HOST_BUILD.
228
+ --signing-key <pem> v3: Ed25519 private key (PKCS8 PEM) that signs the bundle, or OPENWOP_BUNDLE_SIGNING_KEY.
229
+ --signing-key-id <id> v3: the keyId the host publishes for that key, or OPENWOP_BUNDLE_SIGNING_KEY_ID.
230
+ --evidence-tier <t> v3: self | steward | independent (independent needs --verifier-key/--verifier-key-id).
231
+ --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
232
  §C) records per-requirement DISPOSITIONS instead of pass/fail/skip
188
233
  file lists, so "we could not check" stops being indistinguishable
189
234
  from "checked and it holds". See the note it prints.
@@ -245,6 +290,58 @@ function claimedProfilesFor(doc) {
245
290
  profiles.push('openwop-agent-platform');
246
291
  return profiles;
247
292
  }
293
+ /**
294
+ * The v2 profile registry is a set of predicates over the DECLARATION
295
+ * (RFC 0169 §C.1): every listed family present as a record, every listed
296
+ * metadata key present. The v1 derivation cannot stand in — `isCore` wants a
297
+ * root `protocolVersion` plus `supportedEnvelopes`/`schemaVersions`/`limits`,
298
+ * shapes a closed v2 root does not have — so a major-2 run claimed NOTHING and
299
+ * no v2 host could ever certify. Falls back to the empty set only when the
300
+ * registry is genuinely absent from the layout, and says so.
301
+ */
302
+ function claimedProfilesForV2(doc, conformanceRoot) {
303
+ // Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
304
+ // this package — instead of guessing directory shapes. Hand-rolled candidates
305
+ // found the registry in a repo checkout and missed it in every published
306
+ // install, where npm hoists the peer to a SIBLING package dir: the probe that
307
+ // walked one level up landed on the `@openwop/` scope directory, not a
308
+ // package, so a real host run silently claimed nothing.
309
+ const candidates = [];
310
+ try {
311
+ const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
312
+ candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
313
+ }
314
+ catch { /* not installed as a package; the repo-layout candidates below */ }
315
+ candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'));
316
+ const found = candidates.find((c) => existsSync(c));
317
+ if (found === undefined) {
318
+ process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
319
+ return [];
320
+ }
321
+ let registry;
322
+ try {
323
+ registry = JSON.parse(readFileSync(found, 'utf8'));
324
+ }
325
+ catch {
326
+ process.stderr.write(`openwop-conformance --certify: ${found} is unreadable; claimedProfiles is empty.\n`);
327
+ return [];
328
+ }
329
+ const root = doc;
330
+ const isRecord = (k) => {
331
+ const v = root[k];
332
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
333
+ };
334
+ const out = [];
335
+ for (const p of registry.profiles ?? []) {
336
+ if (typeof p.id !== 'string')
337
+ continue;
338
+ const families = Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : [];
339
+ const metadata = Array.isArray(p.predicate?.metadata) ? p.predicate.metadata.map(String) : [];
340
+ if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined))
341
+ out.push(p.id);
342
+ }
343
+ return out;
344
+ }
248
345
  /**
249
346
  * Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
250
347
  * the test-file basename (e.g. `discovery.test.ts`) to align with the basenames
@@ -294,11 +391,14 @@ function scenarioStatesFromReport(report) {
294
391
  async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
295
392
  let major = args.targetMajor;
296
393
  let source = 'flag';
394
+ let dualStack = false;
297
395
  if (major === undefined && baseUrl) {
298
396
  try {
299
397
  const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} });
300
398
  const doc = (await res.json());
301
399
  const pv = doc.preferredVersion ?? (doc.protocolVersions ?? []).map((v) => v).sort((a, b) => Number(b.split('.')[0]) - Number(a.split('.')[0]))[0];
400
+ if ((doc.protocolVersions ?? []).some((v) => v.startsWith('2.')))
401
+ dualStack = true;
302
402
  if (pv) {
303
403
  major = Number(pv.split('.')[0]) >= 2 ? 2 : 1;
304
404
  source = doc.preferredVersion ? 'preferredVersion' : 'max(protocolVersions[])';
@@ -310,6 +410,11 @@ async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
310
410
  major = 1;
311
411
  source = 'default';
312
412
  }
413
+ // Through the overlap `preferredVersion` names the 1.x member (versioning.md
414
+ // §1.1), so auto-detection on a dual-stack host resolves to major 1 by design.
415
+ // Say so, or an operator reads a v1 run as the host's only option.
416
+ if (major === 1 && dualStack)
417
+ source += ' (the host also serves 2.x — pass --target-major 2 to measure it)';
313
418
  const manifest = JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8'));
314
419
  const files = Object.entries(manifest.majors).filter(([, m]) => m.includes(major)).map(([f]) => `src/scenarios/${f}`);
315
420
  return { major, files, source };
@@ -319,10 +424,19 @@ async function runCertify(args, baseUrl, apiKey) {
319
424
  if (outPath === undefined)
320
425
  process.exit(2);
321
426
  // (a) Fetch /.well-known/openwop verbatim + its canonical-JSON SHA-256.
427
+ // The target is resolved FIRST: `/.well-known/openwop` is one resource whose
428
+ // representation the `OpenWOP-Version` header selects, and through the overlap
429
+ // the header-less representation is the v1 document (spec/v2/core/versioning.md
430
+ // §1.1). Fetching it header-less on a major-2 run captured the v1 rendering, so
431
+ // `claimedProfiles` came out as the v1 set and the bundle described a contract
432
+ // the run never measured.
433
+ const here = dirname(fileURLToPath(import.meta.url));
434
+ const conformanceRoot = resolvePath(here, '..');
435
+ const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
322
436
  const discoveryUrl = `${baseUrl.replace(/\/$/, '')}/.well-known/openwop`;
323
437
  let document;
324
438
  try {
325
- const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json' } });
439
+ const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json', ...(target.major === 2 ? { 'OpenWOP-Version': '2.0' } : {}) } });
326
440
  if (!resp.ok) {
327
441
  process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
328
442
  process.exit(2);
@@ -335,11 +449,9 @@ async function runCertify(args, baseUrl, apiKey) {
335
449
  }
336
450
  const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
337
451
  // (b) Derive claimedProfiles from the captured document.
338
- const claimedProfiles = claimedProfilesFor(document);
452
+ const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
339
453
  // (c) Run the suite, capturing per-scenario terminal state via the vitest
340
454
  // JSON reporter. server-targeted scenarios live under src/scenarios/.
341
- const here = dirname(fileURLToPath(import.meta.url));
342
- const conformanceRoot = resolvePath(here, '..');
343
455
  const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
344
456
  const reportFile = join(reportDir, 'vitest-report.json');
345
457
  // RFC 0148 §A ledger sink (S6): every scenario file records its disposition
@@ -354,7 +466,6 @@ async function runCertify(args, baseUrl, apiKey) {
354
466
  env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
355
467
  if (args.implVersion)
356
468
  env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
357
- const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
358
469
  env.OPENWOP_TARGET_MAJOR = String(target.major);
359
470
  process.stderr.write(`openwop-conformance --certify: target major ${target.major} (${target.source}); ${target.files.length} scenario file(s)\n`);
360
471
  const vitestArgs = [
@@ -460,6 +571,90 @@ async function runCertify(args, baseUrl, apiKey) {
460
571
  ' evidence is inspectable, but it does not certify and this process exits 3.\n' +
461
572
  rejectedProfiles.map((v) => ` - ${v.profile}: ${v.unclassified.join(', ')}\n`).join(''));
462
573
  }
574
+ if (args.bundleVersion === '3') {
575
+ // ── Bundle v3 (RFC 0168 §E) ─────────────────────────────────────────────
576
+ const build = args.hostBuild ?? (() => {
577
+ const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(process.env['OPENWOP_HOST_BUILD'] ?? '');
578
+ return mm ? { kind: mm[1], id: mm[2] } : undefined;
579
+ })();
580
+ const signingKeyPem = args.signingKeyPath ? readFileSync(args.signingKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_SIGNING_KEY'];
581
+ const keyId = args.signingKeyId ?? process.env['OPENWOP_BUNDLE_SIGNING_KEY_ID'];
582
+ if (!build || !signingKeyPem || !keyId) {
583
+ 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');
584
+ process.exit(2);
585
+ }
586
+ 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 }) }));
587
+ const totals3 = derived.totals;
588
+ const doc3 = document;
589
+ const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
590
+ const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? doc3['preferredVersion'] : (protocolVersions[0] ?? '');
591
+ // witnessCount: executed-pass rows on the profile's floor (PROFILE_FLOOR_SCENARIOS required + requiredAnyPrefix).
592
+ const witnessCountFor = (profile) => {
593
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
594
+ if (!floor)
595
+ return 0;
596
+ const onFloor = (scenario) => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
597
+ return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
598
+ };
599
+ 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 }));
600
+ let relaxations;
601
+ if (process.env['OPENWOP_HOST_RELAXATIONS']) {
602
+ try {
603
+ relaxations = JSON.parse(process.env['OPENWOP_HOST_RELAXATIONS']);
604
+ }
605
+ catch {
606
+ process.stderr.write('openwop-conformance --certify: OPENWOP_HOST_RELAXATIONS is not JSON\n');
607
+ process.exit(2);
608
+ }
609
+ }
610
+ const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
611
+ const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : undefined;
612
+ const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
613
+ const unsigned = {
614
+ bundleVersion: '3',
615
+ generatedAt: new Date().toISOString(),
616
+ suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
617
+ host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
618
+ discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion },
619
+ claimedProfiles: claimed3,
620
+ results: { totals: totals3, requirements: rows3 },
621
+ witnessSha256: witnessDigest(rows3),
622
+ assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
623
+ ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
624
+ };
625
+ const signature = signBundleV3(unsigned, signingKeyPem, keyId);
626
+ const v3 = { ...unsigned, signature };
627
+ if (args.evidenceTier === 'independent') {
628
+ const vk = args.verifierKeyPath ? readFileSync(args.verifierKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_VERIFIER_KEY'];
629
+ const vkId = args.verifierKeyId ?? process.env['OPENWOP_BUNDLE_VERIFIER_KEY_ID'];
630
+ if (!vk || !vkId) {
631
+ process.stderr.write('openwop-conformance --certify: --evidence-tier independent needs --verifier-key and --verifier-key-id (RFC 0168 §E.2)\n');
632
+ process.exit(2);
633
+ }
634
+ v3.verifierSignature = verifierSign(unsigned, vk, vkId);
635
+ }
636
+ const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem]);
637
+ const scrubbed3 = scrubEvidence(v3, secrets3);
638
+ const v3Out = scrubbed3.value;
639
+ const audit3 = verifyBundleV3(v3Out, { hostPublicKeyPem: publicKeyFromPrivate(signingKeyPem) });
640
+ const emitterDefects = audit3.rejections.filter((r) => !['blocked-certified', 'relaxed-profile-certified', 'independent-unverifiable'].includes(r.kind));
641
+ if (emitterDefects.length > 0) {
642
+ 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');
643
+ process.exit(2);
644
+ }
645
+ const v3Schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'certification-bundle.schema.json'), 'utf8'));
646
+ const v3Ajv = new Ajv2020({ allErrors: true, strict: false });
647
+ addFormats(v3Ajv);
648
+ v3Ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'ids.schema.json'), 'utf8')));
649
+ const v3Validate = v3Ajv.compile(v3Schema);
650
+ if (!v3Validate(v3Out)) {
651
+ process.stderr.write('openwop-conformance --certify: assembled v3 bundle FAILED schema validation:\n' + `${JSON.stringify(v3Validate.errors, null, 2)}\n`);
652
+ process.exit(2);
653
+ }
654
+ writeFileSync(outPath, `${JSON.stringify(v3Out, null, 2)}\n`);
655
+ 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`);
656
+ process.exit(rejectedProfiles.length > 0 ? 3 : failed.length > 0 ? 1 : 0);
657
+ }
463
658
  if (args.bundleVersion === '2') {
464
659
  const scenarioIds = [...passed, ...failed, ...skipped].sort();
465
660
  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);
@@ -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.2",
4
+ "stampSha256": "b238d58665762e832fcd76df7149671705ffa588969cc80737358255d29488c9"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.0",
3
+ "version": "2.0.0-rc.2",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.0.0-rc.0"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.2"
60
60
  }
61
61
  }