@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.
@@ -1869,6 +1869,22 @@
1869
1869
  "supported": {
1870
1870
  "type": "boolean",
1871
1871
  "description": "Whether the host's workflow editor implements chain expansion at author time. `false` (or omission) signals the host does NOT consume workflow-chain packs."
1872
+ },
1873
+ "deferredParameters": {
1874
+ "type": "object",
1875
+ "description": "RFC 0124 (WCP4, `Active`). OPTIONAL. When `supported: true`, the host offers a capability-gated deferred-parameter expansion mode in ADDITION to expansion-time substitution (which remains the default and floor): at drop time it materializes the chain's `parameters` into top-level workflow `variables[]` (author value as `defaultValue`) and rewrites `{{params.<name>}}` into a spec'd runtime binding (PromptTemplate `{{varName}}` with `source:\"variable\"`, or a variable-sourced PortValue), so chain parameters stay overridable per run via `configurable` while the persisted definition keeps ZERO `{{params.*}}` tokens. A parameter marked `x-openwop-sensitive` MUST be deferred or the expansion fail-closed (`sensitive_param_not_deferrable`, 422) — never frozen into persisted `config`. Omission (or `supported:false`) signals the host does NOT offer deferred mode. No host may advertise `supported:true` until RFC 0124 is `Accepted`. See `workflow-chain-packs.md` §\"Deferred-parameter expansion\".",
1876
+ "properties": {
1877
+ "supported": {
1878
+ "type": "boolean",
1879
+ "description": "Whether the host offers the deferred-parameter expansion mode. Requires `capabilities.prompts.supported: true` with `variable` in `prompts.variableSources` for the prompt-bearing rewrite path."
1880
+ }
1881
+ },
1882
+ "required": ["supported"],
1883
+ "additionalProperties": false
1884
+ },
1885
+ "hostExpansionSeam": {
1886
+ "type": "boolean",
1887
+ "description": "RFC 0013 erratum (2026-07-05). OPTIONAL. A **conformance-only test seam** advertisement (category: test harness, cf. `observability.testSeams` — NOT a product capability): when `true`, the host serves `POST /v1/host/sample/workflow-chain:expand` returning the `vendor.openwop.workflow-chain-sample` v1.0.0 expansion that `conformance/src/scenarios/workflow-chain-host-expansion.test.ts` asserts against. Absent/`false` ⇒ that live-host expansion scenario soft-skips; the semantic `workflowChainPacks.supported` claim is witnessed by the server-free `workflow-chain-expansion.test.ts` legs. This flag is DISTINCT from `deferredParameters` (RFC 0124), which is witnessed through its own `POST /v1/host/sample/chain/deferred-expand` seam — so a host MAY advertise `supported` / `deferredParameters.supported` without standing up the RFC 0013 sample-pack seam it was never handed a published fixture for. Advertising `supported:true` no longer conscripts a host into the RFC 0013 host-expansion scenario."
1872
1888
  }
1873
1889
  },
1874
1890
  "required": ["supported"],
@@ -23,6 +23,11 @@
23
23
  },
24
24
  "description": "Ordered list of worker node-ids OR agent-ids to dispatch. Hosts that interpret entries as node-ids dispatch in DAG order; hosts that interpret as agent-ids resolve to nodes via the run's static DAG."
25
25
  },
26
+ "nextWorkerInputs": {
27
+ "type": "array",
28
+ "items": { "type": "object" },
29
+ "description": "RFC 0126 (data-parallel fan-out). OPTIONAL, index-aligned with `nextWorkerIds`: `nextWorkerInputs[i]` is a per-child input object (child input variable names → values) projected into the child dispatched for `nextWorkerIds[i]`, over the RFC 0022 `inputMapping`/`perWorkerInputMappings` projection (per-item value WINS on key collision). Enables fanning ONE `childWorkflowId` over N runtime items with distinct inputs (the map-over-collection pattern). When present, `nextWorkerInputs.length` MUST equal `nextWorkerIds.length` (a host MUST fail the dispatch node with `validation_error` and dispatch NO child otherwise — length is not JSON-Schema-enforceable, so this is a runtime MUST). Gated on `capabilities.dispatch.perItemInput`: a host NOT advertising it, upon receiving a non-empty `nextWorkerInputs`, MUST fail closed with `validation_error` and MUST NOT silently drop-and-dispatch N identical children (the correctness/spend hazard this closes). Replay-safe: rides the recorded `runOrchestrator.decided` event, re-read verbatim on `:fork`, never recomputed (frozen at decision time). A `sensitive`/secret per-item value MUST NOT appear here in plaintext (use the secret channel). See `node-packs.md` §core.dispatch."
30
+ },
26
31
  "confidence": {
27
32
  "type": "number",
28
33
  "minimum": 0,
@@ -88,7 +88,7 @@
88
88
  },
89
89
  "parameters": {
90
90
  "type": "object",
91
- "description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`.",
91
+ "description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`. RECOGNIZED EXTENSION KEY (RFC 0124 / WCP4): a property MAY carry `x-openwop-sensitive: true` to declare the parameter secret-class. A host that recognizes it MUST NOT expansion-time-freeze that parameter (plaintext secret-at-rest leak, SR-1) and MUST NOT materialize it as a plaintext `source:\"variable\"` binding (the value would land in the run-scoped bag / `RunSnapshot.variables` — the same SR-1 leak one layer down). Instead, per the 2026-07-04 §Security amendment: it is deferrable ONLY in a prompt-body position, where it MUST be materialized as a `source:\"secret\"` `PromptVariable` (BYOK-resolved via `capabilities.secrets`, redacted to `[REDACTED:<secretId>]` in `prompt.composed`, never bagged); in ANY other position (whole-value `node.inputs`, embedded non-prompt `config`, or a host lacking deferred / `secrets` support) it MUST fail closed with `sensitive_param_not_deferrable` (422). Per-run supply is a `credentialRef` secret reference, never plaintext. See `workflow-chain-packs.md` §\"Deferred-parameter expansion\" / RFC 0124 §Security.",
92
92
  "additionalProperties": true,
93
93
  "$comment": "Open by design — this field IS a JSON Schema document, so it must accept any of the 30+ JSON Schema 2020-12 keywords (`type`, `properties`, `required`, `oneOf`, `allOf`, etc.). Strict closure would require importing the JSON Schema meta-schema."
94
94
  },
@@ -197,12 +197,32 @@
197
197
  "description": "Target node id (must reference a node in `nodes[]`). MAY use `nodeId.inputPort` syntax."
198
198
  },
199
199
  "condition": {
200
+ "$ref": "#/$defs/EdgeCondition",
201
+ "description": "Optional edge condition — the SAME shape as a top-level workflow edge's condition (workflow-definition.schema.json §EdgeCondition). When present, the edge contributes to the target only if the condition holds against the source node's output, letting a chain express content routing (router/switch/conditional branches). Safety-fix (RFC 0013 amendment 2026-07-03): the field was previously typed `string`, contradicting this description; no chain used the string form (hosts dropped the field at expansion), so correcting it to the object shape breaks no conformant behavior."
202
+ },
203
+ "triggerRule": {
200
204
  "type": "string",
201
- "description": "Optional edge condition expression — same shape as a top-level workflow edge's condition."
205
+ "enum": ["all_success", "any_success", "all_complete", "none_failed", "any_failed"],
206
+ "default": "all_success",
207
+ "description": "Optional fan-in / error-routing rule — the SAME shape and enum as a top-level workflow edge's `triggerRule` (workflow-definition.schema.json §WorkflowEdge). Governs how the target node fires given its incoming edges: `all_success` (default — every incoming edge's source succeeded), `any_success`, `all_complete` (fire when all sources have finished regardless of success — best-effort completion), `none_failed`, `any_failed`. Additive (RFC 0125): omitting it is identical to `all_success`, the implicit prior behavior. Mirrors the RFC 0013 2026-07-03 `condition` amendment's WorkflowEdge→FragmentEdge move. Expansion MUST carry this value onto the resulting `WorkflowEdge` so the scheduler honors it (see `workflow-chain-packs.md` §\"Expansion semantics\")."
202
208
  }
203
209
  },
204
210
  "additionalProperties": false
205
211
  },
212
+ "EdgeCondition": {
213
+ "type": "object",
214
+ "description": "Edge condition — identical to workflow-definition.schema.json §EdgeCondition (RFC 0013 §edges: 'same shape as a top-level workflow definition'). Inlined here so the manifest schema is self-contained for pack-loader validators.",
215
+ "properties": {
216
+ "type": {
217
+ "type": "string",
218
+ "enum": ["expression", "equals", "notEquals", "contains", "regex"]
219
+ },
220
+ "left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved')." },
221
+ "right": { "description": "Right operand value (any JSON value)." },
222
+ "expression": { "type": "string", "description": "Used when type='expression'." }
223
+ },
224
+ "additionalProperties": false
225
+ },
206
226
  "Signing": {
207
227
  "type": "object",
208
228
  "description": "Optional signing metadata. Reuses node-packs.md §signing unchanged.",
@@ -54,10 +54,25 @@ export interface FragmentNode {
54
54
  inputs?: Record<string, unknown>;
55
55
  }
56
56
 
57
+ /** A fan-in / error-routing rule mirrored from `WorkflowEdge.triggerRule`
58
+ * (workflow-definition.schema.json). RFC 0125. */
59
+ export type TriggerRule =
60
+ | 'all_success'
61
+ | 'any_success'
62
+ | 'all_complete'
63
+ | 'none_failed'
64
+ | 'any_failed';
65
+
57
66
  export interface FragmentEdge {
58
67
  from: string;
59
68
  to: string;
60
- condition?: string;
69
+ /** Edge condition — an `EdgeCondition` object (RFC 0013 amendment #818).
70
+ * Carried through expansion opaquely; typed `unknown` since the lib does
71
+ * not evaluate it. */
72
+ condition?: unknown;
73
+ /** Fan-in / error-routing rule (RFC 0125). Carried through expansion onto
74
+ * the resulting WorkflowEdge so the scheduler honors it. */
75
+ triggerRule?: TriggerRule;
61
76
  }
62
77
 
63
78
  /** Per-expansion context the caller supplies. */
@@ -90,7 +105,7 @@ export interface ExpandedFragment {
90
105
  inputs?: Record<string, unknown>;
91
106
  capabilities?: ReadonlyArray<string>;
92
107
  }>;
93
- edges: ReadonlyArray<{ from: string; to: string; condition?: string }>;
108
+ edges: ReadonlyArray<{ from: string; to: string; condition?: unknown; triggerRule?: TriggerRule }>;
94
109
  /** Map of original-fragment-id → rewritten-id, so the caller can
95
110
  * wire the parent workflow's adjacent edges into the expansion. */
96
111
  idMap: ReadonlyMap<string, string>;
@@ -110,6 +125,12 @@ export class ChainUnresolvableTypeIdError extends Error {
110
125
  }
111
126
 
112
127
  const PARAM_PATTERN = /\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
128
+ /** A value that is EXACTLY a single `{{params.<name>}}` token (whole-value),
129
+ * distinct from a token embedded in a larger string. Whole-value tokens are
130
+ * the only non-prompt position deferrable to a variable-sourced PortValue
131
+ * (WCP2 raw-typed rule); an embedded non-prompt token has no runtime `{{}}`
132
+ * construct and MUST resolve at expansion time. */
133
+ const WHOLE_VALUE_PATTERN = /^\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}$/;
113
134
 
114
135
  /** Recursive literal substitution of `{{params.<name>}}` placeholders in
115
136
  * any string field. Non-string values pass through unchanged; nested
@@ -206,8 +227,272 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
206
227
  to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
207
228
  };
208
229
  if (e.condition !== undefined) out.condition = e.condition;
230
+ // RFC 0125: carry the fan-in/error-routing rule onto the expanded
231
+ // WorkflowEdge so the scheduler honors it (mirrors the `condition`
232
+ // pass-through; without this the field is silently dropped at expansion).
233
+ if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
209
234
  return out;
210
235
  });
211
236
 
212
237
  return { nodes: expandedNodes, edges: expandedEdges, idMap };
213
238
  }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // RFC 0124 (WCP4) — Portable per-run parameter deferral.
242
+ //
243
+ // The DEFERRED expansion mode: instead of freezing `{{params.*}}` values into
244
+ // persisted `config`/`inputs` at drop time (the RFC 0013 default), the host
245
+ // materializes the chain's `parameters` into top-level workflow `variables[]`
246
+ // (author value → `defaultValue`) and rewrites each token into an already-spec'd
247
+ // RUNTIME binding — so the persisted fragment carries ZERO `{{params.*}}` tokens
248
+ // yet every parameter stays overridable per run via `configurable`. This is the
249
+ // spec-authoritative reference for `spec/v1/workflow-chain-packs.md`
250
+ // §"Deferred-parameter expansion (RFC 0124)".
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /** The parameter JSON Schema fragment (`chain.parameters`), narrowed to the
254
+ * fields deferred expansion reads: each property's `type`, `description`, and
255
+ * the RFC 0124 `x-openwop-sensitive` extension key. */
256
+ export interface ParameterSchema {
257
+ properties?: Record<
258
+ string,
259
+ { type?: string; description?: string; 'x-openwop-sensitive'?: boolean }
260
+ >;
261
+ }
262
+
263
+ /** Host capability context deferred expansion needs (RFC 0124 §Capability
264
+ * gating). The prompt-bearing rewrite path requires `prompts.variableSources`
265
+ * to include `variable`; a `source:"secret"` sensitive lift requires
266
+ * `capabilities.secrets.supported`. */
267
+ export interface DeferredHostContext {
268
+ /** `capabilities.prompts.variableSources` includes `"variable"`. */
269
+ promptVariableSource: boolean;
270
+ /** `capabilities.secrets.supported`. */
271
+ secretsSupported: boolean;
272
+ }
273
+
274
+ export interface DeferredExpansionContext {
275
+ expansionId: string;
276
+ /** Author-supplied parameter values (already validated) → `defaultValue` seeds. */
277
+ params: Record<string, unknown>;
278
+ /** The chain's `parameters` JSON Schema (type + `x-openwop-sensitive` per property). */
279
+ parameterSchema: ParameterSchema;
280
+ isTypeIdResolvable: (typeId: string) => boolean;
281
+ host: DeferredHostContext;
282
+ }
283
+
284
+ /** A materialized top-level `WorkflowVariable` (subset — the fields deferred
285
+ * expansion sets). A NON-sensitive parameter materializes here with its author
286
+ * value as `defaultValue`. A sensitive parameter does NOT (its value never
287
+ * lands in the run-scoped bag); it is bound as a `source:"secret"` prompt
288
+ * variable instead. */
289
+ export interface MaterializedVariable {
290
+ name: string;
291
+ type: string;
292
+ description?: string;
293
+ defaultValue?: unknown;
294
+ sensitive?: boolean;
295
+ }
296
+
297
+ /** A rewritten prompt-template variable slot (RFC 0027). `source:"variable"`
298
+ * resolves from the run bag; `source:"secret"` resolves a BYOK secret at
299
+ * compose time, redacted in `prompt.composed` (RFC 0124 §Security). */
300
+ export interface PromptVariableBinding {
301
+ name: string;
302
+ source: 'variable' | 'secret';
303
+ }
304
+
305
+ export interface DeferredExpandedFragment {
306
+ nodes: ExpandedFragment['nodes'];
307
+ edges: ExpandedFragment['edges'];
308
+ idMap: ReadonlyMap<string, string>;
309
+ /** Materialized top-level `variables[]` (non-sensitive params only). */
310
+ variables: ReadonlyArray<MaterializedVariable>;
311
+ /** Prompt-site variable bindings introduced by the rewrite. */
312
+ promptVariables: ReadonlyArray<PromptVariableBinding>;
313
+ /** Auto-generated `configurableSchema` mapping the BARE param name (the
314
+ * normative override key) to its type, so a per-run `configurable` keyed on
315
+ * the bare name resolves (R6 cross-host key stability). */
316
+ configurableSchema: { properties: Record<string, { type: string }> };
317
+ }
318
+
319
+ /** Thrown when a `x-openwop-sensitive` parameter cannot be securely deferred:
320
+ * it resolves to a non-prompt position (whole-value `node.inputs`, embedded
321
+ * non-prompt `config`), or the host lacks `secrets`/deferred support. Wire
322
+ * code `sensitive_param_not_deferrable` (HTTP 422) per
323
+ * `workflow-chain-packs.md` §"Error codes" (RFC 0124 §Security). */
324
+ export class SensitiveParamNotDeferrableError extends Error {
325
+ readonly code = 'sensitive_param_not_deferrable';
326
+ readonly httpStatus = 422;
327
+ constructor(readonly param: string, readonly reason: string) {
328
+ super(`sensitive_param_not_deferrable: '${param}' — ${reason}`);
329
+ this.name = 'SensitiveParamNotDeferrableError';
330
+ }
331
+ }
332
+
333
+ /** The prompt-bearing `config` fields a token in which is a "prompt position"
334
+ * (lifted to a PromptTemplate `{{varName}}` slot). Everything else in `config`
335
+ * is a non-prompt position. */
336
+ const PROMPT_CONFIG_FIELDS: ReadonlySet<string> = new Set(['systemPrompt', 'userPrompt']);
337
+
338
+ /**
339
+ * Deferred-mode expansion (RFC 0124). Rewrites every `{{params.<name>}}` token
340
+ * into a spec'd runtime binding and materializes non-sensitive parameters into
341
+ * top-level `variables[]`, leaving ZERO `{{params.*}}` tokens in the persisted
342
+ * fragment. Sensitive parameters (`x-openwop-sensitive`) are handled per
343
+ * §Security: prompt-body → `source:"secret"`; anywhere else → fail closed.
344
+ *
345
+ * @throws SensitiveParamNotDeferrableError when a sensitive parameter is in a
346
+ * non-prompt position, or the host lacks `secrets` support.
347
+ * @throws ChainUnresolvableTypeIdError when any node typeId fails resolution.
348
+ */
349
+ export function expandChainDeferred(
350
+ chain: WorkflowChain,
351
+ ctx: DeferredExpansionContext,
352
+ ): DeferredExpandedFragment {
353
+ for (const node of chain.dag.nodes) {
354
+ if (!ctx.isTypeIdResolvable(node.typeId)) {
355
+ throw new ChainUnresolvableTypeIdError(node.typeId, chain.chainId);
356
+ }
357
+ }
358
+
359
+ const props = ctx.parameterSchema.properties ?? {};
360
+ const isSensitive = (name: string): boolean => props[name]?.['x-openwop-sensitive'] === true;
361
+ const typeOf = (name: string): string => props[name]?.type ?? 'string';
362
+
363
+ const prefix = computePrefix(chain.chainId, ctx.expansionId);
364
+ const fragmentNodeIds = new Set(chain.dag.nodes.map((n) => n.id));
365
+ const idMap = new Map<string, string>();
366
+ for (const id of fragmentNodeIds) idMap.set(id, `${prefix}${id}`);
367
+
368
+ const usedParams = new Set<string>();
369
+ const promptVariables = new Map<string, PromptVariableBinding>();
370
+
371
+ /** Rewrite a prompt-position string: each embedded `{{params.x}}` → a
372
+ * PromptTemplate `{{x}}` slot. A sensitive param binds `source:"secret"`
373
+ * (requires host secrets support), else `source:"variable"`. If the host
374
+ * does not advertise the `variable` prompt source at all, the deferred
375
+ * prompt path is unavailable — the caller falls back to expansion-time
376
+ * substitution (G5); we surface that by returning `null`. */
377
+ function rewritePrompt(text: string): string | null {
378
+ if (!ctx.host.promptVariableSource) return null; // G5 fallback → expansion-time
379
+ return text.replace(PARAM_PATTERN, (_m, name: string) => {
380
+ usedParams.add(name);
381
+ if (isSensitive(name)) {
382
+ if (!ctx.host.secretsSupported) {
383
+ throw new SensitiveParamNotDeferrableError(name, 'host lacks capabilities.secrets support');
384
+ }
385
+ promptVariables.set(name, { name, source: 'secret' });
386
+ } else {
387
+ promptVariables.set(name, { name, source: 'variable' });
388
+ }
389
+ return `{{${name}}}`;
390
+ });
391
+ }
392
+
393
+ /** Rewrite a config object: prompt fields → PromptTemplate slots; a token in
394
+ * any NON-prompt config field is embedded-non-prompt → sensitive fails
395
+ * closed, non-sensitive resolves at expansion time (author-trusted). */
396
+ function rewriteConfig(config: Record<string, unknown>): Record<string, unknown> {
397
+ const out: Record<string, unknown> = {};
398
+ for (const [k, v] of Object.entries(config)) {
399
+ if (typeof v === 'string' && PROMPT_CONFIG_FIELDS.has(k) && PARAM_PATTERN.test(v)) {
400
+ PARAM_PATTERN.lastIndex = 0;
401
+ const rewritten = rewritePrompt(v);
402
+ out[k] = rewritten === null ? substitute(v, ctx.params) : rewritten;
403
+ continue;
404
+ }
405
+ PARAM_PATTERN.lastIndex = 0;
406
+ if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
407
+ // embedded non-prompt token
408
+ PARAM_PATTERN.lastIndex = 0;
409
+ let m: RegExpExecArray | null;
410
+ while ((m = PARAM_PATTERN.exec(v)) !== null) {
411
+ if (isSensitive(m[1])) {
412
+ throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt config field '${k}'`);
413
+ }
414
+ }
415
+ out[k] = substitute(v, ctx.params); // author-trusted expansion-time resolution
416
+ } else {
417
+ out[k] = substitute(v, ctx.params);
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /** Rewrite an inputs object: a WHOLE-VALUE `{{params.x}}` → a variable-sourced
424
+ * PortValue (WCP2 raw-typed); a sensitive whole-value fails closed (no
425
+ * plaintext-bag path for a secret). Embedded input tokens follow the same
426
+ * non-prompt rule as config. */
427
+ function rewriteInputs(inputs: Record<string, unknown>): Record<string, unknown> {
428
+ const out: Record<string, unknown> = {};
429
+ for (const [k, v] of Object.entries(inputs)) {
430
+ const whole = typeof v === 'string' ? WHOLE_VALUE_PATTERN.exec(v) : null;
431
+ if (whole) {
432
+ const name = whole[1];
433
+ if (isSensitive(name)) {
434
+ throw new SensitiveParamNotDeferrableError(name, `whole-value node input '${k}'`);
435
+ }
436
+ usedParams.add(name);
437
+ out[k] = { source: 'variable', variable: name }; // variable-sourced PortValue
438
+ continue;
439
+ }
440
+ PARAM_PATTERN.lastIndex = 0;
441
+ if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
442
+ PARAM_PATTERN.lastIndex = 0;
443
+ let m: RegExpExecArray | null;
444
+ while ((m = PARAM_PATTERN.exec(v)) !== null) {
445
+ if (isSensitive(m[1])) {
446
+ throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt input '${k}'`);
447
+ }
448
+ }
449
+ out[k] = substitute(v, ctx.params);
450
+ } else {
451
+ out[k] = substitute(v, ctx.params);
452
+ }
453
+ }
454
+ return out;
455
+ }
456
+
457
+ const expandedNodes = chain.dag.nodes.map((n) => {
458
+ const out: ExpandedFragment['nodes'][number] = { id: `${prefix}${n.id}`, typeId: n.typeId };
459
+ if (n.name !== undefined) out.name = n.name;
460
+ if (n.position !== undefined) out.position = n.position;
461
+ if (n.config !== undefined) out.config = rewriteConfig(n.config);
462
+ if (n.inputs !== undefined) out.inputs = rewriteInputs(n.inputs);
463
+ if (chain.capabilities && chain.capabilities.length > 0) out.capabilities = [...chain.capabilities];
464
+ return out;
465
+ });
466
+
467
+ const expandedEdges = (chain.dag.edges ?? []).map((e) => {
468
+ const out: ExpandedFragment['edges'][number] = {
469
+ from: rewriteEdgeRef(e.from, fragmentNodeIds, prefix),
470
+ to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
471
+ };
472
+ if (e.condition !== undefined) out.condition = e.condition;
473
+ if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
474
+ return out;
475
+ });
476
+
477
+ // Materialize NON-sensitive used params into top-level variables[]; build the
478
+ // bare-param → type configurableSchema for the override key.
479
+ const variables: MaterializedVariable[] = [];
480
+ const configurableSchema: { properties: Record<string, { type: string }> } = { properties: {} };
481
+ for (const name of usedParams) {
482
+ configurableSchema.properties[name] = { type: typeOf(name) };
483
+ if (isSensitive(name)) continue; // sensitive value never lands in the bag
484
+ const v: MaterializedVariable = { name, type: typeOf(name) };
485
+ if (props[name]?.description !== undefined) v.description = props[name]!.description;
486
+ if (name in ctx.params) v.defaultValue = ctx.params[name];
487
+ variables.push(v);
488
+ }
489
+
490
+ return {
491
+ nodes: expandedNodes,
492
+ edges: expandedEdges,
493
+ idMap,
494
+ variables,
495
+ promptVariables: [...promptVariables.values()],
496
+ configurableSchema,
497
+ };
498
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Data-parallel dispatch — per-item child inputs. `node-packs.md` §"`core.dispatch`
3
+ * per-item input — data-parallel fan-out" (RFC 0126). Validates the additive, OPTIONAL
4
+ * `nextWorkerInputs` array on `NextWorkerDecision` (`orchestrator-decision.schema.json`)
5
+ * and the `capabilities.dispatch.perItemInput` fail-closed gate.
6
+ *
7
+ * `nextWorkerInputs[i]` is a per-child input object, index-aligned with `nextWorkerIds`,
8
+ * projected into the child dispatched for `nextWorkerIds[i]` — fanning ONE childWorkflowId
9
+ * over N runtime items with distinct inputs (the map-over-collection pattern). It rides the
10
+ * recorded `runOrchestrator.decided` event, so `:fork`/replay reproduces byte-identical
11
+ * children.
12
+ *
13
+ * Two layers:
14
+ *
15
+ * A. Always-on, server-free schema probe — `NextWorkerDecision` accepts a well-formed
16
+ * `nextWorkerInputs`, still accepts a decision that omits it (additive/back-compat),
17
+ * rejects a non-object item, and — because `additionalProperties:false` — rejects the
18
+ * field on a pre-RFC-0126 strict validator only when the property name differs (the
19
+ * point of the fail-closed gate). Array-length equality with `nextWorkerIds` is NOT
20
+ * JSON-Schema-expressible, so the schema ADMITS a length-mismatch; that MUST is a
21
+ * HOST runtime check, driven in layer B.
22
+ *
23
+ * B. Capability-gated behavioral legs — on a host advertising
24
+ * `capabilities.dispatch.perItemInput: true` that exposes the dispatch test seam, a
25
+ * length-mismatched decision fails with a validation_error and dispatches no child,
26
+ * and each child receives its own `nextWorkerInputs[i]`. On a host NOT advertising the
27
+ * capability, a non-empty `nextWorkerInputs` MUST fail closed (validation_error), never
28
+ * silently drop-and-dispatch N identical children. No conformant host advertises
29
+ * perItemInput yet — these legs soft-skip until a reference host wires it (the first
30
+ * witness toward `Active → Accepted`).
31
+ *
32
+ * @see spec/v1/node-packs.md §"core.dispatch per-item input — data-parallel fan-out (RFC 0126)"
33
+ * @see spec/v1/capabilities.md §dispatch
34
+ * @see schemas/orchestrator-decision.schema.json
35
+ * @see RFCS/0126-data-parallel-dispatch-per-item-input.md
36
+ */
37
+
38
+ import { describe, it, expect } from 'vitest';
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+ import Ajv2020 from 'ajv/dist/2020.js';
42
+ import addFormats from 'ajv-formats';
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
+
48
+ const DECISION = join(SCHEMAS_DIR, 'orchestrator-decision.schema.json');
49
+
50
+ describe('dispatch-per-item: NextWorkerDecision.nextWorkerInputs schema (always-on, server-free)', () => {
51
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
52
+ addFormats(ajv);
53
+ const validate = ajv.compile(JSON.parse(readFileSync(DECISION, 'utf8')));
54
+
55
+ it('accepts a next-worker decision carrying a well-formed, index-aligned nextWorkerInputs', () => {
56
+ const decision = {
57
+ kind: 'next-worker',
58
+ nextWorkerIds: ['pack.re-engage-contact', 'pack.re-engage-contact', 'pack.re-engage-contact'],
59
+ nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }, { contactId: 'c-3' }],
60
+ };
61
+ expect(
62
+ validate(decision),
63
+ `orchestrator-decision.schema.json §NextWorkerDecision — a well-formed nextWorkerInputs MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
64
+ ).toBe(true);
65
+ });
66
+
67
+ it('still accepts a next-worker decision that omits nextWorkerInputs (additive / back-compat)', () => {
68
+ expect(
69
+ validate({ kind: 'next-worker', nextWorkerIds: ['pack.child'] }),
70
+ 'a pre-RFC-0126 next-worker decision MUST stay valid — the field is OPTIONAL',
71
+ ).toBe(true);
72
+ });
73
+
74
+ it('rejects a non-object nextWorkerInputs item', () => {
75
+ expect(
76
+ validate({ kind: 'next-worker', nextWorkerIds: ['a'], nextWorkerInputs: ['not-an-object'] }),
77
+ 'each nextWorkerInputs entry MUST be a per-child input object',
78
+ ).toBe(false);
79
+ expect(
80
+ validate({ kind: 'next-worker', nextWorkerIds: ['a'], nextWorkerInputs: 'nope' }),
81
+ 'nextWorkerInputs MUST be an array',
82
+ ).toBe(false);
83
+ });
84
+
85
+ it('still rejects an unknown property (additionalProperties:false) — the fail-closed gate', () => {
86
+ expect(
87
+ validate({ kind: 'next-worker', nextWorkerIds: ['a'], perItemInputs: [{ x: 1 }] }),
88
+ 'NextWorkerDecision is additionalProperties:false — a mis-named field MUST be rejected, so old strict validators fail closed on unknown per-item shapes',
89
+ ).toBe(false);
90
+ });
91
+
92
+ it('ADMITS a length-mismatch — array-length equality is a runtime MUST, not schema-expressible', () => {
93
+ expect(
94
+ validate({ kind: 'next-worker', nextWorkerIds: ['a', 'b'], nextWorkerInputs: [{ x: 1 }] }),
95
+ 'the wire schema cannot express nextWorkerInputs.length == nextWorkerIds.length; the host enforces it at decision time (layer B)',
96
+ ).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('dispatch-per-item: per-item input behavior (capability-gated, RFC 0126)', () => {
101
+ it('a host advertising perItemInput projects nextWorkerInputs[i] into child i', async () => {
102
+ const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
103
+ if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
104
+
105
+ const res = await driver.post('/v1/host/sample/dispatch/per-item', {
106
+ nextWorkerIds: ['conformance.child', 'conformance.child'],
107
+ nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
108
+ });
109
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
110
+
111
+ const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }> } | undefined;
112
+ expect(
113
+ body?.children?.length,
114
+ driver.describe('node-packs.md §core.dispatch per-item input', 'one child dispatched per nextWorkerIds entry'),
115
+ ).toBe(2);
116
+ expect(
117
+ body?.children?.map((c) => c.inputs?.contactId),
118
+ driver.describe('node-packs.md §core.dispatch per-item input', 'each child receives its own nextWorkerInputs[i] (per-item value wins over inputMapping)'),
119
+ ).toEqual(['c-1', 'c-2']);
120
+ });
121
+
122
+ it('a length-mismatched nextWorkerInputs fails with a validation_error and dispatches no child', async () => {
123
+ const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
124
+ if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
125
+
126
+ const res = await driver.post('/v1/host/sample/dispatch/per-item', {
127
+ nextWorkerIds: ['conformance.child', 'conformance.child'],
128
+ nextWorkerInputs: [{ contactId: 'c-1' }],
129
+ });
130
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
131
+
132
+ expect(
133
+ res.status >= 400 && res.status < 500,
134
+ driver.describe('node-packs.md §core.dispatch per-item input', 'nextWorkerInputs.length != nextWorkerIds.length MUST fail the dispatch node (4xx validation_error), dispatching no child'),
135
+ ).toBe(true);
136
+ });
137
+
138
+ it('nextWorkerInputs[i] OVERRIDES the inputMapping projection on key collision (G1 precedence)', async () => {
139
+ const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
140
+ if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
141
+
142
+ // The seam applies `inputMapping` first (parent-variable projection, RFC 0022), then overlays
143
+ // nextWorkerInputs[i]. A key present in BOTH MUST resolve to the per-item value (most-specific wins).
144
+ const res = await driver.post('/v1/host/sample/dispatch/per-item', {
145
+ nextWorkerIds: ['conformance.child', 'conformance.child'],
146
+ inputMapping: { contactId: 'from-mapping', region: 'us' },
147
+ nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
148
+ });
149
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
150
+
151
+ const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }> } | undefined;
152
+ expect(
153
+ body?.children?.map((c) => c.inputs?.contactId),
154
+ driver.describe('node-packs.md §core.dispatch per-item input', 'on key collision the per-item value wins over inputMapping (G1)'),
155
+ ).toEqual(['c-1', 'c-2']);
156
+ expect(
157
+ body?.children?.every((c) => c.inputs?.region === 'us'),
158
+ driver.describe('node-packs.md §core.dispatch per-item input', 'non-colliding inputMapping keys still project (per-item merges OVER, does not replace)'),
159
+ ).toBe(true);
160
+ });
161
+
162
+ it('replay re-reads the recorded nextWorkerInputs verbatim — no recomputation (R5 replay-freeze)', async () => {
163
+ const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
164
+ if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
165
+
166
+ // A :fork/replay MUST re-read the per-item inputs frozen in the recorded runOrchestrator.decided
167
+ // decision and reproduce byte-identical children (CP-2), never re-derive them at replay time.
168
+ const res = await driver.post('/v1/host/sample/dispatch/per-item', {
169
+ nextWorkerIds: ['conformance.child', 'conformance.child'],
170
+ nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
171
+ replay: true,
172
+ });
173
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
174
+
175
+ const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }>; replayed?: boolean } | undefined;
176
+ expect(
177
+ body?.children?.map((c) => c.inputs?.contactId),
178
+ driver.describe('node-packs.md §core.dispatch per-item input', 'replay/:fork reproduces the recorded per-item children verbatim (frozen at decision time)'),
179
+ ).toEqual(['c-1', 'c-2']);
180
+ });
181
+
182
+ it('a host NOT advertising perItemInput MUST fail closed on a non-empty nextWorkerInputs', async () => {
183
+ const dispatch = await readCapabilityFamily<{ supported?: boolean; perItemInput?: boolean }>('dispatch');
184
+ if (!dispatch?.supported) return; // no dispatch surface → out of scope
185
+ if (dispatch.perItemInput === true) return; // this leg targets non-supporting hosts
186
+
187
+ const res = await driver.post('/v1/host/sample/dispatch/per-item', {
188
+ nextWorkerIds: ['conformance.child', 'conformance.child'],
189
+ nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
190
+ });
191
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
192
+
193
+ expect(
194
+ res.status >= 400 && res.status < 500,
195
+ driver.describe('node-packs.md §core.dispatch per-item input', 'a host not advertising perItemInput MUST fail closed (4xx) on a non-empty nextWorkerInputs — never silently drop it and dispatch N identical children'),
196
+ ).toBe(true);
197
+ });
198
+ });
@@ -110,6 +110,12 @@ describe('fixtures: node-pack-manifest schema validity', () => {
110
110
  ajv.addSchema(promptKindSchema, './prompt-kind.schema.json');
111
111
  const schema = JSON.parse(readFileSync(PACK_MANIFEST_SCHEMA_PATH, 'utf8'));
112
112
  const validate = ajv.compile(schema);
113
+ // A `pack-manifests/` fixture may be a node pack OR a `kind: "workflow-chain"`
114
+ // pack (RFC 0013) — pick the schema by `kind`, exactly as the registry does.
115
+ const chainSchema = JSON.parse(
116
+ readFileSync(join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json'), 'utf8'),
117
+ );
118
+ const validateChain = ajv.compile(chainSchema);
113
119
 
114
120
  const files = readdirSync(PACK_MANIFEST_FIXTURES_DIR)
115
121
  .filter((f) => f.endsWith('.json'))
@@ -120,17 +126,22 @@ describe('fixtures: node-pack-manifest schema validity', () => {
120
126
  });
121
127
 
122
128
  for (const file of files) {
123
- it(`pack-manifests/${file} validates against node-pack-manifest.schema.json`, () => {
129
+ it(`pack-manifests/${file} validates against its kind's manifest schema`, () => {
124
130
  const data = JSON.parse(
125
131
  readFileSync(join(PACK_MANIFEST_FIXTURES_DIR, file), 'utf8'),
126
- );
127
- const ok = validate(data);
128
- const errors = (validate.errors ?? [])
132
+ ) as { kind?: string };
133
+ const isChain = data.kind === 'workflow-chain';
134
+ const v = isChain ? validateChain : validate;
135
+ const schemaName = isChain
136
+ ? 'workflow-chain-pack-manifest.schema.json'
137
+ : 'node-pack-manifest.schema.json';
138
+ const ok = v(data);
139
+ const errors = (v.errors ?? [])
129
140
  .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
130
141
  .join('\n');
131
142
  expect(
132
143
  ok,
133
- `Fixture pack-manifests/${file} fails node-pack-manifest schema:\n${errors}`,
144
+ `Fixture pack-manifests/${file} fails ${schemaName}:\n${errors}`,
134
145
  ).toBe(true);
135
146
  });
136
147
  }