@openwop/openwop-conformance 2.0.0-rc.49 → 2.0.0-rc.51

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 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 513 scenario files under `src/scenarios/`.
115
+ The current suite has 515 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: 513 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
460
+ Current source tree: 515 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
461
461
 
462
462
  ## Remaining Gaps
463
463
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.0-rc.49",
4
- "stampSha256": "7e65e852ca8df98dbfd604c7da903894a0cbd15feec6fe88f2e0879f88187f22"
3
+ "version": "2.0.0-rc.51",
4
+ "stampSha256": "a38af8e2fe02ce47112f275efab5ad93a735d658600251125116ca482fb0c187"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.49",
3
+ "version": "2.0.0-rc.51",
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.49"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.51"
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": 562,
6
- "tests": 2145,
7
- "withStableId": 2144,
5
+ "files": 564,
6
+ "tests": 2148,
7
+ "withStableId": 2147,
8
8
  "interpolatedTitles": 1,
9
9
  "explicitIds": 2057
10
10
  },
@@ -28147,7 +28147,7 @@
28147
28147
  {
28148
28148
  "id": "openwop.it.v2-run-bulk-cancel.results-come-back-in-request-order-each-id-processed-independently-a-foreign-id",
28149
28149
  "file": "v2-run-bulk-cancel.test.ts",
28150
- "line": 60,
28150
+ "line": 63,
28151
28151
  "title": "results[] come back in request order, each id processed independently, a foreign id refused in its entry and never as a top-level 403",
28152
28152
  "explicitId": null,
28153
28153
  "citations": [
@@ -28171,6 +28171,11 @@
28171
28171
  "requirement": "the foreign-tenant entry MUST be ok: false",
28172
28172
  "interpolated": true
28173
28173
  },
28174
+ {
28175
+ "section": "api/v2/openapi.yaml bulkCancelRuns results[].error",
28176
+ "requirement": null,
28177
+ "interpolated": true
28178
+ },
28174
28179
  {
28175
28180
  "section": "spec/v2/core/identity.md §5",
28176
28181
  "requirement": null,
@@ -28495,6 +28500,179 @@
28495
28500
  }
28496
28501
  ]
28497
28502
  },
28503
+ {
28504
+ "id": "openwop.it.v2-sse-last-event-id.last-event-id-resumes-at-the-next-sequence-never-re-emits-the-resumption-point-l",
28505
+ "file": "v2-sse-last-event-id.test.ts",
28506
+ "line": 59,
28507
+ "title": "Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing",
28508
+ "explicitId": null,
28509
+ "citations": [
28510
+ {
28511
+ "section": null,
28512
+ "requirement": null,
28513
+ "interpolated": true
28514
+ },
28515
+ {
28516
+ "section": null,
28517
+ "requirement": "the completed log MUST stream as at least one frame",
28518
+ "interpolated": true
28519
+ },
28520
+ {
28521
+ "section": null,
28522
+ "requirement": null,
28523
+ "interpolated": true
28524
+ },
28525
+ {
28526
+ "section": null,
28527
+ "requirement": null,
28528
+ "interpolated": true
28529
+ },
28530
+ {
28531
+ "section": null,
28532
+ "requirement": "frames MUST arrive in log order",
28533
+ "interpolated": true
28534
+ },
28535
+ {
28536
+ "section": null,
28537
+ "requirement": null,
28538
+ "interpolated": true
28539
+ },
28540
+ {
28541
+ "section": null,
28542
+ "requirement": null,
28543
+ "interpolated": true
28544
+ },
28545
+ {
28546
+ "section": null,
28547
+ "requirement": null,
28548
+ "interpolated": true
28549
+ },
28550
+ {
28551
+ "section": null,
28552
+ "requirement": null,
28553
+ "interpolated": true
28554
+ },
28555
+ {
28556
+ "section": null,
28557
+ "requirement": null,
28558
+ "interpolated": true
28559
+ },
28560
+ {
28561
+ "section": null,
28562
+ "requirement": "the host MUST close the empty resume itself",
28563
+ "interpolated": true
28564
+ },
28565
+ {
28566
+ "section": null,
28567
+ "requirement": null,
28568
+ "interpolated": true
28569
+ },
28570
+ {
28571
+ "section": null,
28572
+ "requirement": null,
28573
+ "interpolated": true
28574
+ }
28575
+ ]
28576
+ },
28577
+ {
28578
+ "id": "openwop.it.v2-stream-mode-refusal.a-value-outside-the-pattern-a-forbidden-combination-and-an-unimplemented-mode-ar",
28579
+ "file": "v2-stream-mode-refusal.test.ts",
28580
+ "line": 64,
28581
+ "title": "a value outside the pattern, a forbidden combination, and an unimplemented mode are refused 400 unsupported_stream_mode with details.supported — before content negotiation",
28582
+ "explicitId": null,
28583
+ "citations": [
28584
+ {
28585
+ "section": null,
28586
+ "requirement": null,
28587
+ "interpolated": true
28588
+ },
28589
+ {
28590
+ "section": null,
28591
+ "requirement": "the refusal MUST be unsupported_stream_mode",
28592
+ "interpolated": true
28593
+ },
28594
+ {
28595
+ "section": null,
28596
+ "requirement": null,
28597
+ "interpolated": true
28598
+ },
28599
+ {
28600
+ "section": null,
28601
+ "requirement": "a host MUST implement updates, so supported MUST list it",
28602
+ "interpolated": true
28603
+ },
28604
+ {
28605
+ "section": null,
28606
+ "requirement": null,
28607
+ "interpolated": true
28608
+ },
28609
+ {
28610
+ "section": null,
28611
+ "requirement": null,
28612
+ "interpolated": true
28613
+ },
28614
+ {
28615
+ "section": null,
28616
+ "requirement": "the refusal MUST be unsupported_stream_mode",
28617
+ "interpolated": true
28618
+ },
28619
+ {
28620
+ "section": null,
28621
+ "requirement": null,
28622
+ "interpolated": true
28623
+ },
28624
+ {
28625
+ "section": null,
28626
+ "requirement": "the refusal MUST be unsupported_stream_mode",
28627
+ "interpolated": true
28628
+ }
28629
+ ]
28630
+ },
28631
+ {
28632
+ "id": "openwop.it.v2-stream-mode-refusal.updates-streams-200-text-event-stream-with-frames-bufferms-yields-at-least-one-b",
28633
+ "file": "v2-stream-mode-refusal.test.ts",
28634
+ "line": 95,
28635
+ "title": "updates streams 200 text/event-stream with frames; bufferMs yields at least one batch frame whose data is an array and loses nothing; without bufferMs no frame is batch",
28636
+ "explicitId": null,
28637
+ "citations": [
28638
+ {
28639
+ "section": null,
28640
+ "requirement": null,
28641
+ "interpolated": true
28642
+ },
28643
+ {
28644
+ "section": "spec/v2/core/events.md §SSE frames",
28645
+ "requirement": "the completed run's log MUST stream as at least one frame before the server closes"
28646
+ },
28647
+ {
28648
+ "section": "spec/v2/core/events.md §SSE frames",
28649
+ "requirement": null,
28650
+ "interpolated": true
28651
+ },
28652
+ {
28653
+ "section": "spec/v2/core/events.md §SSE frames",
28654
+ "requirement": "without bufferMs no frame is a batch (the control for the batch leg)"
28655
+ },
28656
+ {
28657
+ "section": "spec/v2/core/events.md §SSE frames",
28658
+ "requirement": null,
28659
+ "interpolated": true
28660
+ },
28661
+ {
28662
+ "section": "spec/v2/core/events.md §SSE frames",
28663
+ "requirement": null,
28664
+ "interpolated": true
28665
+ },
28666
+ {
28667
+ "section": "spec/v2/core/events.md §SSE frames",
28668
+ "requirement": "a batch frame's data MUST be an array of RunEventDoc"
28669
+ },
28670
+ {
28671
+ "section": "spec/v2/core/events.md §SSE frames",
28672
+ "requirement": "batching MUST NOT lose or reorder events: the flattened buffered stream equals the unbuffered log (a one-element batch and an unbatched frame are both tolerated)"
28673
+ }
28674
+ ]
28675
+ },
28498
28676
  {
28499
28677
  "id": "openwop.it.v2-stream-sse-projection.every-data-frame-on-the-major-2-stream-carries-the-tenant-bound-runid-of-the-run",
28500
28678
  "file": "v2-stream-sse-projection.test.ts",
@@ -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": 513,
4
+ "files": 515,
5
5
  "v1": 445,
6
- "v2": 69
6
+ "v2": 71
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1411,6 +1411,12 @@
1411
1411
  "v2-run-snapshot-etag.test.ts": [
1412
1412
  2
1413
1413
  ],
1414
+ "v2-sse-last-event-id.test.ts": [
1415
+ 2
1416
+ ],
1417
+ "v2-stream-mode-refusal.test.ts": [
1418
+ 2
1419
+ ],
1414
1420
  "v2-stream-sse-projection.test.ts": [
1415
1421
  2
1416
1422
  ],
@@ -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.49",
4
+ "version": "2.0.0-rc.51",
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": "93ecc218d08555eeac0d652768b78050ae7a4847301ddc6664c7415ab12e038a",
13
- "api/v2/asyncapi.yaml": "bec2259c5ce9ea7c0935093bdae856570b83d2242d5e298b0b6a8684424a7a83",
14
- "api/v2/openapi.yaml": "fc4ee6fdf254f2b414038be709acaeaa6f283505ad8545d1ec6b53d83d1bea25",
12
+ "api/seams-v2.yaml": "3bd8fb68e7af5ced2bc4d732f01e788c416e61a548247d53975ceabef9054447",
13
+ "api/v2/asyncapi.yaml": "e452411b20f85d148c87204fbcdbc50673fa61566043df17942b7c20a107ea76",
14
+ "api/v2/openapi.yaml": "0471cf3410038f001f24ca297176297061a3e5490db3d2fdad4567d0a8187bf2",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -141,7 +141,7 @@
141
141
  "schemas/v2/envelopes/schema.request.schema.json": "be2c5f7aa881cdd252ccff47ac02193659e1393633a1541f4297383205d384a2",
142
142
  "schemas/v2/envelopes/schema.response.schema.json": "d049a081267c06c28879c331b147f3ed821b14ccfd93005c67aff64db0a8c68e",
143
143
  "schemas/v2/envelopes/ui.a2ui-surface.schema.json": "0a06fbbdc8ba797a6818c79336a98e211ac5ba5747945b4e4098072cd3974d79",
144
- "schemas/v2/error-envelope.schema.json": "2e09ac2c32a0c0b7098b0ffeaa2822b5dd82b69c23bb3c07d6be2053e2102b19",
144
+ "schemas/v2/error-envelope.schema.json": "72d3332fad8fa36d07439c830acf352eefc6da2379374faa2ebbe49e98b8cb08",
145
145
  "schemas/v2/eval-summary.schema.json": "bcb165148531d33b7dc01de83da3a049d19ebbef6d12300b17a234c7b03d77d9",
146
146
  "schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
147
147
  "schemas/v2/form-content-pack-manifest.schema.json": "307884e57946703b6f86ec283034bf56beb68bea4a000743ebbc9eebeed31b07",
@@ -199,7 +199,7 @@
199
199
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
200
200
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
201
201
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
202
- "spec/v1/core-standard-manifest.json": "790bd9d6f00c2d258c0d66d56ed1aea0e0edf8d3b8d1ca7aa70db393b252ee48",
202
+ "spec/v1/core-standard-manifest.json": "4dad678d815ae55f911ae770a1b6444da7c49691cb0b2806a9a280642a5beed5",
203
203
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
204
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
205
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -213,7 +213,7 @@
213
213
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
214
214
  "spec/v2/declaration.json": "c15a6a8f08ebd326d1b60398d93d7f7d5abe5c1b8284874fdb13b59f44443210",
215
215
  "spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
216
- "spec/v2/errors.json": "63583dec5a4b6de0bacc2894b0752b3da128a0afed80f3dc244e552bc0dd0e55",
216
+ "spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
217
217
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
218
218
  "spec/v2/event-codemap.schema.json": "ce2d0ca085d7668d7ef745d2560b385ec6881d24a6d4b47c49931986b2ba642d",
219
219
  "spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
@@ -230,8 +230,8 @@
230
230
  "spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
231
231
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
232
232
  "spec/v2/profiles.json": "5019ac8209540bfbeee9c3232530d4e599be0ab926d817ff48edcec8419eb209",
233
- "spec/v2/release.json": "0c1e10938fd53b0b4798e9ac7c92727c3d5c83643847cbb1da91a6920800f27f",
233
+ "spec/v2/release.json": "a09f7660ca91bc333c48cbabbb42bd20b78542b471fe35147d1fa9021b4e40cd",
234
234
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
235
235
  },
236
- "corpusCommit": "67da501622b8d75dff385cb7f20ff8b96f6dd311"
236
+ "corpusCommit": "0c2de2edb7b12baa95940bffda7b3a046ed7078a"
237
237
  }
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);
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { describe, it, expect } from 'vitest';
25
25
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
26
- import { v2Discovery } from '../lib/v2.js';
26
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
27
27
  import { readErrorCode } from '../lib/error-envelope.js';
28
28
  import { softSkip } from '../lib/soft-skip.js';
29
29
  import { req } from '../lib/requirement-ids.js';
@@ -42,7 +42,7 @@ async function create(): Promise<{ runId: string } | { reason: string }> {
42
42
  if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
43
43
  return { runId };
44
44
  }
45
- interface Entry { runId?: unknown; ok?: unknown; status?: unknown; error?: { code?: unknown } | null }
45
+ interface Entry { runId?: unknown; ok?: unknown; status?: unknown; error?: unknown }
46
46
 
47
47
  describe('v2 run-bulk-cancel (runs.md §Cancel)', () => {
48
48
  it('101 ids are refused 400 validation_error with details.maxRunIds', async () => {
@@ -53,7 +53,10 @@ describe('v2 run-bulk-cancel (runs.md §Cancel)', () => {
53
53
  if (res.status === 404) return softSkip('blocked', 'POST /runs:bulk-cancel answered 404 — bulkCancelRuns is a core operation (runs.md §Surface) and is not mounted');
54
54
  expect(res.status, req(ID, DOC, `over the cap (RECOMMENDED 100) the host MUST answer 400 — got ${res.status}`)).toBe(400);
55
55
  expect(readErrorCode(res.json), req(ID, DOC, 'the refusal MUST be validation_error')).toBe('validation_error');
56
- const max = (res.json as { error?: { details?: { maxRunIds?: unknown } } } | null)?.error?.details?.maxRunIds;
56
+ // The v2 envelope is { error: <code>, message, details? } `details` at the ROOT
57
+ // (schemas/v2/error-envelope.schema.json). rc.48/rc.49 read `error.details`
58
+ // and reported a host defect that was this reader's; retracted in rc.50.
59
+ const max = (res.json as { details?: { maxRunIds?: unknown } } | null)?.details?.maxRunIds;
57
60
  expect(typeof max === 'number' && max >= 1 && max <= 100, req(ID, DOC, `details.maxRunIds MUST state the cap (got ${String(max)})`)).toBe(true);
58
61
  });
59
62
 
@@ -72,11 +75,16 @@ describe('v2 run-bulk-cancel (runs.md §Cancel)', () => {
72
75
  expect(entries.map((e) => e.runId), req(ID, DOC, 'results[] MUST be in request order')).toEqual(runIds);
73
76
  const foreign = entries[1]!;
74
77
  expect(foreign.ok, req(ID, DOC, 'the foreign-tenant entry MUST be ok: false')).toBe(false);
75
- const fcode = String(foreign.error?.code);
78
+ // An entry's `error` IS the error envelope (api/v2/openapi.yaml: `$ref error-envelope`):
79
+ // { error: <code>, message, details? } nested under the entry — so the code is
80
+ // `entry.error.error`, read the same way as a top-level envelope.
81
+ const env = v2Validator('error-envelope')(foreign.error);
82
+ expect(env.ok, req(ID, 'api/v2/openapi.yaml bulkCancelRuns results[].error', `an ok: false entry's error MUST be the error envelope { error: <code>, message, details? } (schemas/v2/error-envelope.schema.json; the OpenAPI $refs it for results[].error) — got ${JSON.stringify(foreign.error)}: ${env.errors}`)).toBe(true);
83
+ const fcode = String(readErrorCode(foreign.error));
76
84
  expect(['id_tenant_mismatch', 'not_found'].includes(fcode), req(ID, 'spec/v2/core/identity.md §5', `an id whose tenant segment is not the caller's MUST be refused inside the entry with id_tenant_mismatch (or not_found where existence is not leaked) — identity.md §5 applies inside a bulk entry exactly as on a path; run_forbidden is for a same-tenant run the caller may not cancel — got ${fcode}`)).toBe(true);
77
85
  for (const own of [entries[0]!, entries[2]!]) {
78
86
  const okShape = own.ok === true && ['cancelling', 'cancelled'].includes(String(own.status));
79
- const terminalShape = own.ok === false && String(own.error?.code) === 'run_terminal';
87
+ const terminalShape = own.ok === false && readErrorCode(own.error) === 'run_terminal';
80
88
  expect(okShape || terminalShape, req(ID, DOC, `an own entry MUST be ok: true with cancelling|cancelled, or ok: false run_terminal when the noop already completed — got ${JSON.stringify(own)}`)).toBe(true);
81
89
  }
82
90
  });
@@ -92,7 +92,8 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
92
92
  const again = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, {}));
93
93
  expect(again?.status ?? null, req(ID, DOC, `a second pause on a paused run MUST answer 409 — got ${again?.status ?? 'no response'}`)).toBe(409);
94
94
  expect(readErrorCode(again?.json), req(ID, DOC, `a pause refused on a non-terminal run MUST carry run_state_conflict (got ${String(readErrorCode(again?.json))})`)).toBe('run_state_conflict');
95
- const runStatus = (again?.json as { error?: { details?: { runStatus?: unknown } } } | null)?.error?.details?.runStatus;
95
+ // `details` sits at the envelope ROOT (schemas/v2/error-envelope.schema.json), not under `error`.
96
+ const runStatus = (again?.json as { details?: { runStatus?: unknown } } | null)?.details?.runStatus;
96
97
  expect(runStatus, req(ID, DOC, `run_state_conflict MUST carry details.runStatus naming the status that refused it (got ${String(runStatus)})`)).toBe('paused');
97
98
  const resume = await http(() => driver.post(`/runs/${enc(c.runId)}:resume`, { reason: 'conformance' }));
98
99
  expect(resume?.status ?? null, req(ID, DOC, `resumeRun on a paused run MUST answer 202 — got ${resume?.status ?? 'no response'} ${readErrorCode(resume?.json) ?? ''}`.trim())).toBe(202);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `spec/v2/core/events.md` §SSE frames — `Last-Event-ID` resumes every mode
3
+ * (suite 2.0.0, target major 2; unaided; one run created).
4
+ *
5
+ * The host MUST look up the event with that sequence, MUST begin at the next
6
+ * sequence, and MUST NOT re-emit the resumption point; in `values` mode the
7
+ * resumption MUST emit a `state.snapshot` first. Frames carry `id:` = the
8
+ * sequence (0-based: the first frame's id is `0`, so ids are tested for
9
+ * presence and parsed, never for truthiness).
10
+ *
11
+ * Legs and controls, all on one completed noop run:
12
+ * 1. the full stream: ≥1 frame, numeric ids, closed by the server;
13
+ * 2. resume at the first id: the first resumed id is greater, the resumption
14
+ * id is absent, and the resumed set equals the full set minus every id at
15
+ * or below it — one assertion catches loss and duplication;
16
+ * 3. resume at the last id: zero frames, closed by server (a host that ignores
17
+ * the header re-sends all N against an expected 0; a host that closes
18
+ * every stream empty fails leg 1);
19
+ * 4. if `values` is served: a `values` resume's first frame is
20
+ * `event: state.snapshot` (a frame name, not a type — §SSE frames).
21
+ *
22
+ * @see spec/v2/core/events.md §SSE frames
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
27
+ import { subscribe, type SseEvent } from '../lib/sse.js';
28
+ import { v2Discovery } from '../lib/v2.js';
29
+ import { readErrorCode } from '../lib/error-envelope.js';
30
+ import { softSkip } from '../lib/soft-skip.js';
31
+ import { req } from '../lib/requirement-ids.js';
32
+
33
+ const ID = 'openwop.requirement.0171.sse-last-event-id';
34
+ const DOC = 'spec/v2/core/events.md §SSE frames';
35
+ const NOOP = 'conformance-noop';
36
+ const V2 = { 'OpenWOP-Version': '2.0' };
37
+
38
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
39
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
40
+ const enc = (id: string): string => encodeURIComponent(id);
41
+ /** The frame's sequence, or null when the frame carries no id — `0` is a valid id. */
42
+ function seqOf(f: SseEvent): number | null { if (f.id === null || f.id === '') return null; const n = Number.parseInt(f.id, 10); return Number.isFinite(n) ? n : null; }
43
+
44
+ async function createSettled(): Promise<{ runId: string } | { reason: string }> {
45
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
46
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
47
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
48
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
49
+ const t0 = Date.now();
50
+ while (Date.now() - t0 < 10_000) {
51
+ const s = await http(() => driver.get(`/runs/${enc(runId)}`));
52
+ if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
53
+ await new Promise((r) => setTimeout(r, 250));
54
+ }
55
+ return { reason: 'the noop run did not settle within 10 s' };
56
+ }
57
+
58
+ describe('v2 sse-last-event-id (events.md §SSE frames)', () => {
59
+ it('Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing', async () => {
60
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
61
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
62
+ const path = `/runs/${enc(c.runId)}/events?streamMode=debug`;
63
+ const full = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2 });
64
+ if (full.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation and is not mounted');
65
+ expect(full.status, req(ID, DOC, `the stream MUST answer 200 — got ${full.status}`)).toBe(200);
66
+ expect(full.events.length, req(ID, DOC, 'the completed log MUST stream as at least one frame')).toBeGreaterThan(0);
67
+ expect(full.closedBy, req(ID, DOC, `the host MUST close after the terminal event (closed by ${full.closedBy})`)).toBe('server');
68
+ const ids = full.events.map(seqOf);
69
+ expect(ids.every((n) => n !== null), req(ID, DOC, `every frame MUST carry id: = its sequence (frames without a parseable id: ${ids.filter((n) => n === null).length} of ${ids.length})`)).toBe(true);
70
+ const seqs = ids as number[];
71
+ expect([...seqs].sort((a, b) => a - b), req(ID, DOC, 'frames MUST arrive in log order')).toEqual(seqs);
72
+ const first = seqs[0]!; const last = seqs[seqs.length - 1]!;
73
+
74
+ const resumed = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
75
+ expect(resumed.status, req(ID, DOC, `a resume with Last-Event-ID MUST answer 200 — got ${resumed.status}`)).toBe(200);
76
+ const rseqs = resumed.events.map(seqOf).filter((n): n is number => n !== null);
77
+ expect(rseqs.includes(first), req(ID, DOC, `the host MUST NOT re-emit the resumption point (Last-Event-ID ${first} re-emitted)`)).toBe(false);
78
+ expect(rseqs, req(ID, DOC, `the host MUST begin at the next sequence and lose nothing: resumed ids (${rseqs.join(',')}) MUST equal the full set minus every id ≤ ${first} (${seqs.filter((n) => n > first).join(',')})`)).toEqual(seqs.filter((n) => n > first));
79
+
80
+ const atEnd = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(last) });
81
+ expect(atEnd.status, req(ID, DOC, `a resume at the terminal sequence MUST answer 200 — got ${atEnd.status}`)).toBe(200);
82
+ expect(atEnd.events.length, req(ID, DOC, `a resume at the last sequence (${last}) MUST emit nothing and close — a host ignoring the header re-sends all ${seqs.length} (got ${atEnd.events.length})`)).toBe(0);
83
+ expect(atEnd.closedBy, req(ID, DOC, 'the host MUST close the empty resume itself')).toBe('server');
84
+
85
+ // values: resumption MUST emit a state.snapshot first — only where the host serves values.
86
+ const probe = await http(() => driver.get(`/runs/${enc(c.runId)}/events?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
87
+ const supported = (probe?.json as { details?: { supported?: unknown } } | null)?.details?.supported;
88
+ if (!Array.isArray(supported) || !supported.includes('values')) return softSkip('inapplicable', `values mode not served (details.supported: ${JSON.stringify(supported)}) — the state.snapshot-first resumption leg does not apply`);
89
+ const values = await subscribe(`/runs/${enc(c.runId)}/events?streamMode=values`, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
90
+ expect(values.status, req(ID, DOC, `a values resume MUST answer 200 — got ${values.status}`)).toBe(200);
91
+ expect(values.events[0]?.event ?? null, req(ID, DOC, `in values mode a resumption MUST emit a state.snapshot first (first frame: ${values.events[0]?.event ?? 'none'})`)).toBe('state.snapshot');
92
+ }, 45_000);
93
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `spec/v2/core/events.md` §The events channel — `streamMode` is one pattern,
3
+ * refused with `400 unsupported_stream_mode`, validated before content
4
+ * negotiation; `bufferMs` batches (suite 2.0.0, target major 2; unaided; one
5
+ * run created).
6
+ *
7
+ * Legs:
8
+ * 1. `?streamMode=bogus` → 400 `unsupported_stream_mode`, `details.supported`
9
+ * a non-empty array of individual modes; the same request with
10
+ * `Accept: application/json` → still 400, never 406 (validation runs
11
+ * before content negotiation; the only v2 406 is a version mismatch and
12
+ * this request names a listed major); `?streamMode=updates,values` → 400
13
+ * (`values` never combines); a mode absent from `supported`, if any → 400.
14
+ * 2. control: `?streamMode=updates` → 200 `text/event-stream` with ≥1 frame
15
+ * (a host that answers 400 to every mode fails here; `updates` is a MUST).
16
+ * 3. `?bufferMs=200` → at least one `event: batch` frame whose `data:` is an
17
+ * array of RunEventDoc, and the flattened frames equal the log in order;
18
+ * without `bufferMs` no frame is `batch` (the control). "Every frame is
19
+ * batch" is deliberately NOT asserted: a consumer MUST tolerate an
20
+ * unbatched frame beside a one-element batch (§SSE frames).
21
+ *
22
+ * @see spec/v2/core/events.md §The events channel, §SSE frames
23
+ * @see spec/v2/errors.json unsupported_stream_mode
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
28
+ import { subscribe, type SseEvent } from '../lib/sse.js';
29
+ import { v2Discovery } from '../lib/v2.js';
30
+ import { readErrorCode } from '../lib/error-envelope.js';
31
+ import { softSkip } from '../lib/soft-skip.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+
34
+ const ID = 'openwop.requirement.0171.stream-mode-refusal';
35
+ const DOC = 'spec/v2/core/events.md §The events channel';
36
+ const NOOP = 'conformance-noop';
37
+ const MODES = ['values', 'updates', 'messages', 'debug'];
38
+ const V2 = { 'OpenWOP-Version': '2.0' };
39
+
40
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
41
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
42
+ const enc = (id: string): string => encodeURIComponent(id);
43
+
44
+ async function createSettled(): Promise<{ runId: string } | { reason: string }> {
45
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
46
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
47
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
48
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
49
+ const t0 = Date.now();
50
+ while (Date.now() - t0 < 10_000) {
51
+ const s = await http(() => driver.get(`/runs/${enc(runId)}`));
52
+ if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
53
+ await new Promise((r) => setTimeout(r, 250));
54
+ }
55
+ return { reason: 'the noop run did not settle within 10 s' };
56
+ }
57
+
58
+ /** The events a frame carries: a batch frame's array flattened, a plain frame's one document. */
59
+ function docsOf(frame: SseEvent): Array<Record<string, unknown>> {
60
+ try { const p = JSON.parse(frame.data) as unknown; return (Array.isArray(p) ? p : [p]).filter((x): x is Record<string, unknown> => !!x && typeof x === 'object'); } catch { return []; }
61
+ }
62
+
63
+ describe('v2 stream-mode-refusal (events.md §The events channel)', () => {
64
+ it('a value outside the pattern, a forbidden combination, and an unimplemented mode are refused 400 unsupported_stream_mode with details.supported — before content negotiation', async () => {
65
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
66
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
67
+ const path = `/runs/${enc(c.runId)}/events`;
68
+ const bogus = await http(() => driver.get(`${path}?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
69
+ if (bogus === null) return softSkip('blocked', 'GET /runs/{runId}/events unreachable (fetch failed)');
70
+ if (bogus.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation (runs.md §Surface) and is not mounted');
71
+ expect(bogus.status, req(ID, DOC, `a streamMode outside the pattern MUST be refused 400 — got ${bogus.status}`)).toBe(400);
72
+ expect(readErrorCode(bogus.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
73
+ const supported = (bogus.json as { details?: { supported?: unknown } } | null)?.details?.supported;
74
+ expect(Array.isArray(supported) && supported.length > 0 && supported.every((m) => MODES.includes(String(m))), req(ID, DOC, `details.supported MUST list each individual mode the host serves (got ${JSON.stringify(supported)})`)).toBe(true);
75
+ expect((supported as string[]).includes('updates'), req(ID, DOC, 'a host MUST implement updates, so supported MUST list it')).toBe(true);
76
+
77
+ const negotiated = await http(() => driver.get(`${path}?streamMode=bogus`, { headers: { Accept: 'application/json' } }));
78
+ expect(negotiated?.status ?? null, req(ID, DOC, `validation MUST run before any content negotiation: a bogus streamMode with Accept: application/json is still 400 unsupported_stream_mode, never 406 (the only v2 406 is a version mismatch and this request names a listed major) — got ${negotiated?.status ?? 'no response'}`)).toBe(400);
79
+
80
+ const combo = await http(() => driver.get(`${path}?streamMode=updates,values`, { headers: { Accept: 'text/event-stream' } }));
81
+ expect(combo?.status ?? null, req(ID, DOC, `values never combines: streamMode=updates,values is outside the pattern and MUST be refused 400 — got ${combo?.status ?? 'no response'}`)).toBe(400);
82
+ expect(readErrorCode(combo?.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
83
+
84
+ // The unimplemented-mode leg has something to request only when a mode is
85
+ // absent from details.supported; a host serving all four has no such mode,
86
+ // and the three refusals above are its witness.
87
+ const missing = MODES.find((m) => !(supported as string[]).includes(m));
88
+ if (missing !== undefined) {
89
+ const unimpl = await http(() => driver.get(`${path}?streamMode=${missing}`, { headers: { Accept: 'text/event-stream' } }));
90
+ expect(unimpl?.status ?? null, req(ID, DOC, `a mode the host does not implement (${missing}, absent from details.supported) MUST be refused 400 — got ${unimpl?.status ?? 'no response'}`)).toBe(400);
91
+ expect(readErrorCode(unimpl?.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
92
+ }
93
+ }, 30_000);
94
+
95
+ it('updates streams 200 text/event-stream with frames; bufferMs yields at least one batch frame whose data is an array and loses nothing; without bufferMs no frame is batch', async () => {
96
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
97
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
98
+ const path = `/runs/${enc(c.runId)}/events`;
99
+ const plain = await subscribe(`${path}?streamMode=updates`, { timeoutMs: 8_000, extraHeaders: V2 });
100
+ if (plain.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — not mounted');
101
+ expect(plain.status, req(ID, DOC, `updates is a MUST: streamMode=updates MUST answer 200 — got ${plain.status} (the control for the refusals: a host answering 400 to every mode fails here)`)).toBe(200);
102
+ expect(plain.events.length, req(ID, 'spec/v2/core/events.md §SSE frames', 'the completed run\'s log MUST stream as at least one frame before the server closes')).toBeGreaterThan(0);
103
+ expect(plain.closedBy, req(ID, 'spec/v2/core/events.md §SSE frames', `the host MUST close after the terminal event (closed by ${plain.closedBy})`)).toBe('server');
104
+ expect(plain.events.some((f) => f.event === 'batch'), req(ID, 'spec/v2/core/events.md §SSE frames', 'without bufferMs no frame is a batch (the control for the batch leg)')).toBe(false);
105
+ const plainDocs = plain.events.flatMap(docsOf).map((d) => d['sequence']);
106
+
107
+ const buffered = await subscribe(`${path}?streamMode=updates&bufferMs=200`, { timeoutMs: 8_000, extraHeaders: V2 });
108
+ expect(buffered.status, req(ID, 'spec/v2/core/events.md §SSE frames', `streamMode=updates&bufferMs=200 MUST answer 200 — got ${buffered.status}`)).toBe(200);
109
+ const batches = buffered.events.filter((f) => f.event === 'batch');
110
+ expect(batches.length, req(ID, 'spec/v2/core/events.md §SSE frames', `with bufferMs the host accumulates events into event: batch frames — none seen among ${buffered.events.length} frame(s) (${[...new Set(buffered.events.map((f) => f.event))].join(', ')})`)).toBeGreaterThan(0);
111
+ for (const b of batches) {
112
+ let parsed: unknown; try { parsed = JSON.parse(b.data); } catch { parsed = undefined; }
113
+ expect(Array.isArray(parsed), req(ID, 'spec/v2/core/events.md §SSE frames', 'a batch frame\'s data MUST be an array of RunEventDoc')).toBe(true);
114
+ }
115
+ const bufferedDocs = buffered.events.flatMap(docsOf).map((d) => d['sequence']);
116
+ expect(bufferedDocs, req(ID, 'spec/v2/core/events.md §SSE frames', 'batching MUST NOT lose or reorder events: the flattened buffered stream equals the unbuffered log (a one-element batch and an unbatched frame are both tolerated)')).toEqual(plainDocs);
117
+ }, 30_000);
118
+ });