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