@openwop/openwop-conformance 1.131.0 → 1.133.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.131.0",
3
+ "version": "1.133.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.131.0",
4
- "corpusCommit": "b2a25cf7013d21838b8b5abeaea7dff12355bfc5"
3
+ "suiteVersion": "1.133.0",
4
+ "corpusCommit": "01d2c45b0cc72ac953bc3c996f9bc3004d3ec297"
5
5
  }
@@ -31,24 +31,29 @@
31
31
  * (soft-skips without the advert, HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`)
32
32
  * and records its RFC 0148 §A disposition. The three sub-features are
33
33
  * INDEPENDENTLY optional: a host that wires the base seams but not this
34
- * extension is `blocked` here (named as such via `seamAbsent`) and keeps its
34
+ * extension is `blocked` here (named as such via `softSkip`) and keeps its
35
35
  * `compensation-behavior` witness — the base and the extension are different
36
36
  * claims and are recorded separately.
37
37
  *
38
- * Mode matters, and the first host to advertise pointed it out: `seamAbsent`
39
- * records `blocked` in DEFAULT mode but THROWS under
40
- * `OPENWOP_REQUIRE_BEHAVIOR=true` (RFC 0148 §B an advertised capability
41
- * whose witness seam is missing is a failure in strict certification, not a
42
- * disposition). So a host that flips `compensation.supported` on without the
43
- * §21 recovery extension is honest-red here in strict runs. Wire the extension
44
- * with the advert, or run default mode and read the `blocked` rows.
38
+ * Mode does NOT change that (S24, corrected 2026-08-16 after the first host to
39
+ * wire §21 read the contract back to us): the recovery extension has NO advert
40
+ * flag — `capabilities.compensation` is closed over `supported` /
41
+ * `profileVersion` / `orderingModels` / `manualIntervention` so a host cannot
42
+ * claim or disclaim it, and `seamAbsent` (whose contract is "an ADVERTISED
43
+ * capability whose observation seam is absent", strict-failing) is the wrong
44
+ * tool for it. An absent extension is `softSkip('blocked', …)` in both modes,
45
+ * exactly as §21 says; the ledger still refuses to certify the three §G
46
+ * invariants, so nothing weakens. The BASE seams (`unwind` / `replay`) are a
47
+ * different matter: `compensation.supported` promises them, and a 404 there
48
+ * stays `seamAbsent` (strict-fails) — that is `compensation-behavior`'s wall,
49
+ * not this file's.
45
50
  */
46
51
 
47
52
  import { describe, it, expect } from 'vitest';
48
53
  import { driver } from '../lib/driver.js';
49
54
  import { behaviorGate } from '../lib/behavior-gate.js';
50
55
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
51
- import { seamAbsent } from '../lib/soft-skip.js';
56
+ import { seamAbsent, softSkip } from '../lib/soft-skip.js';
52
57
  import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
53
58
 
54
59
  const PROFILE = 'openwop-compensation';
@@ -93,9 +98,14 @@ async function post(path: string, body: Record<string, unknown>): Promise<{ stat
93
98
  return { status: r.status, json: r.json };
94
99
  }
95
100
 
96
- /** The recovery extension is optional; a base seam that ignores the new fields is `blocked` here, not failed. */
101
+ /**
102
+ * The recovery extension is optional and has no advert flag, so its absence is
103
+ * `blocked` in BOTH modes — `softSkip`, never `seamAbsent` (S24). The three §G
104
+ * invariants stay uncertified either way; strict mode is not entitled to fail
105
+ * a host for a sub-seam it was never asked to claim.
106
+ */
97
107
  function extensionAbsent(what: string): undefined {
98
- return seamAbsent(`the §21 recovery extension is not wired: ${what} (host-sample-test-seams.md §21 "Recovery extension") — retry-stability / operator authority / recorded-facts are unobservable`);
108
+ return softSkip('blocked', `the §21 recovery extension is not wired: ${what} (host-sample-test-seams.md §21 "Recovery extension") — retry-stability / operator authority / recorded-facts are unobservable`);
99
109
  }
100
110
 
101
111
  describe('RFC 0151 §C — inverse-action identity is retry-stable (capability-gated behavior)', () => {
@@ -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
  });