@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.
|
|
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": [
|
|
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": [
|
|
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": {
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": {
|
|
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": {
|
|
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
|
-
{
|
|
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": [
|
|
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,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.
|
|
4
|
-
"corpusCommit": "
|
|
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 `
|
|
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
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
});
|