@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -3
  3. package/dist/cli.js +24 -34
  4. package/dist/lib/requirement-registry.js +69 -0
  5. package/dist/lib/scenario-disposition.js +84 -11
  6. package/dist/lib/seams.js +72 -0
  7. package/dist/lib/soft-skip.js +39 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirement-aliases.json +4 -1
  11. package/requirements.json +1087 -52
  12. package/scenario-majors.json +57 -3
  13. package/schemas/CORPUS-STAMP.json +41 -38
  14. package/src/cli.ts +23 -30
  15. package/src/global-setup.ts +11 -0
  16. package/src/lib/corpus-stamp.ts +24 -2
  17. package/src/lib/era2-seed.ts +12 -1
  18. package/src/lib/fixtures.ts +31 -0
  19. package/src/lib/requirement-registry.ts +68 -0
  20. package/src/lib/scenario-disposition.ts +88 -9
  21. package/src/lib/seams.ts +31 -1
  22. package/src/lib/soft-skip.ts +42 -7
  23. package/src/lib/sse.ts +8 -0
  24. package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
  25. package/src/scenarios/pause-resume.test.ts +159 -75
  26. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
  27. package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
  28. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
  29. package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
  30. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
  31. package/src/scenarios/v2-created-run-readable.test.ts +98 -0
  32. package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
  33. package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
  34. package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
  35. package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
  36. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
  37. package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
  38. package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
  39. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
  40. package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
  41. package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
  42. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  43. package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
  44. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
  45. package/src/scenarios/v2-pack-isolation.test.ts +1 -1
  46. package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
  47. package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
  48. package/src/scenarios/v2-revocation-honored.test.ts +1 -1
  49. package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
  50. package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
  51. package/src/scenarios/v2-run-cancel.test.ts +97 -0
  52. package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
  53. package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
  54. package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
  55. package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
  56. package/src/scenarios/v2-run-options-limits.test.ts +69 -0
  57. package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
  58. package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
  59. package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
  60. package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
  61. package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
  62. package/src/scenarios/v2-subject-link-record.test.ts +1 -1
  63. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
  64. package/src/scenarios/version-negotiation.test.ts +25 -3
  65. 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 { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
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
- if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition)) blocking.push(id);
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
- const discoveryOnly = PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
363
- const runtimeDerived = PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
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
- /** Whether a (v2) discovery document advertises the seams profile. */
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;
@@ -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
- const notes = new Map<string, Array<{ kind: SoftSkipKind; reason: string }>>();
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
- if (!arr.some((n) => n.kind === kind && n.reason === reason)) arr.push({ kind, reason });
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
- const arr = notes.get(file);
80
- if (arr === undefined || arr.length === 0) return null;
81
- const kind = [...arr].sort((a, b) => RANK[a.kind] - RANK[b.kind])[0]!.kind;
82
- const reason = arr.map((n) => (arr.length > 1 ? `[${n.kind}] ${n.reason}` : n.reason)).join('; ');
83
- return { kind, reason };
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
+ });