@openwop/openwop-conformance 1.132.0 → 1.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vendor.openwop.workflow-chain-sample",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "kind": "workflow-chain",
5
5
  "description": "Reference workflow-chain pack proving the RFC 0013 manifest format works end-to-end across schema validation, registry indexing, and the reference expansion library. Two chains: a 1-node 'summarize text' chain showing literal parameter substitution, and a 2-node 'fetch-and-summarize' chain showing multi-node DAG composition with edge wiring and capability propagation. In-tree only (NOT published to packs.openwop.dev); the canonical proof that the chain-pack contract is implementable with real-world-shaped content.",
6
6
  "author": "openwop project",
@@ -18,7 +18,9 @@
18
18
  "description": "Drag-tile that expands to a single core.ai.callPrompt with the summarization system prompt + author-supplied length / tone parameters substituted in. Mirrors the 'Generate PRD' positive example from workflow-chain-packs.md §Examples — the canonical 1-node chain shape.",
19
19
  "parameters": {
20
20
  "type": "object",
21
- "required": ["sourceText"],
21
+ "required": [
22
+ "sourceText"
23
+ ],
22
24
  "properties": {
23
25
  "sourceText": {
24
26
  "type": "string",
@@ -26,7 +28,11 @@
26
28
  },
27
29
  "targetLength": {
28
30
  "type": "string",
29
- "enum": ["one-sentence", "one-paragraph", "executive-summary"],
31
+ "enum": [
32
+ "one-sentence",
33
+ "one-paragraph",
34
+ "executive-summary"
35
+ ],
30
36
  "default": "one-paragraph",
31
37
  "description": "How long the summary should be."
32
38
  },
@@ -43,7 +49,10 @@
43
49
  "id": "summarize-call",
44
50
  "typeId": "core.ai.callPrompt",
45
51
  "name": "Summarize",
46
- "position": { "x": 0, "y": 0 },
52
+ "position": {
53
+ "x": 0,
54
+ "y": 0
55
+ },
47
56
  "config": {
48
57
  "systemPrompt": "You are an expert summarizer. Produce a {{params.targetLength}} summary in a {{params.tone}} tone. Avoid lists unless the source uses them. Avoid hedging.\n\nSource text:\n{{params.sourceText}}",
49
58
  "envelopeType": "summary.v1",
@@ -60,7 +69,9 @@
60
69
  "description": "The summarized text from the AI call's envelope payload."
61
70
  }
62
71
  },
63
- "capabilities": ["cacheable"]
72
+ "capabilities": [
73
+ "cacheable"
74
+ ]
64
75
  },
65
76
  {
66
77
  "chainId": "vendor.openwop.workflow-chain-sample.fetch-and-summarize",
@@ -69,7 +80,9 @@
69
80
  "description": "Drag-tile that expands to a two-node DAG: core.openwop.http.get fetches the URL, then core.ai.callPrompt summarizes the response body. Demonstrates multi-node chain composition with edge wiring, parameter substitution across multiple nodes, and capability propagation (side-effectful from the HTTP fetch propagates to both expanded nodes per spec §Capability propagation).",
70
81
  "parameters": {
71
82
  "type": "object",
72
- "required": ["url"],
83
+ "required": [
84
+ "url"
85
+ ],
73
86
  "properties": {
74
87
  "url": {
75
88
  "type": "string",
@@ -78,7 +91,11 @@
78
91
  },
79
92
  "targetLength": {
80
93
  "type": "string",
81
- "enum": ["one-sentence", "one-paragraph", "executive-summary"],
94
+ "enum": [
95
+ "one-sentence",
96
+ "one-paragraph",
97
+ "executive-summary"
98
+ ],
82
99
  "default": "one-paragraph"
83
100
  }
84
101
  }
@@ -89,7 +106,10 @@
89
106
  "id": "fetch",
90
107
  "typeId": "core.openwop.http.get",
91
108
  "name": "Fetch URL",
92
- "position": { "x": 0, "y": 0 },
109
+ "position": {
110
+ "x": 0,
111
+ "y": 0
112
+ },
93
113
  "config": {
94
114
  "url": "{{params.url}}",
95
115
  "timeoutMs": 30000
@@ -100,7 +120,10 @@
100
120
  "id": "summarize",
101
121
  "typeId": "core.ai.callPrompt",
102
122
  "name": "Summarize",
103
- "position": { "x": 280, "y": 0 },
123
+ "position": {
124
+ "x": 280,
125
+ "y": 0
126
+ },
104
127
  "config": {
105
128
  "systemPrompt": "Produce a {{params.targetLength}} summary of the fetched page content. Strip navigation, ads, and boilerplate. Focus on the article body.",
106
129
  "envelopeType": "summary.v1",
@@ -110,7 +133,10 @@
110
133
  }
111
134
  ],
112
135
  "edges": [
113
- { "from": "fetch.body", "to": "summarize.sourceText" }
136
+ {
137
+ "from": "fetch.body",
138
+ "to": "summarize.sourceText"
139
+ }
114
140
  ]
115
141
  },
116
142
  "outputs": {
@@ -123,7 +149,173 @@
123
149
  "description": "Echo of the input URL for downstream reference."
124
150
  }
125
151
  },
126
- "capabilities": ["side-effectful"]
152
+ "capabilities": [
153
+ "side-effectful"
154
+ ]
155
+ },
156
+ {
157
+ "chainId": "vendor.openwop.workflow-chain-sample.reserve-and-notify",
158
+ "version": "1.0.0",
159
+ "label": "Reserve & Notify (compensating)",
160
+ "description": "RFC 0157 fixture — a two-node chain whose first node declares an RFC 0151 §B inverse action (`compensation`, retry-bounded, `inputMapping` derived from the recorded reserve output + a {{params.*}} literal) and whose second node declares `irreversibleEffect: true` (RFC 0151 UQ4). Carries NO chain-level policy, so it is acceptable on any host — the node declaration describes an inverse action; only a policy requests an unwind. The host-expansion witness asserts both declarations survive verbatim onto the expanded nodes (params substituted, node-id refs re-prefixed) and that neither is silently dropped by a node-rebuilding allowlist.",
161
+ "parameters": {
162
+ "type": "object",
163
+ "required": [
164
+ "reservationUrl"
165
+ ],
166
+ "properties": {
167
+ "reservationUrl": {
168
+ "type": "string",
169
+ "format": "uri",
170
+ "description": "Reservation endpoint. Surfaced via {{params.reservationUrl}} in the reserve node config and its inverse action's inputMapping."
171
+ }
172
+ }
173
+ },
174
+ "dag": {
175
+ "nodes": [
176
+ {
177
+ "id": "reserve",
178
+ "typeId": "core.openwop.http.get",
179
+ "name": "Reserve",
180
+ "position": {
181
+ "x": 0,
182
+ "y": 0
183
+ },
184
+ "config": {
185
+ "url": "{{params.reservationUrl}}",
186
+ "timeoutMs": 30000
187
+ },
188
+ "inputs": {},
189
+ "compensation": {
190
+ "nodeTypeId": "core.openwop.http.get",
191
+ "inputMapping": {
192
+ "url": "{{params.reservationUrl}}",
193
+ "reservationId": "${nodes.reserve.output.body}"
194
+ },
195
+ "retry": {
196
+ "maxAttempts": 3,
197
+ "backoffMs": 250
198
+ },
199
+ "requiresApproval": false
200
+ }
201
+ },
202
+ {
203
+ "id": "notify",
204
+ "typeId": "core.ai.callPrompt",
205
+ "name": "Notify",
206
+ "position": {
207
+ "x": 280,
208
+ "y": 0
209
+ },
210
+ "config": {
211
+ "systemPrompt": "Write a one-line confirmation that the reservation at {{params.reservationUrl}} was placed.",
212
+ "envelopeType": "summary.v1",
213
+ "provider": "anthropic"
214
+ },
215
+ "inputs": {},
216
+ "irreversibleEffect": true
217
+ }
218
+ ],
219
+ "edges": [
220
+ {
221
+ "from": "reserve",
222
+ "to": "notify"
223
+ }
224
+ ]
225
+ },
226
+ "capabilities": [
227
+ "side-effectful"
228
+ ]
229
+ },
230
+ {
231
+ "chainId": "vendor.openwop.workflow-chain-sample.reserve-and-notify-with-policy",
232
+ "version": "1.0.0",
233
+ "label": "Reserve & Notify (compensating, with unwind policy)",
234
+ "description": "RFC 0157 fixture — the same two-node chain PLUS a chain-level compensation POLICY (a mirror of `compensation-policy.schema.json`). On a host that advertises `capabilities.compensation` the policy becomes the registered definition's `settings.compensation`; on a host that does NOT advertise it, expansion of this chain MUST be refused with `capability_required` (`compensation.md` §\"Workflow policy\") rather than accepted silently. The host-expansion witness accepts either outcome and asserts it matches the advert.",
235
+ "parameters": {
236
+ "type": "object",
237
+ "required": [
238
+ "reservationUrl"
239
+ ],
240
+ "properties": {
241
+ "reservationUrl": {
242
+ "type": "string",
243
+ "format": "uri",
244
+ "description": "Reservation endpoint. Surfaced via {{params.reservationUrl}} in the reserve node config and its inverse action's inputMapping."
245
+ }
246
+ }
247
+ },
248
+ "dag": {
249
+ "nodes": [
250
+ {
251
+ "id": "reserve",
252
+ "typeId": "core.openwop.http.get",
253
+ "name": "Reserve",
254
+ "position": {
255
+ "x": 0,
256
+ "y": 0
257
+ },
258
+ "config": {
259
+ "url": "{{params.reservationUrl}}",
260
+ "timeoutMs": 30000
261
+ },
262
+ "inputs": {},
263
+ "compensation": {
264
+ "nodeTypeId": "core.openwop.http.get",
265
+ "inputMapping": {
266
+ "url": "{{params.reservationUrl}}",
267
+ "reservationId": "${nodes.reserve.output.body}"
268
+ },
269
+ "retry": {
270
+ "maxAttempts": 3,
271
+ "backoffMs": 250
272
+ },
273
+ "requiresApproval": false
274
+ }
275
+ },
276
+ {
277
+ "id": "notify",
278
+ "typeId": "core.ai.callPrompt",
279
+ "name": "Notify",
280
+ "position": {
281
+ "x": 280,
282
+ "y": 0
283
+ },
284
+ "config": {
285
+ "systemPrompt": "Write a one-line confirmation that the reservation at {{params.reservationUrl}} was placed.",
286
+ "envelopeType": "summary.v1",
287
+ "provider": "anthropic"
288
+ },
289
+ "inputs": {},
290
+ "irreversibleEffect": true
291
+ }
292
+ ],
293
+ "edges": [
294
+ {
295
+ "from": "reserve",
296
+ "to": "notify"
297
+ }
298
+ ]
299
+ },
300
+ "capabilities": [
301
+ "side-effectful"
302
+ ],
303
+ "compensation": {
304
+ "profileVersion": "1",
305
+ "orderingModel": "reverse-completion",
306
+ "triggers": [
307
+ "node-failure",
308
+ "run-cancel"
309
+ ],
310
+ "retry": {
311
+ "maxAttempts": 3,
312
+ "backoffMs": 250
313
+ },
314
+ "timeoutMs": 30000,
315
+ "exhaustedDisposition": "record-outcome",
316
+ "approvalScope": "declared",
317
+ "onParentCancel": "continue"
318
+ }
127
319
  }
128
320
  ]
129
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.132.0",
3
+ "version": "1.134.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.132.0",
4
- "corpusCommit": "1f702f91558437558816d0e6af251926942d123f"
3
+ "suiteVersion": "1.134.0",
4
+ "corpusCommit": "fbb82fa8ea94c25358a63fda98bd49fdb528693a"
5
5
  }
package/src/lib/driver.ts CHANGED
@@ -35,7 +35,9 @@ class OpenWOPDriver {
35
35
  init: OpenWOPRequestInit = {},
36
36
  ): Promise<OpenWOPResponse> {
37
37
  const env = loadEnv();
38
- const url = `${env.baseUrl}${path}`;
38
+ // An absolute URL is used as-is (a host may advertise its MCP server mount or
39
+ // an A2A endpoint on another origin); a path is joined to the base URL.
40
+ const url = /^https?:\/\//i.test(path) ? path : `${env.baseUrl}${path}`;
39
41
 
40
42
  const headers: Record<string, string> = {
41
43
  Accept: 'application/json',
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Where a host mounts its MCP server (RFC 0153 §B / `mcp-integration.md`
3
+ * §"MCP server composition").
4
+ *
5
+ * The reference boots mount it at the sample-seam path
6
+ * `/v1/host/sample/mcp`, and until suite 1.134.0 every server-side MCP
7
+ * scenario hard-coded that path. The v1.0 discovery baseline has always let a
8
+ * host SAY where its mount is — `capabilities.mcp.serverUrls: string[]`
9
+ * (`mcp-discoverability.test.ts`) — and the first deployed host to advertise
10
+ * `mcp-2026-07-28` mounted at `/v1/host/openwop-app/mcp`, advertised exactly
11
+ * that, and watched ten `server/discover` legs 404 against the sample path and
12
+ * return early (S25, openwop-app H38, 2026-08-17). So: read the advert first,
13
+ * fall back to the sample path, and never treat "the mount answered 404" as a
14
+ * pass — that is `seamAbsent` (the host advertised a server mount it does not
15
+ * serve where it said).
16
+ */
17
+ import { driver } from './driver.js';
18
+ import { capabilityFamily } from './discovery-capabilities.js';
19
+
20
+ export const SAMPLE_MCP_MOUNT = '/v1/host/sample/mcp';
21
+
22
+ interface McpAdvert {
23
+ readonly supported?: boolean;
24
+ readonly serverUrls?: unknown;
25
+ readonly serverMount?: { readonly supported?: boolean };
26
+ }
27
+
28
+ /**
29
+ * The MCP server mount to POST JSON-RPC to: `capabilities.mcp.serverUrls[0]`
30
+ * when the host advertises one (a path is joined to the base URL by the driver;
31
+ * an absolute URL is used as-is), else the sample-seam path.
32
+ */
33
+ export async function mcpServerMount(): Promise<string> {
34
+ const disco = await driver.get('/.well-known/openwop');
35
+ const mcp = capabilityFamily<McpAdvert>(disco.json, 'mcp');
36
+ const urls = Array.isArray(mcp?.serverUrls) ? (mcp?.serverUrls as unknown[]) : [];
37
+ const first = urls.find((u): u is string => typeof u === 'string' && u.length > 0);
38
+ return first ?? SAMPLE_MCP_MOUNT;
39
+ }
@@ -25,6 +25,8 @@
25
25
 
26
26
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27
27
  import { driver } from '../lib/driver.js';
28
+ import { seamAbsent } from '../lib/soft-skip.js';
29
+ import { mcpServerMount } from '../lib/mcp-mount.js';
28
30
  import { behaviorGate } from '../lib/behavior-gate.js';
29
31
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
30
32
  import { McpFakeServer, MCP_ERR } from '../lib/mcp-fake-server.js';
@@ -170,7 +172,7 @@ async function claimsCurrent(): Promise<boolean> {
170
172
  }
171
173
  async function hostRpc(method: string, params: Record<string, unknown>, headers: Record<string, string>) {
172
174
  // The reference host mounts its MCP server at /v1/host/sample/mcp (mcp-server-* scenarios).
173
- const res = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method, params }, { headers });
175
+ const res = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method, params }, { headers });
174
176
  return { status: res.status, body: res.json as { result?: Record<string, unknown>; error?: { code: number; data?: Record<string, unknown> } } };
175
177
  }
176
178
 
@@ -179,7 +181,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — host as MCP ser
179
181
  if (!behaviorGate(PROFILE, await claimsCurrent())) return;
180
182
  const caps = (await mcp())!;
181
183
  const r = await hostRpc('server/discover', { _meta: { [META_V]: '2026-07-28', [META_C]: {} } }, { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'server/discover' });
182
- if (r.status === 404 || r.status === 403) return; // mount not wired at the sample path — the mcp-server-* legs cover that gap
184
+ if (r.status === 404 || r.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} RFC 0153 §B is unobservable at the path the host itself advertised`);
183
185
  expect(r.status, driver.describe('mcp-integration.md §B', 'server/discover is a server MUST under 2026-07-28')).toBe(200);
184
186
  expect(r.body.result?.['resultType']).toBe('complete');
185
187
  expect([...((r.body.result?.['supportedVersions'] as string[] | undefined) ?? [])].sort(), driver.describe('mcp-integration.md §B', 'server/discover.supportedVersions MUST equal capabilities.mcp.protocolVersions — two documents, one fact')).toEqual([...(caps.protocolVersions ?? [])].sort());
@@ -196,7 +198,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — host as MCP ser
196
198
  // answered -32022 — the leg was testing an unstated precedence
197
199
  // (mcp-integration.md §B: agreement is checked before selection).
198
200
  const mismatch = await hostRpc('tools/list', { _meta: { [META_V]: '2025-06-18', [META_C]: {} } }, { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' });
199
- if (mismatch.status === 404 || mismatch.status === 403) return;
201
+ if (mismatch.status === 404 || mismatch.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${mismatch.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
200
202
  expect(mismatch.status, driver.describe('mcp-integration.md §B', 'header ≠ body MUST be refused 400 (HeaderMismatchError -32020) — fail closed')).toBe(400);
201
203
  expect(mismatch.body.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
202
204
  const unsupported = await hostRpc('tools/list', { _meta: { [META_V]: '1999-01-01', [META_C]: {} } }, { 'MCP-Protocol-Version': '1999-01-01', 'Mcp-Method': 'tools/list' });
@@ -22,6 +22,8 @@
22
22
 
23
23
  import { describe, it, expect } from 'vitest';
24
24
  import { driver } from '../lib/driver.js';
25
+ import { seamAbsent } from '../lib/soft-skip.js';
26
+ import { mcpServerMount } from '../lib/mcp-mount.js';
25
27
  import { behaviorGate } from '../lib/behavior-gate.js';
26
28
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
27
29
 
@@ -38,7 +40,7 @@ async function claimsCurrent(): Promise<boolean> {
38
40
  async function listAs(bearer?: string) {
39
41
  const headers: Record<string, string> = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' };
40
42
  if (bearer) headers['authorization'] = `Bearer ${bearer}`;
41
- const res = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {} } } }, { headers });
43
+ const res = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {} } } }, { headers });
42
44
  return { status: res.status, body: res.json as { result?: { tools?: unknown[]; cacheScope?: string; ttlMs?: number } } };
43
45
  }
44
46
 
@@ -46,7 +48,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-cache-tenan
46
48
  it('a per-caller list is cacheScope private; a public list is byte-identical across callers', async () => {
47
49
  if (!behaviorGate(PROFILE, await claimsCurrent())) return;
48
50
  const mine = await listAs();
49
- if (mine.status === 404 || mine.status === 403) return;
51
+ if (mine.status === 404 || mine.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${mine.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
50
52
  expect(['public', 'private'], driver.describe('mcp-integration.md §D', 'cacheScope MUST be present on tools/list')).toContain(mine.body.result?.cacheScope);
51
53
  const other = process.env.OPENWOP_TEST_SECONDARY_API_KEY;
52
54
  if (!other) {
@@ -18,6 +18,8 @@
18
18
 
19
19
  import { describe, it, expect } from 'vitest';
20
20
  import { driver } from '../lib/driver.js';
21
+ import { seamAbsent } from '../lib/soft-skip.js';
22
+ import { mcpServerMount } from '../lib/mcp-mount.js';
21
23
  import { behaviorGate } from '../lib/behavior-gate.js';
22
24
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
23
25
 
@@ -38,10 +40,10 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-aut
38
40
  const { mcp, anon } = await disco();
39
41
  const claims = mcp?.supported === true && (mcp.profiles ?? []).includes(PROFILE) && mcp.serverMount?.supported === true;
40
42
  if (!behaviorGate(PROFILE, claims)) return;
41
- const authed = await driver.post('/v1/host/sample/mcp', REQ, { headers: HDR });
42
- if (authed.status === 404 || authed.status === 403) return; // mount not at the sample path
43
+ const authed = await driver.post(await mcpServerMount(), REQ, { headers: HDR });
44
+ if (authed.status === 404 || authed.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${authed.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
43
45
  expect(authed.status, driver.describe('mcp-integration.md §E', 'the authenticated call MUST succeed at the same path, so a refusal below is not a wrong path')).toBe(200);
44
- const anonymous = await driver.post('/v1/host/sample/mcp', REQ, { headers: HDR, authenticated: false });
46
+ const anonymous = await driver.post(await mcpServerMount(), REQ, { headers: HDR, authenticated: false });
45
47
  if (anon?.supported === true) {
46
48
  // Anonymous is permitted only through the RFC 0132 surface; a 200 here is that surface answering.
47
49
  expect([200, 401, 403]).toContain(anonymous.status);
@@ -23,6 +23,7 @@
23
23
  import { describe, it, expect } from 'vitest';
24
24
  import { softSkip, seamAbsent } from '../lib/soft-skip.js';
25
25
  import { driver } from '../lib/driver.js';
26
+ import { mcpServerMount } from '../lib/mcp-mount.js';
26
27
  import { behaviorGate } from '../lib/behavior-gate.js';
27
28
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
28
29
  import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
@@ -66,7 +67,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-o
66
67
  const caps = await mcp();
67
68
  const claims = caps?.supported === true && (caps.profiles ?? []).includes('mcp-2026-07-28') && caps.serverMount?.supported === true;
68
69
  if (!behaviorGate('mcp-2026-07-28', claims)) return;
69
- const res = await driver.post('/v1/host/sample/mcp', {
70
+ const res = await driver.post(await mcpServerMount(), {
70
71
  jsonrpc: '2.0', id: 1, method: 'tools/list',
71
72
  params: { _meta: { [META_V]: '2026-07-28', [META_C]: { extensions: { 'io.example/authority': { admin: true } } }, 'io.example/authority': { grantScopes: ['*'] } } },
72
73
  }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' } });
@@ -33,6 +33,7 @@
33
33
  import { describe, it, expect } from 'vitest';
34
34
  import { softSkip, seamAbsent } from '../lib/soft-skip.js';
35
35
  import { driver } from '../lib/driver.js';
36
+ import { mcpServerMount } from '../lib/mcp-mount.js';
36
37
  import { behaviorGate } from '../lib/behavior-gate.js';
37
38
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
38
39
  import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
@@ -120,7 +121,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
120
121
  // not seam absence and not a host finding — fail loudly at the source.
121
122
  expect(reg.status, driver.describe('host-sample-test-seams.md §"sample workflows"', `the registration fixture MUST be accepted; a 4xx means the fixture this leg posts is malformed (suite defect): ${JSON.stringify(reg.json).slice(0, 200)}`)).toBeLessThan(400);
122
123
  const hdr = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': TOOL };
123
- const first = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: {}, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
124
+ const first = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: {}, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
124
125
  if (first.status === 404) return seamAbsent('host advertises an MCP server mount but /v1/host/sample/mcp answered 404');
125
126
  const r1 = first.json as { result?: { resultType?: string; inputRequests?: Record<string, { method?: string }>; requestState?: string }; error?: { code: number } };
126
127
  expect(r1.error, driver.describe('mcp-integration.md §C.2', `tools/call MUST NOT error: ${JSON.stringify(r1.error)}`)).toBeUndefined();
@@ -129,11 +130,11 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
129
130
  expect(key, driver.describe('mcp-integration.md §C.2', 'inputRequests MUST carry the elicitation')).toBeDefined();
130
131
  expect(r1.result?.inputRequests?.[key!]?.method).toBe('elicitation/create');
131
132
  expect(typeof r1.result?.requestState, driver.describe('mcp-integration.md §C.2', 'requestState MUST be present (opaque, integrity-protected, bound to principal/TTL/request/runId/interrupt token)')).toBe('string');
132
- const retry = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: r1.result!.requestState, inputResponses: { [key!]: { action: 'accept', content: { name: 'Ada' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
133
+ const retry = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: r1.result!.requestState, inputResponses: { [key!]: { action: 'accept', content: { name: 'Ada' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
133
134
  const r2 = retry.json as { result?: { resultType?: string }; error?: { code: number } };
134
135
  expect(r2.error).toBeUndefined();
135
136
  expect(['complete', 'input_required'], driver.describe('mcp-integration.md §C.2', 'the retry MUST resolve the interrupt (complete) or ask for the next pending input')).toContain(r2.result?.resultType);
136
- const forged = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: 'forged', inputResponses: { [key!]: { action: 'accept', content: { name: 'Eve' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
137
+ const forged = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: 'forged', inputResponses: { [key!]: { action: 'accept', content: { name: 'Eve' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
137
138
  const r3 = forged.json as { result?: { resultType?: string }; error?: { code: number } };
138
139
  expect(r3.error !== undefined || forged.status >= 400, driver.describe('mcp-integration.md §C.2', 'a requestState that fails integrity verification MUST be refused (upstream: attacker-controlled input)')).toBe(true);
139
140
  });
@@ -13,6 +13,8 @@
13
13
 
14
14
  import { describe, it, expect } from 'vitest';
15
15
  import { driver } from '../lib/driver.js';
16
+ import { seamAbsent } from '../lib/soft-skip.js';
17
+ import { mcpServerMount } from '../lib/mcp-mount.js';
16
18
 
17
19
  interface DiscoveryDoc {
18
20
  capabilities?: Record<string, unknown>;
@@ -31,7 +33,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
31
33
  const id = Math.floor(Math.random() * 1e6);
32
34
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
33
35
  if (params !== undefined) req.params = params;
34
- const res = await driver.post('/v1/host/sample/mcp', req);
36
+ const res = await driver.post(await mcpServerMount(), req);
35
37
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
36
38
  }
37
39
 
@@ -71,7 +73,7 @@ describe('mcp-server-elicitation-bridge: behavioral (RFC 0020 §A point 3)', ()
71
73
  required: ['name'],
72
74
  },
73
75
  });
74
- if (r.status === 404) return;
76
+ if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
75
77
  expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
76
78
  const dispatched = !!r.body.result || (!!r.body.error && r.body.error.code !== -32601);
77
79
  expect(
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { describe, it, expect } from 'vitest';
10
10
  import { driver } from '../lib/driver.js';
11
+ import { seamAbsent } from '../lib/soft-skip.js';
12
+ import { mcpServerMount } from '../lib/mcp-mount.js';
11
13
 
12
14
  interface DiscoveryDoc {
13
15
  capabilities?: Record<string, unknown>;
@@ -26,7 +28,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
26
28
  const id = Math.floor(Math.random() * 1e6);
27
29
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
28
30
  if (params !== undefined) req.params = params;
29
- const res = await driver.post('/v1/host/sample/mcp', req);
31
+ const res = await driver.post(await mcpServerMount(), req);
30
32
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
31
33
  }
32
34
 
@@ -65,7 +67,7 @@ describe('mcp-server-prompt-roundtrip: behavioral (RFC 0020)', () => {
65
67
  if (!(await registerPromptWorkflow())) return;
66
68
 
67
69
  const list = await rpc('prompts/list');
68
- if (list.status === 404) return;
70
+ if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
69
71
  const prompts = (list.body.result as { prompts?: Array<{ name: string }> } | undefined)?.prompts ?? [];
70
72
  expect(
71
73
  prompts.find((p) => p.name === PROMPT_NAME),
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { describe, it, expect } from 'vitest';
12
12
  import { driver } from '../lib/driver.js';
13
+ import { seamAbsent } from '../lib/soft-skip.js';
14
+ import { mcpServerMount } from '../lib/mcp-mount.js';
13
15
 
14
16
  interface DiscoveryDoc {
15
17
  capabilities?: Record<string, unknown>;
@@ -28,7 +30,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
28
30
  const id = Math.floor(Math.random() * 1e6);
29
31
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
30
32
  if (params !== undefined) req.params = params;
31
- const res = await driver.post('/v1/host/sample/mcp', req);
33
+ const res = await driver.post(await mcpServerMount(), req);
32
34
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
33
35
  }
34
36
 
@@ -67,7 +69,7 @@ describe('mcp-server-resource-roundtrip: behavioral (RFC 0020)', () => {
67
69
  if (!(await registerResourceWorkflow())) return;
68
70
 
69
71
  const list = await rpc('resources/list');
70
- if (list.status === 404) return;
72
+ if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
71
73
  const resources = (list.body.result as { resources?: Array<{ uri: string }> } | undefined)?.resources ?? [];
72
74
  expect(
73
75
  resources.find((r) => r.uri === RESOURCE_URI),
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { describe, it, expect } from 'vitest';
18
18
  import { driver } from '../lib/driver.js';
19
+ import { seamAbsent } from '../lib/soft-skip.js';
20
+ import { mcpServerMount } from '../lib/mcp-mount.js';
19
21
 
20
22
  interface DiscoveryDoc {
21
23
  capabilities?: Record<string, unknown>;
@@ -34,7 +36,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
34
36
  const id = Math.floor(Math.random() * 1e6);
35
37
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
36
38
  if (params !== undefined) req.params = params;
37
- const res = await driver.post('/v1/host/sample/mcp', req);
39
+ const res = await driver.post(await mcpServerMount(), req);
38
40
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
39
41
  }
40
42
 
@@ -70,7 +72,7 @@ describe('mcp-server-sampling-bridge: behavioral (RFC 0020 §A point 3)', () =>
70
72
  messages: [{ role: 'user', content: { type: 'text', text: 'ping' } }],
71
73
  maxTokens: 16,
72
74
  });
73
- if (r.status === 404) return;
75
+ if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
74
76
  expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
75
77
  const dispatched = !!r.body.result || (!!r.body.error && r.body.error.code !== -32601);
76
78
  expect(
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { describe, it, expect } from 'vitest';
16
16
  import { driver } from '../lib/driver.js';
17
+ import { seamAbsent } from '../lib/soft-skip.js';
18
+ import { mcpServerMount } from '../lib/mcp-mount.js';
17
19
 
18
20
  interface DiscoveryDoc {
19
21
  capabilities?: Record<string, unknown>;
@@ -32,7 +34,7 @@ async function rpc(method: string, params?: Record<string, unknown>): Promise<{
32
34
  const id = Math.floor(Math.random() * 1e6);
33
35
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
34
36
  if (params !== undefined) req.params = params;
35
- const res = await driver.post('/v1/host/sample/mcp', req);
37
+ const res = await driver.post(await mcpServerMount(), req);
36
38
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
37
39
  }
38
40
 
@@ -83,7 +85,7 @@ describe('mcp-server-tool-roundtrip: behavioral (RFC 0020 §A points 1-2)', () =
83
85
  if (!registered) return; // host doesn't expose workflow registration
84
86
 
85
87
  const list = await rpc('tools/list');
86
- if (list.status === 404) return; // host doesn't expose the seam
88
+ if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
87
89
  expect(list.status, 'tools/list MUST 200').toBe(200);
88
90
  const tools = (list.body.result as { tools?: Array<{ name: string }> } | undefined)?.tools ?? [];
89
91
  const found = tools.find((t) => t.name === TEST_TOOL_NAME);
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { describe, it, expect } from 'vitest';
14
14
  import { driver } from '../lib/driver.js';
15
+ import { seamAbsent } from '../lib/soft-skip.js';
16
+ import { mcpServerMount } from '../lib/mcp-mount.js';
15
17
 
16
18
  interface DiscoveryDoc {
17
19
  capabilities?: Record<string, unknown>;
@@ -30,7 +32,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
30
32
  const id = Math.floor(Math.random() * 1e6);
31
33
  const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
32
34
  if (params !== undefined) req.params = params;
33
- const res = await driver.post('/v1/host/sample/mcp', req);
35
+ const res = await driver.post(await mcpServerMount(), req);
34
36
  return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string; data?: unknown } } };
35
37
  }
36
38
 
@@ -77,7 +79,7 @@ describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
77
79
  name: TEST_TOOL_NAME,
78
80
  arguments: { wrongField: 'no' },
79
81
  });
80
- if (r.status === 404) return;
82
+ if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
81
83
  expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
82
84
  expect(
83
85
  r.body.error?.code,
@@ -96,7 +98,7 @@ describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
96
98
  name: TEST_TOOL_NAME,
97
99
  arguments: { text: 'hello' },
98
100
  });
99
- if (r.status === 404) return;
101
+ if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
100
102
  expect(r.status).toBe(200);
101
103
  if (r.body.error) {
102
104
  expect(r.body.error.code, 'valid args MUST NOT trigger -32602').not.toBe(-32602);
@@ -20,9 +20,10 @@
20
20
 
21
21
  import { describe, it, expect } from 'vitest';
22
22
  import { driver } from '../lib/driver.js';
23
+ import { mcpServerMount } from '../lib/mcp-mount.js';
23
24
  import { behaviorGate } from '../lib/behavior-gate.js';
24
25
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
25
- import { softSkip } from '../lib/soft-skip.js';
26
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
26
27
 
27
28
  const PROFILE = 'mcp-2026-07-28';
28
29
  const META_V = 'io.modelcontextprotocol/protocolVersion';
@@ -37,7 +38,7 @@ async function claimsCurrent(): Promise<boolean> {
37
38
  }
38
39
  async function list() {
39
40
  const res = await driver.post(
40
- '/v1/host/sample/mcp',
41
+ await mcpServerMount(),
41
42
  { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {}, [META_I]: { name: 'openwop-conformance', version: 'suite' } } } },
42
43
  { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' } },
43
44
  );
@@ -49,12 +50,12 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — mcp-header-body
49
50
  if (!behaviorGate(PROFILE, await claimsCurrent())) return;
50
51
  const meta = { [META_V]: '2026-07-28', [META_C]: {}, [META_I]: { name: 'openwop-conformance', version: 'suite' } };
51
52
  // (a) Mcp-Method header disagrees with the JSON-RPC method
52
- const m = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'resources/list' } });
53
+ const m = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'resources/list' } });
53
54
  if (m.status === 404 || m.status === 403) return softSkip('blocked', `MCP server mount /v1/host/sample/mcp answered ${m.status}`);
54
55
  expect(m.status, driver.describe('mcp-integration.md §B', 'Mcp-Method MUST equal the body method; disagreement MUST be refused 400 (mcp-header-body-consistent)')).toBe(400);
55
56
  expect((m.json as { error?: { code?: number } }).error?.code, driver.describe('mcp-integration.md §B', 'the refusal is HeaderMismatchError -32020')).toBe(-32020);
56
57
  // (b) Mcp-Name header disagrees with params.name on tools/call
57
- const n = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': 'not-echo' } });
58
+ const n = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': 'not-echo' } });
58
59
  expect(n.status, driver.describe('mcp-integration.md §B', 'Mcp-Name MUST equal params.name; disagreement MUST be refused 400 (mcp-header-body-consistent)')).toBe(400);
59
60
  expect((n.json as { error?: { code?: number } }).error?.code, driver.describe('mcp-integration.md §B', 'the refusal is HeaderMismatchError -32020')).toBe(-32020);
60
61
  });
@@ -64,7 +65,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — mcp-stateless-r
64
65
  it('tools/list succeeds with no initialize and no session; result carries resultType + cache hints; two connections agree', async () => {
65
66
  if (!behaviorGate(PROFILE, await claimsCurrent())) return;
66
67
  const a = await list();
67
- if (a.status === 404 || a.status === 403) return; // mount not at the sample pathcovered by mcp-server-* legs
68
+ if (a.status === 404 || a.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${a.status} RFC 0153 §B is unobservable at the path the host itself advertised`);
68
69
  expect(a.status, driver.describe('mcp-integration.md §B', 'a core request MUST succeed without a prior initialize or a session header')).toBe(200);
69
70
  expect(a.body.error, driver.describe('mcp-integration.md §B', `stateless tools/list MUST NOT error: ${JSON.stringify(a.body.error)}`)).toBeUndefined();
70
71
  expect(a.body.result?.resultType, driver.describe('mcp-integration.md §B', 'every current-revision result carries resultType')).toBe('complete');
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { describe, it, expect } from 'vitest';
28
28
  import { driver } from '../lib/driver.js';
29
+ import { seamAbsent } from '../lib/soft-skip.js';
29
30
  import { behaviorGate } from '../lib/behavior-gate.js';
30
31
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
31
32
  import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
@@ -123,8 +124,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
123
124
  const caps = await mcp();
124
125
  server.reset();
125
126
  const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
126
- if (drive.status === 404 || drive.status === 403) return;
127
- // Discovery is a promise about behavior. A host that negotiates a revision
127
+ if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
128
128
  // it never advertised has made its own discovery document unreliable, which
129
129
  // is worse than advertising nothing — a consumer that read it made a
130
130
  // decision on a fact that was not true.
@@ -149,7 +149,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
149
149
  serverUrl: server.endpoint(),
150
150
  requestVersion: '1999-01-01',
151
151
  });
152
- if (drive.status === 404 || drive.status === 403) return;
152
+ if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
153
153
  expect(
154
154
  drive.status >= 400,
155
155
  driver.describe('RFCS/0153 §B', 'an unsupported revision MUST fail rather than silently proceed'),
@@ -45,7 +45,10 @@ import { driver } from '../lib/driver.js';
45
45
  import { loadEnv } from '../lib/env.js';
46
46
  import { behaviorGate } from '../lib/behavior-gate.js';
47
47
  import { FIXTURES_DIR } from '../lib/paths.js';
48
- import { expandChain, type WorkflowChain } from '../lib/workflow-chain-expansion.js';
48
+ import { expandChain, expandChainWithCompensation, type WorkflowChain, type WorkflowChainWithCompensation } from '../lib/workflow-chain-expansion.js';
49
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
50
+ import { softSkip } from '../lib/soft-skip.js';
51
+ import { readErrorCode } from '../lib/error-envelope.js';
49
52
 
50
53
  const PROFILE = 'workflowChainPacks.hostExpansionSeam';
51
54
  const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
@@ -64,6 +67,9 @@ const PACK = JSON.parse(
64
67
  const SAMPLE_PACK = PACK.name; // vendor.openwop.workflow-chain-sample
65
68
  const CHAIN_1_NODE = 'vendor.openwop.workflow-chain-sample.summarize-text';
66
69
  const CHAIN_2_NODE = 'vendor.openwop.workflow-chain-sample.fetch-and-summarize';
70
+ // RFC 0157 fixtures (pack 1.1.0, suite 1.133.0): node declarations + irreversibleEffect, without / with a chain policy.
71
+ const CHAIN_COMP = 'vendor.openwop.workflow-chain-sample.reserve-and-notify';
72
+ const CHAIN_COMP_POLICY = 'vendor.openwop.workflow-chain-sample.reserve-and-notify-with-policy';
67
73
 
68
74
  function chainById(chainId: string): WorkflowChain {
69
75
  const c = PACK.chains.find((x) => x.chainId === chainId);
@@ -154,7 +160,19 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
154
160
 
155
161
  expect(body.chainId).toBe(CHAIN_1_NODE);
156
162
  expect(body.packName).toBe(SAMPLE_PACK);
157
- expect(body.packVersion).toBe(PACK.version);
163
+ // The host serves the fixture from ITS conformance pin; the fixture gained two
164
+ // RFC 0157 chains at pack 1.1.0 (suite 1.133.0) without touching the two
165
+ // chains these legs expand, so an older pin is a note, not a mismatch — the
166
+ // expansions below still compare like for like. A NEWER pack than this suite
167
+ // knows is a mismatch (the suite cannot vouch for chains it has not seen).
168
+ expect(typeof body.packVersion).toBe('string');
169
+ if (body.packVersion !== PACK.version) {
170
+ const [hm, hn] = body.packVersion.split('.').map(Number);
171
+ const [fm, fn] = PACK.version.split('.').map(Number);
172
+ const older = hm! < fm! || (hm === fm && hn! < fn!);
173
+ expect(older, driver.describe('workflow-chain-host-expansion', `host bundles workflow-chain-sample ${body.packVersion}; this suite's fixture is ${PACK.version} — a host pack NEWER than the suite is unaccounted for`)).toBe(true);
174
+ softSkip('blocked', `host bundles workflow-chain-sample ${body.packVersion} (suite fixture ${PACK.version}) — the RFC 0157 chains are absent there until its conformance pin catches up`);
175
+ }
158
176
  expect(typeof body.expansionId).toBe('string');
159
177
  expect(body.expansionId.length).toBeGreaterThan(0);
160
178
 
@@ -250,4 +268,79 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
250
268
  expect(res.status).toBe(422);
251
269
  expect((res.json as { error: string }).error).toBe('invalid_request');
252
270
  });
271
+ it('RFC 0157 — a compensating chain expands through the live path with `compensation` and `irreversibleEffect` carried verbatim onto the expanded nodes', async () => {
272
+ if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
273
+ const parameters = { reservationUrl: 'https://example.com/reservations' };
274
+ const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_COMP, parameters });
275
+ if (res.status === 404 && readErrorCode(res.json) === 'chain_not_found') {
276
+ // The host serves the bundled fixture from its PINNED conformance package;
277
+ // the RFC 0157 chains arrived with pack 1.1.0 (suite 1.133.0). Older pin ⇒
278
+ // the chain does not exist there yet — unobservable, not a failure.
279
+ return softSkip('blocked', `host's bundled workflow-chain-sample pack predates 1.1.0 (chain ${CHAIN_COMP} answered chain_not_found) — the RFC 0157 carry is unobservable until the host's conformance pin reaches 1.133.0`);
280
+ }
281
+ expect(res.status, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)"', 'a chain whose nodes declare an inverse action / irreversibleEffect (no policy) is acceptable on ANY host — the declaration describes, only the policy requests an unwind')).toBe(200);
282
+ const body = res.json as unknown as {
283
+ expansionId: string;
284
+ nodes: Array<{ id: string; compensation?: unknown; irreversibleEffect?: boolean }>;
285
+ };
286
+ const expected = expandChainWithCompensation(chainById(CHAIN_COMP) as unknown as WorkflowChainWithCompensation, {
287
+ expansionId: body.expansionId,
288
+ params: parameters,
289
+ isTypeIdResolvable: () => true,
290
+ });
291
+ expect(body.nodes).toHaveLength(expected.nodes.length);
292
+ for (const ref of expected.nodes) {
293
+ const node = body.nodes.find((n) => n.id === ref.id);
294
+ expect(node, driver.describe('workflow-chain-packs.md §Expansion semantics', `expanded node ${ref.id} present`)).toBeDefined();
295
+ const refC = (ref as unknown as { compensation?: unknown }).compensation;
296
+ expect(
297
+ node?.compensation,
298
+ driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rules 5b/6b', `node ${ref.id}: the inverse-action declaration MUST survive expansion verbatim (params substituted, node-id refs re-prefixed) — a node-rebuilding allowlist that drops it silently loses the unwind`),
299
+ ).toEqual(refC);
300
+ expect(
301
+ node?.irreversibleEffect,
302
+ driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rule 6c', `node ${ref.id}: irreversibleEffect MUST be copied unchanged`),
303
+ ).toEqual((ref as unknown as { irreversibleEffect?: boolean }).irreversibleEffect);
304
+ }
305
+ // Non-vacuity: the fixture DOES declare both, so at least one node of each kind was compared.
306
+ expect(expected.nodes.some((n) => (n as unknown as { compensation?: unknown }).compensation !== undefined)).toBe(true);
307
+ expect(expected.nodes.some((n) => (n as unknown as { irreversibleEffect?: boolean }).irreversibleEffect === true)).toBe(true);
308
+ });
309
+
310
+ it('RFC 0157 — a chain carrying an unwind POLICY is refused with capability_required on a host that does not advertise compensation, and expands on one that does', async () => {
311
+ if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
312
+ const disco = await driver.get('/.well-known/openwop');
313
+ const advertises = capabilityFamily<{ supported?: boolean }>(disco.json, 'compensation')?.supported === true;
314
+ const parameters = { reservationUrl: 'https://example.com/reservations' };
315
+ const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_COMP_POLICY, parameters });
316
+ if (res.status === 404 && readErrorCode(res.json) === 'chain_not_found') {
317
+ return softSkip('blocked', `host's bundled workflow-chain-sample pack predates 1.1.0 (chain ${CHAIN_COMP_POLICY} answered chain_not_found) — unobservable until the host's conformance pin reaches 1.133.0`);
318
+ }
319
+ if (!advertises) {
320
+ // compensation.md §"Workflow policy": a host that does NOT advertise the family
321
+ // MUST refuse a chain carrying a policy rather than accept a promise it will
322
+ // never honour. Accepting silently is the advertise-and-opt-out failure with
323
+ // the sign flipped.
324
+ expect(res.status >= 400, driver.describe('compensation.md §"Workflow policy"', 'a non-advertising host MUST refuse a chain carrying `compensation` (policy) — 4xx, not 200')).toBe(true);
325
+ expect(readErrorCode(res.json), driver.describe('capabilities.md §"Unsupported capability — refusal contract"', '`capability_required`')).toBe('capability_required');
326
+ return;
327
+ }
328
+ expect(res.status, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)"', 'an advertising host expands the policy-carrying chain')).toBe(200);
329
+ const body = res.json as unknown as { expansionId: string; nodes: unknown[]; settings?: { compensation?: unknown } };
330
+ const expected = expandChainWithCompensation(chainById(CHAIN_COMP_POLICY) as unknown as WorkflowChainWithCompensation, {
331
+ expansionId: body.expansionId,
332
+ params: parameters,
333
+ isTypeIdResolvable: () => true,
334
+ });
335
+ expect(body.nodes).toHaveLength(expected.nodes.length);
336
+ // The policy's destination is the registered definition's settings.compensation
337
+ // (rule 9b). This seam returns the expanded fragment; whether it echoes
338
+ // `settings` is host-optional, so the policy carry is asserted when visible
339
+ // and noted when not — never assumed.
340
+ if (body.settings?.compensation !== undefined) {
341
+ expect(body.settings.compensation, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rule 9b', 'the chain policy becomes settings.compensation, copied verbatim')).toEqual(expected.settingsCompensation);
342
+ } else {
343
+ softSkip('blocked', 'the expand seam does not echo `settings`, so the policy → settings.compensation carry (rule 9b) is unobservable through it; the node carry above was witnessed');
344
+ }
345
+ });
253
346
  });