@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.
- package/README.md +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- 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
|
|
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:
|
|
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 {
|
|
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 = '
|
|
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 =
|
|
114
|
-
if (v !== '
|
|
115
|
-
process.stderr.write(
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.2"
|
|
60
60
|
}
|
|
61
61
|
}
|