@openwop/openwop-conformance 2.0.0-rc.50 → 2.0.0-rc.52

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.50",
4
- "stampSha256": "9047f5b272c57e56d3bae6ed8df7c6544b01b67533e069da34a1c5c4a33d3295"
3
+ "version": "2.0.0-rc.52",
4
+ "stampSha256": "81cdf1c9820f034ec60ac59b15a2e1166af70f2e9744c72251ecd1cd88783bc7"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.50",
3
+ "version": "2.0.0-rc.52",
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.50"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.52"
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
  },
@@ -28464,6 +28464,16 @@
28464
28464
  "section": "spec/v2/core/events.md §run.paused",
28465
28465
  "requirement": null,
28466
28466
  "interpolated": true
28467
+ },
28468
+ {
28469
+ "section": null,
28470
+ "requirement": null,
28471
+ "interpolated": true
28472
+ },
28473
+ {
28474
+ "section": null,
28475
+ "requirement": null,
28476
+ "interpolated": true
28467
28477
  }
28468
28478
  ]
28469
28479
  },
@@ -28500,6 +28510,179 @@
28500
28510
  }
28501
28511
  ]
28502
28512
  },
28513
+ {
28514
+ "id": "openwop.it.v2-sse-last-event-id.last-event-id-resumes-at-the-next-sequence-never-re-emits-the-resumption-point-l",
28515
+ "file": "v2-sse-last-event-id.test.ts",
28516
+ "line": 59,
28517
+ "title": "Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing",
28518
+ "explicitId": null,
28519
+ "citations": [
28520
+ {
28521
+ "section": null,
28522
+ "requirement": null,
28523
+ "interpolated": true
28524
+ },
28525
+ {
28526
+ "section": null,
28527
+ "requirement": "the completed log MUST stream as at least one frame",
28528
+ "interpolated": true
28529
+ },
28530
+ {
28531
+ "section": null,
28532
+ "requirement": null,
28533
+ "interpolated": true
28534
+ },
28535
+ {
28536
+ "section": null,
28537
+ "requirement": null,
28538
+ "interpolated": true
28539
+ },
28540
+ {
28541
+ "section": null,
28542
+ "requirement": "frames MUST arrive in log order",
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": null,
28563
+ "interpolated": true
28564
+ },
28565
+ {
28566
+ "section": null,
28567
+ "requirement": null,
28568
+ "interpolated": true
28569
+ },
28570
+ {
28571
+ "section": null,
28572
+ "requirement": "the host MUST close the empty resume itself",
28573
+ "interpolated": true
28574
+ },
28575
+ {
28576
+ "section": null,
28577
+ "requirement": null,
28578
+ "interpolated": true
28579
+ },
28580
+ {
28581
+ "section": null,
28582
+ "requirement": null,
28583
+ "interpolated": true
28584
+ }
28585
+ ]
28586
+ },
28587
+ {
28588
+ "id": "openwop.it.v2-stream-mode-refusal.a-value-outside-the-pattern-a-forbidden-combination-and-an-unimplemented-mode-ar",
28589
+ "file": "v2-stream-mode-refusal.test.ts",
28590
+ "line": 64,
28591
+ "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",
28592
+ "explicitId": null,
28593
+ "citations": [
28594
+ {
28595
+ "section": null,
28596
+ "requirement": null,
28597
+ "interpolated": true
28598
+ },
28599
+ {
28600
+ "section": null,
28601
+ "requirement": "the refusal MUST be unsupported_stream_mode",
28602
+ "interpolated": true
28603
+ },
28604
+ {
28605
+ "section": null,
28606
+ "requirement": null,
28607
+ "interpolated": true
28608
+ },
28609
+ {
28610
+ "section": null,
28611
+ "requirement": "a host MUST implement updates, so supported MUST list it",
28612
+ "interpolated": true
28613
+ },
28614
+ {
28615
+ "section": null,
28616
+ "requirement": null,
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
+ "section": null,
28631
+ "requirement": null,
28632
+ "interpolated": true
28633
+ },
28634
+ {
28635
+ "section": null,
28636
+ "requirement": "the refusal MUST be unsupported_stream_mode",
28637
+ "interpolated": true
28638
+ }
28639
+ ]
28640
+ },
28641
+ {
28642
+ "id": "openwop.it.v2-stream-mode-refusal.updates-streams-200-text-event-stream-with-frames-bufferms-yields-at-least-one-b",
28643
+ "file": "v2-stream-mode-refusal.test.ts",
28644
+ "line": 95,
28645
+ "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",
28646
+ "explicitId": null,
28647
+ "citations": [
28648
+ {
28649
+ "section": null,
28650
+ "requirement": null,
28651
+ "interpolated": true
28652
+ },
28653
+ {
28654
+ "section": "spec/v2/core/events.md §SSE frames",
28655
+ "requirement": "the completed run's log MUST stream as at least one frame before the server closes"
28656
+ },
28657
+ {
28658
+ "section": "spec/v2/core/events.md §SSE frames",
28659
+ "requirement": null,
28660
+ "interpolated": true
28661
+ },
28662
+ {
28663
+ "section": "spec/v2/core/events.md §SSE frames",
28664
+ "requirement": "without bufferMs no frame is a batch (the control for the batch leg)"
28665
+ },
28666
+ {
28667
+ "section": "spec/v2/core/events.md §SSE frames",
28668
+ "requirement": null,
28669
+ "interpolated": true
28670
+ },
28671
+ {
28672
+ "section": "spec/v2/core/events.md §SSE frames",
28673
+ "requirement": null,
28674
+ "interpolated": true
28675
+ },
28676
+ {
28677
+ "section": "spec/v2/core/events.md §SSE frames",
28678
+ "requirement": "a batch frame's data MUST be an array of RunEventDoc"
28679
+ },
28680
+ {
28681
+ "section": "spec/v2/core/events.md §SSE frames",
28682
+ "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)"
28683
+ }
28684
+ ]
28685
+ },
28503
28686
  {
28504
28687
  "id": "openwop.it.v2-stream-sse-projection.every-data-frame-on-the-major-2-stream-carries-the-tenant-bound-runid-of-the-run",
28505
28688
  "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.50",
4
+ "version": "2.0.0-rc.52",
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": "cf2510ff1fa3f7f7b37221115261e7db1121cdfaa8cdc8d5a651ba367c9cf263",
13
- "api/v2/asyncapi.yaml": "9488e843f433ac656d1984819dbdddfcbb95c099a57afbfe1d33ce8b039c3199",
14
- "api/v2/openapi.yaml": "7b8bdf41af39f3662c90cca68e270ac9bc12318f398af0b2083a0235380ede4b",
12
+ "api/seams-v2.yaml": "54fced8c0f57bb6af60f5d6f35d2da1b7e262ea895a225daa4ce13df3f5eb488",
13
+ "api/v2/asyncapi.yaml": "4d337c6dcf7ff3d3c0256ae6d2388aa644741464b3e6911d2d4156655c7909e0",
14
+ "api/v2/openapi.yaml": "c61a55d1cab7db1747d12fa6af82089ae21cac1415efd28cdf3ecb30c226c11b",
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",
@@ -169,7 +169,7 @@
169
169
  "schemas/v2/residency.schema.json": "af2658a7104cbcacd0e20a91226271c4a77f91ef46ae97612768fe743e6091d8",
170
170
  "schemas/v2/run-ancestry-response.schema.json": "907e36341ba83a2147a5d76963950b6dcd47506b663ecbccae9dfa553f3724d3",
171
171
  "schemas/v2/run-diff-response.schema.json": "6ab6cd265dc7e0a2086c6659c46aa0e7b43e6302949f0adac5d7deb1c7c0ecd3",
172
- "schemas/v2/run-event-payloads.schema.json": "10f56762616535d01f411afdd98a70ecafc0a961f77aa68cf164b1da458464b4",
172
+ "schemas/v2/run-event-payloads.schema.json": "3a7161af78b0dd2730309371f5ea8b889d92a37c708db05e4354209f12ffbe30",
173
173
  "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
174
174
  "schemas/v2/run-options.schema.json": "ff240805478158cbd2f661979c5eb4f5eda11f9c053b7ba587cb0211a9252d74",
175
175
  "schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
@@ -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": "418d5aea57422fc9b1d66b5373632c2bcbecf8687940cde32692bc5d6541c233",
202
+ "spec/v1/core-standard-manifest.json": "6cca7e93821c9bb93c1a4b206e2300be1b2e7f6bea8eb4fdb870cf5c9e0a44d4",
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",
@@ -226,12 +226,12 @@
226
226
  "spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
227
227
  "spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
228
228
  "spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
229
- "spec/v2/id-field-bindings.json": "965814020c462ad13840b41b26f6830ac472734a1e2e5a67140fad92174c0370",
229
+ "spec/v2/id-field-bindings.json": "d9d1f8e266660b581f11020b9511a65c677e026b1a71ada870682ad77e18555d",
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": "7b0a86de05e0890ccaaeb4319d2b8201d08c09b5019c1cde8814295ba9967323",
233
+ "spec/v2/release.json": "80a24ebc5baa87dfe19476762f1999354fb522e58e115e52777618ca7272c298",
234
234
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
235
235
  },
236
- "corpusCommit": "fa29c2ba4251e780acebbe199555b02d8cfa9c92"
236
+ "corpusCommit": "7dded60251d6e769e05b8131728b66c2652318fb"
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);
@@ -102,6 +102,14 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
102
102
  const types = await eventTypes(c.runId);
103
103
  const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
104
104
  expect(p >= 0 && r > p, req(ID, 'spec/v2/core/events.md §run.paused', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
105
+ // An immediate pause cuts the executing attempt BETWEEN events: no terminal
106
+ // node event for it, ever — a node.failed here makes a replay fold a
107
+ // failure the source never had (runs.md §Pause and resume; replay.md).
108
+ const between = (types ?? []).slice(p + 1, r);
109
+ expect(between.some((t) => t === 'node.failed' || t === 'node.completed' || t === 'node.cancelled'), req(ID, DOC, `with drainPolicy immediate the interrupted attempt MUST NOT record a terminal node event (between run.paused and run.resumed: ${between.join(', ') || 'nothing'})`)).toBe(false);
110
+ const poll = await http(() => driver.get(`/runs/${enc(c.runId)}/events/poll?timeout=1`));
111
+ const paused = ((poll?.json as { events?: Array<{ type?: unknown; payload?: { drainPolicy?: unknown } }> } | null)?.events ?? []).find((e) => e.type === 'run.paused');
112
+ expect(paused?.payload?.drainPolicy, req(ID, DOC, `run.paused's payload MUST echo the request's drainPolicy word (got ${String(paused?.payload?.drainPolicy)})`)).toBe('immediate');
105
113
  } finally {
106
114
  await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance cleanup' }));
107
115
  }
@@ -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
+ });