@openwop/openwop-conformance 2.0.7 → 2.0.9

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 CHANGED
@@ -1,5 +1,106 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.0.9] — 2026-09-09 — the rule this suite measures now says what it measures
4
+
5
+ No scenario logic changes, no assertion changes, and no host disposition
6
+ changes. `v2-advertised-path-space-served` behaves exactly as it did in 2.0.8.
7
+ What changed is the corpus prose the scenario cites, and this suite ships that
8
+ corpus as its pinned `@openwop/spec-artifacts` peer — so the version moves with
9
+ it.
10
+
11
+ **`versioning.md` §1.2 quantified wider than this suite has ever measured.** The
12
+ MUST read *"every operation it serves under the other"*, unqualified. This
13
+ scenario has always built its probe set from `spec/v2/path-manifest.json`
14
+ (`parameterlessGets()`), so it measured manifest operations and reported a
15
+ claim about everything a host serves. §1.2 is now scoped to operations **named
16
+ in the manifest** — which is what the instrument does, and what
17
+ `conformance.md` §"Test seams" requires, since that document forbids the
18
+ manifest to name seam operations while the seams profile mounts them.
19
+
20
+ The narrowing keeps the case the rule exists for: the motivating defect was
21
+ `POST /webhooks` answering `404` under major 2 while `POST /v1/webhooks`
22
+ answered `201`, and `webhooks` is a manifest operation.
23
+
24
+ **The docblock now states what stays unmeasured.** Non-GET and parameterised
25
+ manifest operations are bound by the prose and probed by nothing here. The
26
+ `POST /webhooks` defect was found by hand, not by this file.
27
+
28
+ Raised by a tier-2 host asking whether four proprietary `/v1` roots
29
+ (`packs`, `canvas-types`, `_internal`, `packs-test`) were bound. They were not
30
+ — but only because of what the instrument samples, not because the prose said
31
+ so.
32
+
33
+ ## [2.0.8] — 2026-09-07 — the verifier asked a v1 question about v2 hosts
34
+
35
+ No host behaviour changes and no wire changes. One defect in the suite's own
36
+ verifier, which had been refusing correct bundles from correct hosts.
37
+
38
+ **`profileDerivable` now takes the target major, and major 2 derives from
39
+ `spec/v2/profiles.json`.** There were two implementations of "does this
40
+ document derive this profile", and only one of them knew that major 2 exists.
41
+ The EMITTER branched on the target major and, at 2, read the v2 registry: every
42
+ listed family present as a record, every listed metadata key present
43
+ (RFC 0169 §C.1). The VERIFIER called `profileDerivable`, which is the v1
44
+ catalog — `isCore` wants a scalar `protocolVersion` whose major is `1`, plus
45
+ `supportedEnvelopes`, `schemaVersions` and three `limits` integers. A v2
46
+ declaration has none of those. So the verifier's answer for every real v2 host
47
+ was `false`, and a bundle correctly claiming `openwop-discovery-core` was
48
+ refused with `profile-not-derivable`: *"the host does not advertise it"*, about
49
+ a host that advertised exactly it.
50
+
51
+ Both derivations now live in `lib/v2-profiles.ts` and both callers use it, so
52
+ they cannot drift apart again. That is the point of the module boundary, not
53
+ tidiness.
54
+
55
+ **Reported by `myndhyve-1`, corroborated by `openwop-app-1`.** Two independent
56
+ hosts, three suite versions, and the diagnosis was settled across them before a
57
+ line was written here. Neither host had anything to fix.
58
+
59
+ **The trigger was ours, and it was an honesty fix.** This defect is older than
60
+ the reports — it has been wrong since major 2 existed — but it was
61
+ *unreachable* until 2.0.5 taught the bundle to carry `discovery.document`.
62
+ Before that the verifier had nothing to derive from and skipped the check
63
+ entirely. In `myndhyve-1`'s words, which are better than the ones this entry
64
+ started with: **"the defect did not become reachable when a host got healthier;
65
+ it became reachable when the bundle got more honest."** A reader who thinks
66
+ this tracks host health will draw the wrong conclusion about who is exposed.
67
+ The population is *every host cutting on 2.0.5 or later that claims a v2
68
+ profile* — not hosts whose floors went green.
69
+
70
+ **An unreadable registry is a gap, not a refusal.** `v2ProfileIds` returns
71
+ `null`, not `[]`, when `spec/v2/profiles.json` cannot be read, and the v3
72
+ verifier records `derivabilityChecked: false` instead of rejecting. `[]` would
73
+ have made every profile underivable and refused the bundle — converting a fact
74
+ about *this install's layout* into a verdict about the *host*, which
75
+ `conformance.md` §"Whose fact is the reason?" (2.0.7) forbids by name.
76
+
77
+ **The test that certified the bug.** `certification-bundle-v3.test.ts` had one
78
+ row exercising derivability at major 2, and its fixture was a *v1-shaped*
79
+ document (`protocolVersion: '1.11'`, `supportedEnvelopes`, `limits`) inside a
80
+ `targetMajor: 2` bundle. It passed, and it made the v1-predicate verifier look
81
+ correct while that verifier refused every real host. The fixture is now major-2
82
+ shaped, and three added rows pin the dispatch in both directions: a v2
83
+ declaration derives at major 2 and not at major 1, a v1 payload the reverse.
84
+ Disabling the dispatch turns three of them red.
85
+
86
+ **Not changed, and deliberately.** `isCore` stays exactly as it is and stays
87
+ v1-only. `certification-bundle-verify.ts` and `verifyBundleProfile` are
88
+ bundle-format-v2 readers over v1-era evidence with no target major to read;
89
+ they take the default and are untouched. And the `if (!p.certified) continue;`
90
+ guard stays: RFC 0148 §B(1) binds the *certification*, not the listing, so a
91
+ profile a bundle names without certifying makes no claim for derivability to
92
+ falsify. An earlier plan for this release said the guard would be made loud
93
+ anyway; writing it showed the change has no consumer — it would have added a
94
+ verdict field nobody reads, on a hypothesis no measured bundle isolates.
95
+ Reasoning is not a measurement, and inventing surface to dress it as one is the
96
+ opposite of what this thread has been about.
97
+
98
+ **`v2RegistryPath` is collapsed onto `SPEC_V2_DIR`.** The old copy in `cli.ts`
99
+ resolved the peer through Node's resolver *and then kept three guessed
100
+ directory candidates underneath it*, beneath a docblock stating that it
101
+ resolved "instead of guessing directory shapes". The guesses were the half of
102
+ that fix that never landed.
103
+
3
104
  ## [2.0.7] — 2026-09-06 — three claims of coverage that were not coverage
4
105
 
5
106
  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. Falls back to the empty set only when the
301
- * registry is genuinely absent from the layout, and says so.
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 v2RegistryPath(conformanceRoot) {
304
- const candidates = [];
305
- try {
306
- const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
307
- candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
308
- }
309
- catch { /* not installed as a package; the repo-layout candidates below */ }
310
- candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'));
311
- return candidates.find((c) => existsSync(c)) ?? null;
312
- }
313
- function claimedProfilesForV2(doc, conformanceRoot) {
314
- // Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
315
- // this package — instead of guessing directory shapes. Hand-rolled candidates
316
- // found the registry in a repo checkout and missed it in every published
317
- // install, where npm hoists the peer to a SIBLING package dir: the probe that
318
- // walked one level up landed on the `@openwop/` scope directory, not a
319
- // package, so a real host run silently claimed nothing.
320
- const found = v2RegistryPath(conformanceRoot);
321
- if (found === null) {
322
- process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
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
- let registry;
326
- try {
327
- registry = JSON.parse(readFileSync(found, 'utf8'));
328
- }
329
- catch {
330
- process.stderr.write(`openwop-conformance --certify: ${found} is unreadable; claimedProfiles is empty.\n`);
331
- return [];
332
- }
333
- const root = doc;
334
- const isRecord = (k) => {
335
- const v = root[k];
336
- return typeof v === 'object' && v !== null && !Array.isArray(v);
337
- };
338
- const out = [];
339
- for (const p of registry.profiles ?? []) {
340
- if (typeof p.id !== 'string')
341
- continue;
342
- const families = Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : [];
343
- const metadata = Array.isArray(p.predicate?.metadata) ? p.predicate.metadata.map(String) : [];
344
- if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined))
345
- out.push(p.id);
346
- }
347
- return out;
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, conformanceRoot) : claimedProfilesFor(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
- derivabilityChecked = true;
147
- for (const p of bundle.claimedProfiles ?? []) {
148
- if (!p.certified)
149
- continue;
150
- if (!profileDerivable(document, p.id)) {
151
- rejections.push({ kind: 'profile-not-derivable', profile: p.id, detail: `${p.id} is marked certified, but the captured discovery document does not derive it (RFC 0148 §B(1)) — evidence cannot certify a profile the host does not advertise` });
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
  }
@@ -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
- /** Is `profile` derivable from a discovery document? Maps a profile name to its predicate (RFC 0089 §B(1)). */
587
- export function profileDerivable(c, profile) {
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
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.7",
4
- "stampSha256": "1d8f7916c3c32af8dfffdd419b773358b812ff5954829b87bff576eb3f0dc669"
3
+ "version": "2.0.9",
4
+ "stampSha256": "2a7043538bf6c7ce6a3d5ad9836279f5754d9daa969e9dc0307d6c656fdfaf44"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
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.7"
59
+ "@openwop/spec-artifacts": "2.0.9"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -25811,7 +25811,7 @@
25811
25811
  {
25812
25812
  "id": "openwop.it.v2-advertised-path-space-served.a-host-advertising-major-2-reaches-the-surfaces-it-already-serves-under-v1",
25813
25813
  "file": "v2-advertised-path-space-served.test.ts",
25814
- "line": 107,
25814
+ "line": 122,
25815
25815
  "title": "a host advertising major 2 reaches the surfaces it already serves under /v1",
25816
25816
  "explicitId": "openwop.requirement.0172.advertised-path-space-served",
25817
25817
  "citations": [
@@ -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.7",
5
- "corpusTag": "v2.0.7",
4
+ "version": "2.0.9",
5
+ "corpusTag": "v2.0.9",
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": "0d18b9414567efb5662f0a8bc06725d0e038aab14f327db7a1b8ebe3b9f4cbb9",
13
- "api/v2/asyncapi.yaml": "25196e36b92b0dab97904b1f3461826d2066e579963c26c7e6168d2e5688bdfe",
14
- "api/v2/openapi.yaml": "e48e5669918ddda197a17508eca24ea42e834a62b309091c87d063cb52b1b2cb",
12
+ "api/seams-v2.yaml": "85832b378ab3396f031b1503d17a7d252dd2f9bbf753324e05601cf8d50c6b96",
13
+ "api/v2/asyncapi.yaml": "029fccaec2975cf943e11465a9ea98149096d061da2893c438337906e647dc92",
14
+ "api/v2/openapi.yaml": "a406c3723d88f520c387d5e47ff9cabb32ac0cac7b1825c8c768ab6ec0cf27bb",
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": "0484560c0bb72e5da9fa393b0723961e1cb7837eaf90c035ff37b7e7fdad6476",
202
+ "spec/v1/core-standard-manifest.json": "63799de159579793a95daae66accdc4aa7149eb96994235b0841c0d2ffa132bc",
203
203
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
204
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
205
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -211,27 +211,27 @@
211
211
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
212
212
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
213
213
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
214
- "spec/v2/README.md": "f56bd8d58be4ac04543c9f7b2fe92b294738d6121a4f21cbd1ce751e72b94c83",
215
- "spec/v2/core/capabilities.md": "0f222434bbe58c881362a626ab9db6bce0ae2e54cb858e3d71b662dd1fc13150",
216
- "spec/v2/core/conformance.md": "6dda58339117819b77710cd53d25f34cc9e602a35a7958d1afc4e3d4b6b7de23",
217
- "spec/v2/core/connection-packs.md": "34e519dbe5b091c9758729787d398b35b1dc41f1e01ebd028e7e6b838ce2f6ee",
218
- "spec/v2/core/errors.md": "671895de1b7c987a1dfd53fe0a814159efadae0ffd4c8c06bf1749d345270580",
219
- "spec/v2/core/events.md": "7b9207079cede7ec335126b98bcb95d655ae3b7322b18febd1b4df51750b0a33",
220
- "spec/v2/core/form-content-packs.md": "976f6e9b05a015f3bfe0748bc4f8bb11bbbd71460d12769f900bcde006b7cbb1",
221
- "spec/v2/core/headers.md": "b7fc4f77904653ad47360b20302e3f406973821fb6d74ac86d6e81b674f52c4a",
222
- "spec/v2/core/idempotency.md": "0ab68c1e41e81005b560421a3d22f985dda2f7537b2369bff67e40b79c8a581f",
223
- "spec/v2/core/identity.md": "e3fa8d2d87c1dec2673e4bf4aec5f2257c684eaf64161c1a1016a634a87509c2",
224
- "spec/v2/core/interop.md": "f701fddf15343d878faea31401e25e6ffb3672c2c8a87f30adc13b1e3c5c300c",
225
- "spec/v2/core/interrupt.md": "4bd345bccdb8e2762ca95833cc67086158640c27ffa5ef84915a4ab3de348440",
226
- "spec/v2/core/overview.md": "8ece6667535d24ed4013a046cb11e1a649f70e39625fc55e09ceb2c02505ed9c",
227
- "spec/v2/core/packs.md": "90f32947816b4ec2b045b063ad2914126c73353883514a270ad200205dc8e51b",
228
- "spec/v2/core/persistence.md": "e9830ae228bcbc33f03d81a5f2b51e031466feb84387971dd3459bf5e1687701",
229
- "spec/v2/core/replay.md": "19b2fcbda4693895d39b99593f498b76376d77472a813bba6c22fca02e106332",
230
- "spec/v2/core/runs.md": "f5f24df6da76b1d6e2cf655af3bbe3340195c36ec8d9ce0ca6e357385b1a8771",
231
- "spec/v2/core/security-defaults.md": "53a1bc549aaa356a4fcb56cdb1c4f5880d3fb65d5d49dd570a413bb29aff82e5",
232
- "spec/v2/core/versioning.md": "2eb4e8e5a1208a026a3d497d1e0fb76a4f7cb97e514d6f41f45dd66b89405abb",
233
- "spec/v2/core/webhooks.md": "1d2719335f8fb46cd31dbb836b44ea7d78ed54f98ea181927502814603c5b935",
234
- "spec/v2/core/workflow-chain-packs.md": "99ad4aeeeeb05521720dc2ae18ae63e6d604361780ed1b76778ca6d44da70eed",
214
+ "spec/v2/README.md": "453b56e0f9dd84889e90f3e25efda53e550275a112bc72a970252e00335b408b",
215
+ "spec/v2/core/capabilities.md": "3ea74e9cfbca23723e4fadac7d53a44f59940dfe9942e086533103e3677507df",
216
+ "spec/v2/core/conformance.md": "3cae1183d3155572004f192d0c64b921151f4f9905ce2a6a3e29f475334de688",
217
+ "spec/v2/core/connection-packs.md": "460019f7bc9b5c7473eaa5e2825d8e28ff2941306fbd7ba5dfe4c9e174fcbe64",
218
+ "spec/v2/core/errors.md": "acb7a04a0c4ac136eb8dcd8549e8bc6da19a80753bf81d903202c7008cb8802f",
219
+ "spec/v2/core/events.md": "ceee1671e7b585026be61ffa210759157b292d3b56c352bef5d0de98a3db9983",
220
+ "spec/v2/core/form-content-packs.md": "de63b1bb72bfde0c31aeb31d1f09d8f9297550604c771fd65f26d7459eac7be0",
221
+ "spec/v2/core/headers.md": "18c09184e5b64c5f28e4aa8c028b13da074bd9a4ca8e6ae56290faa402c277c0",
222
+ "spec/v2/core/idempotency.md": "9452888f1ed70ffafc4a4f6c770511b3d4e7277009d39b8b8bbca26f5a3c0a32",
223
+ "spec/v2/core/identity.md": "3ba7016e9f78aac6f6ec5be8a2c577c0309e3e62e2ceb6070c2eabbfc7d1b507",
224
+ "spec/v2/core/interop.md": "e5c166cd94050539a694cce01d4b733bd8bfbbf4d30584d6cd8ee1a85009afc5",
225
+ "spec/v2/core/interrupt.md": "a320811c337cb498207cfdc87e24bea8715689101be299f4eb2ae1ea54e6f202",
226
+ "spec/v2/core/overview.md": "6c3d32ded128237ddb9af12bcfaac4079162464906519d0f2861bba6c675ae18",
227
+ "spec/v2/core/packs.md": "1c6bad59fd77a90bed9c904c7d59f1649dd21cf70986962194b10650533a46b5",
228
+ "spec/v2/core/persistence.md": "8e4af460d7390945ffd88731edf69f95a462f55d6569a7d375bf802ca8ddff95",
229
+ "spec/v2/core/replay.md": "5647bc44e994d9fcb2d2fc8acbb6c0f66e1262228b7efc5a1035eb1d3b38a23f",
230
+ "spec/v2/core/runs.md": "ceae2f80fc915c12a22682205e2bec797d311b53dc8883000a4f02d002192778",
231
+ "spec/v2/core/security-defaults.md": "0837314be15d4278f1a835b97f101ba794a025952ad39fd408c6e319756ebf71",
232
+ "spec/v2/core/versioning.md": "d9506ec2e009a84c1ac02924af2867cf2d6b90c872902fe1b0979a8908e494d0",
233
+ "spec/v2/core/webhooks.md": "9a946db3a17f72adba8ad493dc004c856237681f823303599dd45c83b522226b",
234
+ "spec/v2/core/workflow-chain-packs.md": "e0e78926ebb6aa217cf35191d401a51903610469c5d0c464307888fb89e69037",
235
235
  "spec/v2/declaration.json": "bd8a1dcee899a4478ac96b52da6ee07e1cb5fce4465939ac32e19e52f4d5c6b2",
236
236
  "spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
237
237
  "spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
@@ -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": "2937de1d170c218e4d336088035c2a4fe6b7e951b6c6bd8bd375f0580ae07a53",
271
+ "spec/v2/release.json": "14eb79136a01caa739684a20dbe15384c2f5420b30f942e6e328e8c66c446745",
272
272
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
273
273
  },
274
- "corpusCommit": "8f8818dd663240bdd1ea074a25fd9354d0fde2c3"
274
+ "corpusCommit": "e84395f18a99d9266ba94e9eddb23a7e73ef5c33"
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. Falls back to the empty set only when the
322
- * registry is genuinely absent from the layout, and says so.
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 v2RegistryPath(conformanceRoot: string): string | null {
325
- const candidates: string[] = [];
326
- try {
327
- const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
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
- let registry: { profiles?: Array<{ id?: unknown; predicate?: { families?: unknown; metadata?: unknown } }> };
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, conformanceRoot) : claimedProfilesFor(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
- derivabilityChecked = true;
205
- for (const p of bundle.claimedProfiles ?? []) {
206
- if (!p.certified) continue;
207
- if (!profileDerivable(document as DiscoveryPayload, p.id)) {
208
- rejections.push({ kind: 'profile-not-derivable', profile: p.id, detail: `${p.id} is marked certified, but the captured discovery document does not derive it (RFC 0148 §B(1)) evidence cannot certify a profile the host does not advertise` });
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
  }
@@ -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
- /** Is `profile` derivable from a discovery document? Maps a profile name to its predicate (RFC 0089 §B(1)). */
668
- export function profileDerivable(c: DiscoveryPayload, profile: string): boolean {
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
+ }
@@ -36,6 +36,21 @@
36
36
  * need no fixture, mutate nothing, and a route that is not mounted answers 404
37
37
  * regardless of auth, so the check is unaided and safe against a live host.
38
38
  *
39
+ * The manifest scoping is the RULE's scope, not just this instrument's budget.
40
+ * `versioning.md` §1.2 binds "every operation named in
41
+ * `spec/v2/path-manifest.json` that it serves under the other" — surfaces the
42
+ * manifest does not name are outside the paragraph. That qualifier was added
43
+ * after a tier-2 host asked whether its four proprietary `/v1` roots were
44
+ * bound: the older unqualified wording said "every operation it serves", which
45
+ * this scenario has never measured and which `conformance.md` §"Test seams"
46
+ * contradicts by requiring the manifest to omit seam operations. Stating the
47
+ * scope in both places is deliberate — a rule whose prose quantifies wider than
48
+ * its only instrument reports a general claim from a sampled one.
49
+ *
50
+ * What remains UNMEASURED even so: non-GET and parameterised manifest
51
+ * operations. The prose binds them; nothing here probes them. The motivating
52
+ * defect was `POST /webhooks`, found by hand, not by this file.
53
+ *
39
54
  * @see spec/v2/core/versioning.md §1.2
40
55
  * @see RFCS/0172-v2-versioning-and-release.md §A.1
41
56
  */