@openwop/openwop-conformance 1.47.0 → 1.51.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,12 +1,18 @@
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 (never bundled
10
+ * into `@openwop/openwop-conformance`, so no host has been handed it — the
11
+ * scenario soft-skips everywhere until a serving host advertises the seam).
12
+ * Asserts the host's vendor-prefixed expansion endpoint (`POST /v1/host/sample/
13
+ * workflow-chain:expand` — vendor prefix per `host-extensions.md` §"Canonical
14
+ * prefixes") returns expanded fragments equivalent to the spec-authoritative
15
+ * `expandChain()` reference library.
10
16
  *
11
17
  * Why this exists: the four server-free chain scenarios
12
18
  * (manifest-validation, signature-verification, expansion,
@@ -39,7 +45,7 @@ import { driver } from '../lib/driver.js';
39
45
  import { loadEnv } from '../lib/env.js';
40
46
  import { behaviorGate } from '../lib/behavior-gate.js';
41
47
 
42
- const PROFILE = 'workflowChainPacks';
48
+ const PROFILE = 'workflowChainPacks.hostExpansionSeam';
43
49
  const SAMPLE_PACK = 'vendor.openwop.workflow-chain-sample';
44
50
  const CHAIN_1_NODE = 'vendor.openwop.workflow-chain-sample.summarize-text';
45
51
  const CHAIN_2_NODE = 'vendor.openwop.workflow-chain-sample.fetch-and-summarize';
@@ -47,18 +53,25 @@ const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
47
53
 
48
54
  interface ChainCaps {
49
55
  supported?: boolean;
56
+ hostExpansionSeam?: boolean;
50
57
  }
51
58
 
59
+ // RFC 0013 erratum (2026-07-05): this live-host scenario witnesses the OPTIONAL
60
+ // `POST /v1/host/sample/workflow-chain:expand` TEST SEAM, gated on its own
61
+ // `workflowChainPacks.hostExpansionSeam` sub-flag — NOT on the semantic
62
+ // `workflowChainPacks.supported` claim (which is witnessed server-free by
63
+ // `workflow-chain-expansion.test.ts`). This decouples the RFC 0124
64
+ // `deferredParameters` flip from the unpublished RFC 0013 sample-pack fixture.
52
65
  async function isExpansionAdvertised(): Promise<boolean> {
53
66
  const disco = await driver.get('/.well-known/openwop');
54
67
  const caps =
55
68
  (disco.json as { capabilities?: { workflowChainPacks?: ChainCaps } }).capabilities
56
69
  ?.workflowChainPacks ?? {};
57
- return caps.supported === true;
70
+ return caps.hostExpansionSeam === true;
58
71
  }
59
72
 
60
73
  describe('workflow-chain-host-expansion: live host wraps expansion algorithm correctly', () => {
61
- it('host discovery advertises workflowChainPacks.supported when expansion is implemented', async () => {
74
+ it('host discovery advertises workflowChainPacks.hostExpansionSeam when the expand seam is served', async () => {
62
75
  loadEnv();
63
76
  if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
64
77
 
@@ -69,9 +82,10 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
69
82
  caps,
70
83
  driver.describe(
71
84
  'capabilities.md §workflowChainPacks',
72
- 'host advertising the capability MUST set `supported: true` in the discovery block',
85
+ '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
86
  ),
74
87
  ).toBeDefined();
88
+ expect(caps?.hostExpansionSeam).toBe(true);
75
89
  expect(caps?.supported).toBe(true);
76
90
  });
77
91
 
@@ -104,6 +104,65 @@ describe('category: workflow-chain-pack manifest validation', () => {
104
104
  ).toBe(true);
105
105
  });
106
106
 
107
+ it('positive: a FragmentEdge condition takes the top-level EdgeCondition shape (RFC 0013 safety-fix 2026-07-03)', () => {
108
+ // §edges: "Same shape as in a top-level workflow definition." An edge
109
+ // condition therefore MUST be the EdgeCondition object {type,left,right},
110
+ // not a bare string — this is what lets a chain express content routing.
111
+ const conditionalChain = {
112
+ name: 'vendor.acme.router',
113
+ version: '1.0.0',
114
+ kind: 'workflow-chain',
115
+ engines: { openwop: '>=1.0.0' },
116
+ chains: [
117
+ {
118
+ chainId: 'vendor.acme.route',
119
+ version: '1.0.0',
120
+ label: 'Route',
121
+ description: 'Router with a conditional branch.',
122
+ parameters: { type: 'object', properties: {} },
123
+ dag: {
124
+ nodes: [
125
+ { id: 'route', typeId: 'core.flow.router' },
126
+ { id: 'urgent', typeId: 'core.identity' },
127
+ ],
128
+ edges: [
129
+ { from: 'route', to: 'urgent', condition: { type: 'contains', left: 'branches', right: 'urgent' } },
130
+ ],
131
+ },
132
+ },
133
+ ],
134
+ };
135
+ const ok = validate(conditionalChain);
136
+ const errs = (validate.errors ?? []).map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`).join('\n');
137
+ expect(ok, `Object-shaped edge condition MUST validate — got:\n${errs}`).toBe(true);
138
+ });
139
+
140
+ it('negative: a bare-string FragmentEdge condition is rejected (the pre-2026-07-03 shape)', () => {
141
+ const stringCondition = {
142
+ name: 'vendor.acme.legacy',
143
+ version: '1.0.0',
144
+ kind: 'workflow-chain',
145
+ engines: { openwop: '>=1.0.0' },
146
+ chains: [
147
+ {
148
+ chainId: 'vendor.acme.legacy',
149
+ version: '1.0.0',
150
+ label: 'Legacy',
151
+ description: 'x',
152
+ parameters: { type: 'object', properties: {} },
153
+ dag: {
154
+ nodes: [
155
+ { id: 'a', typeId: 'core.identity' },
156
+ { id: 'b', typeId: 'core.identity' },
157
+ ],
158
+ edges: [{ from: 'a', to: 'b', condition: 'output.approved == true' }],
159
+ },
160
+ },
161
+ ],
162
+ };
163
+ expect(validate(stringCondition), 'A string edge condition MUST NOT validate under the corrected schema').toBe(false);
164
+ });
165
+
107
166
  it('negative: manifest mixing chains[] AND nodes[] is rejected (pack_kind_invalid)', () => {
108
167
  // Per workflow-chain-packs.md §Pack kind discriminator: "Manifests MUST
109
168
  // have exactly one of nodes[] (kind=node) OR chains[] (kind=workflow-chain).