@openwop/openwop-conformance 1.48.0 → 1.52.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.
- package/README.md +2 -2
- package/coverage.md +1 -1
- package/fixtures/pack-manifests/workflow-chain-sample.pack.json +129 -0
- package/fixtures.md +5 -4
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +16 -0
- package/schemas/orchestrator-decision.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +22 -2
- package/src/lib/workflow-chain-expansion.ts +287 -2
- package/src/scenarios/dispatch-per-item-input.test.ts +198 -0
- package/src/scenarios/fixtures-valid.test.ts +16 -5
- package/src/scenarios/workflow-chain-deferred-parameters.test.ts +298 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +49 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +123 -100
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +59 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred-parameter expansion — `workflow-chain-packs.md` §"Deferred-parameter
|
|
3
|
+
* expansion (RFC 0124)" + §Security. RFC 0124 (WCP4) lets a workflow-chain
|
|
4
|
+
* pack's `{{params.*}}` stay overridable per run WITHOUT the non-portable
|
|
5
|
+
* app-private runtime tokens RFC 0013 forbids: at drop time the host
|
|
6
|
+
* materializes the chain `parameters` into top-level `variables[]` (author value
|
|
7
|
+
* → `defaultValue`) and rewrites each token into an already-spec'd runtime
|
|
8
|
+
* binding (PromptTemplate `{{varName}}` with `source:"variable"`, or a
|
|
9
|
+
* variable-sourced PortValue), so the persisted fragment carries ZERO
|
|
10
|
+
* `{{params.*}}` tokens.
|
|
11
|
+
*
|
|
12
|
+
* §Security (amended 2026-07-04): a `x-openwop-sensitive` parameter MUST
|
|
13
|
+
* materialize as a `source:"secret"` PromptVariable (BYOK, `[REDACTED]`, never
|
|
14
|
+
* bagged), is deferrable ONLY in a prompt-body position, and FAILS CLOSED
|
|
15
|
+
* (`sensitive_param_not_deferrable`, 422) in a whole-value `node.inputs` /
|
|
16
|
+
* embedded non-prompt config / host lacking `secrets` support. Per-run supply of
|
|
17
|
+
* a sensitive param is a `credentialRef` string, never plaintext (a plaintext
|
|
18
|
+
* `configurable` for a sensitive param ⇒ `validation_error`).
|
|
19
|
+
*
|
|
20
|
+
* Two layers:
|
|
21
|
+
* A. Always-on, server-free legs against the spec-authoritative reference
|
|
22
|
+
* `conformance/src/lib/workflow-chain-expansion.ts` (`expandChainDeferred`)
|
|
23
|
+
* + the capabilities/manifest schema shapes. These pin the deferred-
|
|
24
|
+
* expansion + §Security MUSTs (the new fail-closed error path has no other
|
|
25
|
+
* public test).
|
|
26
|
+
* B. Capability-gated host legs (`workflowChainPacks.deferredParameters.
|
|
27
|
+
* supported`) over the `workflow-chain-host-expansion` seam — override,
|
|
28
|
+
* fork-replay, untrusted-fence, and `[REDACTED]` sensitive compose;
|
|
29
|
+
* soft-skip until a host advertises (openwop-app is the single witness via
|
|
30
|
+
* its own #1281 gated leg; a second PromptTemplate-compose witness is the
|
|
31
|
+
* tracked follow-up per gap G6).
|
|
32
|
+
*
|
|
33
|
+
* @see spec/v1/workflow-chain-packs.md §"Deferred-parameter expansion (RFC 0124)"
|
|
34
|
+
* @see schemas/capabilities.schema.json §workflowChainPacks.deferredParameters
|
|
35
|
+
* @see schemas/workflow-chain-pack-manifest.schema.json (x-openwop-sensitive)
|
|
36
|
+
* @see RFCS/0124-portable-per-run-parameter-deferral.md
|
|
37
|
+
* @see SECURITY/invariants.yaml id: prompt-composed-secret-redaction
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it, expect } from 'vitest';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
44
|
+
import { driver } from '../lib/driver.js';
|
|
45
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
46
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
47
|
+
import {
|
|
48
|
+
expandChainDeferred,
|
|
49
|
+
SensitiveParamNotDeferrableError,
|
|
50
|
+
type WorkflowChain,
|
|
51
|
+
type DeferredExpansionContext,
|
|
52
|
+
} from '../lib/workflow-chain-expansion.js';
|
|
53
|
+
|
|
54
|
+
const CAPS = join(SCHEMAS_DIR, 'capabilities.schema.json');
|
|
55
|
+
const MANIFEST = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
|
|
56
|
+
const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
|
|
57
|
+
|
|
58
|
+
/** Spec-cited assertion message. Unlike `driver.describe`, this does NOT load
|
|
59
|
+
* env, so it is safe in always-on server-free legs (no OPENWOP_BASE_URL). */
|
|
60
|
+
const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
|
|
61
|
+
|
|
62
|
+
const resolvable = () => true;
|
|
63
|
+
const HOST_FULL = { promptVariableSource: true, secretsSupported: true };
|
|
64
|
+
|
|
65
|
+
/** A minimal chain builder for the deferred-expansion legs. */
|
|
66
|
+
function chain(
|
|
67
|
+
nodes: WorkflowChain['dag']['nodes'],
|
|
68
|
+
parameters: object,
|
|
69
|
+
): WorkflowChain {
|
|
70
|
+
return {
|
|
71
|
+
chainId: 'test.deferred',
|
|
72
|
+
version: '1.0.0',
|
|
73
|
+
label: 'Deferred test',
|
|
74
|
+
description: 'x',
|
|
75
|
+
parameters,
|
|
76
|
+
dag: { nodes },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function deferCtx(
|
|
81
|
+
params: Record<string, unknown>,
|
|
82
|
+
parameterSchema: DeferredExpansionContext['parameterSchema'],
|
|
83
|
+
host = HOST_FULL,
|
|
84
|
+
): DeferredExpansionContext {
|
|
85
|
+
return { expansionId: 'a1b2', params, parameterSchema, isTypeIdResolvable: resolvable, host };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deep scan for any residual `{{params.*}}` token in the expanded fragment. */
|
|
89
|
+
function hasResidualToken(frag: unknown): boolean {
|
|
90
|
+
return /\{\{params\./.test(JSON.stringify(frag));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('workflow-chain-deferred: non-sensitive deferral (server-free, RFC 0124)', () => {
|
|
94
|
+
it('materializes variables[] with defaultValue+type and leaves ZERO {{params.*}} tokens (R3)', () => {
|
|
95
|
+
const c = chain(
|
|
96
|
+
[
|
|
97
|
+
{ id: 'n1', typeId: 'core.agent', config: { systemPrompt: 'Hello {{params.topic}}' } },
|
|
98
|
+
{ id: 'n2', typeId: 'core.transform', inputs: { limit: '{{params.count}}' } },
|
|
99
|
+
],
|
|
100
|
+
{
|
|
101
|
+
properties: {
|
|
102
|
+
topic: { type: 'string', description: 'the topic' },
|
|
103
|
+
count: { type: 'number' },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
const out = expandChainDeferred(c, deferCtx({ topic: 'sales', count: 5 }, c.parameters as never));
|
|
108
|
+
|
|
109
|
+
expect(
|
|
110
|
+
hasResidualToken({ nodes: out.nodes, edges: out.edges }),
|
|
111
|
+
cite('workflow-chain-packs.md §Deferred-parameter expansion', 'the persisted fragment MUST contain zero {{params.*}} tokens (R3 portability)'),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
|
|
114
|
+
const topicVar = out.variables.find((v) => v.name === 'topic');
|
|
115
|
+
expect(topicVar, 'topic MUST be materialized as a top-level variable').toBeDefined();
|
|
116
|
+
expect(topicVar!.defaultValue, 'author input becomes defaultValue').toBe('sales');
|
|
117
|
+
expect(topicVar!.type, 'type copied from the parameter schema').toBe('string');
|
|
118
|
+
|
|
119
|
+
// prompt token → {{varName}} + source:"variable"
|
|
120
|
+
expect((out.nodes[0].config as { systemPrompt: string }).systemPrompt).toBe('Hello {{topic}}');
|
|
121
|
+
expect(out.promptVariables.find((p) => p.name === 'topic')?.source).toBe('variable');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('a whole-value input token becomes a variable-sourced PortValue (WCP2 raw-typed)', () => {
|
|
125
|
+
const c = chain(
|
|
126
|
+
[{ id: 'n1', typeId: 'core.transform', inputs: { limit: '{{params.count}}' } }],
|
|
127
|
+
{ properties: { count: { type: 'number' } } },
|
|
128
|
+
);
|
|
129
|
+
const out = expandChainDeferred(c, deferCtx({ count: 5 }, c.parameters as never));
|
|
130
|
+
expect(
|
|
131
|
+
out.nodes[0].inputs!.limit,
|
|
132
|
+
cite('workflow-chain-packs.md §Deferred-parameter expansion', 'a whole-value {{params.x}} input rewrites to a variable-sourced PortValue, not a stringified token'),
|
|
133
|
+
).toEqual({ source: 'variable', variable: 'count' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('the bare param name is the override key in the auto-generated configurableSchema (R6)', () => {
|
|
137
|
+
const c = chain(
|
|
138
|
+
[{ id: 'n1', typeId: 'core.agent', config: { systemPrompt: '{{params.topic}}' } }],
|
|
139
|
+
{ properties: { topic: { type: 'string' } } },
|
|
140
|
+
);
|
|
141
|
+
const out = expandChainDeferred(c, deferCtx({ topic: 't' }, c.parameters as never));
|
|
142
|
+
expect(
|
|
143
|
+
out.configurableSchema.properties.topic,
|
|
144
|
+
cite('workflow-chain-packs.md §Override key + variable naming', 'the bare parameter name is the normative override key'),
|
|
145
|
+
).toEqual({ type: 'string' });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('workflow-chain-deferred: §Security sensitive-parameter MUSTs (server-free, RFC 0124)', () => {
|
|
150
|
+
const sensitiveSchema = { properties: { apiKey: { type: 'string', 'x-openwop-sensitive': true } } };
|
|
151
|
+
|
|
152
|
+
it('a sensitive param in a prompt body materializes as source:"secret" — NO plaintext default persisted', () => {
|
|
153
|
+
const c = chain(
|
|
154
|
+
[{ id: 'n1', typeId: 'core.agent', config: { systemPrompt: 'key={{params.apiKey}}' } }],
|
|
155
|
+
sensitiveSchema,
|
|
156
|
+
);
|
|
157
|
+
const out = expandChainDeferred(c, deferCtx({ apiKey: 'sk-PLAINTEXT-SECRET' }, sensitiveSchema));
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
out.promptVariables.find((p) => p.name === 'apiKey')?.source,
|
|
161
|
+
cite('workflow-chain-packs.md §Security', 'a sensitive prompt-body param MUST bind source:"secret" (not source:"variable")'),
|
|
162
|
+
).toBe('secret');
|
|
163
|
+
// The plaintext secret MUST appear NOWHERE — not as a variable defaultValue, not in the fragment.
|
|
164
|
+
expect(
|
|
165
|
+
JSON.stringify(out).includes('sk-PLAINTEXT-SECRET'),
|
|
166
|
+
cite('workflow-chain-packs.md §Security', 'the sensitive value MUST NOT be materialized into variables[] or the persisted fragment (never bagged, SR-1)'),
|
|
167
|
+
).toBe(false);
|
|
168
|
+
expect(out.variables.find((v) => v.name === 'apiKey'), 'sensitive param is NOT a plaintext top-level variable').toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('a sensitive param in a whole-value node.input FAILS CLOSED (sensitive_param_not_deferrable, 422)', () => {
|
|
172
|
+
const c = chain(
|
|
173
|
+
[{ id: 'n1', typeId: 'core.transform', inputs: { secret: '{{params.apiKey}}' } }],
|
|
174
|
+
sensitiveSchema,
|
|
175
|
+
);
|
|
176
|
+
let err: unknown;
|
|
177
|
+
try {
|
|
178
|
+
expandChainDeferred(c, deferCtx({ apiKey: 'sk-x' }, sensitiveSchema));
|
|
179
|
+
} catch (e) {
|
|
180
|
+
err = e;
|
|
181
|
+
}
|
|
182
|
+
expect(err, 'a sensitive whole-value input MUST throw, not defer').toBeInstanceOf(SensitiveParamNotDeferrableError);
|
|
183
|
+
expect(
|
|
184
|
+
(err as SensitiveParamNotDeferrableError).code,
|
|
185
|
+
cite('workflow-chain-packs.md §Security', 'a sensitive param outside a prompt body MUST fail closed with sensitive_param_not_deferrable'),
|
|
186
|
+
).toBe('sensitive_param_not_deferrable');
|
|
187
|
+
expect((err as SensitiveParamNotDeferrableError).httpStatus).toBe(422);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('a sensitive prompt-body param on a host WITHOUT secrets support FAILS CLOSED (422)', () => {
|
|
191
|
+
const c = chain(
|
|
192
|
+
[{ id: 'n1', typeId: 'core.agent', config: { systemPrompt: '{{params.apiKey}}' } }],
|
|
193
|
+
sensitiveSchema,
|
|
194
|
+
);
|
|
195
|
+
let err: unknown;
|
|
196
|
+
try {
|
|
197
|
+
expandChainDeferred(c, deferCtx({ apiKey: 'sk-x' }, sensitiveSchema, { promptVariableSource: true, secretsSupported: false }));
|
|
198
|
+
} catch (e) {
|
|
199
|
+
err = e;
|
|
200
|
+
}
|
|
201
|
+
expect(
|
|
202
|
+
err,
|
|
203
|
+
cite('workflow-chain-packs.md §Security', 'a host lacking capabilities.secrets MUST NOT plaintext-defer a sensitive param — fail closed'),
|
|
204
|
+
).toBeInstanceOf(SensitiveParamNotDeferrableError);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('negative capability: no prompts.variable source ⇒ prompt token falls back to expansion-time (G5)', () => {
|
|
208
|
+
const c = chain(
|
|
209
|
+
[{ id: 'n1', typeId: 'core.agent', config: { systemPrompt: 'Hi {{params.topic}}' } }],
|
|
210
|
+
{ properties: { topic: { type: 'string' } } },
|
|
211
|
+
);
|
|
212
|
+
const out = expandChainDeferred(
|
|
213
|
+
c,
|
|
214
|
+
deferCtx({ topic: 'sales' }, c.parameters as never, { promptVariableSource: false, secretsSupported: true }),
|
|
215
|
+
);
|
|
216
|
+
// Fallback resolves the token at expansion time — no {{topic}} slot, value inlined, still zero {{params.*}}.
|
|
217
|
+
expect((out.nodes[0].config as { systemPrompt: string }).systemPrompt).toBe('Hi sales');
|
|
218
|
+
expect(hasResidualToken({ nodes: out.nodes }), 'no residual {{params.*}} even on the fallback path').toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('workflow-chain-deferred: schema + spec surface (always-on, server-free)', () => {
|
|
223
|
+
it('capabilities.schema.json §workflowChainPacks.deferredParameters requires supported:boolean', () => {
|
|
224
|
+
const raw = readFileSync(CAPS, 'utf8');
|
|
225
|
+
// The deferredParameters block MUST parse as part of the capabilities schema.
|
|
226
|
+
expect(() => JSON.parse(raw), 'capabilities.schema.json MUST be valid JSON').not.toThrow();
|
|
227
|
+
expect(raw.includes('"deferredParameters"'), 'the deferredParameters capability block MUST exist').toBe(true);
|
|
228
|
+
expect(
|
|
229
|
+
raw.includes('sensitive_param_not_deferrable'),
|
|
230
|
+
cite('capabilities.schema.json §deferredParameters', 'the capability description MUST reference the fail-closed sensitive rule'),
|
|
231
|
+
).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('workflow-chain-pack-manifest.schema.json documents x-openwop-sensitive with the source:"secret" MUST', () => {
|
|
235
|
+
const raw = readFileSync(MANIFEST, 'utf8');
|
|
236
|
+
expect(raw.includes('x-openwop-sensitive'), 'the manifest schema MUST recognize x-openwop-sensitive').toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
raw.includes('source:"secret"') || raw.includes('source:\\"secret\\"'),
|
|
239
|
+
cite('workflow-chain-pack-manifest.schema.json', 'the x-openwop-sensitive description MUST reflect the amended source:"secret" MUST, not the stale plaintext-variable wording'),
|
|
240
|
+
).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('the spec pins the error code + the per-run credentialRef (not plaintext) supply shape', () => {
|
|
244
|
+
const spec = readFileSync(CHAIN_DOC, 'utf8');
|
|
245
|
+
expect(spec.includes('sensitive_param_not_deferrable'), 'error code MUST be documented').toBe(true);
|
|
246
|
+
expect(
|
|
247
|
+
spec.includes('credentialRef'),
|
|
248
|
+
cite('workflow-chain-packs.md §Security', 'per-run supply of a sensitive param MUST be a credentialRef, not plaintext'),
|
|
249
|
+
).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('workflow-chain-deferred: host behavior (capability-gated, RFC 0124)', () => {
|
|
254
|
+
it('a deferred round-trip: bare-param configurable override changes the value; :fork replays it', async () => {
|
|
255
|
+
const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
|
|
256
|
+
if (!behaviorGate('workflowChainPacks.deferredParameters.supported', wcp?.deferredParameters?.supported === true)) return;
|
|
257
|
+
|
|
258
|
+
const res = await driver.post('/v1/host/sample/chain/deferred-expand', {
|
|
259
|
+
chainId: 'conformance.deferred',
|
|
260
|
+
params: { topic: 'default-topic' },
|
|
261
|
+
override: { topic: 'run-topic' },
|
|
262
|
+
fork: true,
|
|
263
|
+
});
|
|
264
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
265
|
+
|
|
266
|
+
const body = res.json as { resolved?: string; forkResolved?: string; contentTrust?: string } | undefined;
|
|
267
|
+
expect(
|
|
268
|
+
body?.resolved,
|
|
269
|
+
cite('workflow-chain-packs.md §Deferred-parameter expansion', 'a bare-param configurable override rebinds the resolved value'),
|
|
270
|
+
).toBe('run-topic');
|
|
271
|
+
expect(
|
|
272
|
+
body?.forkResolved,
|
|
273
|
+
cite('replay.md §Determinism', ':fork replays the same bound value (RunSnapshot.variables byte-equivalence, R4)'),
|
|
274
|
+
).toBe('run-topic');
|
|
275
|
+
expect(
|
|
276
|
+
body?.contentTrust,
|
|
277
|
+
cite('workflow-chain-packs.md §Deferred-parameter expansion step 4', 'a deferred-variable prompt binding composes contentTrust:"untrusted" (R1)'),
|
|
278
|
+
).toBe('untrusted');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('a sensitive param composes as [REDACTED:<credentialRef>] and the plaintext appears nowhere', async () => {
|
|
282
|
+
const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
|
|
283
|
+
if (!behaviorGate('workflowChainPacks.deferredParameters.supported', wcp?.deferredParameters?.supported === true)) return;
|
|
284
|
+
|
|
285
|
+
const res = await driver.post('/v1/host/sample/chain/deferred-expand', {
|
|
286
|
+
chainId: 'conformance.deferred-sensitive',
|
|
287
|
+
sensitiveParam: 'apiKey',
|
|
288
|
+
credentialRef: 'cred-123',
|
|
289
|
+
});
|
|
290
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
291
|
+
|
|
292
|
+
const body = res.json as { composed?: string } | undefined;
|
|
293
|
+
expect(
|
|
294
|
+
body?.composed?.includes('[REDACTED'),
|
|
295
|
+
cite('workflow-chain-packs.md §Security', 'a source:"secret" sensitive var MUST redact to [REDACTED:<credentialRef>] in prompt.composed'),
|
|
296
|
+
).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -307,6 +307,55 @@ describe('category: workflow-chain expansion — edge rewriting', () => {
|
|
|
307
307
|
expect(at(fragment.edges, 1, 'fragment.edges').from).toBe('vendor_acme_twoStep_e3_second');
|
|
308
308
|
expect(at(fragment.edges, 1, 'fragment.edges').to).toBe('parent-downstream');
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
it('preserves edge `condition` and `triggerRule` onto the expanded WorkflowEdge (RFC 0013 amendment #818 + RFC 0125)', () => {
|
|
312
|
+
// A fragment edge carrying content-routing (`condition`) + fan-in
|
|
313
|
+
// (`triggerRule`) MUST survive expansion — otherwise the scheduler never
|
|
314
|
+
// sees them and the fields are silently ignored (the failure mode the
|
|
315
|
+
// RFC 0013 `condition` amendment and RFC 0125 both call out).
|
|
316
|
+
const withEdgeFields: WorkflowChain = {
|
|
317
|
+
...MULTI_NODE,
|
|
318
|
+
dag: {
|
|
319
|
+
...MULTI_NODE.dag,
|
|
320
|
+
edges: [
|
|
321
|
+
{
|
|
322
|
+
from: 'first',
|
|
323
|
+
to: 'second',
|
|
324
|
+
condition: { type: 'equals', left: 'status', right: 'ok' },
|
|
325
|
+
triggerRule: 'all_complete',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const fragment = expandChain(withEdgeFields, {
|
|
331
|
+
expansionId: 'e4',
|
|
332
|
+
params: {},
|
|
333
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
334
|
+
});
|
|
335
|
+
const edge = at(fragment.edges, 0, 'fragment.edges');
|
|
336
|
+
expect(edge.from).toBe('vendor_acme_twoStep_e4_first');
|
|
337
|
+
expect(edge.to).toBe('vendor_acme_twoStep_e4_second');
|
|
338
|
+
expect(
|
|
339
|
+
edge.condition,
|
|
340
|
+
'Per RFC 0013 amendment (2026-07-03): a present `FragmentEdge.condition` MUST be carried onto the expanded WorkflowEdge (not dropped at expansion).',
|
|
341
|
+
).toEqual({ type: 'equals', left: 'status', right: 'ok' });
|
|
342
|
+
expect(
|
|
343
|
+
edge.triggerRule,
|
|
344
|
+
'Per RFC 0125 / workflow-chain-packs.md §"Expansion semantics": expansion MUST preserve `FragmentEdge.triggerRule` onto the resulting WorkflowEdge so the scheduler honors the fan-in / error-routing rule.',
|
|
345
|
+
).toBe('all_complete');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('omits triggerRule on the expanded edge when the fragment edge declares none (default all_success semantics)', () => {
|
|
349
|
+
const fragment = expandChain(MULTI_NODE, {
|
|
350
|
+
expansionId: 'e5',
|
|
351
|
+
params: {},
|
|
352
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
353
|
+
});
|
|
354
|
+
expect(
|
|
355
|
+
at(fragment.edges, 0, 'fragment.edges').triggerRule,
|
|
356
|
+
'When a fragment edge declares no `triggerRule`, the expanded edge MUST NOT carry one — omission is identical to the `all_success` default (preserves wire-shape minimality + prior behavior).',
|
|
357
|
+
).toBeUndefined();
|
|
358
|
+
});
|
|
310
359
|
});
|
|
311
360
|
|
|
312
361
|
describe('category: workflow-chain expansion — capability propagation', () => {
|
|
@@ -1,52 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workflow-chain pack expansion — live-host gate (RFC 0013 Phase 3).
|
|
3
3
|
*
|
|
4
|
-
* Capability-gated scenario.
|
|
5
|
-
* `capabilities.workflowChainPacks.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Capability-gated scenario. **RFC 0013 erratum (2026-07-05):** gates on the
|
|
5
|
+
* OPTIONAL test-seam sub-flag `capabilities.workflowChainPacks.hostExpansionSeam:
|
|
6
|
+
* true` — NOT on the semantic `workflowChainPacks.supported` claim, which is
|
|
7
|
+
* witnessed server-free by `workflow-chain-expansion.test.ts`. This decouples a
|
|
8
|
+
* host's honest `supported` / RFC 0124 `deferredParameters` advertisement from
|
|
9
|
+
* this scenario's `vendor.openwop.workflow-chain-sample` fixture.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* **A-lite follow-up (2026-07-05):** the `vendor.openwop.workflow-chain-sample`
|
|
12
|
+
* pack is now **bundled into the conformance package** at
|
|
13
|
+
* `fixtures/pack-manifests/workflow-chain-sample.pack.json` (host-syncable), and
|
|
14
|
+
* this scenario **LOADS it + derives the expected expansion from the
|
|
15
|
+
* spec-authoritative reference library** (`expandChain()`) instead of hardcoding
|
|
16
|
+
* expected strings — so the published pack is the single source of truth and the
|
|
17
|
+
* assertions can't drift from it. A serving host resolves the SAME bundled pack
|
|
18
|
+
* and MUST produce the SAME expansion the reference library computes.
|
|
19
|
+
*
|
|
20
|
+
* Asserts the host's vendor-prefixed expansion endpoint (`POST /v1/host/sample/
|
|
21
|
+
* workflow-chain:expand` — vendor prefix per `host-extensions.md` §"Canonical
|
|
22
|
+
* prefixes") returns expanded fragments equivalent to `expandChain()` for the
|
|
23
|
+
* same pack + parameters + host-chosen `expansionId`.
|
|
19
24
|
*
|
|
20
25
|
* Coverage:
|
|
21
|
-
* 1. Discovery advertises
|
|
22
|
-
* 2. Positive — 1-node chain expands;
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* reference the rewritten ids.
|
|
26
|
+
* 1. Discovery advertises `hostExpansionSeam` (precondition for the rest).
|
|
27
|
+
* 2. Positive — 1-node chain expands; host output == reference expansion
|
|
28
|
+
* (substituted config + rewritten id + propagated `cacheable`).
|
|
29
|
+
* 3. Positive — 2-node chain with edges expands; host output == reference
|
|
30
|
+
* expansion (rewritten edge endpoints + propagated `side-effectful`).
|
|
27
31
|
* 4. Negative — unknown packName → 404 `pack_not_found`.
|
|
28
32
|
* 5. Negative — known pack, unknown chainId → 404 `chain_not_found`.
|
|
29
33
|
* 6. Negative — malformed body (no chainId) → 422 `invalid_request`.
|
|
30
34
|
*
|
|
31
35
|
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics (normative)"
|
|
32
|
-
* @see
|
|
36
|
+
* @see conformance/src/lib/workflow-chain-expansion.ts (the reference library)
|
|
33
37
|
* @see RFCS/0013-workflow-chain-packs.md (Phase 3)
|
|
34
38
|
*/
|
|
35
39
|
|
|
36
40
|
import { describe, it, expect } from 'vitest';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
37
43
|
|
|
38
44
|
import { driver } from '../lib/driver.js';
|
|
39
45
|
import { loadEnv } from '../lib/env.js';
|
|
40
46
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
47
|
+
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
48
|
+
import { expandChain, type WorkflowChain } from '../lib/workflow-chain-expansion.js';
|
|
49
|
+
|
|
50
|
+
const PROFILE = 'workflowChainPacks.hostExpansionSeam';
|
|
51
|
+
const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
// The pack fixture is bundled with the conformance package (ships in `files`),
|
|
54
|
+
// so a host can sync the IDENTICAL pack and this scenario loads it as the
|
|
55
|
+
// contract source of truth (no hardcoded expansion).
|
|
56
|
+
interface SamplePack {
|
|
57
|
+
name: string;
|
|
58
|
+
version: string;
|
|
59
|
+
chains: Array<WorkflowChain>;
|
|
60
|
+
}
|
|
61
|
+
const PACK = JSON.parse(
|
|
62
|
+
readFileSync(join(FIXTURES_DIR, 'pack-manifests', 'workflow-chain-sample.pack.json'), 'utf8'),
|
|
63
|
+
) as SamplePack;
|
|
64
|
+
const SAMPLE_PACK = PACK.name; // vendor.openwop.workflow-chain-sample
|
|
44
65
|
const CHAIN_1_NODE = 'vendor.openwop.workflow-chain-sample.summarize-text';
|
|
45
66
|
const CHAIN_2_NODE = 'vendor.openwop.workflow-chain-sample.fetch-and-summarize';
|
|
46
|
-
|
|
67
|
+
|
|
68
|
+
function chainById(chainId: string): WorkflowChain {
|
|
69
|
+
const c = PACK.chains.find((x) => x.chainId === chainId);
|
|
70
|
+
if (!c) throw new Error(`fixture missing chain ${chainId}`);
|
|
71
|
+
return c;
|
|
72
|
+
}
|
|
47
73
|
|
|
48
74
|
interface ChainCaps {
|
|
49
75
|
supported?: boolean;
|
|
76
|
+
hostExpansionSeam?: boolean;
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
async function isExpansionAdvertised(): Promise<boolean> {
|
|
@@ -54,11 +81,20 @@ async function isExpansionAdvertised(): Promise<boolean> {
|
|
|
54
81
|
const caps =
|
|
55
82
|
(disco.json as { capabilities?: { workflowChainPacks?: ChainCaps } }).capabilities
|
|
56
83
|
?.workflowChainPacks ?? {};
|
|
57
|
-
return caps.
|
|
84
|
+
return caps.hostExpansionSeam === true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ExpandResponse {
|
|
88
|
+
expansionId: string;
|
|
89
|
+
chainId: string;
|
|
90
|
+
packName: string;
|
|
91
|
+
packVersion: string;
|
|
92
|
+
nodes: Array<{ id: string; typeId: string; config?: Record<string, unknown>; capabilities?: string[] }>;
|
|
93
|
+
edges: Array<{ from: string; to: string }>;
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
describe('workflow-chain-host-expansion: live host wraps expansion algorithm correctly', () => {
|
|
61
|
-
it('host discovery advertises workflowChainPacks.
|
|
97
|
+
it('host discovery advertises workflowChainPacks.hostExpansionSeam when the expand seam is served', async () => {
|
|
62
98
|
loadEnv();
|
|
63
99
|
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
64
100
|
|
|
@@ -69,99 +105,89 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
|
|
|
69
105
|
caps,
|
|
70
106
|
driver.describe(
|
|
71
107
|
'capabilities.md §workflowChainPacks',
|
|
72
|
-
'host
|
|
108
|
+
'a host serving the RFC 0013 host-expansion test seam MUST set `hostExpansionSeam: true` (and, being a chain-pack consumer, `supported: true`) in the discovery block',
|
|
73
109
|
),
|
|
74
110
|
).toBeDefined();
|
|
111
|
+
expect(caps?.hostExpansionSeam).toBe(true);
|
|
75
112
|
expect(caps?.supported).toBe(true);
|
|
76
113
|
});
|
|
77
114
|
|
|
78
|
-
it('positive — 1-node chain expansion
|
|
115
|
+
it('positive — 1-node chain expansion matches the reference library for the bundled pack', async () => {
|
|
79
116
|
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
80
117
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
sourceText: 'The quick brown fox jumps over the lazy dog.',
|
|
86
|
-
targetLength: 'one-sentence',
|
|
87
|
-
tone: 'casual',
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
expect(res.status).toBe(200);
|
|
92
|
-
const body = res.json as {
|
|
93
|
-
expansionId: string;
|
|
94
|
-
chainId: string;
|
|
95
|
-
packName: string;
|
|
96
|
-
packVersion: string;
|
|
97
|
-
nodes: Array<{
|
|
98
|
-
id: string;
|
|
99
|
-
typeId: string;
|
|
100
|
-
config?: { systemPrompt?: string };
|
|
101
|
-
capabilities?: string[];
|
|
102
|
-
}>;
|
|
103
|
-
edges: Array<unknown>;
|
|
118
|
+
const parameters = {
|
|
119
|
+
sourceText: 'The quick brown fox jumps over the lazy dog.',
|
|
120
|
+
targetLength: 'one-sentence',
|
|
121
|
+
tone: 'casual',
|
|
104
122
|
};
|
|
123
|
+
const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_1_NODE, parameters });
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
const body = res.json as ExpandResponse;
|
|
105
126
|
|
|
106
127
|
expect(body.chainId).toBe(CHAIN_1_NODE);
|
|
107
128
|
expect(body.packName).toBe(SAMPLE_PACK);
|
|
108
|
-
expect(body.packVersion).toBe(
|
|
109
|
-
expect(body.nodes).toHaveLength(1);
|
|
110
|
-
expect(body.edges).toHaveLength(0);
|
|
129
|
+
expect(body.packVersion).toBe(PACK.version);
|
|
111
130
|
expect(typeof body.expansionId).toBe('string');
|
|
112
131
|
expect(body.expansionId.length).toBeGreaterThan(0);
|
|
113
132
|
|
|
133
|
+
// Derive the expected fragment from the reference library using the HOST's
|
|
134
|
+
// own expansionId — the host MUST reproduce the same algorithm output.
|
|
135
|
+
const expected = expandChain(chainById(CHAIN_1_NODE), {
|
|
136
|
+
expansionId: body.expansionId,
|
|
137
|
+
params: parameters,
|
|
138
|
+
isTypeIdResolvable: () => true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(body.nodes).toHaveLength(expected.nodes.length);
|
|
142
|
+
expect(body.edges).toHaveLength(expected.edges.length);
|
|
143
|
+
|
|
114
144
|
const node = body.nodes[0]!;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
);
|
|
120
|
-
expect(node.typeId).toBe(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
expect(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
expect(node.capabilities).toEqual(['cacheable']);
|
|
145
|
+
const ref = expected.nodes[0]!;
|
|
146
|
+
expect(
|
|
147
|
+
node.id,
|
|
148
|
+
driver.describe('workflow-chain-packs.md §Expansion semantics', 'host rewrites the node id exactly as the reference library (chainId dots → underscores + expansionId prefix)'),
|
|
149
|
+
).toBe(ref.id);
|
|
150
|
+
expect(node.typeId).toBe(ref.typeId);
|
|
151
|
+
expect(
|
|
152
|
+
node.config?.systemPrompt,
|
|
153
|
+
driver.describe('workflow-chain-packs.md §Expansion semantics', 'host performs the same literal {{params.*}} substitution as the reference library'),
|
|
154
|
+
).toBe((ref.config as { systemPrompt?: string } | undefined)?.systemPrompt);
|
|
155
|
+
expect(
|
|
156
|
+
node.capabilities,
|
|
157
|
+
driver.describe('workflow-chain-packs.md §Capability propagation', 'chain capabilities propagate to the expanded node'),
|
|
158
|
+
).toEqual(ref.capabilities);
|
|
130
159
|
});
|
|
131
160
|
|
|
132
|
-
it('positive — 2-node chain
|
|
161
|
+
it('positive — 2-node chain matches the reference library (edge rewrite + capability propagation)', async () => {
|
|
133
162
|
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
134
163
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
chainId: CHAIN_2_NODE,
|
|
138
|
-
parameters: {
|
|
139
|
-
url: 'https://example.com/article',
|
|
140
|
-
targetLength: 'executive-summary',
|
|
141
|
-
},
|
|
142
|
-
});
|
|
164
|
+
const parameters = { url: 'https://example.com/article', targetLength: 'executive-summary' };
|
|
165
|
+
const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_2_NODE, parameters });
|
|
143
166
|
expect(res.status).toBe(200);
|
|
167
|
+
const body = res.json as ExpandResponse;
|
|
144
168
|
|
|
145
|
-
const
|
|
146
|
-
expansionId:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
expect(body.
|
|
169
|
+
const expected = expandChain(chainById(CHAIN_2_NODE), {
|
|
170
|
+
expansionId: body.expansionId,
|
|
171
|
+
params: parameters,
|
|
172
|
+
isTypeIdResolvable: () => true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(body.nodes).toHaveLength(expected.nodes.length); // 2
|
|
176
|
+
expect(body.edges).toHaveLength(expected.edges.length); // 1
|
|
152
177
|
|
|
153
|
-
// Both expanded nodes get the same prefix; the edge's `from`/`to`
|
|
154
|
-
// refer to fragment node ids and so get rewritten with the same
|
|
155
|
-
// prefix (port suffix preserved).
|
|
156
178
|
const edge = body.edges[0]!;
|
|
157
|
-
const
|
|
158
|
-
expect(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
const refEdge = expected.edges[0]!;
|
|
180
|
+
expect(
|
|
181
|
+
{ from: edge.from, to: edge.to },
|
|
182
|
+
driver.describe('workflow-chain-packs.md §Expansion semantics', 'host rewrites fragment-internal edge endpoints (port suffix preserved) exactly as the reference library'),
|
|
183
|
+
).toEqual({ from: refEdge.from, to: refEdge.to });
|
|
184
|
+
|
|
185
|
+
// side-effectful capability propagated to BOTH expanded nodes (per the ref).
|
|
186
|
+
const refCaps = expected.nodes.map((n) => n.capabilities);
|
|
187
|
+
expect(
|
|
188
|
+
body.nodes.map((n) => n.capabilities),
|
|
189
|
+
driver.describe('workflow-chain-packs.md §Capability propagation', 'chain capability propagates uniformly to every expanded node'),
|
|
190
|
+
).toEqual(refCaps);
|
|
165
191
|
});
|
|
166
192
|
|
|
167
193
|
it('negative — unknown pack returns 404 pack_not_found', async () => {
|
|
@@ -192,10 +218,7 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
|
|
|
192
218
|
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
193
219
|
|
|
194
220
|
// Missing chainId.
|
|
195
|
-
const res = await driver.post(EXPAND_PATH, {
|
|
196
|
-
packName: SAMPLE_PACK,
|
|
197
|
-
parameters: {},
|
|
198
|
-
});
|
|
221
|
+
const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, parameters: {} });
|
|
199
222
|
expect(res.status).toBe(422);
|
|
200
223
|
expect((res.json as { error: string }).error).toBe('invalid_request');
|
|
201
224
|
});
|