@openwop/openwop-conformance 2.0.0-rc.1 → 2.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +57 -9
- package/dist/lib/requirement-registry.js +20 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +241 -3
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +15 -15
- package/src/cli.ts +55 -10
- package/src/lib/requirement-registry.ts +21 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +108 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-stamp-universal.test.ts +103 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/v2-version-header-honored.test.ts +124 -0
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 498 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
118
118
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 498 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,7 @@ import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
|
46
46
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
47
47
|
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
|
|
48
48
|
import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
|
|
49
|
+
import { setV2ProfileFloors } from './lib/requirement-registry.js';
|
|
49
50
|
function parseArgs(argv) {
|
|
50
51
|
let baseUrl;
|
|
51
52
|
let apiKey;
|
|
@@ -299,13 +300,7 @@ function claimedProfilesFor(doc) {
|
|
|
299
300
|
* no v2 host could ever certify. Falls back to the empty set only when the
|
|
300
301
|
* registry is genuinely absent from the layout, and says so.
|
|
301
302
|
*/
|
|
302
|
-
function
|
|
303
|
-
// Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
|
|
304
|
-
// this package — instead of guessing directory shapes. Hand-rolled candidates
|
|
305
|
-
// found the registry in a repo checkout and missed it in every published
|
|
306
|
-
// install, where npm hoists the peer to a SIBLING package dir: the probe that
|
|
307
|
-
// walked one level up landed on the `@openwop/` scope directory, not a
|
|
308
|
-
// package, so a real host run silently claimed nothing.
|
|
303
|
+
function v2RegistryPath(conformanceRoot) {
|
|
309
304
|
const candidates = [];
|
|
310
305
|
try {
|
|
311
306
|
const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
|
|
@@ -313,8 +308,17 @@ function claimedProfilesForV2(doc, conformanceRoot) {
|
|
|
313
308
|
}
|
|
314
309
|
catch { /* not installed as a package; the repo-layout candidates below */ }
|
|
315
310
|
candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'));
|
|
316
|
-
|
|
317
|
-
|
|
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) {
|
|
318
322
|
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
319
323
|
return [];
|
|
320
324
|
}
|
|
@@ -342,6 +346,46 @@ function claimedProfilesForV2(doc, conformanceRoot) {
|
|
|
342
346
|
}
|
|
343
347
|
return out;
|
|
344
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
|
|
351
|
+
* `planned:<name>` entry names a scenario the registry expects but that may not
|
|
352
|
+
* exist yet; it resolves to `v2-<name>.test.ts` when that file is in the
|
|
353
|
+
* manifest and is dropped otherwise, so a planned-but-unwritten floor cannot
|
|
354
|
+
* fail a host for the corpus's own backlog.
|
|
355
|
+
*/
|
|
356
|
+
function v2ProfileFloors(conformanceRoot) {
|
|
357
|
+
const registryPath = v2RegistryPath(conformanceRoot);
|
|
358
|
+
if (registryPath === null)
|
|
359
|
+
return {};
|
|
360
|
+
let known;
|
|
361
|
+
try {
|
|
362
|
+
known = new Set(Object.keys(JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')).majors));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
known = new Set();
|
|
366
|
+
}
|
|
367
|
+
let registry;
|
|
368
|
+
try {
|
|
369
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
const out = {};
|
|
375
|
+
for (const p of registry.profiles ?? []) {
|
|
376
|
+
if (typeof p.id !== 'string')
|
|
377
|
+
continue;
|
|
378
|
+
const raw = Array.isArray(p.floorScenarios) ? p.floorScenarios.map(String) : [];
|
|
379
|
+
const files = [];
|
|
380
|
+
for (const entry of raw) {
|
|
381
|
+
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
382
|
+
if (known.size === 0 || known.has(name))
|
|
383
|
+
files.push(name);
|
|
384
|
+
}
|
|
385
|
+
out[p.id] = files;
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
345
389
|
/**
|
|
346
390
|
* Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
|
|
347
391
|
* the test-file basename (e.g. `discovery.test.ts`) to align with the basenames
|
|
@@ -450,6 +494,10 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
450
494
|
const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
451
495
|
// (b) Derive claimedProfiles from the captured document.
|
|
452
496
|
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
|
|
497
|
+
// Major-2 floors come from spec/v2/profiles.json. Without this the derivation
|
|
498
|
+
// measures a v2 host against v1 scenario files a major-2 run never executes,
|
|
499
|
+
// and refuses certification for not running them.
|
|
500
|
+
setV2ProfileFloors(target.major === 2 ? v2ProfileFloors(conformanceRoot) : null);
|
|
453
501
|
// (c) Run the suite, capturing per-scenario terminal state via the vitest
|
|
454
502
|
// JSON reporter. server-targeted scenarios live under src/scenarios/.
|
|
455
503
|
const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
|
|
@@ -76,7 +76,27 @@ export function floorFilesFor(profile, document) {
|
|
|
76
76
|
* discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
|
|
77
77
|
* it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
|
|
78
78
|
*/
|
|
79
|
+
/**
|
|
80
|
+
* Major-2 floors come from `spec/v2/profiles.json`, not from the v1 table.
|
|
81
|
+
* `PROFILE_FLOOR_SCENARIOS` names v1 scenario FILES (`runs-lifecycle.test.ts`,
|
|
82
|
+
* `discovery.test.ts`, …) that `scenario-majors.json` assigns to major 1 and a
|
|
83
|
+
* major-2 run therefore never executes — so every one of them came back
|
|
84
|
+
* unclassified and a v2 host was refused certification for not running v1
|
|
85
|
+
* scenarios. Set by the runner before deriving; empty means the registry
|
|
86
|
+
* declares no floor for that profile, which is a real "witnesses nothing yet",
|
|
87
|
+
* not an unclassified return.
|
|
88
|
+
*/
|
|
89
|
+
let v2Floors = null;
|
|
90
|
+
export function setV2ProfileFloors(floors) {
|
|
91
|
+
v2Floors = floors;
|
|
92
|
+
}
|
|
79
93
|
export function requirementsFor(profile, document) {
|
|
94
|
+
if (v2Floors !== null) {
|
|
95
|
+
const files = v2Floors[profile];
|
|
96
|
+
if (files === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
return files.map(requirementIdForScenario);
|
|
99
|
+
}
|
|
80
100
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
81
101
|
if (floor === undefined)
|
|
82
102
|
return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@openwop/spec-artifacts",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
4
|
-
"stampSha256": "
|
|
3
|
+
"version": "2.0.0-rc.10",
|
|
4
|
+
"stampSha256": "9bd46582060eb1408c0bcac781805f0cee84cd4b2955251bed4a5b328e19e4af"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.10",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.0-rc.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.10"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 544,
|
|
6
|
+
"tests": 2118,
|
|
7
|
+
"withStableId": 2118,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2057
|
|
10
10
|
},
|
|
@@ -25741,6 +25741,34 @@
|
|
|
25741
25741
|
}
|
|
25742
25742
|
]
|
|
25743
25743
|
},
|
|
25744
|
+
{
|
|
25745
|
+
"id": "openwop.it.v2-advertised-fixtures-exist.every-advertised-fixture-id-is-one-the-corpus-defines",
|
|
25746
|
+
"file": "v2-advertised-fixtures-exist.test.ts",
|
|
25747
|
+
"line": 60,
|
|
25748
|
+
"title": "every advertised fixture id is one the corpus defines",
|
|
25749
|
+
"explicitId": null,
|
|
25750
|
+
"citations": [
|
|
25751
|
+
{
|
|
25752
|
+
"section": null,
|
|
25753
|
+
"requirement": null,
|
|
25754
|
+
"interpolated": true
|
|
25755
|
+
}
|
|
25756
|
+
]
|
|
25757
|
+
},
|
|
25758
|
+
{
|
|
25759
|
+
"id": "openwop.it.v2-advertised-fixtures-exist.a-sampled-advertised-fixture-is-actually-creatable-not-just-listed",
|
|
25760
|
+
"file": "v2-advertised-fixtures-exist.test.ts",
|
|
25761
|
+
"line": 80,
|
|
25762
|
+
"title": "a sampled advertised fixture is actually creatable, not just listed",
|
|
25763
|
+
"explicitId": null,
|
|
25764
|
+
"citations": [
|
|
25765
|
+
{
|
|
25766
|
+
"section": null,
|
|
25767
|
+
"requirement": null,
|
|
25768
|
+
"interpolated": true
|
|
25769
|
+
}
|
|
25770
|
+
]
|
|
25771
|
+
},
|
|
25744
25772
|
{
|
|
25745
25773
|
"id": "openwop.it.v2-approver-enforced.a-resolver-outside-approverslist-is-refused-at-resolve-time",
|
|
25746
25774
|
"file": "v2-approver-enforced.test.ts",
|
|
@@ -26501,6 +26529,49 @@
|
|
|
26501
26529
|
}
|
|
26502
26530
|
]
|
|
26503
26531
|
},
|
|
26532
|
+
{
|
|
26533
|
+
"id": "openwop.it.v2-era-2-append-vocabulary.a-host-append-to-an-open-era-2-run-keeps-the-log-in-v1-vocabulary-so-the-whole-l",
|
|
26534
|
+
"file": "v2-era-2-append-vocabulary.test.ts",
|
|
26535
|
+
"line": 48,
|
|
26536
|
+
"title": "a host append to an open era-2 run keeps the log in v1 vocabulary, so the whole log still reads",
|
|
26537
|
+
"explicitId": null,
|
|
26538
|
+
"citations": [
|
|
26539
|
+
{
|
|
26540
|
+
"section": null,
|
|
26541
|
+
"requirement": null,
|
|
26542
|
+
"interpolated": true
|
|
26543
|
+
},
|
|
26544
|
+
{
|
|
26545
|
+
"section": null,
|
|
26546
|
+
"requirement": null,
|
|
26547
|
+
"interpolated": true
|
|
26548
|
+
},
|
|
26549
|
+
{
|
|
26550
|
+
"section": null,
|
|
26551
|
+
"requirement": null,
|
|
26552
|
+
"interpolated": true
|
|
26553
|
+
},
|
|
26554
|
+
{
|
|
26555
|
+
"section": null,
|
|
26556
|
+
"requirement": null,
|
|
26557
|
+
"interpolated": true
|
|
26558
|
+
}
|
|
26559
|
+
]
|
|
26560
|
+
},
|
|
26561
|
+
{
|
|
26562
|
+
"id": "openwop.it.v2-era-2-append-vocabulary.the-run-keeps-the-era-it-was-created-with-an-append-does-not-promote-it-to-era-3",
|
|
26563
|
+
"file": "v2-era-2-append-vocabulary.test.ts",
|
|
26564
|
+
"line": 119,
|
|
26565
|
+
"title": "the run keeps the era it was created with; an append does not promote it to era 3",
|
|
26566
|
+
"explicitId": null,
|
|
26567
|
+
"citations": [
|
|
26568
|
+
{
|
|
26569
|
+
"section": null,
|
|
26570
|
+
"requirement": null,
|
|
26571
|
+
"interpolated": true
|
|
26572
|
+
}
|
|
26573
|
+
]
|
|
26574
|
+
},
|
|
26504
26575
|
{
|
|
26505
26576
|
"id": "openwop.it.v2-era-key.a-run-created-now-carries-eventlogschemaversion-3-on-its-snapshot",
|
|
26506
26577
|
"file": "v2-era-key.test.ts",
|
|
@@ -26544,6 +26615,44 @@
|
|
|
26544
26615
|
}
|
|
26545
26616
|
]
|
|
26546
26617
|
},
|
|
26618
|
+
{
|
|
26619
|
+
"id": "openwop.it.v2-era-stamp-universal.discovery-advertises-an-era-the-host-actually-writes-and-a-new-run-carries-it",
|
|
26620
|
+
"file": "v2-era-stamp-universal.test.ts",
|
|
26621
|
+
"line": 40,
|
|
26622
|
+
"title": "discovery advertises an era the host actually writes, and a new run carries it",
|
|
26623
|
+
"explicitId": null,
|
|
26624
|
+
"citations": [
|
|
26625
|
+
{
|
|
26626
|
+
"section": null,
|
|
26627
|
+
"requirement": null,
|
|
26628
|
+
"interpolated": true
|
|
26629
|
+
},
|
|
26630
|
+
{
|
|
26631
|
+
"section": null,
|
|
26632
|
+
"requirement": null,
|
|
26633
|
+
"interpolated": true
|
|
26634
|
+
},
|
|
26635
|
+
{
|
|
26636
|
+
"section": null,
|
|
26637
|
+
"requirement": null,
|
|
26638
|
+
"interpolated": true
|
|
26639
|
+
}
|
|
26640
|
+
]
|
|
26641
|
+
},
|
|
26642
|
+
{
|
|
26643
|
+
"id": "openwop.it.v2-era-stamp-universal.a-v2-era-host-stamps-3-so-its-new-runs-are-never-read-as-the-v1-era",
|
|
26644
|
+
"file": "v2-era-stamp-universal.test.ts",
|
|
26645
|
+
"line": 80,
|
|
26646
|
+
"title": "a v2-era host stamps 3, so its new runs are never read as the v1 era",
|
|
26647
|
+
"explicitId": null,
|
|
26648
|
+
"citations": [
|
|
26649
|
+
{
|
|
26650
|
+
"section": null,
|
|
26651
|
+
"requirement": "a host advertising era 3 MUST stamp 3 on every run it creates — an unstamped run made after the cut is indistinguishable from a pre-cut run and every reader translates it as era 2, which is a silent wrong read rather than an error",
|
|
26652
|
+
"interpolated": true
|
|
26653
|
+
}
|
|
26654
|
+
]
|
|
26655
|
+
},
|
|
26547
26656
|
{
|
|
26548
26657
|
"id": "openwop.it.v2-error-registry.an-error-response-is-the-closed-envelope-with-a-registered-code-at-its-registere",
|
|
26549
26658
|
"file": "v2-error-registry.test.ts",
|
|
@@ -28015,6 +28124,49 @@
|
|
|
28015
28124
|
}
|
|
28016
28125
|
]
|
|
28017
28126
|
},
|
|
28127
|
+
{
|
|
28128
|
+
"id": "openwop.it.v2-version-header-honored.openwop-version-is-honored-or-refused-never-ignored",
|
|
28129
|
+
"file": "v2-version-header-honored.test.ts",
|
|
28130
|
+
"line": 61,
|
|
28131
|
+
"title": "OpenWOP-Version is honored or refused — never ignored",
|
|
28132
|
+
"explicitId": null,
|
|
28133
|
+
"citations": [
|
|
28134
|
+
{
|
|
28135
|
+
"section": null,
|
|
28136
|
+
"requirement": "a host that does not serve major 2 MUST refuse with 406 protocol_version_unsupported, which it did",
|
|
28137
|
+
"interpolated": true
|
|
28138
|
+
},
|
|
28139
|
+
{
|
|
28140
|
+
"section": null,
|
|
28141
|
+
"requirement": "answering 200 to OpenWOP-Version: 2.0 with a body byte-identical to the header-less fetch means the header was IGNORED, not honored — the host served v1 and called it v2. Refusing with 406 is the correct answer for a host that does not implement major 2; silently returning v1 is the one answer that no presence-gated scenario can detect, because an absent v2 shape records `inapplicable` whether the host refused or ignored",
|
|
28142
|
+
"interpolated": true
|
|
28143
|
+
}
|
|
28144
|
+
]
|
|
28145
|
+
},
|
|
28146
|
+
{
|
|
28147
|
+
"id": "openwop.it.v2-version-header-honored.a-host-advertising-two-majors-serves-two-different-representations",
|
|
28148
|
+
"file": "v2-version-header-honored.test.ts",
|
|
28149
|
+
"line": 92,
|
|
28150
|
+
"title": "a host advertising two majors serves two different representations",
|
|
28151
|
+
"explicitId": null,
|
|
28152
|
+
"citations": [
|
|
28153
|
+
{
|
|
28154
|
+
"section": null,
|
|
28155
|
+
"requirement": null,
|
|
28156
|
+
"interpolated": true
|
|
28157
|
+
},
|
|
28158
|
+
{
|
|
28159
|
+
"section": null,
|
|
28160
|
+
"requirement": "the v1 document and the closed v2 root differ by construction (capabilities.md §1), so a host advertising both majors returning identical bytes for both has advertised a major it does not actually serve",
|
|
28161
|
+
"interpolated": true
|
|
28162
|
+
},
|
|
28163
|
+
{
|
|
28164
|
+
"section": null,
|
|
28165
|
+
"requirement": null,
|
|
28166
|
+
"interpolated": true
|
|
28167
|
+
}
|
|
28168
|
+
]
|
|
28169
|
+
},
|
|
28018
28170
|
{
|
|
28019
28171
|
"id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
|
|
28020
28172
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
@@ -34383,6 +34535,68 @@
|
|
|
34383
34535
|
}
|
|
34384
34536
|
]
|
|
34385
34537
|
},
|
|
34538
|
+
{
|
|
34539
|
+
"id": "openwop.it.v2-bundle-signature-attributable.the-v2-discovery-root-publishes-signingkeys-the-surface-the-rfc-named",
|
|
34540
|
+
"file": "v2-bundle-signature-attributable.test.ts",
|
|
34541
|
+
"line": 85,
|
|
34542
|
+
"title": "the v2 discovery root publishes signingKeys[] — the surface the RFC named",
|
|
34543
|
+
"explicitId": null,
|
|
34544
|
+
"citations": [
|
|
34545
|
+
{
|
|
34546
|
+
"section": null,
|
|
34547
|
+
"requirement": "the v2 discovery root MUST carry signingKeys[] — RFC 0168 disposed of \"an Ed25519 attestation without a key registry is a signature nobody can check\" by naming this surface, so if it is absent the disposition names a place that does not exist and every bundle signature is unattributable",
|
|
34548
|
+
"interpolated": true
|
|
34549
|
+
},
|
|
34550
|
+
{
|
|
34551
|
+
"section": null,
|
|
34552
|
+
"requirement": null,
|
|
34553
|
+
"interpolated": true
|
|
34554
|
+
},
|
|
34555
|
+
{
|
|
34556
|
+
"section": null,
|
|
34557
|
+
"requirement": "a signingKeys[] entry MUST be closed; an open key record lets a host attach meaning the verifier does not read",
|
|
34558
|
+
"interpolated": true
|
|
34559
|
+
},
|
|
34560
|
+
{
|
|
34561
|
+
"section": null,
|
|
34562
|
+
"requirement": "a signingKeys[] entry MUST be able to record retirement, because a retired key has to stay listed — dropping it silently invalidates every bundle it already signed, which is the opposite of what an evidence trail is for",
|
|
34563
|
+
"interpolated": true
|
|
34564
|
+
}
|
|
34565
|
+
]
|
|
34566
|
+
},
|
|
34567
|
+
{
|
|
34568
|
+
"id": "openwop.it.v2-bundle-signature-attributable.the-front-door-gate-verifies-the-attestation-instead-of-inspecting-the-string",
|
|
34569
|
+
"file": "v2-bundle-signature-attributable.test.ts",
|
|
34570
|
+
"line": 116,
|
|
34571
|
+
"title": "the Front-door gate verifies the attestation instead of inspecting the string",
|
|
34572
|
+
"explicitId": null,
|
|
34573
|
+
"citations": [
|
|
34574
|
+
{
|
|
34575
|
+
"section": null,
|
|
34576
|
+
"requirement": "the gate MUST NOT accept a bundle signature by presence: `typeof signature.sig === 'string'` is satisfied by any string, so it cannot distinguish a host key from a keypair minted seconds earlier by whoever wrote the bundle",
|
|
34577
|
+
"interpolated": true
|
|
34578
|
+
},
|
|
34579
|
+
{
|
|
34580
|
+
"section": null,
|
|
34581
|
+
"requirement": "the gate MUST resolve signature.keyId against the host's published signingKeys[] and verify the Ed25519 attestation under it — resolving without verifying, or verifying against a key the bundle supplied itself, both leave the signature unaccountable",
|
|
34582
|
+
"interpolated": true
|
|
34583
|
+
}
|
|
34584
|
+
]
|
|
34585
|
+
},
|
|
34586
|
+
{
|
|
34587
|
+
"id": "openwop.it.v2-bundle-signature-attributable.the-four-signature-outcomes-stay-distinct-collapsing-any-two-restores-the-defect",
|
|
34588
|
+
"file": "v2-bundle-signature-attributable.test.ts",
|
|
34589
|
+
"line": 131,
|
|
34590
|
+
"title": "the four signature outcomes stay distinct — collapsing any two restores the defect",
|
|
34591
|
+
"explicitId": null,
|
|
34592
|
+
"citations": [
|
|
34593
|
+
{
|
|
34594
|
+
"section": null,
|
|
34595
|
+
"requirement": null,
|
|
34596
|
+
"interpolated": true
|
|
34597
|
+
}
|
|
34598
|
+
]
|
|
34599
|
+
},
|
|
34386
34600
|
{
|
|
34387
34601
|
"id": "openwop.it.v2-corpus-tag-pinned.every-vendoring-consumer-in-the-cross-repo-inventory-records-a-published-corpus",
|
|
34388
34602
|
"file": "v2-corpus-tag-pinned.test.ts",
|
|
@@ -34411,6 +34625,30 @@
|
|
|
34411
34625
|
}
|
|
34412
34626
|
]
|
|
34413
34627
|
},
|
|
34628
|
+
{
|
|
34629
|
+
"id": "openwop.it.v2-error-registry-prose-parity.errors-md-states-the-registry-count-it-was-generated-from-and-renders-every-code",
|
|
34630
|
+
"file": "v2-error-registry-prose-parity.test.ts",
|
|
34631
|
+
"line": 26,
|
|
34632
|
+
"title": "errors.md states the registry count it was generated from and renders every code",
|
|
34633
|
+
"explicitId": null,
|
|
34634
|
+
"citations": [
|
|
34635
|
+
{
|
|
34636
|
+
"section": null,
|
|
34637
|
+
"requirement": null,
|
|
34638
|
+
"interpolated": true
|
|
34639
|
+
},
|
|
34640
|
+
{
|
|
34641
|
+
"section": null,
|
|
34642
|
+
"requirement": "errors.md states the registry size at least once, so the claim is checkable",
|
|
34643
|
+
"interpolated": true
|
|
34644
|
+
},
|
|
34645
|
+
{
|
|
34646
|
+
"section": null,
|
|
34647
|
+
"requirement": null,
|
|
34648
|
+
"interpolated": true
|
|
34649
|
+
}
|
|
34650
|
+
]
|
|
34651
|
+
},
|
|
34414
34652
|
{
|
|
34415
34653
|
"id": "openwop.it.v2-event-naming-rule.every-closed-enum-v2-event-type-is-two-kebab-segments-and-every-recorded-artifac",
|
|
34416
34654
|
"file": "v2-event-naming-rule.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 498,
|
|
5
5
|
"v1": 444,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 55
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1228,6 +1228,9 @@
|
|
|
1228
1228
|
"trigger-stream-cdc-sources.test.ts": [
|
|
1229
1229
|
1
|
|
1230
1230
|
],
|
|
1231
|
+
"v2-advertised-fixtures-exist.test.ts": [
|
|
1232
|
+
2
|
|
1233
|
+
],
|
|
1231
1234
|
"v2-approver-enforced.test.ts": [
|
|
1232
1235
|
2
|
|
1233
1236
|
],
|
|
@@ -1267,9 +1270,15 @@
|
|
|
1267
1270
|
"v2-enum-growth-rule.test.ts": [
|
|
1268
1271
|
2
|
|
1269
1272
|
],
|
|
1273
|
+
"v2-era-2-append-vocabulary.test.ts": [
|
|
1274
|
+
2
|
|
1275
|
+
],
|
|
1270
1276
|
"v2-era-key.test.ts": [
|
|
1271
1277
|
2
|
|
1272
1278
|
],
|
|
1279
|
+
"v2-era-stamp-universal.test.ts": [
|
|
1280
|
+
2
|
|
1281
|
+
],
|
|
1273
1282
|
"v2-error-registry.test.ts": [
|
|
1274
1283
|
2
|
|
1275
1284
|
],
|
|
@@ -1372,6 +1381,9 @@
|
|
|
1372
1381
|
"v2-v1-signed-webhook-accepted.test.ts": [
|
|
1373
1382
|
2
|
|
1374
1383
|
],
|
|
1384
|
+
"v2-version-header-honored.test.ts": [
|
|
1385
|
+
2
|
|
1386
|
+
],
|
|
1375
1387
|
"v2-webhook-durable-delivery.test.ts": [
|
|
1376
1388
|
2
|
|
1377
1389
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
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.0-rc.
|
|
4
|
+
"version": "2.0.0-rc.10",
|
|
5
5
|
"corpusTag": null,
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -9,9 +9,9 @@
|
|
|
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": "09450ae021c2230e440257875149337984f319b42a7808037130305e3c48390f",
|
|
13
|
+
"api/v2/asyncapi.yaml": "0dbb71b3832ee159e03f633cbb5464a14b565d2335b9f00ec8aee456bac950be",
|
|
14
|
+
"api/v2/openapi.yaml": "a5ad8b811af848060c62feaebc9be8450bc5ca113ba84158c3b97a3bdef66ad9",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
|
|
33
33
|
"schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
|
|
34
34
|
"schemas/capabilities.schema.json": "e672c35d503532d8039b5552115f5ec150d60317f537f13180d830b0fe315947",
|
|
35
|
-
"schemas/certification-bundle-v2.schema.json": "
|
|
35
|
+
"schemas/certification-bundle-v2.schema.json": "2f85530dd04824a15810c94e96869362dee1e78396eca21fdd18e5501416bb7d",
|
|
36
36
|
"schemas/channel-presence-payload.schema.json": "f9d0ef4219814c2b765baf3e68cbe5eea2cb42379f516a93a0459dcd5f7db47e",
|
|
37
37
|
"schemas/channel-written-payload.schema.json": "54723a56bc5a35232f64544ca686ac1ac959a5fb05b32b2401facca6b3ef51a3",
|
|
38
38
|
"schemas/chat-card-pack-manifest.schema.json": "402f7e9081cc39857a52e772061f4e034a957dcbc17501fe6e8e0bcb3d337e42",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"schemas/v2/artifact-type-pack-manifest.schema.json": "c911757562e521cb4f31a47c097f562573b64087efe60c8acb81fd352367d300",
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "cb54c4d89ba5dfb73f49aa8959a980be65c2dc2a87a12f71ba36d3e836fe85da",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
118
|
-
"schemas/v2/capabilities.schema.json": "
|
|
119
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
118
|
+
"schemas/v2/capabilities.schema.json": "792669b2cc7b1fd44ce9a4fd3e79696ad1277734e29299a4504b39eda15913ed",
|
|
119
|
+
"schemas/v2/certification-bundle.schema.json": "d441198893c01cd14d84abe9c5bf591b69f47554db749c01899512f36cea11fa",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "9475d46f358d21c19126e78204987a5633979917a2293653354ef5c5e044d54e",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
122
122
|
"schemas/v2/chat-card-pack-manifest.schema.json": "566982ce509a96d1619d58a72e383eff0773fe43fc65e4bfc85a4fe158f4eaff",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"schemas/v2/goal.schema.json": "d202ef21ed3dbff87c25fd4b553fa978862a3200999878d25c5918f6794b3060",
|
|
150
150
|
"schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
|
|
151
151
|
"schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
|
|
152
|
-
"schemas/v2/ids.schema.json": "
|
|
152
|
+
"schemas/v2/ids.schema.json": "dc6c99dea35a3416167b2b883ac08694794da1c7552bbfa48fc489c612d1cf97",
|
|
153
153
|
"schemas/v2/localized-content-language-settings.schema.json": "92240f19a2b4b2f05ab41a797f703485a28813848035478e72f395edd04c1d35",
|
|
154
154
|
"schemas/v2/localized-content-page-response.schema.json": "476b5f2775b42e35e679814a4ce846a7be1d5311d3a748c10df4275a78b0fb27",
|
|
155
155
|
"schemas/v2/localized-content-page.schema.json": "717bb51fbc64d0b34eb0a4b35c8b463d08d505267bf251b4cbc67e0a016a567a",
|
|
@@ -169,11 +169,11 @@
|
|
|
169
169
|
"schemas/v2/residency.schema.json": "af2658a7104cbcacd0e20a91226271c4a77f91ef46ae97612768fe743e6091d8",
|
|
170
170
|
"schemas/v2/run-ancestry-response.schema.json": "907e36341ba83a2147a5d76963950b6dcd47506b663ecbccae9dfa553f3724d3",
|
|
171
171
|
"schemas/v2/run-diff-response.schema.json": "6ab6cd265dc7e0a2086c6659c46aa0e7b43e6302949f0adac5d7deb1c7c0ecd3",
|
|
172
|
-
"schemas/v2/run-event-payloads.schema.json": "
|
|
173
|
-
"schemas/v2/run-event.schema.json": "
|
|
172
|
+
"schemas/v2/run-event-payloads.schema.json": "bd5cb6687828a86fb4411d331893ec183dce2c52cb27c22035d4cd7a8bc3c1a2",
|
|
173
|
+
"schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
|
|
174
174
|
"schemas/v2/run-options.schema.json": "6d6052596ab0f17acfb00fdfc89907da7724f952e5ffbe1f81d94ba23d4c77cd",
|
|
175
175
|
"schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
|
|
176
|
-
"schemas/v2/run-snapshot.schema.json": "
|
|
176
|
+
"schemas/v2/run-snapshot.schema.json": "01408d8dac93e2d08d8db30f9cd7a4d903dce882a60bc1d48c3e6b921267f2a3",
|
|
177
177
|
"schemas/v2/security-advisory.schema.json": "6a382f737730a738d634266315e9d335e5760020a0cdd9f30727c2f987a01770",
|
|
178
178
|
"schemas/v2/self-hosted-runner-dispatch-frame.schema.json": "d8129f11d4e731d17ea951e66faaea2086081d13958b62ceaaa359fd6637dd7d",
|
|
179
179
|
"schemas/v2/self-hosted-runner-registration.schema.json": "93d60003a668cacb0eeac37699ca2884c6b79332d9494cb9e0a1752ac8f8a16c",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
199
199
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
200
200
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
201
|
-
"spec/v1/core-standard-manifest.json": "
|
|
201
|
+
"spec/v1/core-standard-manifest.json": "a9e4e6d32753d76974f1a61d5e31bd1fcde5ad0100cf3c7aae6770f3e6fca5c6",
|
|
202
202
|
"spec/v1/deprecations.json": "520d79927a8ed43ccf62c207a7cf6cf7b19b16e00bbbbdf0410667e939c19d85",
|
|
203
203
|
"spec/v1/deprecations.schema.json": "4cdbbb8eb9c0092913a5704c943880f0c8581bcf0f640a7df5a84105d53c8aee",
|
|
204
204
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
211
211
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
212
212
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
213
|
-
"spec/v2/declaration.json": "
|
|
213
|
+
"spec/v2/declaration.json": "52b8c24a2dd463d44086129bf792ad108746f5102778cbbf0ff469fa534eca48",
|
|
214
214
|
"spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
|
|
215
215
|
"spec/v2/errors.json": "ad1725dc7c11002450e8abc682858eb5c11952a0718ea8ef6bdb2fb5ae6e7e21",
|
|
216
216
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
229
229
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
230
230
|
"spec/v2/profiles.json": "1f0ee40491131cf561ef19a0394c47ba476b4567514fe16befb6cada4a10a5a4",
|
|
231
|
-
"spec/v2/release.json": "
|
|
231
|
+
"spec/v2/release.json": "7eb5cd641f6bb4d3143b3ddf8602bb2d856efc3c2db80cca2f15fe3a4f78c7f3"
|
|
232
232
|
},
|
|
233
|
-
"corpusCommit": "
|
|
233
|
+
"corpusCommit": "f71d788362ba5a32f167c2c70a1e534ddebc4f31"
|
|
234
234
|
}
|
package/src/cli.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type DiscoveryPayload,
|
|
55
55
|
PROFILE_FLOOR_SCENARIOS,
|
|
56
56
|
} from './lib/profiles.js';
|
|
57
|
+
import { setV2ProfileFloors } from './lib/requirement-registry.js';
|
|
57
58
|
|
|
58
59
|
interface ParsedArgs {
|
|
59
60
|
readonly baseUrl: string | undefined;
|
|
@@ -320,14 +321,8 @@ function claimedProfilesFor(doc: DiscoveryPayload): string[] {
|
|
|
320
321
|
* no v2 host could ever certify. Falls back to the empty set only when the
|
|
321
322
|
* registry is genuinely absent from the layout, and says so.
|
|
322
323
|
*/
|
|
323
|
-
function
|
|
324
|
-
|
|
325
|
-
// this package — instead of guessing directory shapes. Hand-rolled candidates
|
|
326
|
-
// found the registry in a repo checkout and missed it in every published
|
|
327
|
-
// install, where npm hoists the peer to a SIBLING package dir: the probe that
|
|
328
|
-
// walked one level up landed on the `@openwop/` scope directory, not a
|
|
329
|
-
// package, so a real host run silently claimed nothing.
|
|
330
|
-
const candidates = [];
|
|
324
|
+
function v2RegistryPath(conformanceRoot: string): string | null {
|
|
325
|
+
const candidates: string[] = [];
|
|
331
326
|
try {
|
|
332
327
|
const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
|
|
333
328
|
candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
|
|
@@ -337,8 +332,18 @@ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): s
|
|
|
337
332
|
resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'),
|
|
338
333
|
resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'),
|
|
339
334
|
);
|
|
340
|
-
|
|
341
|
-
|
|
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) {
|
|
342
347
|
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
343
348
|
return [];
|
|
344
349
|
}
|
|
@@ -364,6 +369,42 @@ function claimedProfilesForV2(doc: DiscoveryPayload, conformanceRoot: string): s
|
|
|
364
369
|
return out;
|
|
365
370
|
}
|
|
366
371
|
|
|
372
|
+
/**
|
|
373
|
+
* `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
|
|
374
|
+
* `planned:<name>` entry names a scenario the registry expects but that may not
|
|
375
|
+
* exist yet; it resolves to `v2-<name>.test.ts` when that file is in the
|
|
376
|
+
* manifest and is dropped otherwise, so a planned-but-unwritten floor cannot
|
|
377
|
+
* fail a host for the corpus's own backlog.
|
|
378
|
+
*/
|
|
379
|
+
function v2ProfileFloors(conformanceRoot: string): Record<string, readonly string[]> {
|
|
380
|
+
const registryPath = v2RegistryPath(conformanceRoot);
|
|
381
|
+
if (registryPath === null) return {};
|
|
382
|
+
let known: Set<string>;
|
|
383
|
+
try {
|
|
384
|
+
known = new Set(Object.keys((JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')) as { majors: Record<string, number[]> }).majors));
|
|
385
|
+
} catch {
|
|
386
|
+
known = new Set();
|
|
387
|
+
}
|
|
388
|
+
let registry: { profiles?: Array<{ id?: unknown; floorScenarios?: unknown }> };
|
|
389
|
+
try {
|
|
390
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
391
|
+
} catch {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
const out: Record<string, readonly string[]> = {};
|
|
395
|
+
for (const p of registry.profiles ?? []) {
|
|
396
|
+
if (typeof p.id !== 'string') continue;
|
|
397
|
+
const raw = Array.isArray(p.floorScenarios) ? (p.floorScenarios as unknown[]).map(String) : [];
|
|
398
|
+
const files: string[] = [];
|
|
399
|
+
for (const entry of raw) {
|
|
400
|
+
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
401
|
+
if (known.size === 0 || known.has(name)) files.push(name);
|
|
402
|
+
}
|
|
403
|
+
out[p.id] = files;
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
367
408
|
/** A single scenario test file's terminal state, derived from the vitest JSON report. */
|
|
368
409
|
type ScenarioState = 'passed' | 'failed' | 'skipped';
|
|
369
410
|
|
|
@@ -475,6 +516,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
475
516
|
|
|
476
517
|
// (b) Derive claimedProfiles from the captured document.
|
|
477
518
|
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
|
|
519
|
+
// Major-2 floors come from spec/v2/profiles.json. Without this the derivation
|
|
520
|
+
// measures a v2 host against v1 scenario files a major-2 run never executes,
|
|
521
|
+
// and refuses certification for not running them.
|
|
522
|
+
setV2ProfileFloors(target.major === 2 ? v2ProfileFloors(conformanceRoot) : null);
|
|
478
523
|
|
|
479
524
|
// (c) Run the suite, capturing per-scenario terminal state via the vitest
|
|
480
525
|
// JSON reporter. server-targeted scenarios live under src/scenarios/.
|
|
@@ -78,7 +78,28 @@ export function floorFilesFor(profile: string, document?: Readonly<Record<string
|
|
|
78
78
|
* discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
|
|
79
79
|
* it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
|
|
80
80
|
*/
|
|
81
|
+
/**
|
|
82
|
+
* Major-2 floors come from `spec/v2/profiles.json`, not from the v1 table.
|
|
83
|
+
* `PROFILE_FLOOR_SCENARIOS` names v1 scenario FILES (`runs-lifecycle.test.ts`,
|
|
84
|
+
* `discovery.test.ts`, …) that `scenario-majors.json` assigns to major 1 and a
|
|
85
|
+
* major-2 run therefore never executes — so every one of them came back
|
|
86
|
+
* unclassified and a v2 host was refused certification for not running v1
|
|
87
|
+
* scenarios. Set by the runner before deriving; empty means the registry
|
|
88
|
+
* declares no floor for that profile, which is a real "witnesses nothing yet",
|
|
89
|
+
* not an unclassified return.
|
|
90
|
+
*/
|
|
91
|
+
let v2Floors: Readonly<Record<string, readonly string[]>> | null = null;
|
|
92
|
+
|
|
93
|
+
export function setV2ProfileFloors(floors: Readonly<Record<string, readonly string[]>> | null): void {
|
|
94
|
+
v2Floors = floors;
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
export function requirementsFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
|
|
98
|
+
if (v2Floors !== null) {
|
|
99
|
+
const files = v2Floors[profile];
|
|
100
|
+
if (files === undefined) return null;
|
|
101
|
+
return files.map(requirementIdForScenario);
|
|
102
|
+
}
|
|
82
103
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
83
104
|
if (floor === undefined) return null;
|
|
84
105
|
if (floor.discoveryOnly === true) return [];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/conformance.md` — an advertised conformance fixture is a claim
|
|
3
|
+
* the host must be able to honour (suite 2.0.0, target major 2; unaided).
|
|
4
|
+
*
|
|
5
|
+
* `fixtures[]` in discovery gates scenarios: `isFixtureAdvertised(id)` decides
|
|
6
|
+
* whether a scenario runs at all. Nothing checked that an advertised id names a
|
|
7
|
+
* fixture the corpus actually defines, or that the host can serve it. A host
|
|
8
|
+
* whose advertised list and seeded set drift apart therefore fails somewhere
|
|
9
|
+
* else entirely — the scenario gated on the missing fixture attempts, fails on
|
|
10
|
+
* a run that cannot be created, and the failure is attributed to that
|
|
11
|
+
* scenario's requirement rather than to the advertisement that was wrong.
|
|
12
|
+
*
|
|
13
|
+
* No host is currently known to exhibit this. A tier-2 host was thought to
|
|
14
|
+
* (46 seeded against 47 advertised) and then verified and retracted it — the
|
|
15
|
+
* count had been eyeballed from an array literal rather than measured, and the
|
|
16
|
+
* two sets are in fact identical. The scenario is kept because the failure mode
|
|
17
|
+
* is a property of the gating mechanism, not of that host: `fixtures[]` decides
|
|
18
|
+
* whether a scenario runs, so a wrong advertisement is charged to whatever runs
|
|
19
|
+
* next. Leg 1 is also the stronger check — an id the corpus catalog does not
|
|
20
|
+
* define is wrong however well a host's own two lists agree with each other.
|
|
21
|
+
*
|
|
22
|
+
* Two legs, both cheap:
|
|
23
|
+
* 1. the advertised ids are a subset of the corpus fixture catalog — the
|
|
24
|
+
* vocabulary is closed, so an id the corpus does not define is a typo or
|
|
25
|
+
* an invention, not a capability;
|
|
26
|
+
* 2. a bounded sample of advertised fixtures is actually creatable, so the
|
|
27
|
+
* list is a claim about reachable state rather than a wish.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/core/conformance.md
|
|
30
|
+
* @see conformance/fixtures.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
35
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
36
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
37
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
38
|
+
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
40
|
+
|
|
41
|
+
const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
|
|
42
|
+
const DOC = 'spec/v2/core/conformance.md §Fixtures';
|
|
43
|
+
/** Creating one run per advertised fixture would be a load test, not a check. */
|
|
44
|
+
const SAMPLE = 5;
|
|
45
|
+
|
|
46
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function advertisedIds(doc: Record<string, unknown>): string[] {
|
|
55
|
+
const raw = doc['fixtures'];
|
|
56
|
+
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('v2-advertised-fixtures-exist (conformance.md §Fixtures)', () => {
|
|
60
|
+
it('every advertised fixture id is one the corpus defines', async () => {
|
|
61
|
+
const doc = await v2Discovery().catch(() => null);
|
|
62
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
63
|
+
const ids = advertisedIds(doc);
|
|
64
|
+
if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[] — there is no claim to falsify');
|
|
65
|
+
if (FIXTURES_DIR === null || !existsSync(FIXTURES_DIR)) {
|
|
66
|
+
return softSkip('blocked', 'the fixture catalog is absent from this layout, so an advertised id cannot be checked against it');
|
|
67
|
+
}
|
|
68
|
+
const catalog = new Set(
|
|
69
|
+
readdirSync(FIXTURES_DIR)
|
|
70
|
+
.filter((f) => f.endsWith('.json'))
|
|
71
|
+
.map((f) => f.replace(/\.json$/, '')),
|
|
72
|
+
);
|
|
73
|
+
const unknown = ids.filter((id) => !catalog.has(id));
|
|
74
|
+
expect(
|
|
75
|
+
unknown,
|
|
76
|
+
req(ID, DOC, `every id in fixtures[] MUST name a fixture the corpus defines — the vocabulary is closed, so an id the catalog does not carry is a typo or an invention rather than a capability (${unknown.length} unknown of ${ids.length}: ${unknown.slice(0, 5).join(', ')})`),
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a sampled advertised fixture is actually creatable, not just listed', async () => {
|
|
81
|
+
const doc = await v2Discovery().catch(() => null);
|
|
82
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
83
|
+
const ids = advertisedIds(doc);
|
|
84
|
+
if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[]');
|
|
85
|
+
|
|
86
|
+
// Deterministic sample: the first N by sort order, so a failure is
|
|
87
|
+
// reproducible and a host cannot pass by luck of ordering.
|
|
88
|
+
const sample = [...ids].sort().slice(0, SAMPLE);
|
|
89
|
+
const unreachable: string[] = [];
|
|
90
|
+
let attempted = 0;
|
|
91
|
+
for (const id of sample) {
|
|
92
|
+
const res = await http(() => driver.post('/runs', { workflowId: id, inputs: {} }));
|
|
93
|
+
if (res === null) {
|
|
94
|
+
return softSkip('blocked', `POST /runs was unreachable while sampling advertised fixtures (stopped at ${id})`);
|
|
95
|
+
}
|
|
96
|
+
attempted += 1;
|
|
97
|
+
// 201 is the claim honoured. A 4xx that names the workflow as unknown is
|
|
98
|
+
// the drift this leg exists to catch; any other status is a different
|
|
99
|
+
// problem and is not judged here.
|
|
100
|
+
if (res.status !== 201) unreachable.push(`${id} → ${res.status}`);
|
|
101
|
+
}
|
|
102
|
+
if (attempted === 0) return softSkip('blocked', 'no advertised fixture could be attempted');
|
|
103
|
+
expect(
|
|
104
|
+
unreachable,
|
|
105
|
+
req(ID, DOC, `an advertised fixture MUST be creatable: fixtures[] gates whether a scenario runs at all, so a listed-but-unseeded fixture makes some OTHER scenario fail on a run that cannot exist, and the failure is attributed to the wrong requirement (${unreachable.length} of ${attempted} sampled: ${unreachable.join(', ')})`),
|
|
106
|
+
).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A / `spec/v2/core/persistence.md` §The writer rule — an append to a
|
|
3
|
+
* run in era `2` uses v1 vocabulary (suite 2.0.0, target major 2; seam-gated).
|
|
4
|
+
*
|
|
5
|
+
* The reader rule translates an era-`2` log through the codemap at the storage
|
|
6
|
+
* boundary. That is only coherent if the log stays in ONE vocabulary: the era
|
|
7
|
+
* key is fixed at run creation and fixes the log's vocabulary for the run's
|
|
8
|
+
* lifetime. A host that upgrades while runs are open and then begins writing v2
|
|
9
|
+
* names into an era-`2` log breaks the reader two ways — a renamed type gets
|
|
10
|
+
* mapped a second time, and a v2-only name is not on the codemap's v1 side at
|
|
11
|
+
* all, so the read fails with `event_type_unmapped`.
|
|
12
|
+
*
|
|
13
|
+
* This is not a hypothetical for a host with human-approval interrupts, where a
|
|
14
|
+
* run can stay open for days across a deploy. Draining era-`2` runs before
|
|
15
|
+
* serving v2 is explicitly not the path (`persistence.md` §"Runs pinned to v1"),
|
|
16
|
+
* so the writer rule is what makes an in-flight run safe across the cut.
|
|
17
|
+
*
|
|
18
|
+
* The witness: seed an era-`2` run that is still `running`, drive one canonical
|
|
19
|
+
* mutation so the HOST's own writer appends a terminal event, then read the
|
|
20
|
+
* whole log back under major 2. A host that appended in v1 vocabulary reads
|
|
21
|
+
* back cleanly; a host that appended in v2 vocabulary fails the read or returns
|
|
22
|
+
* a type the closed v2 registry does not carry.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/persistence.md §The writer rule
|
|
25
|
+
* @see RFCS/0176-v2-persisted-data-and-coexistence.md §A
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
30
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { codemapV1toV2, era2Gate, eventsOf, pollEvents, seedEra2Log, v1FixtureLog, type ReadEvent } from '../lib/era2-seed.js';
|
|
35
|
+
|
|
36
|
+
const ID = 'openwop.requirement.0176.era-2-append-vocabulary';
|
|
37
|
+
const DOC = 'spec/v2/core/persistence.md §The writer rule';
|
|
38
|
+
|
|
39
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () => {
|
|
48
|
+
it('a host append to an open era-2 run keeps the log in v1 vocabulary, so the whole log still reads', async () => {
|
|
49
|
+
const doc = await v2Discovery().catch(() => null);
|
|
50
|
+
const gate = era2Gate(doc);
|
|
51
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
52
|
+
|
|
53
|
+
// An era-2 log that is still OPEN: `running`, not `completed`. A completed
|
|
54
|
+
// run is never appended to, so it cannot witness the writer rule.
|
|
55
|
+
const seeded = await seedEra2Log(v1FixtureLog().slice(0, 2), 'running');
|
|
56
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
57
|
+
const runId = seeded.runId;
|
|
58
|
+
|
|
59
|
+
const before = await pollEvents(runId);
|
|
60
|
+
if (before === null || before.status !== 200) {
|
|
61
|
+
return softSkip('blocked', `GET /runs/${runId}/events/poll answered ${before?.status ?? 'no response'} on the seeded era-2 run — the log cannot be read back`);
|
|
62
|
+
}
|
|
63
|
+
const seedCount = eventsOf(before).length;
|
|
64
|
+
|
|
65
|
+
// One canonical mutation so the HOST's own writer appends. Cancel is the
|
|
66
|
+
// universally available terminal transition; a host that refuses it on a
|
|
67
|
+
// seeded run records `blocked` rather than a pass.
|
|
68
|
+
const cancelled = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
69
|
+
if (cancelled === null || (cancelled.status !== 200 && cancelled.status !== 202 && cancelled.status !== 204)) {
|
|
70
|
+
return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancelled?.status ?? 'no response'} on a seeded era-2 run — no canonical mutation drove the host's writer, so the append is unwitnessed`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const after = await pollEvents(runId);
|
|
74
|
+
if (after === null) return softSkip('blocked', 'the event read failed after the append');
|
|
75
|
+
|
|
76
|
+
// Failure mode 1: the read itself refuses. A host that wrote a v2-only name
|
|
77
|
+
// into an era-2 log produces a type the codemap cannot map FROM.
|
|
78
|
+
if (after.status !== 200) {
|
|
79
|
+
const code = readErrorCode(after.json);
|
|
80
|
+
expect(
|
|
81
|
+
code,
|
|
82
|
+
req(ID, DOC, `reading the era-2 log after the host's own append failed with ${after.status} ${String(code)} — an append in v2 vocabulary is exactly what makes a translated read fail, and the era key fixes the log's vocabulary for the run's lifetime`),
|
|
83
|
+
).toBe(null);
|
|
84
|
+
return softSkip('blocked', `the era-2 read failed with ${after.status} after the host's own append — the assertion above already recorded the refusal`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rows = eventsOf(after) as ReadEvent[];
|
|
88
|
+
expect(
|
|
89
|
+
rows.length,
|
|
90
|
+
req(ID, DOC, `the append MUST be visible in the log (seeded ${seedCount}, read ${rows.length} after cancel)`),
|
|
91
|
+
).toBeGreaterThan(seedCount);
|
|
92
|
+
|
|
93
|
+
// Failure mode 2: the read succeeds but returns a name the v2 registry does
|
|
94
|
+
// not carry — a v1 name that survived untranslated because it was written
|
|
95
|
+
// after the upgrade and the host treated the log as era 3.
|
|
96
|
+
const v2Names = new Set(codemapV1toV2().values());
|
|
97
|
+
const v1Names = new Set(codemapV1toV2().keys());
|
|
98
|
+
for (const row of rows.slice(seedCount)) {
|
|
99
|
+
const type = String(row.type ?? '');
|
|
100
|
+
// A renamed type is the discriminator: its v1 spelling must NOT appear on
|
|
101
|
+
// the wire, because every reader translates an era-2 log.
|
|
102
|
+
const renamedV1 = v1Names.has(type) && !v2Names.has(type);
|
|
103
|
+
expect(
|
|
104
|
+
renamedV1,
|
|
105
|
+
req(ID, DOC, `the appended event reads as its v2 name, not its v1 spelling (${type}) — every reader translates an era-2 log, so a v1 name reaching the wire means the append bypassed the storage boundary`),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sequence stays contiguous across the era boundary the append crosses.
|
|
110
|
+
const seqs = rows.map((r) => Number(r.sequence)).sort((a, b) => a - b);
|
|
111
|
+
for (let i = 0; i < seqs.length; i++) {
|
|
112
|
+
expect(
|
|
113
|
+
seqs[i],
|
|
114
|
+
req(ID, DOC, `the appended rows continue the seeded sequence space without a gap or restart (expected ${i}, got ${String(seqs[i])})`),
|
|
115
|
+
).toBe(i);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the run keeps the era it was created with; an append does not promote it to era 3', async () => {
|
|
120
|
+
const doc = await v2Discovery().catch(() => null);
|
|
121
|
+
const gate = era2Gate(doc);
|
|
122
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
123
|
+
|
|
124
|
+
const seeded = await seedEra2Log(v1FixtureLog().slice(0, 2), 'running');
|
|
125
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
126
|
+
const runId = seeded.runId;
|
|
127
|
+
|
|
128
|
+
await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
129
|
+
|
|
130
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
131
|
+
if (snap === null || snap.status !== 200) {
|
|
132
|
+
return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} — the era key cannot be read back`);
|
|
133
|
+
}
|
|
134
|
+
const era = (snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion;
|
|
135
|
+
expect(
|
|
136
|
+
era === undefined || era === 2,
|
|
137
|
+
req(ID, DOC, `the era key is fixed at run creation: a run seeded in era 2 and then appended to MUST still read as era 2 (got ${String(era)}) — promoting it to 3 would strand the seeded rows in a vocabulary the reader no longer translates`),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A.7 / `spec/v2/core/persistence.md` §The era key — every creation
|
|
3
|
+
* path stamps the same era, and discovery advertises that one value (suite
|
|
4
|
+
* 2.0.0, target major 2; unaided).
|
|
5
|
+
*
|
|
6
|
+
* The era trichotomy (absent ⇒ `2`, `2` = v1 era, `3` = v2 era) is sound only
|
|
7
|
+
* because a v2 host stamps `3` on EVERY run it creates. A host with more than
|
|
8
|
+
* one creation path that leaves one unstamped produces runs indistinguishable
|
|
9
|
+
* from pre-cut runs, and every reader translates them as era `2` — a silent
|
|
10
|
+
* wrong read, not an error. A tier-2 host found exactly this shape in review:
|
|
11
|
+
* one creation path writing `2`, another writing nothing, and discovery
|
|
12
|
+
* advertising `1`, a value no path wrote.
|
|
13
|
+
*
|
|
14
|
+
* This is witnessable unaided because the host's own discovery document states
|
|
15
|
+
* the value it claims to write, and a run created through the canonical path
|
|
16
|
+
* states what it actually wrote. A disagreement between them is the defect.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/persistence.md §The era key
|
|
19
|
+
* @see RFCS/0176-v2-persisted-data-and-coexistence.md §A
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
24
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { req } from '../lib/requirement-ids.js';
|
|
27
|
+
|
|
28
|
+
const ID = 'openwop.requirement.0176.era-stamp-universal';
|
|
29
|
+
const DOC = 'spec/v2/core/persistence.md §The era key';
|
|
30
|
+
|
|
31
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('v2-era-stamp-universal (RFC 0176 §A.7)', () => {
|
|
40
|
+
it('discovery advertises an era the host actually writes, and a new run carries it', async () => {
|
|
41
|
+
const doc = await v2Discovery().catch(() => null);
|
|
42
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
43
|
+
|
|
44
|
+
const advertised = doc['eventLogSchemaVersion'];
|
|
45
|
+
if (advertised === undefined) {
|
|
46
|
+
return softSkip('inapplicable', 'the host advertises no eventLogSchemaVersion — the era axis is undeclared, so there is no claim to falsify here');
|
|
47
|
+
}
|
|
48
|
+
expect(
|
|
49
|
+
typeof advertised === 'number' && Number.isInteger(advertised),
|
|
50
|
+
req(ID, DOC, `eventLogSchemaVersion is the era key and MUST be an integer (got ${JSON.stringify(advertised)})`),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
|
|
53
|
+
const created = await http(() => driver.post('/runs', { workflowId: 'conformance-noop', inputs: {} }));
|
|
54
|
+
if (created === null || created.status !== 201) {
|
|
55
|
+
return softSkip('blocked', `POST /runs answered ${created?.status ?? 'no response'} — a run the host created is what states the era it actually writes`);
|
|
56
|
+
}
|
|
57
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
58
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'POST /runs returned no runId');
|
|
59
|
+
|
|
60
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
61
|
+
if (snap === null || snap.status !== 200) {
|
|
62
|
+
return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} — the snapshot is where the written era is reported`);
|
|
63
|
+
}
|
|
64
|
+
const written = (snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion;
|
|
65
|
+
|
|
66
|
+
// The snapshot field is REQUIRED on the wire and may be synthesized for an
|
|
67
|
+
// era-2 run, but a run this host just created is not era 2 — it is whatever
|
|
68
|
+
// the host writes for new runs, which is the value discovery names.
|
|
69
|
+
expect(
|
|
70
|
+
written,
|
|
71
|
+
req(ID, DOC, `the run snapshot MUST carry the era key — it is required on the wire, synthesized from absent-⇒-2 for historical runs and stated outright for a run the host just created (got ${JSON.stringify(written)})`),
|
|
72
|
+
).not.toBeUndefined();
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
written,
|
|
76
|
+
req(ID, DOC, `discovery MUST advertise the value the host writes for new runs and nothing else: discovery says ${JSON.stringify(advertised)}, the run it just created says ${JSON.stringify(written)}. A host whose creation paths disagree has no single value to advertise, and whatever it publishes is false for some of its own runs`),
|
|
77
|
+
).toBe(advertised);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a v2-era host stamps 3, so its new runs are never read as the v1 era', async () => {
|
|
81
|
+
const doc = await v2Discovery().catch(() => null);
|
|
82
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
83
|
+
const advertised = doc['eventLogSchemaVersion'];
|
|
84
|
+
if (advertised === undefined) return softSkip('inapplicable', 'the host advertises no eventLogSchemaVersion');
|
|
85
|
+
if (advertised !== 3) {
|
|
86
|
+
return softSkip('inapplicable', `the host advertises era ${JSON.stringify(advertised)}, not 3 — this scenario checks that the advertised value AGREES with what the creation paths write, whatever that value is. Whether it MUST be 3 is v2-era-key's assertion, not this one; a host reached under target major 2 is a v2 host and persistence.md requires 3 there`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const created = await http(() => driver.post('/runs', { workflowId: 'conformance-noop', inputs: {} }));
|
|
90
|
+
if (created === null || created.status !== 201) {
|
|
91
|
+
return softSkip('blocked', `POST /runs answered ${created?.status ?? 'no response'}`);
|
|
92
|
+
}
|
|
93
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
94
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'POST /runs returned no runId');
|
|
95
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
96
|
+
if (snap === null || snap.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'}`);
|
|
97
|
+
|
|
98
|
+
expect(
|
|
99
|
+
(snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion,
|
|
100
|
+
req(ID, DOC, 'a host advertising era 3 MUST stamp 3 on every run it creates — an unstamped run made after the cut is indistinguishable from a pre-cut run and every reader translates it as era 2, which is a silent wrong read rather than an error'),
|
|
101
|
+
).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -40,7 +40,7 @@ describe('v2 interrupt-token-scheme (RFC 0170 §E.1)', () => {
|
|
|
40
40
|
if (res === null) return softSkip('blocked', 'GET /interrupts/{token} unreachable (fetch failed)');
|
|
41
41
|
expect([401, 404].includes(res.status), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', DOC, `a token outside the ow2.<alg>.<kid>.<payload>.<mac> grammar MUST NOT resolve — 401 interrupt_token_invalid (or 404 not_found where the signed-token surface is not mounted); got ${res.status}`)).toBe(true);
|
|
42
42
|
const code = readErrorCode(res.json);
|
|
43
|
-
expect(res.status === 401 ? code === 'interrupt_token_invalid' : code === 'not_found', req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry
|
|
43
|
+
expect(res.status === 401 ? code === 'interrupt_token_invalid' : (code === 'not_found' || code === 'interrupt_not_found'), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry a registered code (401 → interrupt_token_invalid; 404 → not_found OR the more precise interrupt_not_found, both registered in spec/v2/errors.json for this state — a scenario narrower than its own registry fails the host that answers more precisely); got ${String(code)}`)).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('a well-formed token under a kid the host does not hold is 401 interrupt_token_invalid', async () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0172 §A.3 — the `OpenWOP-Version` header is HONORED or REFUSED, never
|
|
3
|
+
* ignored (suite 2.0.0, target major 2; unaided).
|
|
4
|
+
*
|
|
5
|
+
* A host that does not implement v2 has two correct answers to
|
|
6
|
+
* `OpenWOP-Version: 2`: serve the v2 representation, or refuse with `406
|
|
7
|
+
* protocol_version_unsupported`. There is a third thing it can do, and it is
|
|
8
|
+
* the dangerous one — return `200` with the v1 document, ignoring the header
|
|
9
|
+
* entirely.
|
|
10
|
+
*
|
|
11
|
+
* That third case is invisible to every presence-gated scenario in the suite.
|
|
12
|
+
* A scenario that skips when a v2 shape is absent records `inapplicable`
|
|
13
|
+
* whether the host REFUSED major 2 or silently handed back v1; both are
|
|
14
|
+
* non-failures, so a bundle looks clean while witnessing nothing. This is not
|
|
15
|
+
* hypothetical: it is the shape that let RFC 0165 sit `Accepted` on a host
|
|
16
|
+
* that served none of it — the acceptance cited merged PRs, the gated
|
|
17
|
+
* scenarios recorded `inapplicable`, and nothing in the chain ever compared
|
|
18
|
+
* the two representations. Measured on a live tier-1 host 2026-09-04:
|
|
19
|
+
* `OpenWOP-Version: 2` returned `200` with a body byte-identical to the
|
|
20
|
+
* header-less fetch. Credit to the openwop-app session for the probe.
|
|
21
|
+
*
|
|
22
|
+
* The check is cheap and needs no v2 support: fetch the resource twice, once
|
|
23
|
+
* with the header and once without. A host advertising two majors MUST NOT
|
|
24
|
+
* return the same bytes for both, because the two representations differ by
|
|
25
|
+
* construction (`capabilities.md` §1). A host advertising one major MUST
|
|
26
|
+
* refuse rather than ignore.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v2/core/versioning.md §1.3
|
|
29
|
+
* @see RFCS/0172-v2-versioning-and-release.md §A.3
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { loadEnv } from '../lib/env.js';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
|
|
37
|
+
const ID = 'openwop.requirement.0172.version-header-honored';
|
|
38
|
+
const DOC = 'spec/v2/core/versioning.md §1.3';
|
|
39
|
+
const PATH = '/.well-known/openwop';
|
|
40
|
+
|
|
41
|
+
interface Fetched {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
readonly body: string;
|
|
44
|
+
readonly version: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchRepresentation(header: string | null): Promise<Fetched | null> {
|
|
48
|
+
const { baseUrl, apiKey } = loadEnv();
|
|
49
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
50
|
+
if (header !== null) headers['OpenWOP-Version'] = header;
|
|
51
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${PATH}`, { headers });
|
|
54
|
+
return { status: res.status, body: await res.text(), version: res.headers.get('openwop-version') };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('v2-version-header-honored (RFC 0172 §A.3)', () => {
|
|
61
|
+
it('OpenWOP-Version is honored or refused — never ignored', async () => {
|
|
62
|
+
const bare = await fetchRepresentation(null);
|
|
63
|
+
const asked = await fetchRepresentation('2.0');
|
|
64
|
+
if (bare === null || asked === null) {
|
|
65
|
+
return softSkip('blocked', `${PATH} unreachable for one of the two probes — the comparison needs both`);
|
|
66
|
+
}
|
|
67
|
+
if (bare.status !== 200) {
|
|
68
|
+
return softSkip('blocked', `the header-less GET ${PATH} answered ${bare.status}; there is no baseline representation to compare against`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A refusal is a correct answer and ends the check: the host has told the
|
|
72
|
+
// truth about not serving major 2.
|
|
73
|
+
if (asked.status === 406) {
|
|
74
|
+
expect(
|
|
75
|
+
asked.status,
|
|
76
|
+
req(ID, DOC, 'a host that does not serve major 2 MUST refuse with 406 protocol_version_unsupported, which it did'),
|
|
77
|
+
).toBe(406);
|
|
78
|
+
return softSkip('inapplicable', 'the host refused major 2 with the specified 406 — a correct answer, and there is no second representation to compare bytes against');
|
|
79
|
+
}
|
|
80
|
+
if (asked.status !== 200) {
|
|
81
|
+
return softSkip('blocked', `GET ${PATH} with OpenWOP-Version: 2.0 answered ${asked.status} — neither the v2 representation (200) nor the specified refusal (406), so this scenario cannot rule on it`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 200 means the host claims to have served major 2. The bytes decide
|
|
85
|
+
// whether it actually did.
|
|
86
|
+
expect(
|
|
87
|
+
asked.body === bare.body,
|
|
88
|
+
req(ID, DOC, `answering 200 to OpenWOP-Version: 2.0 with a body byte-identical to the header-less fetch means the header was IGNORED, not honored — the host served v1 and called it v2. Refusing with 406 is the correct answer for a host that does not implement major 2; silently returning v1 is the one answer that no presence-gated scenario can detect, because an absent v2 shape records \`inapplicable\` whether the host refused or ignored`),
|
|
89
|
+
).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('a host advertising two majors serves two different representations', async () => {
|
|
93
|
+
const bare = await fetchRepresentation(null);
|
|
94
|
+
if (bare === null || bare.status !== 200) {
|
|
95
|
+
return softSkip('blocked', `the header-less GET ${PATH} answered ${bare?.status ?? 'no response'}`);
|
|
96
|
+
}
|
|
97
|
+
let doc: Record<string, unknown>;
|
|
98
|
+
try {
|
|
99
|
+
doc = JSON.parse(bare.body) as Record<string, unknown>;
|
|
100
|
+
} catch {
|
|
101
|
+
return softSkip('blocked', `${PATH} did not return a JSON object`);
|
|
102
|
+
}
|
|
103
|
+
const versions = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).map(String) : [];
|
|
104
|
+
const majors = new Set(versions.map((v) => v.split('.')[0]));
|
|
105
|
+
if (!(majors.has('1') && majors.has('2'))) {
|
|
106
|
+
return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — one major or none, so there is no second representation to differ from`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const asked = await fetchRepresentation('2.0');
|
|
110
|
+
if (asked === null) return softSkip('blocked', `${PATH} unreachable under OpenWOP-Version: 2.0`);
|
|
111
|
+
expect(
|
|
112
|
+
asked.status,
|
|
113
|
+
req(ID, DOC, `a host advertising both majors MUST serve major 2 when asked for it, not refuse (got ${asked.status})`),
|
|
114
|
+
).toBe(200);
|
|
115
|
+
expect(
|
|
116
|
+
asked.body === bare.body,
|
|
117
|
+
req(ID, DOC, 'the v1 document and the closed v2 root differ by construction (capabilities.md §1), so a host advertising both majors returning identical bytes for both has advertised a major it does not actually serve'),
|
|
118
|
+
).toBe(false);
|
|
119
|
+
expect(
|
|
120
|
+
asked.version?.trim().split('.')[0] ?? null,
|
|
121
|
+
req(ID, DOC, `the response to OpenWOP-Version: 2.0 MUST carry OpenWOP-Version naming the major it served (got ${String(asked.version)})`),
|
|
122
|
+
).toBe('2');
|
|
123
|
+
});
|
|
124
|
+
});
|