@openwop/openwop-conformance 2.0.7 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/dist/cli.js +15 -47
- package/dist/lib/certification-bundle-v3.js +30 -6
- package/dist/lib/profiles.js +28 -2
- package/dist/lib/v2-profiles.js +106 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/schemas/CORPUS-STAMP.json +8 -8
- package/src/cli.ts +15 -48
- package/src/lib/certification-bundle-v3.ts +28 -5
- package/src/lib/profiles.ts +28 -2
- package/src/lib/v2-profiles.ts +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.8] — 2026-09-07 — the verifier asked a v1 question about v2 hosts
|
|
4
|
+
|
|
5
|
+
No host behaviour changes and no wire changes. One defect in the suite's own
|
|
6
|
+
verifier, which had been refusing correct bundles from correct hosts.
|
|
7
|
+
|
|
8
|
+
**`profileDerivable` now takes the target major, and major 2 derives from
|
|
9
|
+
`spec/v2/profiles.json`.** There were two implementations of "does this
|
|
10
|
+
document derive this profile", and only one of them knew that major 2 exists.
|
|
11
|
+
The EMITTER branched on the target major and, at 2, read the v2 registry: every
|
|
12
|
+
listed family present as a record, every listed metadata key present
|
|
13
|
+
(RFC 0169 §C.1). The VERIFIER called `profileDerivable`, which is the v1
|
|
14
|
+
catalog — `isCore` wants a scalar `protocolVersion` whose major is `1`, plus
|
|
15
|
+
`supportedEnvelopes`, `schemaVersions` and three `limits` integers. A v2
|
|
16
|
+
declaration has none of those. So the verifier's answer for every real v2 host
|
|
17
|
+
was `false`, and a bundle correctly claiming `openwop-discovery-core` was
|
|
18
|
+
refused with `profile-not-derivable`: *"the host does not advertise it"*, about
|
|
19
|
+
a host that advertised exactly it.
|
|
20
|
+
|
|
21
|
+
Both derivations now live in `lib/v2-profiles.ts` and both callers use it, so
|
|
22
|
+
they cannot drift apart again. That is the point of the module boundary, not
|
|
23
|
+
tidiness.
|
|
24
|
+
|
|
25
|
+
**Reported by `myndhyve-1`, corroborated by `openwop-app-1`.** Two independent
|
|
26
|
+
hosts, three suite versions, and the diagnosis was settled across them before a
|
|
27
|
+
line was written here. Neither host had anything to fix.
|
|
28
|
+
|
|
29
|
+
**The trigger was ours, and it was an honesty fix.** This defect is older than
|
|
30
|
+
the reports — it has been wrong since major 2 existed — but it was
|
|
31
|
+
*unreachable* until 2.0.5 taught the bundle to carry `discovery.document`.
|
|
32
|
+
Before that the verifier had nothing to derive from and skipped the check
|
|
33
|
+
entirely. In `myndhyve-1`'s words, which are better than the ones this entry
|
|
34
|
+
started with: **"the defect did not become reachable when a host got healthier;
|
|
35
|
+
it became reachable when the bundle got more honest."** A reader who thinks
|
|
36
|
+
this tracks host health will draw the wrong conclusion about who is exposed.
|
|
37
|
+
The population is *every host cutting on 2.0.5 or later that claims a v2
|
|
38
|
+
profile* — not hosts whose floors went green.
|
|
39
|
+
|
|
40
|
+
**An unreadable registry is a gap, not a refusal.** `v2ProfileIds` returns
|
|
41
|
+
`null`, not `[]`, when `spec/v2/profiles.json` cannot be read, and the v3
|
|
42
|
+
verifier records `derivabilityChecked: false` instead of rejecting. `[]` would
|
|
43
|
+
have made every profile underivable and refused the bundle — converting a fact
|
|
44
|
+
about *this install's layout* into a verdict about the *host*, which
|
|
45
|
+
`conformance.md` §"Whose fact is the reason?" (2.0.7) forbids by name.
|
|
46
|
+
|
|
47
|
+
**The test that certified the bug.** `certification-bundle-v3.test.ts` had one
|
|
48
|
+
row exercising derivability at major 2, and its fixture was a *v1-shaped*
|
|
49
|
+
document (`protocolVersion: '1.11'`, `supportedEnvelopes`, `limits`) inside a
|
|
50
|
+
`targetMajor: 2` bundle. It passed, and it made the v1-predicate verifier look
|
|
51
|
+
correct while that verifier refused every real host. The fixture is now major-2
|
|
52
|
+
shaped, and three added rows pin the dispatch in both directions: a v2
|
|
53
|
+
declaration derives at major 2 and not at major 1, a v1 payload the reverse.
|
|
54
|
+
Disabling the dispatch turns three of them red.
|
|
55
|
+
|
|
56
|
+
**Not changed, and deliberately.** `isCore` stays exactly as it is and stays
|
|
57
|
+
v1-only. `certification-bundle-verify.ts` and `verifyBundleProfile` are
|
|
58
|
+
bundle-format-v2 readers over v1-era evidence with no target major to read;
|
|
59
|
+
they take the default and are untouched. And the `if (!p.certified) continue;`
|
|
60
|
+
guard stays: RFC 0148 §B(1) binds the *certification*, not the listing, so a
|
|
61
|
+
profile a bundle names without certifying makes no claim for derivability to
|
|
62
|
+
falsify. An earlier plan for this release said the guard would be made loud
|
|
63
|
+
anyway; writing it showed the change has no consumer — it would have added a
|
|
64
|
+
verdict field nobody reads, on a hypothesis no measured bundle isolates.
|
|
65
|
+
Reasoning is not a measurement, and inventing surface to dress it as one is the
|
|
66
|
+
opposite of what this thread has been about.
|
|
67
|
+
|
|
68
|
+
**`v2RegistryPath` is collapsed onto `SPEC_V2_DIR`.** The old copy in `cli.ts`
|
|
69
|
+
resolved the peer through Node's resolver *and then kept three guessed
|
|
70
|
+
directory candidates underneath it*, beneath a docblock stating that it
|
|
71
|
+
resolved "instead of guessing directory shapes". The guesses were the half of
|
|
72
|
+
that fix that never landed.
|
|
73
|
+
|
|
3
74
|
## [2.0.7] — 2026-09-06 — three claims of coverage that were not coverage
|
|
4
75
|
|
|
5
76
|
No host behaviour changes. One gate changes disposition, and the two prose
|
package/dist/cli.js
CHANGED
|
@@ -34,7 +34,6 @@
|
|
|
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';
|
|
38
37
|
import { createHash } from 'node:crypto';
|
|
39
38
|
import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
40
39
|
import { tmpdir } from 'node:os';
|
|
@@ -47,6 +46,7 @@ import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/cer
|
|
|
47
46
|
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
|
|
48
47
|
import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
|
|
49
48
|
import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
|
|
49
|
+
import { v2ProfileIds } from './lib/v2-profiles.js';
|
|
50
50
|
function parseArgs(argv) {
|
|
51
51
|
let baseUrl;
|
|
52
52
|
let apiKey;
|
|
@@ -297,54 +297,22 @@ function claimedProfilesFor(doc) {
|
|
|
297
297
|
* metadata key present. The v1 derivation cannot stand in — `isCore` wants a
|
|
298
298
|
* root `protocolVersion` plus `supportedEnvelopes`/`schemaVersions`/`limits`,
|
|
299
299
|
* shapes a closed v2 root does not have — so a major-2 run claimed NOTHING and
|
|
300
|
-
* no v2 host could ever certify.
|
|
301
|
-
*
|
|
300
|
+
* no v2 host could ever certify.
|
|
301
|
+
*
|
|
302
|
+
* The derivation itself now lives in `lib/v2-profiles.ts`, because the VERIFIER
|
|
303
|
+
* needs the same answer and had been computing a different one: it asked the v1
|
|
304
|
+
* predicates about v2 documents and refused every real major-2 bundle. Emitter
|
|
305
|
+
* and verifier share one function so they cannot drift apart again. All this
|
|
306
|
+
* wrapper adds is the operator-facing warning — a CLI concern, not a
|
|
307
|
+
* derivation one.
|
|
302
308
|
*/
|
|
303
|
-
function
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
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');
|
|
309
|
+
function claimedProfilesForV2(doc) {
|
|
310
|
+
const ids = v2ProfileIds(doc);
|
|
311
|
+
if (ids === null) {
|
|
312
|
+
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found or unreadable in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
323
313
|
return [];
|
|
324
314
|
}
|
|
325
|
-
|
|
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;
|
|
315
|
+
return [...ids];
|
|
348
316
|
}
|
|
349
317
|
/**
|
|
350
318
|
* `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
|
|
@@ -474,7 +442,7 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
474
442
|
}
|
|
475
443
|
const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
476
444
|
// (b) Derive claimedProfiles from the captured document.
|
|
477
|
-
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document
|
|
445
|
+
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document) : claimedProfilesFor(document);
|
|
478
446
|
// Major-2 floors come from spec/v2/profiles.json. Without this the derivation
|
|
479
447
|
// measures a v2 host against v1 scenario files a major-2 run never executes,
|
|
480
448
|
// and refuses certification for not running them.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
21
21
|
import { profileDerivable } from './profiles.js';
|
|
22
|
+
import { v2RegistryAvailable } from './v2-profiles.js';
|
|
22
23
|
export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
|
|
23
24
|
/** Deterministic JSON: keys sorted at every level, no whitespace. */
|
|
24
25
|
export function canonicalJSON(value) {
|
|
@@ -143,12 +144,35 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
143
144
|
rejections.push({ kind: 'discovery-digest', detail: `discovery.document hashes to ${digest.slice(0, 12)} but discovery.sha256 is ${String(bundle.discovery?.sha256).slice(0, 12)} — the captured document is not the one the signature attests to` });
|
|
144
145
|
}
|
|
145
146
|
else {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
// WHICH catalog decides derivability is the bundle's own `targetMajor`,
|
|
148
|
+
// not this verifier's assumption. Reading the old code: it always asked
|
|
149
|
+
// the v1 predicates, which require a scalar `protocolVersion` of major 1
|
|
150
|
+
// plus `supportedEnvelopes`/`schemaVersions`/`limits`. A v2 declaration
|
|
151
|
+
// (RFC 0169 §C.1) carries `protocolVersions`/`preferredVersion` and
|
|
152
|
+
// family records instead, so the answer for every real major-2 host was
|
|
153
|
+
// `false` and this loop rejected `openwop-discovery-core` as "not
|
|
154
|
+
// advertised" on a document that advertises it. Two hosts hit it the
|
|
155
|
+
// moment their bundles began carrying `discovery.document` in 2.0.5.
|
|
156
|
+
const targetMajor = bundle.suite?.targetMajor === 2 ? 2 : 1;
|
|
157
|
+
// A major-2 bundle needs the v2 registry to decide anything. Its absence
|
|
158
|
+
// is a fact about THIS INSTALL's layout, not about the host, so it is
|
|
159
|
+
// recorded as the gap v3 already has a flag for rather than spent as a
|
|
160
|
+
// rejection (`conformance.md` §"Whose fact is the reason?"). Refusing a
|
|
161
|
+
// host because our own corpus file is missing is the substitution that
|
|
162
|
+
// rule exists to forbid.
|
|
163
|
+
if (targetMajor === 2 && !v2RegistryAvailable()) {
|
|
164
|
+
derivabilityChecked = false;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
derivabilityChecked = true;
|
|
168
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
169
|
+
// §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
|
|
170
|
+
// names without certifying makes no claim for derivability to falsify.
|
|
171
|
+
if (!p.certified)
|
|
172
|
+
continue;
|
|
173
|
+
if (!profileDerivable(document, p.id, targetMajor)) {
|
|
174
|
+
rejections.push({ kind: 'profile-not-derivable', profile: p.id, detail: `${p.id} is marked certified, but the captured discovery document does not derive it at major ${targetMajor} (RFC 0148 §B(1)) — evidence cannot certify a profile the host does not advertise` });
|
|
175
|
+
}
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
}
|
package/dist/lib/profiles.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
*
|
|
15
15
|
* **Derivation is deterministic and pure.** Same payload, same profile
|
|
16
16
|
* set. No time-of-day, host-specific state, or hidden inputs.
|
|
17
|
+
*
|
|
18
|
+
* Major 2 derives from `spec/v2/profiles.json` instead; `profileDerivable`
|
|
19
|
+
* dispatches on the target major and `./v2-profiles.js` holds that half. The
|
|
20
|
+
* import is one-way at runtime — v2-profiles takes only the `DiscoveryPayload`
|
|
21
|
+
* TYPE from here, which erases at compile time.
|
|
17
22
|
*/
|
|
23
|
+
import { v2ProfileDerivable } from './v2-profiles.js';
|
|
18
24
|
/**
|
|
19
25
|
* Closed v1.x catalog. Adding a profile requires an RFC per
|
|
20
26
|
* `RFCS/0001-rfc-process.md`.
|
|
@@ -583,8 +589,28 @@ export const PROFILE_FLOOR_SCENARIOS = {
|
|
|
583
589
|
],
|
|
584
590
|
},
|
|
585
591
|
};
|
|
586
|
-
/**
|
|
587
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Is `profile` derivable from a discovery document? Maps a profile name to its
|
|
594
|
+
* predicate (RFC 0089 §B(1)).
|
|
595
|
+
*
|
|
596
|
+
* `targetMajor` selects WHICH catalog answers. The v1 catalog below is a set of
|
|
597
|
+
* predicates over a v1 discovery payload — `isCore` alone requires a scalar
|
|
598
|
+
* `protocolVersion` with major `1`, `supportedEnvelopes`, `schemaVersions` and
|
|
599
|
+
* three `limits` integers. A v2 declaration has none of them (RFC 0169 §C.1
|
|
600
|
+
* restructured the root), so asking these predicates about a v2 document does
|
|
601
|
+
* not return an unknown: it returns `false`, confidently, for a host that
|
|
602
|
+
* advertises the profile. Major 2 delegates to the registry-driven derivation
|
|
603
|
+
* the emitter already used.
|
|
604
|
+
*
|
|
605
|
+
* The default is `1` so every v1-era call site — `verifyBundleProfile` below,
|
|
606
|
+
* the bundle-format-v2 verifier — keeps the behaviour it was written against.
|
|
607
|
+
* A v2 caller opts in explicitly; the v3 verifier reads the major off
|
|
608
|
+
* `bundle.suite.targetMajor` rather than a flag of its own, so verification
|
|
609
|
+
* stays self-contained in the bundle.
|
|
610
|
+
*/
|
|
611
|
+
export function profileDerivable(c, profile, targetMajor = 1) {
|
|
612
|
+
if (targetMajor === 2)
|
|
613
|
+
return v2ProfileDerivable(c, profile);
|
|
588
614
|
if (profile === 'openwop-core-standard')
|
|
589
615
|
return isCoreStandard(c);
|
|
590
616
|
if (profile === 'openwop-agent-platform')
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The major-2 profile derivation: `spec/v2/profiles.json` as a predicate over a
|
|
3
|
+
* DECLARATION (RFC 0169 §C.1).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is its own module
|
|
6
|
+
*
|
|
7
|
+
* There were two implementations of "does this document derive this profile",
|
|
8
|
+
* and only one of them knew that major 2 exists.
|
|
9
|
+
*
|
|
10
|
+
* The EMITTER (`cli.ts`) branched on the target major and, at 2, read this
|
|
11
|
+
* registry. The VERIFIER (`certification-bundle-v3.ts`) called
|
|
12
|
+
* `profiles.profileDerivable`, which is the v1 catalog: `isCore` wants a scalar
|
|
13
|
+
* `protocolVersion` whose major is `1`, plus `supportedEnvelopes`,
|
|
14
|
+
* `schemaVersions` and `limits.clarificationRounds`. A v2 declaration has none
|
|
15
|
+
* of those — RFC 0169 restructured the root into `protocolVersions` /
|
|
16
|
+
* `preferredVersion` and family records — so the verifier's answer for every
|
|
17
|
+
* real v2 host was `false`, and a bundle that correctly claimed
|
|
18
|
+
* `openwop-discovery-core` was refused with `profile-not-derivable`: "the host
|
|
19
|
+
* does not advertise it", about a host that advertised exactly it.
|
|
20
|
+
*
|
|
21
|
+
* The emitter and the verifier now call THIS function, so they cannot disagree
|
|
22
|
+
* again. That is the point of the module boundary — not tidiness.
|
|
23
|
+
*
|
|
24
|
+
* ## Unevaluable is not false
|
|
25
|
+
*
|
|
26
|
+
* `v2ProfileIds` returns `null`, not `[]`, when the registry cannot be read.
|
|
27
|
+
* The distinction is load-bearing. `[]` would make every certified profile
|
|
28
|
+
* underivable and reject the bundle — converting a fact about the SUITE'S
|
|
29
|
+
* layout (the corpus file is missing from this install) into a verdict about
|
|
30
|
+
* the HOST (it does not advertise what it advertises). `conformance.md`
|
|
31
|
+
* §"Whose fact is the reason?" forbids exactly that substitution: where the
|
|
32
|
+
* predicate is a fact about the suite, the row records the gap rather than
|
|
33
|
+
* spending the host's evidence. Here the gap is `derivabilityChecked: false`,
|
|
34
|
+
* the flag v3 already carries for a bundle that shipped no document.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { SPEC_V2_DIR } from './paths.js';
|
|
39
|
+
/**
|
|
40
|
+
* Path to `spec/v2/profiles.json`, or null when this layout has no v2 corpus.
|
|
41
|
+
*
|
|
42
|
+
* Anchored on `SPEC_V2_DIR` — one resolver, already correct for all three
|
|
43
|
+
* layouts (env override, repo checkout, published install with the
|
|
44
|
+
* `@openwop/spec-artifacts` peer). The shape this replaces resolved the peer
|
|
45
|
+
* through Node AND kept three guessed directory candidates underneath it,
|
|
46
|
+
* under a docblock that said it resolved "instead of guessing directory
|
|
47
|
+
* shapes". The guesses were the half of that fix that never landed.
|
|
48
|
+
*/
|
|
49
|
+
export function v2RegistryPath() {
|
|
50
|
+
if (SPEC_V2_DIR === null)
|
|
51
|
+
return null;
|
|
52
|
+
const path = join(SPEC_V2_DIR, 'profiles.json');
|
|
53
|
+
return existsSync(path) ? path : null;
|
|
54
|
+
}
|
|
55
|
+
function readRegistry() {
|
|
56
|
+
const found = v2RegistryPath();
|
|
57
|
+
if (found === null)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(found, 'utf8'));
|
|
61
|
+
return parsed.profiles ?? [];
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True when the registry is present AND parseable, so derivability can be decided at all. */
|
|
68
|
+
export function v2RegistryAvailable() {
|
|
69
|
+
return readRegistry() !== null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every profile the declaration derives at major 2, or `null` when the registry
|
|
73
|
+
* is unavailable (see the module docblock — `null` is not `[]`).
|
|
74
|
+
*
|
|
75
|
+
* The predicate is the registry's own: every listed family present as a record,
|
|
76
|
+
* every listed metadata key present at the root. Nothing here is hand-written
|
|
77
|
+
* per profile, so adding a v2 profile is a corpus edit and not a code edit.
|
|
78
|
+
*/
|
|
79
|
+
export function v2ProfileIds(doc) {
|
|
80
|
+
const profiles = readRegistry();
|
|
81
|
+
if (profiles === null)
|
|
82
|
+
return null;
|
|
83
|
+
const root = doc;
|
|
84
|
+
const isRecord = (k) => {
|
|
85
|
+
const v = root[k];
|
|
86
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
87
|
+
};
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const p of profiles) {
|
|
90
|
+
if (typeof p.id !== 'string')
|
|
91
|
+
continue;
|
|
92
|
+
const families = Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : [];
|
|
93
|
+
const metadata = Array.isArray(p.predicate?.metadata) ? p.predicate.metadata.map(String) : [];
|
|
94
|
+
if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined))
|
|
95
|
+
out.push(p.id);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Is `profile` derivable from `doc` at major 2? False when the registry is
|
|
101
|
+
* unavailable — callers that must distinguish "not derivable" from "could not
|
|
102
|
+
* be decided" check {@link v2RegistryAvailable} first, as the v3 verifier does.
|
|
103
|
+
*/
|
|
104
|
+
export function v2ProfileDerivable(doc, profile) {
|
|
105
|
+
return v2ProfileIds(doc)?.includes(profile) ?? false;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.8"
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.
|
|
5
|
-
"corpusTag": "v2.0.
|
|
4
|
+
"version": "2.0.8",
|
|
5
|
+
"corpusTag": "v2.0.8",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "86cac3079b27351e7f5e30f63393a25501584b8a38c893d1d7a65802c18fbb24",
|
|
13
|
+
"api/v2/asyncapi.yaml": "b1b44dac3dd253509b036aaa30cafdac500311ef0f5045646651ddff184fe379",
|
|
14
|
+
"api/v2/openapi.yaml": "516bf500bb740400b679550439b69996a46d07062c8137fdd95c60c9e81bffb2",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "c118d81c173a26a9349dba9a9958c310c658f798564540c26c3e614a3fdd3514",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -268,8 +268,8 @@
|
|
|
268
268
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
269
269
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
270
270
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
271
|
-
"spec/v2/release.json": "
|
|
271
|
+
"spec/v2/release.json": "5bf850da0ebcc8aaedbc9ded9064a760d4de798061ebeff0d8b890d78dc1d13a",
|
|
272
272
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
273
273
|
},
|
|
274
|
-
"corpusCommit": "
|
|
274
|
+
"corpusCommit": "949920131eb3c035a14d6fb1d3e60163949803bb"
|
|
275
275
|
}
|
package/src/cli.ts
CHANGED
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
import { spawnSync } from 'node:child_process';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
38
|
-
import { createRequire } from 'node:module';
|
|
39
38
|
import { createHash } from 'node:crypto';
|
|
40
39
|
import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
41
40
|
import { tmpdir } from 'node:os';
|
|
@@ -55,6 +54,7 @@ import {
|
|
|
55
54
|
PROFILE_FLOOR_SCENARIOS,
|
|
56
55
|
} from './lib/profiles.js';
|
|
57
56
|
import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
|
|
57
|
+
import { v2ProfileIds } from './lib/v2-profiles.js';
|
|
58
58
|
|
|
59
59
|
interface ParsedArgs {
|
|
60
60
|
readonly baseUrl: string | undefined;
|
|
@@ -318,55 +318,22 @@ function claimedProfilesFor(doc: DiscoveryPayload): string[] {
|
|
|
318
318
|
* metadata key present. The v1 derivation cannot stand in — `isCore` wants a
|
|
319
319
|
* root `protocolVersion` plus `supportedEnvelopes`/`schemaVersions`/`limits`,
|
|
320
320
|
* shapes a closed v2 root does not have — so a major-2 run claimed NOTHING and
|
|
321
|
-
* no v2 host could ever certify.
|
|
322
|
-
*
|
|
321
|
+
* no v2 host could ever certify.
|
|
322
|
+
*
|
|
323
|
+
* The derivation itself now lives in `lib/v2-profiles.ts`, because the VERIFIER
|
|
324
|
+
* needs the same answer and had been computing a different one: it asked the v1
|
|
325
|
+
* predicates about v2 documents and refused every real major-2 bundle. Emitter
|
|
326
|
+
* and verifier share one function so they cannot drift apart again. All this
|
|
327
|
+
* wrapper adds is the operator-facing warning — a CLI concern, not a
|
|
328
|
+
* derivation one.
|
|
323
329
|
*/
|
|
324
|
-
function
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
|
|
329
|
-
} catch { /* not installed as a package; the repo-layout candidates below */ }
|
|
330
|
-
candidates.push(
|
|
331
|
-
resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'),
|
|
332
|
-
resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'),
|
|
333
|
-
resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'),
|
|
334
|
-
);
|
|
335
|
-
return candidates.find((c) => existsSync(c)) ?? null;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): string[] {
|
|
339
|
-
// Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
|
|
340
|
-
// this package — instead of guessing directory shapes. Hand-rolled candidates
|
|
341
|
-
// found the registry in a repo checkout and missed it in every published
|
|
342
|
-
// install, where npm hoists the peer to a SIBLING package dir: the probe that
|
|
343
|
-
// walked one level up landed on the `@openwop/` scope directory, not a
|
|
344
|
-
// package, so a real host run silently claimed nothing.
|
|
345
|
-
const found = v2RegistryPath(conformanceRoot);
|
|
346
|
-
if (found === null) {
|
|
347
|
-
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
330
|
+
function claimedProfilesForV2(doc: DiscoveryPayload): string[] {
|
|
331
|
+
const ids = v2ProfileIds(doc);
|
|
332
|
+
if (ids === null) {
|
|
333
|
+
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found or unreadable in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
348
334
|
return [];
|
|
349
335
|
}
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
registry = JSON.parse(readFileSync(found, 'utf8'));
|
|
353
|
-
} catch {
|
|
354
|
-
process.stderr.write(`openwop-conformance --certify: ${found} is unreadable; claimedProfiles is empty.\n`);
|
|
355
|
-
return [];
|
|
356
|
-
}
|
|
357
|
-
const root = doc as unknown as Record<string, unknown>;
|
|
358
|
-
const isRecord = (k: string): boolean => {
|
|
359
|
-
const v = root[k];
|
|
360
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
361
|
-
};
|
|
362
|
-
const out: string[] = [];
|
|
363
|
-
for (const p of registry.profiles ?? []) {
|
|
364
|
-
if (typeof p.id !== 'string') continue;
|
|
365
|
-
const families = Array.isArray(p.predicate?.families) ? (p.predicate.families as unknown[]).map(String) : [];
|
|
366
|
-
const metadata = Array.isArray(p.predicate?.metadata) ? (p.predicate.metadata as unknown[]).map(String) : [];
|
|
367
|
-
if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined)) out.push(p.id);
|
|
368
|
-
}
|
|
369
|
-
return out;
|
|
336
|
+
return [...ids];
|
|
370
337
|
}
|
|
371
338
|
|
|
372
339
|
/**
|
|
@@ -500,7 +467,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
500
467
|
const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
501
468
|
|
|
502
469
|
// (b) Derive claimedProfiles from the captured document.
|
|
503
|
-
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document
|
|
470
|
+
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document) : claimedProfilesFor(document);
|
|
504
471
|
// Major-2 floors come from spec/v2/profiles.json. Without this the derivation
|
|
505
472
|
// measures a v2 host against v1 scenario files a major-2 run never executes,
|
|
506
473
|
// and refuses certification for not running them.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
|
|
21
21
|
import { profileDerivable, type DiscoveryPayload } from './profiles.js';
|
|
22
|
+
import { v2RegistryAvailable } from './v2-profiles.js';
|
|
22
23
|
|
|
23
24
|
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
24
25
|
|
|
@@ -201,11 +202,33 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
201
202
|
if (digest !== bundle.discovery?.sha256) {
|
|
202
203
|
rejections.push({ kind: 'discovery-digest', detail: `discovery.document hashes to ${digest.slice(0, 12)} but discovery.sha256 is ${String(bundle.discovery?.sha256).slice(0, 12)} — the captured document is not the one the signature attests to` });
|
|
203
204
|
} else {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
// WHICH catalog decides derivability is the bundle's own `targetMajor`,
|
|
206
|
+
// not this verifier's assumption. Reading the old code: it always asked
|
|
207
|
+
// the v1 predicates, which require a scalar `protocolVersion` of major 1
|
|
208
|
+
// plus `supportedEnvelopes`/`schemaVersions`/`limits`. A v2 declaration
|
|
209
|
+
// (RFC 0169 §C.1) carries `protocolVersions`/`preferredVersion` and
|
|
210
|
+
// family records instead, so the answer for every real major-2 host was
|
|
211
|
+
// `false` and this loop rejected `openwop-discovery-core` as "not
|
|
212
|
+
// advertised" on a document that advertises it. Two hosts hit it the
|
|
213
|
+
// moment their bundles began carrying `discovery.document` in 2.0.5.
|
|
214
|
+
const targetMajor = bundle.suite?.targetMajor === 2 ? 2 : 1;
|
|
215
|
+
// A major-2 bundle needs the v2 registry to decide anything. Its absence
|
|
216
|
+
// is a fact about THIS INSTALL's layout, not about the host, so it is
|
|
217
|
+
// recorded as the gap v3 already has a flag for rather than spent as a
|
|
218
|
+
// rejection (`conformance.md` §"Whose fact is the reason?"). Refusing a
|
|
219
|
+
// host because our own corpus file is missing is the substitution that
|
|
220
|
+
// rule exists to forbid.
|
|
221
|
+
if (targetMajor === 2 && !v2RegistryAvailable()) {
|
|
222
|
+
derivabilityChecked = false;
|
|
223
|
+
} else {
|
|
224
|
+
derivabilityChecked = true;
|
|
225
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
226
|
+
// §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
|
|
227
|
+
// names without certifying makes no claim for derivability to falsify.
|
|
228
|
+
if (!p.certified) continue;
|
|
229
|
+
if (!profileDerivable(document as DiscoveryPayload, p.id, targetMajor)) {
|
|
230
|
+
rejections.push({ kind: 'profile-not-derivable', profile: p.id, detail: `${p.id} is marked certified, but the captured discovery document does not derive it at major ${targetMajor} (RFC 0148 §B(1)) — evidence cannot certify a profile the host does not advertise` });
|
|
231
|
+
}
|
|
209
232
|
}
|
|
210
233
|
}
|
|
211
234
|
}
|
package/src/lib/profiles.ts
CHANGED
|
@@ -14,8 +14,15 @@
|
|
|
14
14
|
*
|
|
15
15
|
* **Derivation is deterministic and pure.** Same payload, same profile
|
|
16
16
|
* set. No time-of-day, host-specific state, or hidden inputs.
|
|
17
|
+
*
|
|
18
|
+
* Major 2 derives from `spec/v2/profiles.json` instead; `profileDerivable`
|
|
19
|
+
* dispatches on the target major and `./v2-profiles.js` holds that half. The
|
|
20
|
+
* import is one-way at runtime — v2-profiles takes only the `DiscoveryPayload`
|
|
21
|
+
* TYPE from here, which erases at compile time.
|
|
17
22
|
*/
|
|
18
23
|
|
|
24
|
+
import { v2ProfileDerivable } from './v2-profiles.js';
|
|
25
|
+
|
|
19
26
|
/**
|
|
20
27
|
* Closed v1.x catalog. Adding a profile requires an RFC per
|
|
21
28
|
* `RFCS/0001-rfc-process.md`.
|
|
@@ -664,8 +671,27 @@ export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
|
|
|
664
671
|
},
|
|
665
672
|
};
|
|
666
673
|
|
|
667
|
-
/**
|
|
668
|
-
|
|
674
|
+
/**
|
|
675
|
+
* Is `profile` derivable from a discovery document? Maps a profile name to its
|
|
676
|
+
* predicate (RFC 0089 §B(1)).
|
|
677
|
+
*
|
|
678
|
+
* `targetMajor` selects WHICH catalog answers. The v1 catalog below is a set of
|
|
679
|
+
* predicates over a v1 discovery payload — `isCore` alone requires a scalar
|
|
680
|
+
* `protocolVersion` with major `1`, `supportedEnvelopes`, `schemaVersions` and
|
|
681
|
+
* three `limits` integers. A v2 declaration has none of them (RFC 0169 §C.1
|
|
682
|
+
* restructured the root), so asking these predicates about a v2 document does
|
|
683
|
+
* not return an unknown: it returns `false`, confidently, for a host that
|
|
684
|
+
* advertises the profile. Major 2 delegates to the registry-driven derivation
|
|
685
|
+
* the emitter already used.
|
|
686
|
+
*
|
|
687
|
+
* The default is `1` so every v1-era call site — `verifyBundleProfile` below,
|
|
688
|
+
* the bundle-format-v2 verifier — keeps the behaviour it was written against.
|
|
689
|
+
* A v2 caller opts in explicitly; the v3 verifier reads the major off
|
|
690
|
+
* `bundle.suite.targetMajor` rather than a flag of its own, so verification
|
|
691
|
+
* stays self-contained in the bundle.
|
|
692
|
+
*/
|
|
693
|
+
export function profileDerivable(c: DiscoveryPayload, profile: string, targetMajor: 1 | 2 = 1): boolean {
|
|
694
|
+
if (targetMajor === 2) return v2ProfileDerivable(c, profile);
|
|
669
695
|
if (profile === 'openwop-core-standard') return isCoreStandard(c);
|
|
670
696
|
if (profile === 'openwop-agent-platform') return agentPlatformStatus(c) !== 'none';
|
|
671
697
|
if ((PROFILE_NAMES as readonly string[]).includes(profile)) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The major-2 profile derivation: `spec/v2/profiles.json` as a predicate over a
|
|
3
|
+
* DECLARATION (RFC 0169 §C.1).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is its own module
|
|
6
|
+
*
|
|
7
|
+
* There were two implementations of "does this document derive this profile",
|
|
8
|
+
* and only one of them knew that major 2 exists.
|
|
9
|
+
*
|
|
10
|
+
* The EMITTER (`cli.ts`) branched on the target major and, at 2, read this
|
|
11
|
+
* registry. The VERIFIER (`certification-bundle-v3.ts`) called
|
|
12
|
+
* `profiles.profileDerivable`, which is the v1 catalog: `isCore` wants a scalar
|
|
13
|
+
* `protocolVersion` whose major is `1`, plus `supportedEnvelopes`,
|
|
14
|
+
* `schemaVersions` and `limits.clarificationRounds`. A v2 declaration has none
|
|
15
|
+
* of those — RFC 0169 restructured the root into `protocolVersions` /
|
|
16
|
+
* `preferredVersion` and family records — so the verifier's answer for every
|
|
17
|
+
* real v2 host was `false`, and a bundle that correctly claimed
|
|
18
|
+
* `openwop-discovery-core` was refused with `profile-not-derivable`: "the host
|
|
19
|
+
* does not advertise it", about a host that advertised exactly it.
|
|
20
|
+
*
|
|
21
|
+
* The emitter and the verifier now call THIS function, so they cannot disagree
|
|
22
|
+
* again. That is the point of the module boundary — not tidiness.
|
|
23
|
+
*
|
|
24
|
+
* ## Unevaluable is not false
|
|
25
|
+
*
|
|
26
|
+
* `v2ProfileIds` returns `null`, not `[]`, when the registry cannot be read.
|
|
27
|
+
* The distinction is load-bearing. `[]` would make every certified profile
|
|
28
|
+
* underivable and reject the bundle — converting a fact about the SUITE'S
|
|
29
|
+
* layout (the corpus file is missing from this install) into a verdict about
|
|
30
|
+
* the HOST (it does not advertise what it advertises). `conformance.md`
|
|
31
|
+
* §"Whose fact is the reason?" forbids exactly that substitution: where the
|
|
32
|
+
* predicate is a fact about the suite, the row records the gap rather than
|
|
33
|
+
* spending the host's evidence. Here the gap is `derivabilityChecked: false`,
|
|
34
|
+
* the flag v3 already carries for a bundle that shipped no document.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
import { SPEC_V2_DIR } from './paths.js';
|
|
40
|
+
import type { DiscoveryPayload } from './profiles.js';
|
|
41
|
+
|
|
42
|
+
interface V2RegistryEntry {
|
|
43
|
+
readonly id?: unknown;
|
|
44
|
+
readonly predicate?: { readonly families?: unknown; readonly metadata?: unknown };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Path to `spec/v2/profiles.json`, or null when this layout has no v2 corpus.
|
|
49
|
+
*
|
|
50
|
+
* Anchored on `SPEC_V2_DIR` — one resolver, already correct for all three
|
|
51
|
+
* layouts (env override, repo checkout, published install with the
|
|
52
|
+
* `@openwop/spec-artifacts` peer). The shape this replaces resolved the peer
|
|
53
|
+
* through Node AND kept three guessed directory candidates underneath it,
|
|
54
|
+
* under a docblock that said it resolved "instead of guessing directory
|
|
55
|
+
* shapes". The guesses were the half of that fix that never landed.
|
|
56
|
+
*/
|
|
57
|
+
export function v2RegistryPath(): string | null {
|
|
58
|
+
if (SPEC_V2_DIR === null) return null;
|
|
59
|
+
const path = join(SPEC_V2_DIR, 'profiles.json');
|
|
60
|
+
return existsSync(path) ? path : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRegistry(): readonly V2RegistryEntry[] | null {
|
|
64
|
+
const found = v2RegistryPath();
|
|
65
|
+
if (found === null) return null;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(found, 'utf8')) as { profiles?: V2RegistryEntry[] };
|
|
68
|
+
return parsed.profiles ?? [];
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when the registry is present AND parseable, so derivability can be decided at all. */
|
|
75
|
+
export function v2RegistryAvailable(): boolean {
|
|
76
|
+
return readRegistry() !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every profile the declaration derives at major 2, or `null` when the registry
|
|
81
|
+
* is unavailable (see the module docblock — `null` is not `[]`).
|
|
82
|
+
*
|
|
83
|
+
* The predicate is the registry's own: every listed family present as a record,
|
|
84
|
+
* every listed metadata key present at the root. Nothing here is hand-written
|
|
85
|
+
* per profile, so adding a v2 profile is a corpus edit and not a code edit.
|
|
86
|
+
*/
|
|
87
|
+
export function v2ProfileIds(doc: DiscoveryPayload): readonly string[] | null {
|
|
88
|
+
const profiles = readRegistry();
|
|
89
|
+
if (profiles === null) return null;
|
|
90
|
+
const root = doc as unknown as Record<string, unknown>;
|
|
91
|
+
const isRecord = (k: string): boolean => {
|
|
92
|
+
const v = root[k];
|
|
93
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
94
|
+
};
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
for (const p of profiles) {
|
|
97
|
+
if (typeof p.id !== 'string') continue;
|
|
98
|
+
const families = Array.isArray(p.predicate?.families) ? (p.predicate.families as unknown[]).map(String) : [];
|
|
99
|
+
const metadata = Array.isArray(p.predicate?.metadata) ? (p.predicate.metadata as unknown[]).map(String) : [];
|
|
100
|
+
if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined)) out.push(p.id);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Is `profile` derivable from `doc` at major 2? False when the registry is
|
|
107
|
+
* unavailable — callers that must distinguish "not derivable" from "could not
|
|
108
|
+
* be decided" check {@link v2RegistryAvailable} first, as the v3 verifier does.
|
|
109
|
+
*/
|
|
110
|
+
export function v2ProfileDerivable(doc: DiscoveryPayload, profile: string): boolean {
|
|
111
|
+
return v2ProfileIds(doc)?.includes(profile) ?? false;
|
|
112
|
+
}
|