@openwop/openwop-conformance 2.0.0-rc.8 → 2.0.0
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 +46 -0
- package/README.md +8 -3
- package/dist/cli.js +24 -34
- package/dist/lib/requirement-registry.js +69 -0
- package/dist/lib/scenario-disposition.js +84 -11
- package/dist/lib/seams.js +72 -0
- package/dist/lib/soft-skip.js +39 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +4 -1
- package/requirements.json +1087 -52
- package/scenario-majors.json +57 -3
- package/schemas/CORPUS-STAMP.json +41 -38
- package/src/cli.ts +23 -30
- package/src/global-setup.ts +11 -0
- package/src/lib/corpus-stamp.ts +24 -2
- package/src/lib/era2-seed.ts +12 -1
- package/src/lib/fixtures.ts +31 -0
- package/src/lib/requirement-registry.ts +68 -0
- package/src/lib/scenario-disposition.ts +88 -9
- package/src/lib/seams.ts +31 -1
- package/src/lib/soft-skip.ts +42 -7
- package/src/lib/sse.ts +8 -0
- package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
- package/src/scenarios/pause-resume.test.ts +159 -75
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
- package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
- package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
- package/src/scenarios/v2-created-run-readable.test.ts +98 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
- package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
- package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
- package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
- package/src/scenarios/v2-pack-isolation.test.ts +1 -1
- package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
- package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
- package/src/scenarios/v2-revocation-honored.test.ts +1 -1
- package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
- package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
- package/src/scenarios/v2-run-cancel.test.ts +97 -0
- package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
- package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
- package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
- package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
- package/src/scenarios/v2-run-options-limits.test.ts +69 -0
- package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
- package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
- package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
- package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
- package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
- package/src/scenarios/v2-subject-link-record.test.ts +1 -1
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
- package/src/scenarios/version-negotiation.test.ts +25 -3
- package/src/setup.ts +104 -50
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
|
|
29
29
|
import { scenarioFileOfItId } from './requirement-ids.js';
|
|
30
30
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
31
|
-
import {
|
|
31
|
+
import { targetMajor } from './seams.js';
|
|
32
|
+
import { PKG_ROOT_PATH } from './paths.js';
|
|
33
|
+
import { v2ProfileFloorFiles } from './requirement-registry.js';
|
|
34
|
+
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
|
|
32
35
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
33
36
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
34
37
|
import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
|
|
@@ -40,6 +43,20 @@ export function floorScenarioFiles(): ReadonlySet<string> {
|
|
|
40
43
|
for (const f of floor.required) out.add(f);
|
|
41
44
|
for (const c of floor.conditional ?? []) for (const f of c.required) out.add(f);
|
|
42
45
|
}
|
|
46
|
+
// At target major 2 the floors come from the declaration, not the v1 hand
|
|
47
|
+
// table. The ledger and --certify MUST agree on this set, or a floor file is
|
|
48
|
+
// minted `openwop.scenario.*` and looked up as `openwop.floor.*` — which is
|
|
49
|
+
// exactly what refused a tier-1 host's first production bundle over 101
|
|
50
|
+
// executed-pass rows (see v2ProfileFloorFiles).
|
|
51
|
+
//
|
|
52
|
+
// rc.55: the runner installs the v2 floor map (`setV2ProfileFloors`) but
|
|
53
|
+
// reads `targetMajor()` from ITS OWN process.env, which `--target-major 2`
|
|
54
|
+
// never set — so the worker (env set) minted `openwop.floor.v2-…` and the
|
|
55
|
+
// runner (env unset) looked up `openwop.scenario.v2-…`. Either signal is
|
|
56
|
+
// the same fact; honour both so the two halves cannot disagree again.
|
|
57
|
+
if (targetMajor() === 2 || v2FloorsActive()) {
|
|
58
|
+
for (const files of Object.values(v2ProfileFloorFiles(PKG_ROOT_PATH))) for (const f of files) out.add(f);
|
|
59
|
+
}
|
|
43
60
|
return out;
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -62,6 +79,40 @@ export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
|
|
|
62
79
|
|
|
63
80
|
export type FileTestState = 'pass' | 'fail' | 'skip';
|
|
64
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The per-`it` record (RFC 0148 §A at test granularity), as `setup.ts`
|
|
84
|
+
* computes it in `afterEach`. Pure so a lib test can pin it:
|
|
85
|
+
* - fail ⇒ executed-fail (detail = the first error message)
|
|
86
|
+
* - pass with ≥ 1 assertion ⇒ executed-pass
|
|
87
|
+
* - a behaviorGate entry journaled during the test ⇒ that gate's disposition
|
|
88
|
+
* - pass with 0 assertions ⇒ the softSkip note written DURING THIS TEST
|
|
89
|
+
* (`inapplicable` / `skipped` / `blocked`, worst-first),
|
|
90
|
+
* else `blocked` + the unclassified-return marker
|
|
91
|
+
* - vitest skip (ctx.skip / it.skip) ⇒ the note written before the skip, else `skipped`
|
|
92
|
+
*
|
|
93
|
+
* rc.56: the fourth line is new. Until then a zero-assertion pass consulted the
|
|
94
|
+
* journal only, so a leg that returned `softSkip('inapplicable', …)` was
|
|
95
|
+
* recorded `blocked / unclassified return` at `it` granularity while its file
|
|
96
|
+
* row (which does read the notes — `resolveFileRecord`) was `inapplicable`.
|
|
97
|
+
* A bundle with any `blocked` row does not certify (RFC 0168 §E.1), so the
|
|
98
|
+
* dishonest per-`it` rows denied certification to every profile on a host that
|
|
99
|
+
* simply did not advertise the gated surface.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveItRecord(
|
|
102
|
+
state: FileTestState,
|
|
103
|
+
assertionCalls: number,
|
|
104
|
+
gate: { disposition: 'inapplicable' | 'skipped'; detail?: string } | undefined,
|
|
105
|
+
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
|
|
106
|
+
firstError?: string,
|
|
107
|
+
): { disposition: Disposition; detail?: string } {
|
|
108
|
+
if (state === 'fail') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
109
|
+
if (state === 'pass' && assertionCalls > 0) return { disposition: 'executed-pass' };
|
|
110
|
+
if (gate !== undefined) return { disposition: gate.disposition, detail: gate.detail ?? `${gate.disposition} (gate recorded no reason)` };
|
|
111
|
+
if (noted !== null) return { disposition: noted.kind, detail: noted.reason };
|
|
112
|
+
if (state === 'pass') return { disposition: 'blocked', detail: 'unclassified return: the test passed with zero assertions and recorded no reason — RFC 0148 §A resolves it to blocked, never to a pass' };
|
|
113
|
+
return { disposition: 'skipped', detail: 'vitest skipped the test (ctx.skip / it.skip) without a recorded gate reason' };
|
|
114
|
+
}
|
|
115
|
+
|
|
65
116
|
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
66
117
|
* the ONE disposition the file records. */
|
|
67
118
|
export function fileDisposition(
|
|
@@ -202,6 +253,13 @@ export interface DerivedProfileVerdict {
|
|
|
202
253
|
readonly runtimeDerived: boolean;
|
|
203
254
|
/** For runtime-derived profiles: every floor row is a witnessed executed-pass. */
|
|
204
255
|
readonly held: boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Floor rows that are `executed-pass` with `assertionCount > 0` — the number
|
|
258
|
+
* of things actually witnessed. At major 2 this IS `witnessCount` on the
|
|
259
|
+
* bundle, and a profile with zero of them is not certifiable however its
|
|
260
|
+
* other rows read (RFC 0148 §A: no certification without an execution witness).
|
|
261
|
+
*/
|
|
262
|
+
readonly witnessedPasses: number;
|
|
205
263
|
}
|
|
206
264
|
|
|
207
265
|
export interface Derivation {
|
|
@@ -263,9 +321,15 @@ export function deriveRequirementDispositions(
|
|
|
263
321
|
perFile.set(file, row);
|
|
264
322
|
}
|
|
265
323
|
|
|
266
|
-
// Prefix requirements: derived from the matching files.
|
|
324
|
+
// Prefix requirements: derived from the matching files. These are the v1 hand
|
|
325
|
+
// table's `requiredAnyPrefix` groups (`interrupt-`); at major 2 the floors are
|
|
326
|
+
// the declaration's and have no prefix groups — and the v1 `interrupt-*` files
|
|
327
|
+
// never run at major 2, so until rc.45 every major-2 bundle carried one
|
|
328
|
+
// `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
|
|
329
|
+
// scenario ran"), which by RFC 0168 §E.1 denied certification to every
|
|
330
|
+
// profile on every major-2 bundle. The fifth unjoined floor site.
|
|
267
331
|
const prefixIds = new Set<string>();
|
|
268
|
-
for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
|
|
332
|
+
if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
|
|
269
333
|
for (const prefix of [...prefixIds].sort()) {
|
|
270
334
|
const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
|
|
271
335
|
const id = requirementIdForPrefix(prefix);
|
|
@@ -328,12 +392,18 @@ export function deriveRequirementDispositions(
|
|
|
328
392
|
if (ids === null) {
|
|
329
393
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
330
394
|
const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
|
|
331
|
-
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
|
|
395
|
+
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false, witnessedPasses: 0 });
|
|
332
396
|
continue;
|
|
333
397
|
}
|
|
334
398
|
const unclassified: string[] = [];
|
|
335
399
|
const blocking: string[] = [];
|
|
336
400
|
let witnessedPasses = 0;
|
|
401
|
+
// At major 2 the floor is the declaration's (`v2ProfileFloorFiles`), and
|
|
402
|
+
// the v1 hand table says nothing about these profiles: not their floor, not
|
|
403
|
+
// `discoveryOnly`, not `runtimeDerived`. Reading it here was the fourth
|
|
404
|
+
// unjoined floor site — `openwop-discovery-core` is `discoveryOnly` in v1
|
|
405
|
+
// terms, so at major 2 it certified whatever its v2 floor file said.
|
|
406
|
+
const atMajor2 = v2FloorsActive();
|
|
337
407
|
for (const id of ids) {
|
|
338
408
|
const r = rowById.get(id);
|
|
339
409
|
const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
|
|
@@ -354,24 +424,33 @@ export function deriveRequirementDispositions(
|
|
|
354
424
|
const silent = !fromLedger && (ledgerPresent || r?.disposition === 'blocked');
|
|
355
425
|
if (r === undefined || silent || vacuous) unclassified.push(id);
|
|
356
426
|
// Unclassified always blocks: a requirement nobody recorded cannot certify.
|
|
357
|
-
|
|
427
|
+
// A `skipped` floor row at major 2 is an opt-in the host withheld — the
|
|
428
|
+
// suite was not allowed to look. That is not evidence the requirement
|
|
429
|
+
// holds; it blocks the floor (a v1 floor keeps the CERTIFIABLE reading).
|
|
430
|
+
if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition) || (atMajor2 && r.disposition === 'skipped')) blocking.push(id);
|
|
358
431
|
}
|
|
359
432
|
// discoveryOnly floors have ids.length === 0 and certify by design here (the
|
|
360
433
|
// requirement-ledger's verifyProfileRequirements is stricter; the runner
|
|
361
|
-
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
|
|
362
|
-
|
|
363
|
-
const
|
|
434
|
+
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately). Neither flag
|
|
435
|
+
// exists at major 2.
|
|
436
|
+
const discoveryOnly = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
|
|
437
|
+
const runtimeDerived = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
|
|
364
438
|
// A runtime-derived profile is HELD only when every floor row is a witnessed
|
|
365
439
|
// pass ("derivable from which scenarios pass" — profiles.md). Anything else
|
|
366
440
|
// means the host does not hold it: not a rejection, not a blocked claim.
|
|
367
441
|
const held = ids.length > 0 && witnessedPasses === ids.length;
|
|
442
|
+
// Major 2: a floor certifies only on a witnessed pass. `inapplicable` rows
|
|
443
|
+
// are honest per file (capabilities.md §2 requires the omission) but a floor
|
|
444
|
+
// that is inapplicable end to end has witnessed nothing and certifies nothing.
|
|
445
|
+
const certifiableAt2 = ids.length > 0 && blocking.length === 0 && witnessedPasses >= 1;
|
|
368
446
|
verdicts.push({
|
|
369
447
|
profile,
|
|
370
448
|
unclassified: runtimeDerived && !held ? [] : unclassified,
|
|
371
449
|
blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
|
|
372
|
-
certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
450
|
+
certifiable: atMajor2 ? certifiableAt2 : runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
373
451
|
runtimeDerived,
|
|
374
452
|
held,
|
|
453
|
+
witnessedPasses,
|
|
375
454
|
});
|
|
376
455
|
}
|
|
377
456
|
return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
|
package/src/lib/seams.ts
CHANGED
|
@@ -33,7 +33,37 @@ export function isSeamPath(path: string): boolean {
|
|
|
33
33
|
return V1_SEAM_PREFIXES.some(([re]) => re.test(path)) || path.startsWith(`${SEAMS_PREFIX}/`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Whether a (v2) discovery document advertises the seams profile.
|
|
38
|
+
*
|
|
39
|
+
* **The disposition rule, and it is load-bearing (rc.62).** A scenario that
|
|
40
|
+
* finds this FALSE records `inapplicable`, never `blocked`. The host has not
|
|
41
|
+
* claimed the instrument, so the obligation is out of scope for it — the same
|
|
42
|
+
* sense as `softSkip('inapplicable', 'host does not advertise X')` in
|
|
43
|
+
* `soft-skip.ts`. `blocked` is reserved for the DIFFERENT fact one line later
|
|
44
|
+
* in several of these files: the profile IS advertised and the seam answers
|
|
45
|
+
* 404 — an obligation the host took on and the suite could not measure.
|
|
46
|
+
*
|
|
47
|
+
* Why it matters that these are not the same. `blocked` is bundle-wide fatal
|
|
48
|
+
* (`verifyBundleV3` `blocked-certified`, RFC 0168 §E.1). Until rc.62 fifteen
|
|
49
|
+
* scenarios recorded `blocked` on an ABSENT advert while the seams profile's
|
|
50
|
+
* predicate was empty — and an empty predicate is vacuously satisfied, so
|
|
51
|
+
* `claimedProfilesForV2` claimed the profile for every v2 host. Together that
|
|
52
|
+
* denied certification of EVERY profile to any host that had simply not
|
|
53
|
+
* mounted the conformance seams: 19 of MyndHyve's 45 blocked rows and 9 of
|
|
54
|
+
* openwop-workflow-engine's 29, on hosts that never advertised the profile
|
|
55
|
+
* they were being held to. Mounting test-only surface is not a precondition of
|
|
56
|
+
* certifying `openwop-discovery-core`, and a barrier of that shape falls
|
|
57
|
+
* hardest on the independent implementers the v1 end-of-support clock needs.
|
|
58
|
+
*
|
|
59
|
+
* The predicate (`spec/v2/declaration.json`) now requires `conformance` at the
|
|
60
|
+
* discovery root, which is NECESSARY but not sufficient; this function is the
|
|
61
|
+
* sufficient test, applied per scenario. A host with a `conformance` block
|
|
62
|
+
* naming some other profile claims seams-v2, records every floor row
|
|
63
|
+
* `inapplicable`, witnesses nothing, and so certifies nothing — without a
|
|
64
|
+
* single blocked row. Claimed-and-unwitnessed and not-claimed are both honest;
|
|
65
|
+
* blocked-because-unclaimed was not.
|
|
66
|
+
*/
|
|
37
67
|
export function seamsProfileAdvertised(doc: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
|
38
68
|
const conf = doc?.['conformance'];
|
|
39
69
|
return typeof conf === 'object' && conf !== null && (conf as Record<string, unknown>)['seamsProfile'] === SEAMS_PROFILE_ID;
|
package/src/lib/soft-skip.ts
CHANGED
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* resolves it to blocked" — and stays UNCLASSIFIED for certification (a floor
|
|
23
23
|
* row with that disposition still rejects), so the honest bundle row and the
|
|
24
24
|
* pressure to say why both survive.
|
|
25
|
+
*
|
|
26
|
+
* rc.56: every note also carries a sequence number, so the per-`it` row can
|
|
27
|
+
* read the notes written DURING ITS OWN TEST (`softSkipMark()` at test start,
|
|
28
|
+
* `softSkipDispositionSince(file, mark)` at test end). Until rc.56 the
|
|
29
|
+
* per-`it` row consulted only the journal's `behaviorGate` entries, never a
|
|
30
|
+
* softSkip note, so a leg that returned `softSkip('inapplicable', 'a2a facet
|
|
31
|
+
* not advertised')` was recorded `blocked / unclassified return` at `it`
|
|
32
|
+
* granularity while its file row was honestly `inapplicable` — and a bundle
|
|
33
|
+
* with any `blocked` row does not certify (RFC 0168 §E.1). Forty-five such
|
|
34
|
+
* rows on a host that simply does not advertise A2A/MCP denied certification
|
|
35
|
+
* to every profile it claimed.
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
import { expect } from 'vitest';
|
|
@@ -32,7 +43,10 @@ export type SoftSkipKind = 'inapplicable' | 'skipped' | 'blocked';
|
|
|
32
43
|
/** Detail marker the runner writes for a zero-assertion file that noted nothing. */
|
|
33
44
|
export const UNCLASSIFIED_RETURN_DETAIL = 'every test returned early with zero assertions and no recorded reason — unclassified return; RFC 0148 §A resolves it to blocked (add softSkip(kind, reason) at the early return)';
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
interface Note { readonly kind: SoftSkipKind; readonly reason: string; readonly seq: number }
|
|
47
|
+
|
|
48
|
+
const notes = new Map<string, Note[]>();
|
|
49
|
+
let seq = 0;
|
|
36
50
|
|
|
37
51
|
function currentFile(): string | null {
|
|
38
52
|
try {
|
|
@@ -48,7 +62,9 @@ export function softSkip(kind: SoftSkipKind, reason: string): undefined {
|
|
|
48
62
|
const file = currentFile();
|
|
49
63
|
if (file === null) return undefined;
|
|
50
64
|
const arr = notes.get(file) ?? [];
|
|
51
|
-
|
|
65
|
+
// Every call is recorded with its own sequence number so a per-test window
|
|
66
|
+
// sees it; the file-level join de-duplicates identical (kind, reason) pairs.
|
|
67
|
+
arr.push({ kind, reason, seq: ++seq });
|
|
52
68
|
notes.set(file, arr);
|
|
53
69
|
return undefined;
|
|
54
70
|
}
|
|
@@ -69,6 +85,15 @@ export function seamAbsent(reason: string): undefined {
|
|
|
69
85
|
|
|
70
86
|
const RANK: Record<SoftSkipKind, number> = { blocked: 0, skipped: 1, inapplicable: 2 };
|
|
71
87
|
|
|
88
|
+
function fold(arr: readonly Note[]): { kind: SoftSkipKind; reason: string } | null {
|
|
89
|
+
if (arr.length === 0) return null;
|
|
90
|
+
const uniq: Note[] = [];
|
|
91
|
+
for (const n of arr) if (!uniq.some((u) => u.kind === n.kind && u.reason === n.reason)) uniq.push(n);
|
|
92
|
+
const kind = [...uniq].sort((a, b) => RANK[a.kind] - RANK[b.kind])[0]!.kind;
|
|
93
|
+
const reason = uniq.map((n) => (uniq.length > 1 ? `[${n.kind}] ${n.reason}` : n.reason)).join('; ');
|
|
94
|
+
return { kind, reason };
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
/**
|
|
73
98
|
* The noted disposition for a file, worst-first when mixed (`blocked` beats
|
|
74
99
|
* `skipped` beats `inapplicable` — a file that could not check one thing is
|
|
@@ -76,14 +101,24 @@ const RANK: Record<SoftSkipKind, number> = { blocked: 0, skipped: 1, inapplicabl
|
|
|
76
101
|
* the reasons joined. `null` when nothing was noted.
|
|
77
102
|
*/
|
|
78
103
|
export function softSkipDisposition(file: string): { kind: SoftSkipKind; reason: string } | null {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
return fold(notes.get(file) ?? []);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A position in the note sequence; pass it to `softSkipDispositionSince`. */
|
|
108
|
+
export function softSkipMark(): number {
|
|
109
|
+
return seq;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The noted disposition for a file counting only notes written AFTER `mark`
|
|
114
|
+
* — the notes of the test that is ending. Same fold as the file rule.
|
|
115
|
+
*/
|
|
116
|
+
export function softSkipDispositionSince(file: string, mark: number): { kind: SoftSkipKind; reason: string } | null {
|
|
117
|
+
return fold((notes.get(file) ?? []).filter((n) => n.seq > mark));
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
/** Test hook. */
|
|
87
121
|
export function resetSoftSkips(): void {
|
|
88
122
|
notes.clear();
|
|
123
|
+
seq = 0;
|
|
89
124
|
}
|
package/src/lib/sse.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface SseSubscribeOptions {
|
|
|
33
33
|
readonly lastEventId?: string;
|
|
34
34
|
/** Optional fetch-level abort. Useful for cancellation in long tests. */
|
|
35
35
|
readonly signal?: AbortSignal;
|
|
36
|
+
/**
|
|
37
|
+
* Extra request headers. A major-2 caller MUST pass `OpenWOP-Version: 2.0`:
|
|
38
|
+
* this helper sends none by itself, and a header-less request on an
|
|
39
|
+
* unversioned path is served the host's `preferredVersion` major
|
|
40
|
+
* (versioning.md §1.3) — 1.x through the overlap.
|
|
41
|
+
*/
|
|
42
|
+
readonly extraHeaders?: Record<string, string>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface SseSubscribeResult {
|
|
@@ -62,6 +69,7 @@ export async function subscribe(
|
|
|
62
69
|
if (opts.lastEventId) {
|
|
63
70
|
headers['Last-Event-ID'] = opts.lastEventId;
|
|
64
71
|
}
|
|
72
|
+
for (const [k, v] of Object.entries(opts.extraHeaders ?? {})) headers[k] = v;
|
|
65
73
|
|
|
66
74
|
const internalAbort = new AbortController();
|
|
67
75
|
const timeoutHandle = setTimeout(() => internalAbort.abort(), timeoutMs);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v1/version-negotiation.md` §Stamping / §Legacy detection — the two
|
|
3
|
+
* run-document stamping MUSTs, and the legacy rule that makes their absence
|
|
4
|
+
* actively harmful (suite 2.0.0, target major 1; unaided).
|
|
5
|
+
*
|
|
6
|
+
* The rule is a v1 `MUST` and has been since the contract was written:
|
|
7
|
+
*
|
|
8
|
+
* §Stamping "Every persisted run document MUST carry an
|
|
9
|
+
* `eventLogSchemaVersion: number` field. The current v1
|
|
10
|
+
* value is `2`."
|
|
11
|
+
* §Legacy detection "Hosts identify an older run document as legacy when
|
|
12
|
+
* `eventLogSchemaVersion` is undefined or `< 2`" … legacy
|
|
13
|
+
* runs have "no event subcollection … Readers MUST fall
|
|
14
|
+
* back to the snapshot for state."
|
|
15
|
+
*
|
|
16
|
+
* **Nothing in the suite has ever asserted it.** `version-negotiation.test.ts`
|
|
17
|
+
* opens by claiming it checks "the four version axes (`engineVersion`,
|
|
18
|
+
* `eventLogSchemaVersion`, per-event `schemaVersion`, `pinnedVersions`) appear
|
|
19
|
+
* where the spec says they should" — and `protocolVersion` is the only axis it
|
|
20
|
+
* asserts. Across all 444 v1 scenario files the sole occurrence of the
|
|
21
|
+
* identifier `eventLogSchemaVersion` was that sentence: a docstring describing
|
|
22
|
+
* a check that does not exist. **A comment claiming coverage is worse than no
|
|
23
|
+
* comment**, because it answers "is this tested?" for anyone who greps, and
|
|
24
|
+
* answers it wrongly.
|
|
25
|
+
*
|
|
26
|
+
* Both production hosts were measured on 2026-09-04 and neither stamps the
|
|
27
|
+
* field on any run it has ever served. Each found it independently, after the
|
|
28
|
+
* other published its own greps.
|
|
29
|
+
*
|
|
30
|
+
* The consequence fails in the direction that punishes correctness. A client
|
|
31
|
+
* following §Legacy detection exactly classifies every such run as legacy and
|
|
32
|
+
* reads the snapshot — **ignoring the event log the host is in fact serving**.
|
|
33
|
+
* The host under-serves the conforming reader and over-serves the careless one.
|
|
34
|
+
*
|
|
35
|
+
* Why the schema could not catch it: `run-snapshot.schema.json` requires only
|
|
36
|
+
* `runId`, `workflowId` and `status`, so a snapshot missing the field validates
|
|
37
|
+
* cleanly. The obligation is prose-only, which is exactly the shape that needs
|
|
38
|
+
* a scenario rather than a keyword.
|
|
39
|
+
*
|
|
40
|
+
* @see spec/v1/version-negotiation.md §Stamping
|
|
41
|
+
* @see spec/v1/version-negotiation.md §Legacy detection
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { describe, it, expect } from 'vitest';
|
|
45
|
+
import { driver } from '../lib/driver.js';
|
|
46
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
47
|
+
import { req } from '../lib/requirement-ids.js';
|
|
48
|
+
|
|
49
|
+
const ID_STAMPED = 'openwop.requirement.version-negotiation.era-key-stamped';
|
|
50
|
+
const ID_NOT_LEGACY = 'openwop.requirement.version-negotiation.era-key-not-legacy';
|
|
51
|
+
const ID_ENGINE = 'openwop.requirement.version-negotiation.engine-version-stamped';
|
|
52
|
+
const DOC = 'spec/v1/version-negotiation.md §Stamping';
|
|
53
|
+
|
|
54
|
+
interface Snapshot { readonly eventLogSchemaVersion?: unknown; readonly engineVersion?: unknown }
|
|
55
|
+
|
|
56
|
+
/** A run this host created moments ago — the one case where "legacy" cannot apply. */
|
|
57
|
+
async function freshRun(): Promise<{ runId: string } | { skip: string }> {
|
|
58
|
+
try {
|
|
59
|
+
// v1 path keys are explicit: the driver's unversioned rewrite is a major-2
|
|
60
|
+
// behaviour, and `/runs` answers 404 on a v1 host. The first version of this
|
|
61
|
+
// file used `/runs` and therefore SOFT-SKIPPED against a host that violates
|
|
62
|
+
// the rule — passing vacuously, which is the failure this scenario exists to
|
|
63
|
+
// catch, committed by the scenario itself.
|
|
64
|
+
const created = await driver.post('/v1/runs', { workflowId: 'conformance-noop', inputs: {} });
|
|
65
|
+
if (created.status !== 201) return { skip: `POST /v1/runs answered ${created.status} — no run to inspect` };
|
|
66
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
67
|
+
if (typeof runId !== 'string') return { skip: 'POST /v1/runs returned no runId' };
|
|
68
|
+
return { runId };
|
|
69
|
+
} catch {
|
|
70
|
+
return { skip: 'POST /v1/runs unreachable' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('era-key-stamped-v1 (version-negotiation.md §Stamping)', () => {
|
|
75
|
+
it('a run the host just created carries eventLogSchemaVersion', async () => {
|
|
76
|
+
const r = await freshRun();
|
|
77
|
+
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
78
|
+
|
|
79
|
+
let snap;
|
|
80
|
+
try {
|
|
81
|
+
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
82
|
+
} catch {
|
|
83
|
+
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
84
|
+
}
|
|
85
|
+
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
86
|
+
|
|
87
|
+
const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
|
|
88
|
+
expect(
|
|
89
|
+
value,
|
|
90
|
+
req(ID_STAMPED, DOC, 'every persisted run document MUST carry an eventLogSchemaVersion — the field is prose-only (run-snapshot.schema.json requires just runId, workflowId and status), so a snapshot without it validates cleanly and only this check can see its absence'),
|
|
91
|
+
).not.toBeUndefined();
|
|
92
|
+
expect(
|
|
93
|
+
typeof value === 'number',
|
|
94
|
+
req(ID_STAMPED, DOC, `eventLogSchemaVersion MUST be a number (got ${JSON.stringify(value)})`),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a freshly created run is not classified legacy by the host\'s own rule', async () => {
|
|
99
|
+
const r = await freshRun();
|
|
100
|
+
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
101
|
+
|
|
102
|
+
let snap;
|
|
103
|
+
try {
|
|
104
|
+
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
105
|
+
} catch {
|
|
106
|
+
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
107
|
+
}
|
|
108
|
+
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
109
|
+
const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
|
|
110
|
+
if (value === undefined) {
|
|
111
|
+
return softSkip('blocked', 'the field is absent — the stamping leg above records that; legacy classification cannot be judged separately from it');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// §Legacy detection: "undefined or < 2" is legacy, and a legacy run means
|
|
115
|
+
// "no event subcollection … Readers MUST fall back to the snapshot". A host
|
|
116
|
+
// that serves an event log while stamping a legacy value is telling a
|
|
117
|
+
// conforming client to ignore the log it is serving.
|
|
118
|
+
expect(
|
|
119
|
+
typeof value === 'number' && value >= 2,
|
|
120
|
+
req(ID_NOT_LEGACY, 'spec/v1/version-negotiation.md §Legacy detection', `a run created moments ago MUST NOT be legacy: legacy is "undefined or < 2", and a legacy run is specified to have no event subcollection so readers MUST fall back to the snapshot. Stamping ${JSON.stringify(value)} on a new run instructs a CONFORMING client to ignore the event log this host is serving it — the failure lands on the correct reader and spares the careless one`),
|
|
121
|
+
).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('a run the host just created carries engineVersion — the legacy escape cannot reach it', async () => {
|
|
125
|
+
const r = await freshRun();
|
|
126
|
+
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
127
|
+
|
|
128
|
+
let snap;
|
|
129
|
+
try {
|
|
130
|
+
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
131
|
+
} catch {
|
|
132
|
+
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
133
|
+
}
|
|
134
|
+
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
135
|
+
|
|
136
|
+
// §Stamping: "Every persisted run document MUST carry an `engineVersion:
|
|
137
|
+
// number` field … Servers MAY omit this field on legacy runs that predate
|
|
138
|
+
// the contract." The escape is scoped to runs that PREDATE the contract, so
|
|
139
|
+
// it cannot cover a run created seconds ago — which is why this leg creates
|
|
140
|
+
// one rather than inspecting whatever happens to be in the store.
|
|
141
|
+
//
|
|
142
|
+
// Asserted here because nothing else asserts it ON A RUN: version-fold.test.ts
|
|
143
|
+
// reads engineVersion from the DISCOVERY document, and wasm-pack-load.test.ts
|
|
144
|
+
// carries it only as a type field. Both mention the identifier, so a grep
|
|
145
|
+
// suggests coverage that does not exist for this requirement.
|
|
146
|
+
const value = (snap.json as Snapshot | null)?.engineVersion;
|
|
147
|
+
expect(
|
|
148
|
+
value,
|
|
149
|
+
req(ID_ENGINE, DOC, 'every persisted run document MUST carry engineVersion; the "MAY omit" escape applies only to legacy runs that predate the contract, and this run was created moments ago'),
|
|
150
|
+
).not.toBeUndefined();
|
|
151
|
+
expect(
|
|
152
|
+
typeof value === 'number',
|
|
153
|
+
req(ID_ENGINE, DOC, `engineVersion MUST be a number set to the writer engine's CURRENT_ENGINE_VERSION at write time (got ${JSON.stringify(value)})`),
|
|
154
|
+
).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|