@openwop/openwop-conformance 2.0.0-rc.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +8 -3
  3. package/dist/cli.js +24 -34
  4. package/dist/lib/requirement-registry.js +69 -0
  5. package/dist/lib/scenario-disposition.js +84 -11
  6. package/dist/lib/seams.js +72 -0
  7. package/dist/lib/soft-skip.js +39 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +3 -3
  10. package/requirement-aliases.json +4 -1
  11. package/requirements.json +1030 -57
  12. package/scenario-majors.json +57 -3
  13. package/schemas/CORPUS-STAMP.json +40 -37
  14. package/src/cli.ts +23 -30
  15. package/src/global-setup.ts +11 -0
  16. package/src/lib/corpus-stamp.ts +24 -2
  17. package/src/lib/era2-seed.ts +12 -1
  18. package/src/lib/fixtures.ts +31 -0
  19. package/src/lib/requirement-registry.ts +68 -0
  20. package/src/lib/scenario-disposition.ts +88 -9
  21. package/src/lib/seams.ts +31 -1
  22. package/src/lib/soft-skip.ts +42 -7
  23. package/src/lib/sse.ts +8 -0
  24. package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
  25. package/src/scenarios/pause-resume.test.ts +159 -75
  26. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
  27. package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
  28. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
  29. package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
  30. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
  31. package/src/scenarios/v2-created-run-readable.test.ts +98 -0
  32. package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
  33. package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
  34. package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
  35. package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
  36. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
  37. package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
  38. package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
  39. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
  40. package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
  41. package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
  42. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  43. package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
  44. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
  45. package/src/scenarios/v2-pack-isolation.test.ts +1 -1
  46. package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
  47. package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
  48. package/src/scenarios/v2-revocation-honored.test.ts +1 -1
  49. package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
  50. package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
  51. package/src/scenarios/v2-run-cancel.test.ts +97 -0
  52. package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
  53. package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
  54. package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
  55. package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
  56. package/src/scenarios/v2-run-options-limits.test.ts +69 -0
  57. package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
  58. package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
  59. package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
  60. package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
  61. package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
  62. package/src/scenarios/v2-subject-link-record.test.ts +1 -1
  63. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
  64. package/src/scenarios/v2-webhook-durable-delivery.test.ts +58 -7
  65. package/src/scenarios/version-negotiation.test.ts +25 -3
  66. package/src/setup.ts +104 -50
@@ -2,17 +2,31 @@
2
2
  * Track 13: operator-driven pause/resume (rest-endpoints.md v1.1).
3
3
  *
4
4
  * Exercises the `POST /v1/runs/{runId}:pause` and `:resume` endpoints
5
- * against a long-running fixture (`conformance-delay` or `conformance-cancellable`).
5
+ * against a long-running fixture (`conformance-cancellable` or `conformance-delay`;
6
+ * both take `inputs.delayMs` — conformance/fixtures.md).
6
7
  *
7
8
  * Verifies:
8
- * 1. :pause on a running run transitions to `paused` and emits `run.paused`.
9
- * 2. :resume on a paused run transitions to `running` and emits `run.resumed`.
10
- * 3. :pause on a terminal run returns 409 with details.runStatus.
11
- * 4. :resume on a non-paused run returns 409 with details.runStatus.
9
+ * 1. :pause (immediate) on a running run transitions to `paused`; :resume
10
+ * returns it to `running` and it reaches a terminal.
11
+ * 2. :resume on a non-paused run returns 409 with details.runStatus.
12
+ * 3. :pause on an already-paused run returns 409 (details.runStatus paused)
13
+ * without a matching Idempotency-Key, and 202 with the cached response
14
+ * when the same key is sent — rest-endpoints.md §:pause Idempotency.
15
+ * 4. :pause on a terminal run returns 409 with details.runStatus.
16
+ * 5. drain-current-node lets the executing node reach a terminal FIRST:
17
+ * node.completed precedes run.paused in the log.
12
18
  *
13
- * Capability gating: skips when the host doesn't advertise
14
- * `capabilities.runs.pauseResume.supported: true` (when present) AND
15
- * skips when no long-running fixture is advertised.
19
+ * rc.53 (2026-09-05): a tier-1 host un-skipping this file found it
20
+ * contradicting rest-endpoints.md in three places — it expected 200/202 on a
21
+ * second :pause (the prose says 409 unless the Idempotency-Key matches), it
22
+ * asserted an error code `conflict` that v1 never named (v1 mandates 409 +
23
+ * details.runStatus and names only run_terminal for a terminal run), and it
24
+ * paused a 30 s node under drain-current-node and polled 10 s for `paused`
25
+ * (drain lets the node finish; a host that yields mid-node under drain is the
26
+ * non-conforming one). It also sent `delaySeconds`, a field the fixtures do
27
+ * not define. v1 is frozen; the scenario was the defect in every case.
28
+ *
29
+ * Capability gating: skips when no long-running fixture is advertised.
16
30
  *
17
31
  * @see spec/v1/rest-endpoints.md §pause/resume
18
32
  */
@@ -31,35 +45,49 @@ const FIXTURE =
31
45
  null;
32
46
 
33
47
  const SKIP = !FIXTURE;
48
+ const DOC_PAUSE = 'rest-endpoints.md POST /v1/runs/{runId}:pause';
49
+ const DOC_RESUME = 'rest-endpoints.md POST /v1/runs/{runId}:resume';
50
+
51
+ async function cancel(runId: string): Promise<void> {
52
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, { reason: 'conformance-cleanup' });
53
+ }
54
+
55
+ async function eventTypes(runId: string): Promise<string[]> {
56
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
57
+ const events = (res.json as { events?: unknown } | null)?.events;
58
+ return Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : [];
59
+ }
34
60
 
35
61
  describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal', () => {
36
62
  it('pause transitions to paused; resume returns the run to running', async () => {
37
63
  const create = await driver.post('/v1/runs', {
38
64
  workflowId: FIXTURE!,
39
- inputs: { delaySeconds: 30 },
65
+ // 3 s, not 30 s: the resumed node.started is a FRESH attempt (runs.md
66
+ // §Pause and resume; rc.52), so a 30 s node resumed at ~60 ms completes
67
+ // at ~30.2 s — past the suite's 30 s test timeout by construction on
68
+ // any conforming host. rc.53 shipped 30 s and a tier-1 host measured
69
+ // exactly that. The pause is `immediate`, issued within ~60 ms of
70
+ // `running`, so 3 s is ample for the pause to land first.
71
+ inputs: { delayMs: 3_000 },
40
72
  });
41
73
  expect(create.status).toBe(201);
42
74
  const runId = (create.json as { runId: string }).runId;
43
75
 
44
76
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
45
77
 
78
+ // `immediate` snapshots between events, so `paused` is observable within
79
+ // milliseconds. Under drain-current-node the node would finish first
80
+ // (rest-endpoints.md); that semantic is witnessed by the drain leg below.
46
81
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
47
82
  reason: 'conformance-test',
48
- drainPolicy: 'drain-current-node',
83
+ drainPolicy: 'immediate',
49
84
  });
50
85
  if (pause.status === 404) {
51
- // Pause endpoint not yet implemented by the host — surface the skip honestly.
52
- // eslint-disable-next-line no-console
53
- console.warn(
54
- '[pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest',
55
- );
56
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
57
- reason: 'conformance-cleanup',
58
- });
86
+ await cancel(runId);
59
87
  return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early ([pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest) (seam, prior step, or fixture unavailable)');
60
88
  }
61
- expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
62
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
89
+ expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
90
+ DOC_PAUSE,
63
91
  ':pause MUST return 202 on a pausable run',
64
92
  )).toBe(202);
65
93
 
@@ -68,8 +96,8 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
68
96
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {
69
97
  reason: 'conformance-test',
70
98
  });
71
- expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
72
- 'rest-endpoints.md POST /v1/runs/{runId}:resume',
99
+ expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
100
+ DOC_RESUME,
73
101
  ':resume MUST return 202 on a paused run',
74
102
  )).toBe(202);
75
103
 
@@ -82,7 +110,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
82
110
  it('resuming a running (not paused) run returns 409 with details.runStatus', async () => {
83
111
  const create = await driver.post('/v1/runs', {
84
112
  workflowId: FIXTURE!,
85
- inputs: { delaySeconds: 30 },
113
+ inputs: { delayMs: 30_000 },
86
114
  });
87
115
  expect(create.status).toBe(201);
88
116
  const runId = (create.json as { runId: string }).runId;
@@ -91,61 +119,85 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
91
119
 
92
120
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
93
121
  if (resume.status === 404) {
94
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
95
- reason: 'conformance-cleanup',
96
- });
122
+ await cancel(runId);
97
123
  return softSkip('blocked', 'precondition not met — `resume.status === 404` returned early (seam, prior step, or fixture unavailable)');
98
124
  }
99
- expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
100
- 'rest-endpoints.md POST /v1/runs/{runId}:resume',
125
+ expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
126
+ DOC_RESUME,
101
127
  ':resume on a non-paused run MUST return 409',
102
128
  )).toBe(409);
103
129
 
130
+ // v1 mandates the status and details.runStatus; it names no code for
131
+ // this case (the registry is open — error-envelope.schema.json), so
132
+ // none is asserted. `run_state_conflict` is the v2 answer (runs.md).
104
133
  const body = resume.json as { error?: string; details?: { runStatus?: string } };
105
- expect(body.error).toBe('conflict');
106
- expect(typeof body.details?.runStatus).toBe('string');
134
+ expect(typeof body.details?.runStatus, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
135
+ DOC_RESUME,
136
+ 'the 409 MUST carry details.runStatus with the actual state',
137
+ )).toBe('string');
107
138
 
108
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
109
- reason: 'conformance-cleanup',
110
- });
139
+ await cancel(runId);
111
140
  });
112
141
  });
113
142
 
114
- describe.skipIf(SKIP)('pause/resume: pause is idempotent when already paused', () => {
115
- it(':pause on an already-paused run is a no-op (200/202) idempotent', async () => {
143
+ describe.skipIf(SKIP)('pause/resume: a second :pause is 409 without a matching Idempotency-Key and 202 with one', () => {
144
+ it(':pause on an already-paused run returns 409 with details.runStatus paused, unless the request carries the original Idempotency-Key', async () => {
116
145
  const create = await driver.post('/v1/runs', {
117
146
  workflowId: FIXTURE!,
118
- inputs: { delaySeconds: 30 },
147
+ inputs: { delayMs: 30_000 },
119
148
  });
120
149
  expect(create.status).toBe(201);
121
150
  const runId = (create.json as { runId: string }).runId;
122
151
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
123
152
 
124
- const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
153
+ const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, { drainPolicy: 'immediate' });
125
154
  if (first.status === 404) {
126
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
127
- reason: 'conformance-cleanup',
128
- });
155
+ await cancel(runId);
129
156
  return softSkip('blocked', 'precondition not met — `first.status === 404` returned early (seam, prior step, or fixture unavailable)');
130
157
  }
131
- expect([200, 202]).toContain(first.status);
158
+ expect(first.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
159
+ DOC_PAUSE,
160
+ ':pause MUST return 202 on a pausable run',
161
+ )).toBe(202);
132
162
  await pollUntilStatus(runId, 'paused', { timeoutMs: 10_000 });
133
163
 
134
- // Idempotent second :pause MUST NOT 409 just because the run is
135
- // already paused. 200/202 are both acceptable per the additive
136
- // contract; 409 would force callers to read state before calling.
164
+ // rest-endpoints.md §:pause Idempotency: "a :pause against a run that is
165
+ // already paused returns 409 (with the existing pause's pausedAt in
166
+ // details) unless the request carries Idempotency-Key matching the
167
+ // original pause, in which case the host returns 202 with the cached
168
+ // response." Until rc.53 this leg asserted 200/202 here, citing an
169
+ // "additive contract" the prose does not contain.
137
170
  const second = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
138
- expect(
139
- [200, 202].includes(second.status),
140
- req('openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent',
141
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
142
- ':pause on an already-paused run MUST be idempotent (200/202), not 409',
143
- ),
144
- ).toBe(true);
145
-
146
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
147
- reason: 'conformance-cleanup',
148
- });
171
+ expect(second.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
172
+ DOC_PAUSE,
173
+ ':pause on an already-paused run without a matching Idempotency-Key MUST return 409',
174
+ )).toBe(409);
175
+ const body = second.json as { details?: { runStatus?: string } };
176
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
177
+ DOC_PAUSE,
178
+ 'the 409 MUST carry details.runStatus: paused',
179
+ )).toBe('paused');
180
+ await cancel(runId);
181
+
182
+ // The keyed form: the same Idempotency-Key on both pauses collapses the
183
+ // second into the cached 202.
184
+ const keyed = await driver.post('/v1/runs', { workflowId: FIXTURE!, inputs: { delayMs: 30_000 } });
185
+ expect(keyed.status).toBe(201);
186
+ const keyedId = (keyed.json as { runId: string }).runId;
187
+ await pollUntilStatus(keyedId, 'running', { timeoutMs: 10_000 });
188
+ const key = `openwop-conformance-pause-${keyedId.replace(/[^A-Za-z0-9._~-]/g, '-')}`.slice(0, 128);
189
+ const k1 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
190
+ expect(k1.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
191
+ DOC_PAUSE,
192
+ ':pause with an Idempotency-Key MUST return 202 on a pausable run',
193
+ )).toBe(202);
194
+ await pollUntilStatus(keyedId, 'paused', { timeoutMs: 10_000 });
195
+ const k2 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
196
+ expect(k2.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
197
+ DOC_PAUSE,
198
+ 'a :pause carrying the original Idempotency-Key MUST return 202 with the cached response, not 409',
199
+ )).toBe(202);
200
+ await cancel(keyedId);
149
201
  });
150
202
  });
151
203
 
@@ -160,19 +212,58 @@ describe.skipIf(SKIP)('pause/resume: :pause on a terminal run returns 409', () =
160
212
 
161
213
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
162
214
  if (pause.status === 404) return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
163
- expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
164
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
215
+ expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
216
+ DOC_PAUSE,
165
217
  ':pause on a terminal run MUST return 409',
166
218
  )).toBe(409);
167
219
 
220
+ // v1 mandates details.runStatus; `run_terminal` is the one code v1 names
221
+ // for a mutation on a terminal run (rest-endpoints.md, bulk-cancel), and
222
+ // the registry is open, so the code is not asserted here.
168
223
  const body = pause.json as { error?: string; details?: { runStatus?: string } };
169
- expect(body.error).toBe('conflict');
170
- // Spec requires `details.runStatus` to disclose the terminal state so
171
- // the caller can decide whether to retry or surface the conflict.
172
224
  expect(['completed', 'failed', 'cancelled']).toContain(body.details?.runStatus);
173
225
  });
174
226
  });
175
227
 
228
+ describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node reach a terminal first', () => {
229
+ it('under drain-current-node node.completed precedes run.paused in the log', async () => {
230
+ // A short node: drain has something to wait for that finishes inside the
231
+ // poll window. A 30 s node under drain pauses after 30 s — that is the
232
+ // semantic, not a bug (rest-endpoints.md: "lets the executing node reach
233
+ // a terminal before the run transitions to paused").
234
+ const create = await driver.post('/v1/runs', {
235
+ workflowId: FIXTURE!,
236
+ inputs: { delayMs: 3_000 },
237
+ });
238
+ expect(create.status).toBe(201);
239
+ const runId = (create.json as { runId: string }).runId;
240
+ await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
241
+
242
+ const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
243
+ reason: 'conformance-drain',
244
+ drainPolicy: 'drain-current-node',
245
+ });
246
+ if (pause.status === 404) {
247
+ await cancel(runId);
248
+ return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
249
+ }
250
+ expect(pause.status, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
251
+ DOC_PAUSE,
252
+ ':pause with drainPolicy drain-current-node MUST return 202 (pause requested)',
253
+ )).toBe(202);
254
+
255
+ await pollUntilStatus(runId, 'paused', { timeoutMs: 20_000 });
256
+ const types = await eventTypes(runId);
257
+ const done = types.indexOf('node.completed');
258
+ const paused = types.indexOf('run.paused');
259
+ expect(done >= 0 && paused > done, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
260
+ DOC_PAUSE,
261
+ `drain-current-node MUST let the executing node reach a terminal before the run transitions to paused — node.completed MUST precede run.paused (types: ${types.join(', ')})`,
262
+ )).toBe(true);
263
+ await cancel(runId);
264
+ });
265
+ });
266
+
176
267
  describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
177
268
  it(':pause MUST NOT silently override an active interrupt suspend', async () => {
178
269
  // If the host seeds an approval fixture, drive a suspend then attempt
@@ -198,18 +289,15 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
198
289
  reason: 'race-test',
199
290
  });
200
291
  if (pause.status === 404) {
201
- // Cleanup.
202
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
203
- reason: 'conformance-cleanup',
204
- });
292
+ await cancel(runId);
205
293
  return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
206
294
  }
207
295
 
208
296
  // Either rejection (preferred) or stacked-pause is OK; silent override is not.
209
297
  if (pause.status === 409) {
210
298
  const body = pause.json as { details?: { runStatus?: string } };
211
- expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
212
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
299
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
300
+ DOC_PAUSE,
213
301
  ':pause-during-suspend MUST surface the active waiting-* status in the conflict envelope',
214
302
  )).toMatch(/^waiting-/);
215
303
  } else {
@@ -220,13 +308,11 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
220
308
  const status = (snap.json as { status: string }).status;
221
309
  expect(
222
310
  status === 'paused' || status.startsWith('waiting-'),
223
- req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend', 'rest-endpoints.md POST /v1/runs/{runId}:pause', ':pause-during-suspend MUST NOT silently discard the active interrupt'),
311
+ req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend', DOC_PAUSE, ':pause-during-suspend MUST NOT silently discard the active interrupt'),
224
312
  ).toBe(true);
225
313
  }
226
314
 
227
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
228
- reason: 'conformance-cleanup',
229
- });
315
+ await cancel(runId);
230
316
  });
231
317
  });
232
318
 
@@ -249,7 +335,7 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
249
335
  for (const policy of drainPolicies) {
250
336
  const create = await driver.post('/v1/runs', {
251
337
  workflowId: FIXTURE!,
252
- inputs: { delaySeconds: 30 },
338
+ inputs: { delayMs: 30_000 },
253
339
  });
254
340
  expect(create.status).toBe(201);
255
341
  const runId = (create.json as { runId: string }).runId;
@@ -260,14 +346,12 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
260
346
  reason: `conformance-drainpolicy-${policy}`,
261
347
  drainPolicy: policy,
262
348
  });
263
- expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
349
+ expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
264
350
  'capabilities.md §`runs.pauseResume.drainPolicies` + rest-endpoints.md POST /v1/runs/{runId}:pause',
265
351
  `host-advertised drainPolicy='${policy}' MUST be accepted on :pause`,
266
352
  )).toBe(202);
267
353
 
268
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
269
- reason: 'conformance-cleanup',
270
- });
354
+ await cancel(runId);
271
355
  }
272
356
  });
273
357
  });
@@ -3,40 +3,42 @@
3
3
  * the host must be able to honour (suite 2.0.0, target major 2; unaided).
4
4
  *
5
5
  * `fixtures[]` in discovery gates scenarios: `isFixtureAdvertised(id)` decides
6
- * whether a scenario runs at all. Nothing checked that an advertised id names a
7
- * fixture the corpus actually defines, or that the host can serve it. A host
8
- * whose advertised list and seeded set drift apart therefore fails somewhere
9
- * else entirely the scenario gated on the missing fixture attempts, fails on
10
- * a run that cannot be created, and the failure is attributed to that
11
- * scenario's requirement rather than to the advertisement that was wrong.
6
+ * whether a scenario runs at all. So a host whose advertised list and seeded set
7
+ * drift apart fails somewhere else entirely the scenario gated on the missing
8
+ * fixture attempts, fails on a run that cannot be created, and the failure is
9
+ * attributed to that scenario's requirement rather than to the advertisement
10
+ * that was wrong. That misattribution is what this scenario exists to catch.
12
11
  *
13
- * No host is currently known to exhibit this. A tier-2 host was thought to
14
- * (46 seeded against 47 advertised) and then verified and retracted it the
15
- * count had been eyeballed from an array literal rather than measured, and the
16
- * two sets are in fact identical. The scenario is kept because the failure mode
17
- * is a property of the gating mechanism, not of that host: `fixtures[]` decides
18
- * whether a scenario runs, so a wrong advertisement is charged to whatever runs
19
- * next. Leg 1 is also the stronger check an id the corpus catalog does not
20
- * define is wrong however well a host's own two lists agree with each other.
12
+ * **This scenario shipped with a second leg that was wrong, and the correction
13
+ * matters more than the check.** That leg asserted the advertised ids are a
14
+ * SUBSET of `conformance/fixtures/` "the vocabulary is closed, so an id the
15
+ * corpus does not define is a typo or an invention". The vocabulary is not
16
+ * closed. Host-supplied fixtures are the normal case: dozens of ids the
17
+ * scenarios gate on are deliberately not shipped, and `v2-approver-enforced`
18
+ * says so in its own docstring it needs an approval fixture whose
19
+ * `approversList` names a principal the suite is not, and records `blocked`
20
+ * naming it precisely because "no such fixture ships in `conformance/fixtures/`".
21
21
  *
22
- * Two legs, both cheap:
23
- * 1. the advertised ids are a subset of the corpus fixture catalog the
24
- * vocabulary is closed, so an id the corpus does not define is a typo or
25
- * an invention, not a capability;
26
- * 2. a bounded sample of advertised fixtures is actually creatable, so the
27
- * list is a claim about reachable state rather than a wish.
22
+ * So the leg failed a host for doing exactly what the corpus asks. It was found
23
+ * by running the suite against the reference host, which advertised two
24
+ * host-supplied fixtures and was marked non-conformant for it. Set membership
25
+ * cannot distinguish a typo from a legitimate host fixture, and a check that
26
+ * cannot tell those apart is not a check — it is a coin flip that happens to
27
+ * land on "fail" for correct hosts.
28
+ *
29
+ * What survives is the leg that was always sound: an advertised fixture MUST be
30
+ * creatable. That holds whoever defines it, and it is the one that catches the
31
+ * drift the misattribution comes from.
28
32
  *
29
33
  * @see spec/v2/core/conformance.md
30
- * @see conformance/fixtures.md
34
+ * @see conformance/src/scenarios/v2-approver-enforced.test.ts (a host-supplied fixture, by design)
31
35
  */
32
36
 
33
37
  import { describe, it, expect } from 'vitest';
34
- import { existsSync, readdirSync } from 'node:fs';
35
38
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
36
39
  import { v2Discovery } from '../lib/v2.js';
37
40
  import { softSkip } from '../lib/soft-skip.js';
38
41
  import { req } from '../lib/requirement-ids.js';
39
- import { FIXTURES_DIR } from '../lib/paths.js';
40
42
 
41
43
  const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
42
44
  const DOC = 'spec/v2/core/conformance.md §Fixtures';
@@ -57,26 +59,6 @@ function advertisedIds(doc: Record<string, unknown>): string[] {
57
59
  }
58
60
 
59
61
  describe('v2-advertised-fixtures-exist (conformance.md §Fixtures)', () => {
60
- it('every advertised fixture id is one the corpus defines', async () => {
61
- const doc = await v2Discovery().catch(() => null);
62
- if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
63
- const ids = advertisedIds(doc);
64
- if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[] — there is no claim to falsify');
65
- if (FIXTURES_DIR === null || !existsSync(FIXTURES_DIR)) {
66
- return softSkip('blocked', 'the fixture catalog is absent from this layout, so an advertised id cannot be checked against it');
67
- }
68
- const catalog = new Set(
69
- readdirSync(FIXTURES_DIR)
70
- .filter((f) => f.endsWith('.json'))
71
- .map((f) => f.replace(/\.json$/, '')),
72
- );
73
- const unknown = ids.filter((id) => !catalog.has(id));
74
- expect(
75
- unknown,
76
- req(ID, DOC, `every id in fixtures[] MUST name a fixture the corpus defines — the vocabulary is closed, so an id the catalog does not carry is a typo or an invention rather than a capability (${unknown.length} unknown of ${ids.length}: ${unknown.slice(0, 5).join(', ')})`),
77
- ).toEqual([]);
78
- });
79
-
80
62
  it('a sampled advertised fixture is actually creatable, not just listed', async () => {
81
63
  const doc = await v2Discovery().catch(() => null);
82
64
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
@@ -0,0 +1,165 @@
1
+ /**
2
+ * RFC 0172 §A.1 / `spec/v2/core/versioning.md` §1.2 — a host that advertises
3
+ * major 2 serves the major-2 PATH SPACE, not just the major-2 discovery
4
+ * document (suite 2.0.0, target major 2; unaided).
5
+ *
6
+ * `v2-version-header-honored` checks that `OpenWOP-Version` is honored or
7
+ * refused rather than ignored. It probes `/.well-known/openwop`, because that
8
+ * is the one resource whose representation the header selects. **It therefore
9
+ * cannot see a host that negotiates correctly on the well-known resource and
10
+ * has mounted almost none of the rest of the v2 surface.**
11
+ *
12
+ * That is not hypothetical. A tier-1 host advertising
13
+ * `protocolVersions: ["1.1","2.0"]` was found serving **two of fifteen**
14
+ * top-level segments of the v2 path space: its unversioned mount was a
15
+ * deliberate allowlist (`['/runs','/interrupts']`, chosen over a blanket
16
+ * `/v1`-strip because the host serves a large non-`/v1` surface a blanket
17
+ * rewrite would shadow) and the list was simply incomplete. Every probe used to
18
+ * call the dual stack live — `protocolVersions`, `preferredVersion`, the
19
+ * response header, the two differing representations — hits `/.well-known`, so
20
+ * every one of them passed. `POST /webhooks` under major 2 returned `404` while
21
+ * `POST /v1/webhooks` returned `201`. The host found this itself by applying
22
+ * the artifact rule to a scenario it had first classified as a harness defect.
23
+ *
24
+ * The discriminator is a PAIR, not a single probe, because "this host does not
25
+ * implement webhooks at all" and "this host implements webhooks but did not
26
+ * mount them under major 2" are different facts that a lone `404` cannot
27
+ * separate:
28
+ *
29
+ * /v1<path> exists AND <path> is 404 under major 2 ⇒ the advertisement
30
+ * overstates: the surface exists and major 2 does not reach it.
31
+ *
32
+ * both 404 ⇒ the host does not serve that surface in either major. Not this
33
+ * scenario's business, and recorded as neither pass nor failure.
34
+ *
35
+ * Only parameterless GETs from `spec/v2/path-manifest.json` are probed: they
36
+ * need no fixture, mutate nothing, and a route that is not mounted answers 404
37
+ * regardless of auth, so the check is unaided and safe against a live host.
38
+ *
39
+ * @see spec/v2/core/versioning.md §1.2
40
+ * @see RFCS/0172-v2-versioning-and-release.md §A.1
41
+ */
42
+
43
+ import { describe, it, expect } from 'vitest';
44
+ import { loadEnv } from '../lib/env.js';
45
+ import { softSkip } from '../lib/soft-skip.js';
46
+ import { req } from '../lib/requirement-ids.js';
47
+ import { readFileSync } from 'node:fs';
48
+ import { join } from 'node:path';
49
+ import { SCHEMAS_DIR } from '../lib/paths.js';
50
+
51
+ const ID = 'openwop.requirement.0172.advertised-path-space-served';
52
+ const DOC = 'spec/v2/core/versioning.md §1.2';
53
+
54
+ /** The well-known resource is the one the HEADER selects; it has no /v1 twin to pair against. */
55
+ const NOT_PAIRABLE = new Set(['/.well-known/openwop']);
56
+
57
+ interface Probe { readonly status: number | null; readonly version: string | null; readonly html: boolean }
58
+
59
+ async function get(path: string, major2: boolean): Promise<Probe> {
60
+ const { baseUrl, apiKey } = loadEnv();
61
+ const headers: Record<string, string> = { Accept: 'application/json' };
62
+ if (major2) headers['OpenWOP-Version'] = '2.0';
63
+ if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
64
+ try {
65
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, { headers });
66
+ const ct = res.headers.get('content-type') ?? '';
67
+ return { status: res.status, version: res.headers.get('openwop-version'), html: /text\/html/i.test(ct) };
68
+ } catch {
69
+ return { status: null, version: null, html: false };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * "Reached under major 2" is NOT "any status but 404". A hosting layer in
75
+ * front of the host answers every unrouted path with `200 text/html` — the
76
+ * SPA shell — and no `OpenWOP-Version` header, and that satisfied the first
77
+ * version of this check. Measured 2026-09-05 on a tier-1 host's public origin:
78
+ * `/.well-known/openwop` and `/v1/**` were rewritten to the backend and every
79
+ * unversioned major-2 path fell through to `index.html` with a 200, while the
80
+ * Cloud Run URL one hop behind answered every one correctly. The scenario was
81
+ * green on production for ten hours because a shell and a mount produced the
82
+ * same status code. `versioning.md` §1.4 requires `OpenWOP-Version` on every
83
+ * response; a response without it did not come from the host.
84
+ */
85
+ function reachedUnderMajor2(p: Probe): boolean {
86
+ if (p.status === null || p.status === 404) return false;
87
+ if (p.version === null) return false;
88
+ if (p.html) return false;
89
+ return true;
90
+ }
91
+
92
+ function parameterlessGets(): string[] {
93
+ try {
94
+ const manifest = JSON.parse(
95
+ readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8'),
96
+ ) as { operations?: ReadonlyArray<{ method: string; path: string }> };
97
+ return (manifest.operations ?? [])
98
+ .filter((o) => o.method === 'GET' && !o.path.includes('{') && !NOT_PAIRABLE.has(o.path))
99
+ .map((o) => o.path)
100
+ .sort();
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
107
+ it('a host advertising major 2 reaches the surfaces it already serves under /v1', async () => {
108
+ const { baseUrl } = loadEnv();
109
+ const bare = await (async () => {
110
+ try {
111
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: { Accept: 'application/json' } });
112
+ return res.status === 200 ? ((await res.json()) as Record<string, unknown>) : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ })();
117
+ if (!bare) return softSkip('blocked', 'the discovery document is unreadable, so the advertised majors are unknown');
118
+
119
+ const versions = Array.isArray(bare['protocolVersions']) ? (bare['protocolVersions'] as unknown[]).map(String) : [];
120
+ if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
121
+ return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — it does not claim major 2, so there is no path space to hold it to`);
122
+ }
123
+
124
+ const paths = parameterlessGets();
125
+ if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
126
+
127
+ const overstated: string[] = [];
128
+ const served: string[] = [];
129
+ let pairable = 0;
130
+ for (const path of paths) {
131
+ const v1 = await get(`/v1${path}`, false);
132
+ if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
133
+ // The host does not serve this surface in EITHER major. Legitimate, and a
134
+ // different question from the one asked here.
135
+ if (v1.status === 404) continue;
136
+ pairable += 1;
137
+
138
+ const v2 = await get(path, true);
139
+ if (v2.status === null) return softSkip('blocked', `the host became unreachable while probing ${path}`);
140
+ // 401/403/422 all answer "this path exists" and count as reached. A 404
141
+ // does not — and neither does a 200 that did not come from the host: no
142
+ // OpenWOP-Version header, or an HTML body, is a hosting fallback wearing
143
+ // a success code (see reachedUnderMajor2).
144
+ if (!reachedUnderMajor2(v2)) {
145
+ overstated.push(`${path} (/v1 → ${v1.status}, major 2 → ${v2.status}${v2.version === null ? ', no OpenWOP-Version header' : ''}${v2.html ? ', text/html body' : ''})`);
146
+ } else {
147
+ served.push(path);
148
+ }
149
+ }
150
+
151
+ if (pairable === 0) {
152
+ return softSkip('inapplicable', 'no parameterless GET in the v2 manifest is served under /v1 either, so there is no pair to compare and the advertisement cannot be checked this way');
153
+ }
154
+
155
+ expect(
156
+ overstated,
157
+ req(ID, DOC, `a host advertising major 2 MUST reach, under major 2, the surfaces it already serves under /v1 — advertising the major is a claim about the PATH SPACE and not only about the well-known resource, whose representation the header selects and which therefore passes even when almost nothing else is mounted (${overstated.length} of ${pairable} pairable surface(s) unreachable: ${overstated.slice(0, 6).join('; ')})`),
158
+ ).toEqual([]);
159
+
160
+ expect(
161
+ served.length,
162
+ req(ID, DOC, 'at least one non-well-known surface MUST be reachable under major 2, or the advertisement rests entirely on the one resource the header selects'),
163
+ ).toBeGreaterThan(0);
164
+ });
165
+ });
@@ -39,7 +39,7 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
39
39
  async function gate(): Promise<{ floor: string } | { kind: 'blocked' | 'inapplicable'; reason: string }> {
40
40
  const doc = await discovery();
41
41
  if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
42
- if (!seamsProfileAdvertised(doc)) return { kind: 'blocked', reason: 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the assurance floor is seam-gated' };
42
+ if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the assurance floor is seam-gated' };
43
43
  const auth = await familyAdvertised('auth');
44
44
  const lanes = Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
45
45
  const workload = lanes.find((l) => l['lane'] === 'workload');