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

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 505 scenario files under `src/scenarios/`.
115
+ The current suite has 513 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: 505 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
460
+ Current source tree: 513 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.47",
4
- "stampSha256": "e01df77670a96a963b5061a29eb92f4f77e204c8ea65061dba2787a44a97e135"
3
+ "version": "2.0.0-rc.49",
4
+ "stampSha256": "7e65e852ca8df98dbfd604c7da903894a0cbd15feec6fe88f2e0879f88187f22"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.47",
3
+ "version": "2.0.0-rc.49",
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.47"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.49"
60
60
  }
61
61
  }
@@ -1,4 +1,6 @@
1
1
  {
2
2
  "$comment": "old id → new id. Add a row when a test title is reworded so bundles citing the old id still resolve.",
3
- "aliases": {}
3
+ "aliases": {
4
+ "openwop.it.v2-run-cancel.cancelling-a-terminal-run-answers-409-run-terminal-or-200-idempotently-the-prose": "openwop.it.v2-run-cancel.cancelling-a-terminal-run-is-refused-409-run-terminal"
5
+ }
4
6
  }
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": 554,
6
- "tests": 2133,
7
- "withStableId": 2132,
5
+ "files": 562,
6
+ "tests": 2145,
7
+ "withStableId": 2144,
8
8
  "interpolatedTitles": 1,
9
9
  "explicitIds": 2057
10
10
  },
@@ -28081,6 +28081,161 @@
28081
28081
  }
28082
28082
  ]
28083
28083
  },
28084
+ {
28085
+ "id": "openwop.it.v2-run-annotation-not-event.an-annotation-is-created-and-listed-and-never-enters-the-run-s-event-log",
28086
+ "file": "v2-run-annotation-not-event.test.ts",
28087
+ "line": 40,
28088
+ "title": "an annotation is created and listed, and never enters the run's event log",
28089
+ "explicitId": null,
28090
+ "citations": [
28091
+ {
28092
+ "section": null,
28093
+ "requirement": null,
28094
+ "interpolated": true
28095
+ },
28096
+ {
28097
+ "section": "schemas/v2/annotation.schema.json",
28098
+ "requirement": null,
28099
+ "interpolated": true
28100
+ },
28101
+ {
28102
+ "section": null,
28103
+ "requirement": null,
28104
+ "interpolated": true
28105
+ },
28106
+ {
28107
+ "section": null,
28108
+ "requirement": "listAnnotations MUST return the annotation just created (the control: a host that discards annotations must not pass the log assertion for free)",
28109
+ "interpolated": true
28110
+ },
28111
+ {
28112
+ "section": null,
28113
+ "requirement": "run.annotated is a live notification and MUST NOT enter the event log",
28114
+ "interpolated": true
28115
+ },
28116
+ {
28117
+ "section": null,
28118
+ "requirement": null,
28119
+ "interpolated": true
28120
+ }
28121
+ ]
28122
+ },
28123
+ {
28124
+ "id": "openwop.it.v2-run-bulk-cancel.101-ids-are-refused-400-validation-error-with-details-maxrunids",
28125
+ "file": "v2-run-bulk-cancel.test.ts",
28126
+ "line": 48,
28127
+ "title": "101 ids are refused 400 validation_error with details.maxRunIds",
28128
+ "explicitId": null,
28129
+ "citations": [
28130
+ {
28131
+ "section": null,
28132
+ "requirement": null,
28133
+ "interpolated": true
28134
+ },
28135
+ {
28136
+ "section": null,
28137
+ "requirement": "the refusal MUST be validation_error",
28138
+ "interpolated": true
28139
+ },
28140
+ {
28141
+ "section": null,
28142
+ "requirement": null,
28143
+ "interpolated": true
28144
+ }
28145
+ ]
28146
+ },
28147
+ {
28148
+ "id": "openwop.it.v2-run-bulk-cancel.results-come-back-in-request-order-each-id-processed-independently-a-foreign-id",
28149
+ "file": "v2-run-bulk-cancel.test.ts",
28150
+ "line": 60,
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
+ "explicitId": null,
28153
+ "citations": [
28154
+ {
28155
+ "section": null,
28156
+ "requirement": null,
28157
+ "interpolated": true
28158
+ },
28159
+ {
28160
+ "section": null,
28161
+ "requirement": null,
28162
+ "interpolated": true
28163
+ },
28164
+ {
28165
+ "section": null,
28166
+ "requirement": "results[] MUST be in request order",
28167
+ "interpolated": true
28168
+ },
28169
+ {
28170
+ "section": null,
28171
+ "requirement": "the foreign-tenant entry MUST be ok: false",
28172
+ "interpolated": true
28173
+ },
28174
+ {
28175
+ "section": "spec/v2/core/identity.md §5",
28176
+ "requirement": null,
28177
+ "interpolated": true
28178
+ },
28179
+ {
28180
+ "section": null,
28181
+ "requirement": null,
28182
+ "interpolated": true
28183
+ }
28184
+ ]
28185
+ },
28186
+ {
28187
+ "id": "openwop.it.v2-run-cancel.cancelling-a-terminal-run-is-refused-409-run-terminal",
28188
+ "file": "v2-run-cancel.test.ts",
28189
+ "line": 65,
28190
+ "title": "cancelling a terminal run is refused 409 run_terminal",
28191
+ "explicitId": null,
28192
+ "citations": [
28193
+ {
28194
+ "section": null,
28195
+ "requirement": null,
28196
+ "interpolated": true
28197
+ },
28198
+ {
28199
+ "section": null,
28200
+ "requirement": "the refusal MUST be run_terminal",
28201
+ "interpolated": true
28202
+ }
28203
+ ]
28204
+ },
28205
+ {
28206
+ "id": "openwop.it.v2-run-cancel.cancelling-a-running-run-answers-200-with-cancelling-cancelled-emits-run-cancell",
28207
+ "file": "v2-run-cancel.test.ts",
28208
+ "line": 79,
28209
+ "title": "cancelling a running run answers 200 with cancelling|cancelled, emits run.cancelled within 5 s, and the snapshot reads cancelled",
28210
+ "explicitId": null,
28211
+ "citations": [
28212
+ {
28213
+ "section": null,
28214
+ "requirement": null,
28215
+ "interpolated": true
28216
+ },
28217
+ {
28218
+ "section": null,
28219
+ "requirement": "the 200 body MUST carry the run's runId",
28220
+ "interpolated": true
28221
+ },
28222
+ {
28223
+ "section": null,
28224
+ "requirement": null,
28225
+ "interpolated": true
28226
+ },
28227
+ {
28228
+ "section": null,
28229
+ "requirement": null,
28230
+ "interpolated": true
28231
+ },
28232
+ {
28233
+ "section": "spec/v2/core/events.md §run.cancelled",
28234
+ "requirement": null,
28235
+ "interpolated": true
28236
+ }
28237
+ ]
28238
+ },
28084
28239
  {
28085
28240
  "id": "openwop.it.v2-run-completed-outputs.the-run-completed-event-of-a-run-that-completed-carries-an-outputs-object",
28086
28241
  "file": "v2-run-completed-outputs.test.ts",
@@ -28105,6 +28260,241 @@
28105
28260
  }
28106
28261
  ]
28107
28262
  },
28263
+ {
28264
+ "id": "openwop.it.v2-run-diff-identical.two-identical-logs-diff-to-divergedatseq-null-and-empty-eventdiffs-in-a-response",
28265
+ "file": "v2-run-diff-identical.test.ts",
28266
+ "line": 54,
28267
+ "title": "two identical logs diff to divergedAtSeq null and empty eventDiffs, in a response that validates and echoes both ids",
28268
+ "explicitId": null,
28269
+ "citations": [
28270
+ {
28271
+ "section": null,
28272
+ "requirement": null,
28273
+ "interpolated": true
28274
+ },
28275
+ {
28276
+ "section": "schemas/v2/run-diff-response.schema.json",
28277
+ "requirement": null,
28278
+ "interpolated": true
28279
+ },
28280
+ {
28281
+ "section": null,
28282
+ "requirement": "the response MUST name the two runs it compared",
28283
+ "interpolated": true
28284
+ },
28285
+ {
28286
+ "section": null,
28287
+ "requirement": null,
28288
+ "interpolated": true
28289
+ },
28290
+ {
28291
+ "section": null,
28292
+ "requirement": "identical logs MUST yield empty eventDiffs",
28293
+ "interpolated": true
28294
+ },
28295
+ {
28296
+ "section": null,
28297
+ "requirement": null,
28298
+ "interpolated": true
28299
+ }
28300
+ ]
28301
+ },
28302
+ {
28303
+ "id": "openwop.it.v2-run-fork-refusals.replay-overlay-400-a-fromseq-outside-the-log-422-fork-point-invalid-a-valid-repl",
28304
+ "file": "v2-run-fork-refusals.test.ts",
28305
+ "line": 38,
28306
+ "title": "replay+overlay → 400; a fromSeq outside the log → 422 fork_point_invalid; a valid replay fork → 201 with the parent's owner verbatim",
28307
+ "explicitId": null,
28308
+ "citations": [
28309
+ {
28310
+ "section": null,
28311
+ "requirement": null,
28312
+ "interpolated": true
28313
+ },
28314
+ {
28315
+ "section": null,
28316
+ "requirement": null,
28317
+ "interpolated": true
28318
+ },
28319
+ {
28320
+ "section": null,
28321
+ "requirement": "the refusal MUST be fork_point_invalid",
28322
+ "interpolated": true
28323
+ },
28324
+ {
28325
+ "section": null,
28326
+ "requirement": null,
28327
+ "interpolated": true
28328
+ },
28329
+ {
28330
+ "section": null,
28331
+ "requirement": "the 201 MUST name the parent as sourceRunId",
28332
+ "interpolated": true
28333
+ },
28334
+ {
28335
+ "section": null,
28336
+ "requirement": "the 201 MUST echo the mode",
28337
+ "interpolated": true
28338
+ },
28339
+ {
28340
+ "section": null,
28341
+ "requirement": "the 201 MUST carry runId and eventsUrl",
28342
+ "interpolated": true
28343
+ },
28344
+ {
28345
+ "section": "RFCS/0170 §A.4",
28346
+ "requirement": "the child's owner MUST be copied verbatim from the parent"
28347
+ }
28348
+ ]
28349
+ },
28350
+ {
28351
+ "id": "openwop.it.v2-run-options-limits.more-than-100-tags-a-tag-over-256-characters-and-an-unknown-root-key-are-each-re",
28352
+ "file": "v2-run-options-limits.test.ts",
28353
+ "line": 46,
28354
+ "title": "more than 100 tags, a tag over 256 characters, and an unknown root key are each refused 400 validation_error",
28355
+ "explicitId": null,
28356
+ "citations": []
28357
+ },
28358
+ {
28359
+ "id": "openwop.it.v2-run-options-limits.odd-format-tags-and-free-form-metadata-are-accepted-and-surface-unchanged-on-the",
28360
+ "file": "v2-run-options-limits.test.ts",
28361
+ "line": 56,
28362
+ "title": "odd-format tags and free-form metadata are accepted and surface unchanged on the snapshot",
28363
+ "explicitId": null,
28364
+ "citations": [
28365
+ {
28366
+ "section": null,
28367
+ "requirement": null,
28368
+ "interpolated": true
28369
+ },
28370
+ {
28371
+ "section": null,
28372
+ "requirement": "tags MUST surface unchanged on RunSnapshot",
28373
+ "interpolated": true
28374
+ },
28375
+ {
28376
+ "section": null,
28377
+ "requirement": "metadata MUST be persisted and surface unchanged on RunSnapshot",
28378
+ "interpolated": true
28379
+ }
28380
+ ]
28381
+ },
28382
+ {
28383
+ "id": "openwop.it.v2-run-pause-resume.pause-and-resume-on-a-terminal-run-answer-409-with-an-error-envelope",
28384
+ "file": "v2-run-pause-resume.test.ts",
28385
+ "line": 59,
28386
+ "title": "pause and resume on a terminal run answer 409 with an error envelope",
28387
+ "explicitId": null,
28388
+ "citations": [
28389
+ {
28390
+ "section": null,
28391
+ "requirement": null,
28392
+ "interpolated": true
28393
+ },
28394
+ {
28395
+ "section": null,
28396
+ "requirement": null,
28397
+ "interpolated": true
28398
+ },
28399
+ {
28400
+ "section": null,
28401
+ "requirement": null,
28402
+ "interpolated": true
28403
+ },
28404
+ {
28405
+ "section": null,
28406
+ "requirement": null,
28407
+ "interpolated": true
28408
+ }
28409
+ ]
28410
+ },
28411
+ {
28412
+ "id": "openwop.it.v2-run-pause-resume.a-running-run-pauses-202-paused-refuses-a-second-pause-409-resumes-202-running-a",
28413
+ "file": "v2-run-pause-resume.test.ts",
28414
+ "line": 77,
28415
+ "title": "a running run pauses (202 paused), refuses a second pause (409), resumes (202 running), and the log carries run.paused then run.resumed",
28416
+ "explicitId": null,
28417
+ "citations": [
28418
+ {
28419
+ "section": null,
28420
+ "requirement": null,
28421
+ "interpolated": true
28422
+ },
28423
+ {
28424
+ "section": null,
28425
+ "requirement": "the 202 MUST carry the runId",
28426
+ "interpolated": true
28427
+ },
28428
+ {
28429
+ "section": null,
28430
+ "requirement": null,
28431
+ "interpolated": true
28432
+ },
28433
+ {
28434
+ "section": null,
28435
+ "requirement": null,
28436
+ "interpolated": true
28437
+ },
28438
+ {
28439
+ "section": null,
28440
+ "requirement": null,
28441
+ "interpolated": true
28442
+ },
28443
+ {
28444
+ "section": null,
28445
+ "requirement": null,
28446
+ "interpolated": true
28447
+ },
28448
+ {
28449
+ "section": null,
28450
+ "requirement": null,
28451
+ "interpolated": true
28452
+ },
28453
+ {
28454
+ "section": null,
28455
+ "requirement": null,
28456
+ "interpolated": true
28457
+ },
28458
+ {
28459
+ "section": "spec/v2/core/events.md §run.paused",
28460
+ "requirement": null,
28461
+ "interpolated": true
28462
+ }
28463
+ ]
28464
+ },
28465
+ {
28466
+ "id": "openwop.it.v2-run-snapshot-etag.a-matching-if-none-match-receives-304-with-no-body-and-the-version-header-a-non",
28467
+ "file": "v2-run-snapshot-etag.test.ts",
28468
+ "line": 34,
28469
+ "title": "a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200",
28470
+ "explicitId": null,
28471
+ "citations": [
28472
+ {
28473
+ "section": null,
28474
+ "requirement": null,
28475
+ "interpolated": true
28476
+ },
28477
+ {
28478
+ "section": null,
28479
+ "requirement": null,
28480
+ "interpolated": true
28481
+ },
28482
+ {
28483
+ "section": "spec/v2/core/versioning.md §1.4",
28484
+ "requirement": "every response carries OpenWOP-Version, the 304 included"
28485
+ },
28486
+ {
28487
+ "section": null,
28488
+ "requirement": null,
28489
+ "interpolated": true
28490
+ },
28491
+ {
28492
+ "section": null,
28493
+ "requirement": "the 200 body is the snapshot",
28494
+ "interpolated": true
28495
+ }
28496
+ ]
28497
+ },
28108
28498
  {
28109
28499
  "id": "openwop.it.v2-stream-sse-projection.every-data-frame-on-the-major-2-stream-carries-the-tenant-bound-runid-of-the-run",
28110
28500
  "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": 505,
4
+ "files": 513,
5
5
  "v1": 445,
6
- "v2": 61
6
+ "v2": 69
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1384,9 +1384,33 @@
1384
1384
  "v2-revocation-honored.test.ts": [
1385
1385
  2
1386
1386
  ],
1387
+ "v2-run-annotation-not-event.test.ts": [
1388
+ 2
1389
+ ],
1390
+ "v2-run-bulk-cancel.test.ts": [
1391
+ 2
1392
+ ],
1393
+ "v2-run-cancel.test.ts": [
1394
+ 2
1395
+ ],
1387
1396
  "v2-run-completed-outputs.test.ts": [
1388
1397
  2
1389
1398
  ],
1399
+ "v2-run-diff-identical.test.ts": [
1400
+ 2
1401
+ ],
1402
+ "v2-run-fork-refusals.test.ts": [
1403
+ 2
1404
+ ],
1405
+ "v2-run-options-limits.test.ts": [
1406
+ 2
1407
+ ],
1408
+ "v2-run-pause-resume.test.ts": [
1409
+ 2
1410
+ ],
1411
+ "v2-run-snapshot-etag.test.ts": [
1412
+ 2
1413
+ ],
1390
1414
  "v2-stream-sse-projection.test.ts": [
1391
1415
  2
1392
1416
  ],
@@ -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.47",
4
+ "version": "2.0.0-rc.49",
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": "261192d83c24610ddc6608d1945a3c5ead744a217256215ddec1542fc8736fa4",
13
- "api/v2/asyncapi.yaml": "d88f8e208244ce79bc3cc43b4e531bb6b3ca366e3f3ccc1cccd4dbe611171db3",
14
- "api/v2/openapi.yaml": "48498431525577ad9057448140c7e75c426b3d040af95f751c591994b0832cef",
12
+ "api/seams-v2.yaml": "93ecc218d08555eeac0d652768b78050ae7a4847301ddc6664c7415ab12e038a",
13
+ "api/v2/asyncapi.yaml": "bec2259c5ce9ea7c0935093bdae856570b83d2242d5e298b0b6a8684424a7a83",
14
+ "api/v2/openapi.yaml": "fc4ee6fdf254f2b414038be709acaeaa6f283505ad8545d1ec6b53d83d1bea25",
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": "c08241b74e41f4e3524c25f1c611195ed0e79b49a8ecf63e304add67070bdce5",
144
+ "schemas/v2/error-envelope.schema.json": "2e09ac2c32a0c0b7098b0ffeaa2822b5dd82b69c23bb3c07d6be2053e2102b19",
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",
@@ -171,7 +171,7 @@
171
171
  "schemas/v2/run-diff-response.schema.json": "6ab6cd265dc7e0a2086c6659c46aa0e7b43e6302949f0adac5d7deb1c7c0ecd3",
172
172
  "schemas/v2/run-event-payloads.schema.json": "10f56762616535d01f411afdd98a70ecafc0a961f77aa68cf164b1da458464b4",
173
173
  "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
174
- "schemas/v2/run-options.schema.json": "6d6052596ab0f17acfb00fdfc89907da7724f952e5ffbe1f81d94ba23d4c77cd",
174
+ "schemas/v2/run-options.schema.json": "ff240805478158cbd2f661979c5eb4f5eda11f9c053b7ba587cb0211a9252d74",
175
175
  "schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
176
176
  "schemas/v2/run-snapshot.schema.json": "0ee8a50c6be210db415a7c4345298e70a6ae3dd0dd764ccc514bc1c501437586",
177
177
  "schemas/v2/security-advisory.schema.json": "6a382f737730a738d634266315e9d335e5760020a0cdd9f30727c2f987a01770",
@@ -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": "c1c51901a9edea30480c4fe3948855043825fc95355385fcfcf052587efb7319",
202
+ "spec/v1/core-standard-manifest.json": "790bd9d6f00c2d258c0d66d56ed1aea0e0edf8d3b8d1ca7aa70db393b252ee48",
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": "c2dd3fa0401efa0dc78da3b21e32b58a89ed5114cebdde96d3eb999a702118f0",
216
+ "spec/v2/errors.json": "63583dec5a4b6de0bacc2894b0752b3da128a0afed80f3dc244e552bc0dd0e55",
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": "a58243063a8b46f46653cd2a8eaf1916661e43e93094e680465d4c7241071de1",
233
+ "spec/v2/release.json": "0c1e10938fd53b0b4798e9ac7c92727c3d5c83643847cbb1da91a6920800f27f",
234
234
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
235
235
  },
236
- "corpusCommit": "ad53400d97752f80dd8115d534202329ed5e88df"
236
+ "corpusCommit": "67da501622b8d75dff385cb7f20ff8b96f6dd311"
237
237
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Annotations, artifacts, eval summary — an annotation
3
+ * is a live notification, never a run event (suite 2.0.0, target major 2; gated
4
+ * on `feedback`; one run created).
5
+ *
6
+ * `createAnnotation` answers 201 with `schemas/v2/annotation.schema.json`;
7
+ * `listAnnotations` returns `{ annotations[] }` carrying it (the control — a host
8
+ * that discards annotations would otherwise pass the next assertion for free);
9
+ * the run's event log MUST NOT carry it: no `run.annotated` in the poll, and
10
+ * the event count MUST NOT grow across the create. `runs.md` §Surface says
11
+ * `501` when `feedback` is unadvertised while the registry has no 501 code but
12
+ * `credential_unavailable` (finding 4, filed); 501 or 404 records inapplicable.
13
+ *
14
+ * @see spec/v2/core/runs.md §Annotations, artifacts, eval summary
15
+ * @see schemas/v2/annotation-create.schema.json
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
20
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
21
+ import { readErrorCode } from '../lib/error-envelope.js';
22
+ import { softSkip } from '../lib/soft-skip.js';
23
+ import { req } from '../lib/requirement-ids.js';
24
+
25
+ const ID = 'openwop.requirement.0170.run-annotation-not-event';
26
+ const DOC = 'spec/v2/core/runs.md §Annotations, artifacts, eval summary';
27
+ const NOOP = 'conformance-noop';
28
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
31
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
32
+ const enc = (id: string): string => encodeURIComponent(id);
33
+ async function events(runId: string): Promise<Array<{ type?: unknown }> | null> {
34
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
35
+ const ev = (res?.json as { events?: unknown } | null)?.events;
36
+ return res?.status === 200 && Array.isArray(ev) ? (ev as Array<{ type?: unknown }>) : null;
37
+ }
38
+
39
+ describe('v2 run-annotation-not-event (runs.md §Annotations)', () => {
40
+ it('an annotation is created and listed, and never enters the run\'s event log', async () => {
41
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
42
+ if (!(await gateFamily('feedback'))) return softSkip('inapplicable', 'feedback family not advertised (gate recorded under openwop.family.feedback) — annotations are gated on feedback (runs.md §Surface)');
43
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
44
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
45
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
46
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
47
+ const t0 = Date.now();
48
+ while (Date.now() - t0 < 10_000) { const s = await http(() => driver.get(`/runs/${enc(runId)}`)); if (s?.status === 200 && TERMINAL.has(String((s.json as { status?: unknown }).status))) break; await new Promise((r) => setTimeout(r, 250)); }
49
+ const before = await events(runId);
50
+ if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 before the annotation');
51
+
52
+ const ann = await http(() => driver.post(`/runs/${enc(runId)}/annotations`, { signal: { kind: 'label', label: 'conformance' }, note: 'openwop conformance annotation' }));
53
+ if (ann === null) return softSkip('blocked', 'POST /runs/{runId}/annotations unreachable (fetch failed)');
54
+ if (ann.status === 501 || ann.status === 404) return softSkip('blocked', `POST /runs/{runId}/annotations answered ${ann.status} although feedback is advertised — the surface is not mounted (an unadvertised feedback answers 404 not_found; 501 is not a registered shape)`);
55
+ expect(ann.status, req(ID, DOC, `createAnnotation MUST answer 201 — got ${ann.status} ${readErrorCode(ann.json) ?? ''}`.trim())).toBe(201);
56
+ const check = v2Validator('annotation')(ann.json);
57
+ expect(check.ok, req(ID, 'schemas/v2/annotation.schema.json', `the 201 body MUST validate: ${check.errors}`)).toBe(true);
58
+ const annotationId = (ann.json as { annotationId?: unknown } | null)?.annotationId;
59
+
60
+ const list = await http(() => driver.get(`/runs/${enc(runId)}/annotations`));
61
+ if (list === null) return softSkip('blocked', 'GET /runs/{runId}/annotations unreachable (fetch failed)');
62
+ expect(list.status, req(ID, DOC, `listAnnotations MUST answer 200 — got ${list.status}`)).toBe(200);
63
+ const rows = (list.json as { annotations?: unknown } | null)?.annotations;
64
+ expect(Array.isArray(rows) && rows.some((r) => (r as { annotationId?: unknown }).annotationId === annotationId), req(ID, DOC, 'listAnnotations MUST return the annotation just created (the control: a host that discards annotations must not pass the log assertion for free)')).toBe(true);
65
+
66
+ const after = await events(runId);
67
+ if (after === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 after the annotation');
68
+ expect(after.some((e) => String(e.type) === 'run.annotated'), req(ID, DOC, 'run.annotated is a live notification and MUST NOT enter the event log')).toBe(false);
69
+ expect(after.length, req(ID, DOC, `the event log MUST NOT grow across an annotation (${before.length} → ${after.length})`)).toBe(before.length);
70
+ }, 30_000);
71
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Cancel — `bulkCancelRuns` (suite 2.0.0, target major 2;
3
+ * unaided).
4
+ *
5
+ * Legs:
6
+ * 1. over the cap: 101 syntactically valid tenant-bound ids MUST be refused
7
+ * `400 validation_error` with `details.maxRunIds` (creates nothing);
8
+ * 2. `[own, foreign-tenant, own]` MUST answer `200 { results[] }` in request
9
+ * order even when every entry failed; the foreign entry is `ok: false` with
10
+ * an error envelope in the entry — never a top-level 403. The prose names
11
+ * `run_forbidden` for a run the caller cannot see while identity.md §5 names
12
+ * `id_tenant_mismatch` / `not_found` for a foreign tenant segment; the leg
13
+ * accepts any of the three and the disagreement is filed (finding 3). An
14
+ * own entry on an already-terminal noop is `ok: true` with
15
+ * `cancelling | cancelled` or `ok: false` with `run_terminal` (finding 1).
16
+ *
17
+ * The foreign id matches the runId grammar, so a 400 on leg 2 is a host defect,
18
+ * not body validation.
19
+ *
20
+ * @see spec/v2/core/runs.md §Cancel
21
+ * @see spec/v2/core/identity.md §5
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
26
+ import { v2Discovery } from '../lib/v2.js';
27
+ import { readErrorCode } from '../lib/error-envelope.js';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { req } from '../lib/requirement-ids.js';
30
+
31
+ const ID = 'openwop.requirement.0170.run-bulk-cancel';
32
+ const DOC = 'spec/v2/core/runs.md §Cancel';
33
+ const NOOP = 'conformance-noop';
34
+ const FOREIGN = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
35
+
36
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
37
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
38
+ async function create(): Promise<{ runId: string } | { reason: string }> {
39
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
40
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
41
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
42
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
43
+ return { runId };
44
+ }
45
+ interface Entry { runId?: unknown; ok?: unknown; status?: unknown; error?: { code?: unknown } | null }
46
+
47
+ describe('v2 run-bulk-cancel (runs.md §Cancel)', () => {
48
+ it('101 ids are refused 400 validation_error with details.maxRunIds', async () => {
49
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
50
+ const runIds = Array.from({ length: 101 }, (_, i) => `openwop-conformance-bulk-cap/opaque${String(i).padStart(3, '0')}0123456789abcdef`);
51
+ const res = await http(() => driver.post('/runs:bulk-cancel', { runIds }));
52
+ if (res === null) return softSkip('blocked', 'POST /runs:bulk-cancel unreachable (fetch failed)');
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
+ expect(res.status, req(ID, DOC, `over the cap (RECOMMENDED 100) the host MUST answer 400 — got ${res.status}`)).toBe(400);
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;
57
+ expect(typeof max === 'number' && max >= 1 && max <= 100, req(ID, DOC, `details.maxRunIds MUST state the cap (got ${String(max)})`)).toBe(true);
58
+ });
59
+
60
+ it('results[] come back in request order, each id processed independently, a foreign id refused in its entry and never as a top-level 403', async () => {
61
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
62
+ const a = await create(); if ('reason' in a) return softSkip('blocked', a.reason);
63
+ const b = await create(); if ('reason' in b) return softSkip('blocked', b.reason);
64
+ const runIds = [a.runId, FOREIGN, b.runId];
65
+ const res = await http(() => driver.post('/runs:bulk-cancel', { runIds, reason: 'conformance' }));
66
+ if (res === null) return softSkip('blocked', 'POST /runs:bulk-cancel unreachable (fetch failed)');
67
+ if (res.status === 404) return softSkip('blocked', 'POST /runs:bulk-cancel answered 404 — not mounted');
68
+ expect(res.status, req(ID, DOC, `bulkCancelRuns MUST answer 200 even when entries fail — a foreign id MUST NOT surface as a top-level ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
69
+ const results = (res.json as { results?: unknown } | null)?.results;
70
+ expect(Array.isArray(results) && results.length === 3, req(ID, DOC, `results[] MUST carry one entry per requested id (got ${Array.isArray(results) ? results.length : typeof results})`)).toBe(true);
71
+ const entries = results as Entry[];
72
+ expect(entries.map((e) => e.runId), req(ID, DOC, 'results[] MUST be in request order')).toEqual(runIds);
73
+ const foreign = entries[1]!;
74
+ expect(foreign.ok, req(ID, DOC, 'the foreign-tenant entry MUST be ok: false')).toBe(false);
75
+ const fcode = String(foreign.error?.code);
76
+ 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
+ for (const own of [entries[0]!, entries[2]!]) {
78
+ const okShape = own.ok === true && ['cancelling', 'cancelled'].includes(String(own.status));
79
+ const terminalShape = own.ok === false && String(own.error?.code) === 'run_terminal';
80
+ 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
+ }
82
+ });
83
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Cancel — `cancelRun` (suite 2.0.0, target major 2).
3
+ *
4
+ * Witness class: witnessable — the positive leg is gated on the
5
+ * `conformance-cancellable` fixture (a run that stays `running` for `delayMs`);
6
+ * the terminal-run leg is unaided.
7
+ *
8
+ * Legs:
9
+ * 1. cancelling a run that is already terminal — `runs.md` §Cancel is SILENT
10
+ * on this case; the registry has `run_terminal` (409) and 200-idempotent is
11
+ * the other defensible answer. The leg accepts either, records which, and
12
+ * the ambiguity is filed for the prose (rc.48 CHANGELOG, finding 1).
13
+ * 2. on `conformance-cancellable`: `POST /runs/{runId}/cancel` on a running
14
+ * run answers `200 { runId, status: cancelling | cancelled }`; `run.cancelled`
15
+ * is in the log within 5 s; the snapshot reads `cancelled`.
16
+ *
17
+ * Until rc.48 nothing at major 2 witnessed §Cancel at all — runs.md carried 61
18
+ * MUSTs and 12 witness sites, every one under §Create.
19
+ *
20
+ * @see spec/v2/core/runs.md §Cancel
21
+ * @see conformance/fixtures.md §conformance-cancellable
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
26
+ import { v2Discovery } from '../lib/v2.js';
27
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
28
+ import { readErrorCode } from '../lib/error-envelope.js';
29
+ import { softSkip } from '../lib/soft-skip.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+
32
+ const ID = 'openwop.requirement.0170.run-cancel';
33
+ const DOC = 'spec/v2/core/runs.md §Cancel';
34
+ const NOOP = 'conformance-noop';
35
+ const CANCELLABLE = 'conformance-cancellable';
36
+
37
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
38
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
39
+ const enc = (id: string): string => encodeURIComponent(id);
40
+
41
+ async function create(body: Record<string, unknown>): Promise<{ runId: string } | { reason: string }> {
42
+ const res = await http(() => driver.post('/runs', body));
43
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
44
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
45
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
46
+ return { runId };
47
+ }
48
+ async function statusOf(runId: string): Promise<string | null> {
49
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
50
+ return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
51
+ }
52
+ async function waitFor(runId: string, pred: (s: string) => boolean, ms: number): Promise<string | null> {
53
+ const t0 = Date.now(); let last: string | null = null;
54
+ while (Date.now() - t0 < ms) { last = await statusOf(runId); if (last !== null && pred(last)) return last; await new Promise((r) => setTimeout(r, 250)); }
55
+ return last;
56
+ }
57
+ async function eventTypes(runId: string): Promise<string[] | null> {
58
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
59
+ const events = (res?.json as { events?: unknown } | null)?.events;
60
+ return res?.status === 200 && Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : null;
61
+ }
62
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
63
+
64
+ describe('v2 run-cancel (runs.md §Cancel)', () => {
65
+ it('cancelling a terminal run is refused 409 run_terminal', async () => {
66
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
67
+ const c = await create({ workflowId: NOOP });
68
+ if ('reason' in c) return softSkip('blocked', c.reason);
69
+ const s = await waitFor(c.runId, (x) => TERMINAL.has(x), 10_000);
70
+ if (s === null || !TERMINAL.has(s)) return softSkip('blocked', `the noop run did not reach a terminal status within 10 s (last: ${s ?? 'unreadable'})`);
71
+ const res = await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, {}));
72
+ if (res === null) return softSkip('blocked', 'POST /runs/{runId}/cancel unreachable (fetch failed)');
73
+ const code = readErrorCode(res.json);
74
+ const status = (res.json as { status?: unknown } | null)?.status;
75
+ expect(res.status, req(ID, DOC, `a cancel on a run that is already terminal (${s}) MUST be refused 409 run_terminal — got ${res.status} ${code ?? String(status)}; a 200 echoing the terminal state is outside §Cancel's 200 grammar { runId, status: cancelling | cancelled }`)).toBe(409);
76
+ expect(code, req(ID, DOC, 'the refusal MUST be run_terminal')).toBe('run_terminal');
77
+ });
78
+
79
+ it('cancelling a running run answers 200 with cancelling|cancelled, emits run.cancelled within 5 s, and the snapshot reads cancelled', async () => {
80
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
81
+ if (!isFixtureAdvertised(CANCELLABLE)) return softSkip('inapplicable', `${CANCELLABLE} fixture not advertised — no run stays running long enough to cancel`);
82
+ const c = await create({ workflowId: CANCELLABLE, inputs: { delayMs: 15_000 } });
83
+ if ('reason' in c) return softSkip('blocked', c.reason);
84
+ const running = await waitFor(c.runId, (x) => x === 'running' || TERMINAL.has(x), 5_000);
85
+ if (running !== 'running') return softSkip('blocked', `${CANCELLABLE} did not reach running within 5 s (last: ${running ?? 'unreadable'})`);
86
+ const res = await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance' }));
87
+ if (res === null) return softSkip('blocked', 'POST /runs/{runId}/cancel unreachable (fetch failed)');
88
+ expect(res.status, req(ID, DOC, `cancelRun on a running run MUST answer 200 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
89
+ const body = res.json as { runId?: unknown; status?: unknown } | null;
90
+ expect(body?.runId, req(ID, DOC, 'the 200 body MUST carry the run\'s runId')).toBe(c.runId);
91
+ expect(['cancelling', 'cancelled'].includes(String(body?.status)), req(ID, DOC, `status MUST be cancelling or cancelled (got ${String(body?.status)})`)).toBe(true);
92
+ const final = await waitFor(c.runId, (x) => x === 'cancelled', 5_000);
93
+ expect(final, req(ID, DOC, `the snapshot MUST read cancelled within 5 s of an accepted cancel (last: ${final ?? 'unreadable'})`)).toBe('cancelled');
94
+ const types = await eventTypes(c.runId);
95
+ expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/events.md §run.cancelled', `the log MUST carry run.cancelled once the cascade completes (types: ${(types ?? []).join(', ')})`)).toBe(true);
96
+ }, 30_000);
97
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Diff and ancestry — `diffRun` is a pure function of
3
+ * the two logs (suite 2.0.0, target major 2; unaided; two runs created, a third
4
+ * when `conformance-failure` is advertised).
5
+ *
6
+ * Identical logs MUST yield `divergedAtSeq: null` and empty `eventDiffs`;
7
+ * `eventId`, `runId`, `timestamp` and other run-scoped fields MUST be excluded
8
+ * from the comparison — so two `conformance-noop` runs ARE identical logs.
9
+ * `diffRun` is OPTIONAL (`404` when absent → inapplicable).
10
+ *
11
+ * Controls: the response MUST validate against
12
+ * `schemas/v2/run-diff-response.schema.json` and echo both ids in `a` / `b`
13
+ * (a host answering `{}` to any pair fails there); and, when
14
+ * `conformance-failure` is advertised, a noop-vs-failure diff MUST diverge.
15
+ * Without that fixture the divergence control records `inapplicable`.
16
+ *
17
+ * @see spec/v2/core/runs.md §Diff and ancestry
18
+ * @see schemas/v2/run-diff-response.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
23
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+ import { readErrorCode } from '../lib/error-envelope.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { req } from '../lib/requirement-ids.js';
28
+
29
+ const ID = 'openwop.requirement.0170.run-diff-identical';
30
+ const DOC = 'spec/v2/core/runs.md §Diff and ancestry';
31
+ const NOOP = 'conformance-noop';
32
+ const FAILURE = 'conformance-failure';
33
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
34
+
35
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
36
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
37
+ const enc = (id: string): string => encodeURIComponent(id);
38
+ async function createSettled(workflowId: string): Promise<{ runId: string } | { reason: string }> {
39
+ const res = await http(() => driver.post('/runs', { workflowId }));
40
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
41
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
42
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${workflowId}} answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
43
+ const t0 = Date.now();
44
+ while (Date.now() - t0 < 10_000) {
45
+ const s = await http(() => driver.get(`/runs/${enc(runId)}`));
46
+ if (s?.status === 200 && TERMINAL.has(String((s.json as { status?: unknown }).status))) return { runId };
47
+ await new Promise((r) => setTimeout(r, 250));
48
+ }
49
+ return { reason: `${workflowId} run ${runId} did not settle within 10 s` };
50
+ }
51
+ interface Diff { a?: unknown; b?: unknown; divergedAtSeq?: unknown; eventDiffs?: unknown }
52
+
53
+ describe('v2 run-diff-identical (runs.md §Diff and ancestry)', () => {
54
+ it('two identical logs diff to divergedAtSeq null and empty eventDiffs, in a response that validates and echoes both ids', async () => {
55
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
56
+ const a = await createSettled(NOOP); if ('reason' in a) return softSkip('blocked', a.reason);
57
+ const b = await createSettled(NOOP); if ('reason' in b) return softSkip('blocked', b.reason);
58
+ const res = await http(() => driver.get(`/runs/${enc(a.runId)}:diff?against=${enc(b.runId)}`));
59
+ if (res === null) return softSkip('blocked', 'GET /runs/{runId}:diff unreachable (fetch failed)');
60
+ if (res.status === 404) return softSkip('inapplicable', 'diffRun is OPTIONAL and answered 404 (runs.md §Surface)');
61
+ expect(res.status, req(ID, DOC, `diffRun MUST answer 200 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(200);
62
+ const check = v2Validator('run-diff-response')(res.json);
63
+ expect(check.ok, req(ID, 'schemas/v2/run-diff-response.schema.json', `the response MUST validate: ${check.errors}`)).toBe(true);
64
+ const d = res.json as Diff;
65
+ expect([d.a, d.b], req(ID, DOC, 'the response MUST name the two runs it compared')).toEqual([a.runId, b.runId]);
66
+ expect(d.divergedAtSeq, req(ID, DOC, `identical logs MUST yield divergedAtSeq: null (got ${String(d.divergedAtSeq)}) — eventId, runId, timestamp and other run-scoped fields MUST be excluded from the comparison`)).toBeNull();
67
+ expect(d.eventDiffs, req(ID, DOC, 'identical logs MUST yield empty eventDiffs')).toEqual([]);
68
+ if (!isFixtureAdvertised(FAILURE)) return softSkip('inapplicable', `${FAILURE} fixture not advertised — the divergence control (a noop-vs-failure diff MUST diverge) cannot run`);
69
+ const f = await createSettled(FAILURE); if ('reason' in f) return softSkip('blocked', f.reason);
70
+ const dv = await http(() => driver.get(`/runs/${enc(a.runId)}:diff?against=${enc(f.runId)}`));
71
+ if (dv === null || dv.status !== 200) return softSkip('blocked', `the divergence control diff answered ${dv?.status ?? 'no response'}`);
72
+ const x = dv.json as Diff;
73
+ expect(typeof x.divergedAtSeq === 'number' && Array.isArray(x.eventDiffs) && x.eventDiffs.length > 0, req(ID, DOC, `a noop log and a failure log MUST diverge (divergedAtSeq ${String(x.divergedAtSeq)}, ${Array.isArray(x.eventDiffs) ? x.eventDiffs.length : 0} diff(s)) — the identical-logs pass above is only evidence if a different pair does not also read identical`)).toBe(true);
74
+ }, 45_000);
75
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Fork — the refusals and the copied owner (suite
3
+ * 2.0.0, target major 2; gated on `replay`; one run created plus one fork).
4
+ *
5
+ * A `replay` fork with a non-empty `runOptionsOverlay` MUST be rejected 400
6
+ * (the overlay is `branch`-only); a `fromSeq` not in the source log MUST be
7
+ * rejected `422 fork_point_invalid`; a valid `replay` fork answers `201 {
8
+ * runId, sourceRunId, mode, status, eventsUrl }` and the child's `owner` is
9
+ * copied verbatim from the parent (RFC 0170 §A.4). The 201 is the control for
10
+ * the two refusals. Determinism and side-effect suppression are replay.md's
11
+ * and witnessed there.
12
+ *
13
+ * @see spec/v2/core/runs.md §Fork
14
+ * @see RFCS/0170 §A.4
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
20
+ import { readErrorCode } from '../lib/error-envelope.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const ID = 'openwop.requirement.0170.run-fork-refusals';
25
+ const DOC = 'spec/v2/core/runs.md §Fork';
26
+ const NOOP = 'conformance-noop';
27
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
28
+
29
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
30
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
31
+ const enc = (id: string): string => encodeURIComponent(id);
32
+ async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
33
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
34
+ return res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
35
+ }
36
+
37
+ describe('v2 run-fork-refusals (runs.md §Fork)', () => {
38
+ it('replay+overlay → 400; a fromSeq outside the log → 422 fork_point_invalid; a valid replay fork → 201 with the parent\'s owner verbatim', async () => {
39
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
40
+ if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
41
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
42
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
43
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
44
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
45
+ const t0 = Date.now(); let parent: Record<string, unknown> | null = null;
46
+ while (Date.now() - t0 < 10_000) { parent = await snapshot(runId); if (parent && TERMINAL.has(String(parent['status']))) break; await new Promise((r) => setTimeout(r, 250)); }
47
+ if (!parent || !TERMINAL.has(String(parent['status']))) return softSkip('blocked', 'the noop run did not settle within 10 s');
48
+
49
+ const overlay = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', runOptionsOverlay: { tags: ['conformance'] } }));
50
+ if (overlay === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
51
+ if (overlay.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 with replay advertised — forkRun is not mounted');
52
+ expect(overlay.status, req(ID, DOC, `a replay fork with a non-empty runOptionsOverlay MUST be rejected 400 (the overlay is branch-only) — got ${overlay.status} ${readErrorCode(overlay.json) ?? ''}`.trim())).toBe(400);
53
+
54
+ const bad = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'branch', fromSeq: 999_999 }));
55
+ if (bad === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
56
+ expect(bad.status, req(ID, DOC, `a fromSeq not in the source log MUST be rejected 422 — got ${bad.status} ${readErrorCode(bad.json) ?? ''}`.trim())).toBe(422);
57
+ expect(readErrorCode(bad.json), req(ID, DOC, 'the refusal MUST be fork_point_invalid')).toBe('fork_point_invalid');
58
+
59
+ const ok = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay' }));
60
+ if (ok === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
61
+ expect(ok.status, req(ID, DOC, `a valid replay fork MUST answer 201 — got ${ok.status} ${readErrorCode(ok.json) ?? ''} (the control for the two refusals above)`.trim())).toBe(201);
62
+ const body = ok.json as { runId?: unknown; sourceRunId?: unknown; mode?: unknown; eventsUrl?: unknown } | null;
63
+ expect(body?.sourceRunId, req(ID, DOC, 'the 201 MUST name the parent as sourceRunId')).toBe(runId);
64
+ expect(body?.mode, req(ID, DOC, 'the 201 MUST echo the mode')).toBe('replay');
65
+ expect(typeof body?.runId === 'string' && typeof body?.eventsUrl === 'string', req(ID, DOC, 'the 201 MUST carry runId and eventsUrl')).toBe(true);
66
+ const child = await snapshot(String(body?.runId));
67
+ if (!child) return softSkip('blocked', 'the child snapshot could not be read');
68
+ expect(child['owner'], req(ID, 'RFCS/0170 §A.4', 'the child\'s owner MUST be copied verbatim from the parent')).toEqual(parent['owner']);
69
+ }, 30_000);
70
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Run options — `tags`, `metadata`, and the closed
3
+ * create body (suite 2.0.0, target major 2; unaided; one run created).
4
+ *
5
+ * `tags` is opaque: a host MUST NOT reject a tag on format and MUST answer
6
+ * `400 validation_error` over the limits (100 entries, 256 characters).
7
+ * `metadata` MUST be persisted and surface unchanged on the snapshot, as MUST
8
+ * `tags`. The create body is closed at the composition, so an unknown ROOT key
9
+ * MUST be refused `400 validation_error` (`v2-configurable-closed` covers keys
10
+ * inside `configurable` only).
11
+ *
12
+ * The refusals are the control for the acceptance and vice versa: a host that
13
+ * accepts everything fails the 400 legs; a host that refuses odd tags fails the
14
+ * 201 leg. `""` is deliberately not among the odd tags — the schema's
15
+ * `minLength: 1` is a length rule, not a format rule (finding 4).
16
+ *
17
+ * @see spec/v2/core/runs.md §Run options
18
+ * @see schemas/v2/run-options.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
23
+ import { v2Discovery } from '../lib/v2.js';
24
+ import { readErrorCode } from '../lib/error-envelope.js';
25
+ import { softSkip } from '../lib/soft-skip.js';
26
+ import { req } from '../lib/requirement-ids.js';
27
+
28
+ const ID = 'openwop.requirement.0171.run-options-limits';
29
+ const DOC = 'spec/v2/core/runs.md §Run options';
30
+ const NOOP = 'conformance-noop';
31
+ const ODD_TAGS = ['has space', 'emoji 🚀', 'slash/colon:pipe|', 'UPPER-and_under', 'x'.repeat(256)];
32
+ const METADATA = { nested: { a: 1, b: [1, '2', null, true] }, s: 'unicode ∞ ok', n: 0 };
33
+
34
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
35
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
36
+
37
+ async function refused(body: Record<string, unknown>, what: string): Promise<string | null> {
38
+ const res = await http(() => driver.post('/runs', body));
39
+ if (res === null) return `POST /runs unreachable (fetch failed) for ${what}`;
40
+ expect(res.status, req(ID, DOC, `${what} MUST be refused 400 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(400);
41
+ expect(readErrorCode(res.json), req(ID, DOC, `the refusal for ${what} MUST be validation_error`)).toBe('validation_error');
42
+ return null;
43
+ }
44
+
45
+ describe('v2 run-options-limits (runs.md §Run options)', () => {
46
+ it('more than 100 tags, a tag over 256 characters, and an unknown root key are each refused 400 validation_error', async () => {
47
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
48
+ const probes: Array<[Record<string, unknown>, string]> = [
49
+ [{ workflowId: NOOP, tags: Array.from({ length: 101 }, (_, i) => `t${i}`) }, '101 tags'],
50
+ [{ workflowId: NOOP, tags: ['y'.repeat(257)] }, 'a 257-character tag'],
51
+ [{ workflowId: NOOP, conformanceUnknownRootKey: 1 }, 'an unknown root key on the create body (closed at the composition)'],
52
+ ];
53
+ for (const [body, what] of probes) { const r = await refused(body, what); if (r) return softSkip('blocked', r); }
54
+ });
55
+
56
+ it('odd-format tags and free-form metadata are accepted and surface unchanged on the snapshot', async () => {
57
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
58
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP, tags: ODD_TAGS, metadata: METADATA }));
59
+ if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
60
+ expect(res.status, req(ID, DOC, `a host MUST NOT reject a tag on format and MUST accept free-form metadata — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(201);
61
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
62
+ if (typeof runId !== 'string') return softSkip('blocked', 'the 201 carried no runId');
63
+ const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
64
+ if (snap === null || snap.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} for the run just created`);
65
+ const body = snap.json as { tags?: unknown; metadata?: unknown } | null;
66
+ expect(body?.tags, req(ID, DOC, 'tags MUST surface unchanged on RunSnapshot')).toEqual(ODD_TAGS);
67
+ expect(body?.metadata, req(ID, DOC, 'metadata MUST be persisted and surface unchanged on RunSnapshot')).toEqual(METADATA);
68
+ });
69
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Pause and resume (suite 2.0.0, target major 2).
3
+ *
4
+ * Legs:
5
+ * 1. unaided: `:pause` and `:resume` on a terminal run MUST answer 409 with an
6
+ * error envelope. The prose names no code; only `run_terminal` is
7
+ * registered (finding 2). The leg asserts 409 + a code and names none. On
8
+ * its own this leg is one-sided — a host that answers 409 to everything
9
+ * passes it — so its recorded reason says so when leg 2 cannot run.
10
+ * 2. on `conformance-delay`: pause → `202 { runId, status: paused }`; pause
11
+ * again → 409; resume → `202 { runId, status: running }`; the log carries
12
+ * `run.paused` then `run.resumed`. The run is cancelled at the end so
13
+ * nothing is left running.
14
+ *
15
+ * @see spec/v2/core/runs.md §Pause and resume
16
+ * @see conformance/fixtures.md §conformance-delay
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
21
+ import { v2Discovery } from '../lib/v2.js';
22
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
23
+ import { readErrorCode } from '../lib/error-envelope.js';
24
+ import { softSkip } from '../lib/soft-skip.js';
25
+ import { req } from '../lib/requirement-ids.js';
26
+
27
+ const ID = 'openwop.requirement.0170.run-pause-resume';
28
+ const DOC = 'spec/v2/core/runs.md §Pause and resume';
29
+ const NOOP = 'conformance-noop';
30
+ const DELAY = 'conformance-delay';
31
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
32
+
33
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
34
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
35
+ const enc = (id: string): string => encodeURIComponent(id);
36
+ async function create(body: Record<string, unknown>): Promise<{ runId: string } | { reason: string }> {
37
+ const res = await http(() => driver.post('/runs', body));
38
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
39
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
40
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
41
+ return { runId };
42
+ }
43
+ async function statusOf(runId: string): Promise<string | null> {
44
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
45
+ return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
46
+ }
47
+ async function waitFor(runId: string, pred: (s: string) => boolean, ms: number): Promise<string | null> {
48
+ const t0 = Date.now(); let last: string | null = null;
49
+ while (Date.now() - t0 < ms) { last = await statusOf(runId); if (last !== null && pred(last)) return last; await new Promise((r) => setTimeout(r, 250)); }
50
+ return last;
51
+ }
52
+ async function eventTypes(runId: string): Promise<string[] | null> {
53
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
54
+ const events = (res?.json as { events?: unknown } | null)?.events;
55
+ return res?.status === 200 && Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : null;
56
+ }
57
+
58
+ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
59
+ it('pause and resume on a terminal run answer 409 with an error envelope', async () => {
60
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
61
+ const c = await create({ workflowId: NOOP });
62
+ if ('reason' in c) return softSkip('blocked', c.reason);
63
+ const s = await waitFor(c.runId, (x) => TERMINAL.has(x), 10_000);
64
+ if (s === null || !TERMINAL.has(s)) return softSkip('blocked', `the noop run did not reach a terminal status within 10 s (last: ${s ?? 'unreadable'})`);
65
+ const pause = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, {}));
66
+ if (pause === null) return softSkip('blocked', 'POST /runs/{runId}:pause unreachable (fetch failed)');
67
+ if (pause.status === 404) return softSkip('blocked', 'POST /runs/{runId}:pause answered 404 — pauseRun is a core operation (runs.md §Surface) and is not mounted');
68
+ expect(pause.status, req(ID, DOC, `pause on a terminal (${s}) run MUST answer 409 — got ${pause.status} ${readErrorCode(pause.json) ?? ''}`.trim())).toBe(409);
69
+ expect(readErrorCode(pause.json), req(ID, DOC, `a pause refused because the run is terminal MUST carry run_terminal (got ${String(readErrorCode(pause.json))})`)).toBe('run_terminal');
70
+ const resume = await http(() => driver.post(`/runs/${enc(c.runId)}:resume`, {}));
71
+ if (resume === null) return softSkip('blocked', 'POST /runs/{runId}:resume unreachable (fetch failed)');
72
+ expect(resume.status, req(ID, DOC, `resume on a run that is not paused MUST answer 409 — got ${resume.status} ${readErrorCode(resume.json) ?? ''}`.trim())).toBe(409);
73
+ expect(readErrorCode(resume.json), req(ID, DOC, `a resume refused because the run is terminal MUST carry run_terminal (got ${String(readErrorCode(resume.json))})`)).toBe('run_terminal');
74
+ if (!isFixtureAdvertised(DELAY)) return softSkip('inapplicable', `${DELAY} fixture not advertised — the positive pause/resume leg cannot run, so the two 409s above are witnessed without their control (a host answering 409 to everything would pass them)`);
75
+ });
76
+
77
+ it('a running run pauses (202 paused), refuses a second pause (409), resumes (202 running), and the log carries run.paused then run.resumed', async () => {
78
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
79
+ if (!isFixtureAdvertised(DELAY)) return softSkip('inapplicable', `${DELAY} fixture not advertised — no run stays running long enough to pause`);
80
+ const c = await create({ workflowId: DELAY, inputs: { delayMs: 15_000 } });
81
+ if ('reason' in c) return softSkip('blocked', c.reason);
82
+ const running = await waitFor(c.runId, (x) => x === 'running' || TERMINAL.has(x), 5_000);
83
+ if (running !== 'running') return softSkip('blocked', `${DELAY} did not reach running within 5 s (last: ${running ?? 'unreadable'})`);
84
+ try {
85
+ const pause = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, { reason: 'conformance', drainPolicy: 'immediate' }));
86
+ if (pause === null) return softSkip('blocked', 'POST /runs/{runId}:pause unreachable (fetch failed)');
87
+ if (pause.status === 404) return softSkip('blocked', 'POST /runs/{runId}:pause answered 404 — not mounted');
88
+ expect(pause.status, req(ID, DOC, `pauseRun on a running run MUST answer 202 — got ${pause.status} ${readErrorCode(pause.json) ?? ''}`.trim())).toBe(202);
89
+ const pb = pause.json as { runId?: unknown; status?: unknown } | null;
90
+ expect(pb?.runId, req(ID, DOC, 'the 202 MUST carry the runId')).toBe(c.runId);
91
+ expect(pb?.status, req(ID, DOC, `the 202 MUST carry status paused (got ${String(pb?.status)})`)).toBe('paused');
92
+ const again = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, {}));
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
+ 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;
96
+ expect(runStatus, req(ID, DOC, `run_state_conflict MUST carry details.runStatus naming the status that refused it (got ${String(runStatus)})`)).toBe('paused');
97
+ const resume = await http(() => driver.post(`/runs/${enc(c.runId)}:resume`, { reason: 'conformance' }));
98
+ 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);
99
+ const rb = resume?.json as { runId?: unknown; status?: unknown } | null;
100
+ expect(rb?.status, req(ID, DOC, `the 202 MUST carry status running (got ${String(rb?.status)})`)).toBe('running');
101
+ const types = await eventTypes(c.runId);
102
+ const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
103
+ 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);
104
+ } finally {
105
+ await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance cleanup' }));
106
+ }
107
+ }, 30_000);
108
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `spec/v2/core/runs.md` §Snapshot — the conditional GET (suite 2.0.0, target
3
+ * major 2; unaided; one run created).
4
+ *
5
+ * The 200 SHOULD carry a strong `ETag`; WHEN PRESENT, a matching
6
+ * `If-None-Match` MUST receive `304` with no body. The SHOULD gates the MUST:
7
+ * a host that sends no ETag records `inapplicable`. Every response carries
8
+ * `OpenWOP-Version` (versioning.md §1.4), the 304 included.
9
+ *
10
+ * Control: a non-matching `If-None-Match` MUST receive 200 with the body — a
11
+ * host that answers 304 to any conditional request fails here. `headers.md`
12
+ * scopes `If-None-Match` to the discovery document; runs.md applies it to the
13
+ * snapshot with a MUST (finding 5, filed).
14
+ *
15
+ * @see spec/v2/core/runs.md §Snapshot
16
+ * @see spec/v2/core/versioning.md §1.4
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
21
+ import { v2Discovery } from '../lib/v2.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const ID = 'openwop.requirement.0170.run-snapshot-etag';
27
+ const DOC = 'spec/v2/core/runs.md §Snapshot';
28
+ const NOOP = 'conformance-noop';
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
31
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
32
+
33
+ describe('v2 run-snapshot-etag (runs.md §Snapshot)', () => {
34
+ it('a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200', async () => {
35
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
36
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
37
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
38
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
39
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''} — create refused`.trim());
40
+ const path = `/runs/${encodeURIComponent(runId)}`;
41
+ // Let the noop settle so the tag is stable across the two conditional reads.
42
+ await new Promise((r) => setTimeout(r, 1_000));
43
+ const first = await http(() => driver.get(path));
44
+ if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
45
+ const etag = first.headers.get('etag');
46
+ if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
47
+ const hit = await http(() => driver.get(path, { headers: { 'If-None-Match': etag } }));
48
+ if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
49
+ expect(hit.status, req(ID, DOC, `a request whose If-None-Match matches the ETag MUST receive 304 — got ${hit.status}`)).toBe(304);
50
+ expect(hit.text.length, req(ID, DOC, `the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
51
+ expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', 'every response carries OpenWOP-Version, the 304 included')).not.toBeNull();
52
+ const miss = await http(() => driver.get(path, { headers: { 'If-None-Match': '"openwop-conformance-no-such-tag"' } }));
53
+ if (miss === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
54
+ expect(miss.status, req(ID, DOC, `a non-matching If-None-Match MUST receive 200 with the body — got ${miss.status} (a host answering 304 to any conditional request is not honouring the tag)`)).toBe(200);
55
+ expect((miss.json as { runId?: unknown } | null)?.runId, req(ID, DOC, 'the 200 body is the snapshot')).toBe(runId);
56
+ });
57
+ });