@openwop/openwop-conformance 2.0.6 → 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 CHANGED
@@ -1,5 +1,135 @@
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
+
74
+ ## [2.0.7] — 2026-09-06 — three claims of coverage that were not coverage
75
+
76
+ No host behaviour changes. One gate changes disposition, and the two prose
77
+ corrections remove claims the corpus was making about its own evidence.
78
+
79
+ **`vendorControlGate` answers `blocked`, not `inapplicable`, when
80
+ `spec/v2/declaration.json` cannot be resolved.** 2.0.6 — the release that fixed
81
+ this scenario — got the disposition wrong on the branch it had just repaired.
82
+ `inapplicable` asserts *the requirement does not bind this host*, which is a
83
+ statement about the host made on the strength of a fact about the suite, and a
84
+ false one: the rule binds exactly as before and the suite merely failed to read
85
+ its own corpus. It is also the quiet answer. `inapplicable` certifies;
86
+ `blocked` is bundle-wide fatal (RFC 0168 §E.1). The disposition that was wrong
87
+ was the one that made no sound.
88
+
89
+ As of 2.0.6 that branch is unreachable — the registry resolves in every layout
90
+ and the publish workflow asserts it. That is the argument *for* making it
91
+ fatal. An unreachable branch answering `inapplicable` is a trapdoor back to the
92
+ D1 resolution defect, which was invisible precisely because it degraded a live
93
+ witness into a quiet skip.
94
+
95
+ **The general rule is now written down** (`spec/v2/core/conformance.md`
96
+ §"Whose fact is the reason?"): a soft-skip reason MUST name a fact about the
97
+ host under test, and where the predicate is instead a fact about the suite —
98
+ its layout, its corpus, a fixture it cannot resolve — the row MUST record
99
+ `blocked`. Gate ordering follows (host facts before suite facts) but is not the
100
+ guarantee; ordering only decides which *true* reason is reported. The guarantee
101
+ is that a suite-side gate can never be silent, because it is never
102
+ `inapplicable`.
103
+
104
+ Credit where it is due: this came from a host operator who predicted a third
105
+ failure mode I had not considered — a row already `inapplicable` for a true host
106
+ reason, re-gated onto a suite-side precondition, stays `inapplicable`. `skip →
107
+ skip`, no count moves, no gate reddens, and the row silently stops describing
108
+ the host it names. Measured against the tree, their case does not bite this
109
+ scenario (the seams gate returns first, at `:96`, above the precondition at
110
+ `:97`), but the rule they proposed was right and the ordering only held by
111
+ construction — nothing written down stopped the next scenario from getting it
112
+ wrong.
113
+
114
+ `era2-unmapped-gates.test.ts` gains a row asserting that **no** reachable
115
+ verdict in either gate is `inapplicable`, so a future suite-side gate that
116
+ soft-skips quietly reddens a test instead of a bundle. Sabotage-verified:
117
+ restoring 2.0.6's disposition reddens exactly those two rows and no others.
118
+
119
+ ### Corpus prose
120
+
121
+ - **`persistence.md` §"The seat"** claimed `v2-v1-events-translated` reading
122
+ through poll, SSE and a fork meant "a wrapper-only adapter is caught". It does
123
+ not. Three wrappers pass those three legs exactly as one correctly seated
124
+ adapter does, and the rule binds *every* reader, including ones the suite has
125
+ no name for. The seat is a **claims-check** discharged by ADR disclosure and
126
+ audit; the scenario catches a reader that was *missed*, not an adapter that
127
+ was *misplaced*. The clause also cited a scenario by the wrong name and
128
+ pointed at `conformance.md`, which said nothing about any of it. Both MUSTs
129
+ are unchanged — only the false coverage claim is gone.
130
+ - **RFC 0180** supplies the vendor-org registration procedure the registry
131
+ never had, and `persistence.md` §"The codemap is data" now points at it.
132
+
3
133
  ## [2.0.6] — 2026-09-06 — the release that made a rule uncheckable
4
134
 
5
135
  **If you pinned 2.0.5, `v2-unmapped-type-refused` did not run against your
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.6",
4
- "stampSha256": "4f7707b8acb297687e2cc9017f2fa34191033dd0b5db6c1cc64c2c04477ecee6"
3
+ "version": "2.0.8",
4
+ "stampSha256": "3c52e6a451f700ac99aac8aea616d1cf5d26124902b07c9bf00dff039fff2eb0"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.6",
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.6"
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.6",
5
- "corpusTag": "v2.0.6",
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": "594aad07baceb9c8e00dc9fe91dd8f6f99bc6624c64eaf5be91dbb5f22a35c48",
13
- "api/v2/asyncapi.yaml": "5839f6cb16ed8997a335491a6700ddc2f4166c994ca2838863d4029c1ef6d58e",
14
- "api/v2/openapi.yaml": "f1288076f774dbb46b885519b94719df84b31b4535fe2a5bccb9881d2e09b0ea",
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": "0018230eb426fb0af82545b9f63920cc9504a9eed2a03c44ef61cf73f429e82b",
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",
@@ -213,7 +213,7 @@
213
213
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
214
214
  "spec/v2/README.md": "f56bd8d58be4ac04543c9f7b2fe92b294738d6121a4f21cbd1ce751e72b94c83",
215
215
  "spec/v2/core/capabilities.md": "0f222434bbe58c881362a626ab9db6bce0ae2e54cb858e3d71b662dd1fc13150",
216
- "spec/v2/core/conformance.md": "91418b882190e116ab9dac14527f52b8582b0462f41e96beeceb07e3fcc5f99a",
216
+ "spec/v2/core/conformance.md": "6dda58339117819b77710cd53d25f34cc9e602a35a7958d1afc4e3d4b6b7de23",
217
217
  "spec/v2/core/connection-packs.md": "34e519dbe5b091c9758729787d398b35b1dc41f1e01ebd028e7e6b838ce2f6ee",
218
218
  "spec/v2/core/errors.md": "671895de1b7c987a1dfd53fe0a814159efadae0ffd4c8c06bf1749d345270580",
219
219
  "spec/v2/core/events.md": "7b9207079cede7ec335126b98bcb95d655ae3b7322b18febd1b4df51750b0a33",
@@ -225,14 +225,14 @@
225
225
  "spec/v2/core/interrupt.md": "4bd345bccdb8e2762ca95833cc67086158640c27ffa5ef84915a4ab3de348440",
226
226
  "spec/v2/core/overview.md": "8ece6667535d24ed4013a046cb11e1a649f70e39625fc55e09ceb2c02505ed9c",
227
227
  "spec/v2/core/packs.md": "90f32947816b4ec2b045b063ad2914126c73353883514a270ad200205dc8e51b",
228
- "spec/v2/core/persistence.md": "e18195e078d7b0b799055005567e8d86a2ea7518c87809cfaf43dfe9f5d5c8ee",
228
+ "spec/v2/core/persistence.md": "e9830ae228bcbc33f03d81a5f2b51e031466feb84387971dd3459bf5e1687701",
229
229
  "spec/v2/core/replay.md": "19b2fcbda4693895d39b99593f498b76376d77472a813bba6c22fca02e106332",
230
230
  "spec/v2/core/runs.md": "f5f24df6da76b1d6e2cf655af3bbe3340195c36ec8d9ce0ca6e357385b1a8771",
231
231
  "spec/v2/core/security-defaults.md": "53a1bc549aaa356a4fcb56cdb1c4f5880d3fb65d5d49dd570a413bb29aff82e5",
232
232
  "spec/v2/core/versioning.md": "2eb4e8e5a1208a026a3d497d1e0fb76a4f7cb97e514d6f41f45dd66b89405abb",
233
233
  "spec/v2/core/webhooks.md": "1d2719335f8fb46cd31dbb836b44ea7d78ed54f98ea181927502814603c5b935",
234
234
  "spec/v2/core/workflow-chain-packs.md": "99ad4aeeeeb05521720dc2ae18ae63e6d604361780ed1b76778ca6d44da70eed",
235
- "spec/v2/declaration.json": "9e191dffae9f95e622b3fc7087b8c17a2f42177bb4db3c7ee5fdf650443e036d",
235
+ "spec/v2/declaration.json": "bd8a1dcee899a4478ac96b52da6ee07e1cb5fce4465939ac32e19e52f4d5c6b2",
236
236
  "spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
237
237
  "spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
238
238
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -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": "7735a176082e42a30d7884654d79ac7affd7fc50f4b7346085f7d841769c6819",
271
+ "spec/v2/release.json": "5bf850da0ebcc8aaedbc9ded9064a760d4de798061ebeff0d8b890d78dc1d13a",
272
272
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
273
273
  },
274
- "corpusCommit": "0ebdbc9ad741d49c44731e2bd255499a7da6c4a7"
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. 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
  }
@@ -253,11 +253,24 @@ export function unmappedRefusalGate(registered: ReadonlySet<string> | undefined,
253
253
  * not name MUST be read under its own name unchanged.
254
254
  *
255
255
  * This leg genuinely needs the registry to resolve — it asserts that an org IS
256
- * registered, and an absent registry cannot establish that. `inapplicable` here
257
- * is honest rather than over-gating.
256
+ * registered, and an absent registry cannot establish that.
257
+ *
258
+ * AN UNRESOLVABLE REGISTRY IS `blocked`, NOT `inapplicable` (conformance.md
259
+ * §Whose fact is the reason?). Suite 2.0.6 said `inapplicable`, which asserts
260
+ * the requirement does not bind this host — a statement about the HOST made on
261
+ * the strength of a fact about the SUITE, and a false one: the rule binds
262
+ * exactly as before and the suite merely failed to read its own corpus.
263
+ * `inapplicable` also certifies, while `blocked` is bundle-wide fatal
264
+ * (RFC 0168 §E.1), so the wrong disposition is the silent one.
265
+ *
266
+ * As of 2.0.6 this branch is unreachable — the registry resolves in every
267
+ * layout and the publish workflow asserts it. That is the argument FOR making
268
+ * it fatal, not against: an unreachable branch answering `inapplicable` is a
269
+ * trapdoor back to the D1 resolution defect, which was invisible precisely
270
+ * because it degraded a live witness into a quiet skip.
258
271
  */
259
272
  export function vendorControlGate(registered: ReadonlySet<string> | undefined, map: ReadonlyMap<string, string>, type: string): Gate {
260
- if (registered === undefined) return { ok: false, kind: 'inapplicable', reason: 'spec/v2/declaration.json is not resolvable in this layout — the control leg asserts that a REGISTERED org passes through, and guessing which orgs are registered would make the suite the registry' };
273
+ if (registered === undefined) return { ok: false, kind: 'blocked', reason: 'spec/v2/declaration.json is not resolvable in this layout — the control leg asserts that a REGISTERED org passes through, and guessing which orgs are registered would make the suite the registry. This is a fact about the SUITE, so it is blocked and not inapplicable (conformance.md §Whose fact is the reason?)' };
261
274
  if (!registered.has(orgOf(type))) return { ok: false, kind: 'blocked', reason: `the control leg needs org '${orgOf(type)}' registered in spec/v2/declaration.json extensions (registered: ${[...registered].join(', ') || 'none'}) — without a registered org the positive half of the vendor rule cannot be driven at all` };
262
275
  if (map.has(type)) return { ok: false, kind: 'blocked', reason: `${type} now has a codemap row (→ ${String(map.get(type))}) — the control leg requires a type the codemap does not name` };
263
276
  return { ok: true };
@@ -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
+ }