@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.
@@ -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. Skips when the host doesn't advertise
5
- * `capabilities.workflowChainPacks.supported: true`. Asserts the host's
6
- * vendor-prefixed expansion endpoint (`POST /v1/host/sample/workflow-
7
- * chain:expand` vendor prefix per `host-extensions.md` §"Canonical
8
- * prefixes") returns expanded fragments equivalent to the spec-
9
- * authoritative `expandChain()` reference library.
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
- * Why this exists: the four server-free chain scenarios
12
- * (manifest-validation, signature-verification, expansion,
13
- * unresolvable-typeid) cover the pure logic. This scenario proves a
14
- * reference host wraps the algorithm correctly fetch + verify +
15
- * locate + expand and emits the same wire shape any consumer
16
- * implementing the spec would. Without it, the RFC's "reference host
17
- * implements expansion" acceptance criterion cannot be verified
18
- * end-to-end against an actual deployment.
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 the capability (precondition for the rest).
22
- * 2. Positive — 1-node chain expands; substituted config + rewritten
23
- * id + propagated capabilities match the pure-library output for
24
- * the same input.
25
- * 3. Positive 2-node chain with edges expands; edge endpoints
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 capabilities.md §workflowChainPacks
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
- const PROFILE = 'workflowChainPacks';
43
- const SAMPLE_PACK = 'vendor.openwop.workflow-chain-sample';
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
- const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
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.supported === true;
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.supported when expansion is implemented', async () => {
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 advertising the capability MUST set `supported: true` in the discovery block',
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 via the host returns substituted config + rewritten id', async () => {
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 res = await driver.post(EXPAND_PATH, {
82
- packName: SAMPLE_PACK,
83
- chainId: CHAIN_1_NODE,
84
- parameters: {
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('1.0.0');
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
- // Step 6: id rewriting — chainId's dots become underscores +
116
- // expansionId suffix + original fragment id.
117
- expect(node.id).toMatch(
118
- /^vendor_openwop_workflow-chain-sample_summarize-text_[a-f0-9]+_summarize-call$/,
119
- );
120
- expect(node.typeId).toBe('core.ai.callPrompt');
121
-
122
- // Step 5: literal substitution.
123
- const sysPrompt = node.config?.systemPrompt ?? '';
124
- expect(sysPrompt).toContain('a one-sentence summary');
125
- expect(sysPrompt).toContain('a casual tone');
126
- expect(sysPrompt).toContain('The quick brown fox jumps over the lazy dog.');
127
-
128
- // Step 8: capability propagation.
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 with edges expands with rewritten edge endpoints', async () => {
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 res = await driver.post(EXPAND_PATH, {
136
- packName: SAMPLE_PACK,
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 body = res.json as {
146
- expansionId: string;
147
- nodes: Array<{ id: string; typeId: string; capabilities?: string[] }>;
148
- edges: Array<{ from: string; to: string }>;
149
- };
150
- expect(body.nodes).toHaveLength(2);
151
- expect(body.edges).toHaveLength(1);
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 prefix = `vendor_openwop_workflow-chain-sample_fetch-and-summarize_${body.expansionId}_`;
158
- expect(edge.from).toBe(`${prefix}fetch.body`);
159
- expect(edge.to).toBe(`${prefix}summarize.sourceText`);
160
-
161
- // side-effectful capability propagated to BOTH expanded nodes.
162
- for (const node of body.nodes) {
163
- expect(node.capabilities, `node ${node.id} inherits chain capability`).toEqual(['side-effectful']);
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
  });