@openwop/openwop-conformance 1.51.0 → 1.53.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,200 @@
1
+ /**
2
+ * Purpose-propagation — permitted-use labels (RFC 0128) —
3
+ * `capabilities.md` §purposePropagation + `a2a-integration.md`
4
+ * §"Purpose-propagation labels".
5
+ *
6
+ * The conformance-testable core of RFC 0128 §3: a host advertising
7
+ * `capabilities.purposePropagation` MUST re-emit a received
8
+ * `permittedPurposes` label on onward OpenWOP-envelope hops (MAY narrow,
9
+ * MUST NOT widen), a derived output MUST NOT carry a purpose absent from any
10
+ * contributing labelled input, and `[]`-labelled data MUST NOT be forwarded
11
+ * onward at all. The internal-use restriction (§4) is deliberately NOT tested
12
+ * — it is not observable over the wire.
13
+ *
14
+ * Two layers:
15
+ *
16
+ * A. Always-on, server-free schema probes:
17
+ * - a labelled TriggerEvent validates; a non-array label fails;
18
+ * - absent vs `[]` are distinct wire states (both validate — the
19
+ * semantic difference is behavioral, asserted in layer B);
20
+ * - the `capabilities.purposePropagation` family shape: `supported`
21
+ * REQUIRED, `additionalProperties:false`.
22
+ *
23
+ * B. Capability-gated behavioral legs via the two-hop seam
24
+ * `POST /v1/host/sample/purpose-propagation/forward` (the suite plays
25
+ * hop A — the labelled sender — and hop C — the onward receiver — around
26
+ * the host at B). Soft-skips when the seam is unwired (404/405);
27
+ * REQUIRED once `purposePropagation.supported` is advertised
28
+ * (advertise-only-what-you-honor):
29
+ * 1. survive/narrow — a forwarded label arrives ⊆ what B received;
30
+ * 2. never-widen — strictly no purpose beyond the input set;
31
+ * 3. derived output — a merge of two labelled inputs arrives ⊆ their
32
+ * intersection (multi-input never-widen — transformation does not
33
+ * launder a grant);
34
+ * 4. unlabelled inputs add no constraint to a merge;
35
+ * 5. `[]` fail-closed — a `[]`-labelled record is dropped from onward
36
+ * emission, with an unlabelled twin as the positive control
37
+ * (non-arrival is evidence, not a timeout artifact).
38
+ *
39
+ * @see RFCS/0128-purpose-propagation-permitted-use-labels.md §3
40
+ * @see spec/v1/capabilities.md §purposePropagation
41
+ * @see spec/v1/a2a-integration.md §"Purpose-propagation labels"
42
+ */
43
+
44
+ import { describe, it, expect } from 'vitest';
45
+ import { readFileSync } from 'node:fs';
46
+ import { join } from 'node:path';
47
+ import Ajv2020 from 'ajv/dist/2020.js';
48
+ import addFormats from 'ajv-formats';
49
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
50
+ import { driver } from '../lib/driver.js';
51
+ import { behaviorGate } from '../lib/behavior-gate.js';
52
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
53
+
54
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
55
+ const SEAM = '/v1/host/sample/purpose-propagation/forward';
56
+
57
+ interface OnwardEmission {
58
+ recordId?: string;
59
+ surface?: string;
60
+ permittedPurposes?: string[];
61
+ }
62
+ interface ForwardResponse {
63
+ onward?: OnwardEmission[];
64
+ dropped?: string[];
65
+ }
66
+
67
+ function subsetOf(actual: string[] | undefined, allowed: string[]): boolean {
68
+ return (actual ?? []).every((p) => allowed.includes(p));
69
+ }
70
+
71
+ describe('purpose-propagation: label schema (always-on, server-free)', () => {
72
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
73
+ addFormats(ajv);
74
+ const validate = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
75
+ const FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-change.json');
76
+
77
+ it('a labelled TriggerEvent validates; a non-array label fails', () => {
78
+ const ev = JSON.parse(readFileSync(FIXTURE, 'utf8'));
79
+ expect(validate(ev), `a labelled TriggerEvent MUST validate (RFC 0128 §1). Errors: ${JSON.stringify(validate.errors)}`).toBe(true);
80
+
81
+ ev.permittedPurposes = 'analytics';
82
+ expect(validate(ev), 'RFC 0128 §1 — permittedPurposes MUST be string[]').toBe(false);
83
+ });
84
+
85
+ it('absent and [] are BOTH wire-valid — distinct states (unlabelled vs no-onward-use)', () => {
86
+ const ev = JSON.parse(readFileSync(FIXTURE, 'utf8'));
87
+ delete ev.permittedPurposes;
88
+ expect(validate(ev), 'RFC 0128 §1 — an unlabelled event (absent label) MUST validate').toBe(true);
89
+ ev.permittedPurposes = [];
90
+ expect(validate(ev), 'RFC 0128 §1 — a []-labelled event MUST validate (behavioral meaning: no onward use)').toBe(true);
91
+ });
92
+
93
+ it('the capabilities.purposePropagation family requires `supported` and closes its shape', () => {
94
+ const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'));
95
+ const fam = caps.properties?.purposePropagation;
96
+ expect(fam, 'capabilities.schema.json MUST define the purposePropagation family (RFC 0128 §2)').toBeDefined();
97
+ expect(fam.required, 'purposePropagation MUST require `supported`').toContain('supported');
98
+ expect(fam.additionalProperties, 'purposePropagation MUST close its shape').toBe(false);
99
+ const ajvFam = new Ajv2020({ allErrors: true, strict: false });
100
+ const vf = ajvFam.compile(fam);
101
+ expect(vf({ supported: true, propagatesOnward: true }), 'the canonical advert MUST validate').toBe(true);
102
+ expect(vf({ propagatesOnward: true }), 'an advert without `supported` MUST fail').toBe(false);
103
+ });
104
+ });
105
+
106
+ describe.skipIf(HTTP_SKIP)('purpose-propagation: two-hop onward behavior (capability-gated)', () => {
107
+ async function seamPost(body: Record<string, unknown>): Promise<ForwardResponse | null> {
108
+ const res = await driver.post(SEAM, body);
109
+ if (res.status === 404 || res.status === 405) return null; // seam unwired — soft-skip
110
+ return (res.json as ForwardResponse | undefined) ?? {};
111
+ }
112
+
113
+ async function gate(): Promise<boolean> {
114
+ const fam = await readCapabilityFamily<{ supported?: boolean }>('purposePropagation');
115
+ return behaviorGate('purposePropagation', fam?.supported === true);
116
+ }
117
+
118
+ it('a forwarded label survives ⊆ the received set (re-emit; MAY narrow, MUST NOT widen)', async () => {
119
+ if (!(await gate())) return;
120
+ const input = ['analytics', 'marketing-email'];
121
+ const res = await seamPost({ mode: 'forward', records: [{ id: 'r1', permittedPurposes: input, data: { k: 1 } }] });
122
+ if (res === null) return;
123
+
124
+ const onward = res.onward ?? [];
125
+ expect(
126
+ onward.length > 0,
127
+ driver.describe('RFC 0128 §3', 'an advertising host MUST re-emit a received label on the onward hop — silent loss fails (the promise is propagation)'),
128
+ ).toBe(true);
129
+ for (const o of onward) {
130
+ expect(
131
+ Array.isArray(o.permittedPurposes) && subsetOf(o.permittedPurposes, input),
132
+ driver.describe('RFC 0128 §3', `the onward label MUST be a subset of what the host received — got ${JSON.stringify(o.permittedPurposes)} vs input ${JSON.stringify(input)} (widening is the testable violation)`),
133
+ ).toBe(true);
134
+ }
135
+ });
136
+
137
+ it('a derived (merged) output arrives ⊆ the intersection of contributing labelled inputs', async () => {
138
+ if (!(await gate())) return;
139
+ const res = await seamPost({
140
+ mode: 'merge',
141
+ records: [
142
+ { id: 'a', permittedPurposes: ['analytics', 'marketing-email'], data: { k: 1 } },
143
+ { id: 'b', permittedPurposes: ['analytics'], data: { k: 2 } },
144
+ ],
145
+ });
146
+ if (res === null) return;
147
+ const onward = res.onward ?? [];
148
+ expect(onward.length > 0, driver.describe('RFC 0128 §3', 'a merge of forwardable labelled inputs MUST produce an onward emission')).toBe(true);
149
+ for (const o of onward) {
150
+ expect(
151
+ subsetOf(o.permittedPurposes, ['analytics']),
152
+ driver.describe('RFC 0128 §3', `a derived output MUST NOT carry a purpose absent from any contributing labelled input — transformation does not launder a grant; got ${JSON.stringify(o.permittedPurposes)}, allowed ⊆ ["analytics"]`),
153
+ ).toBe(true);
154
+ }
155
+ });
156
+
157
+ it('an unlabelled input adds no constraint to a merge', async () => {
158
+ if (!(await gate())) return;
159
+ const res = await seamPost({
160
+ mode: 'merge',
161
+ records: [
162
+ { id: 'a', permittedPurposes: ['analytics'], data: { k: 1 } },
163
+ { id: 'b', data: { k: 2 } },
164
+ ],
165
+ });
166
+ if (res === null) return;
167
+ for (const o of res.onward ?? []) {
168
+ expect(
169
+ subsetOf(o.permittedPurposes, ['analytics']),
170
+ driver.describe('RFC 0128 §3', 'an unlabelled input asserts no constraint — the derived label is still bounded by the labelled input(s)'),
171
+ ).toBe(true);
172
+ }
173
+ });
174
+
175
+ it('[]-labelled data is fail-closed dropped from onward emission (positive control: unlabelled twin forwards)', async () => {
176
+ if (!(await gate())) return;
177
+ const res = await seamPost({
178
+ mode: 'forward',
179
+ records: [
180
+ { id: 'blocked', permittedPurposes: [], data: { k: 1 } },
181
+ { id: 'control', data: { k: 1 } },
182
+ ],
183
+ });
184
+ if (res === null) return;
185
+
186
+ const onwardIds = (res.onward ?? []).map((o) => o.recordId);
187
+ expect(
188
+ !onwardIds.includes('blocked'),
189
+ driver.describe('RFC 0128 §3', 'permittedPurposes: [] means no onward use — a conformant host MUST NOT forward []-labelled data to a further sink at all'),
190
+ ).toBe(true);
191
+ expect(
192
+ (res.dropped ?? []).includes('blocked'),
193
+ driver.describe('RFC 0128 §3', 'the []-labelled record MUST be reported dropped (fail-closed, observable)'),
194
+ ).toBe(true);
195
+ expect(
196
+ onwardIds.includes('control'),
197
+ driver.describe('RFC 0128 §3', 'the unlabelled twin (positive control) MUST forward — proving the non-arrival of the []-labelled record is the rule firing, not a dead seam'),
198
+ ).toBe(true);
199
+ });
200
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Streaming & CDC trigger sources (RFC 0127) — `trigger-bridge.md` §F.5.
3
+ *
4
+ * Verifies the two additive externally-originated sources — `stream` (a
5
+ * message consumed from a Kafka/Kinesis/Pub-Sub broker) and `change` (a
6
+ * warehouse/DB change-data-capture record) — layered on the RFC 0083/0099
7
+ * bridge with the envelope / SSRF posture / content-free `trigger.*` events /
8
+ * §C-1 dedup floor reused verbatim.
9
+ *
10
+ * Two layers:
11
+ *
12
+ * A. Always-on, server-free schema probes:
13
+ * - the canonical `stream` + `change` TriggerEvent fixtures validate;
14
+ * - a `change` event WITHOUT `op` fails (`ChangeEvent` requires it —
15
+ * RFC 0127 §2, schema-enforced);
16
+ * - an out-of-enum `op` fails;
17
+ * - the §F.1 exactly-one rule extends to the new sources (a
18
+ * `source:"stream"` event carrying a `change` sub-object fails);
19
+ * - a registration accepts `source:"stream"` / `source:"change"`;
20
+ * - the capabilities enums (`triggerBridge.sources[]` +
21
+ * `ingestion.externalSources[]`) include both values (regression pin).
22
+ *
23
+ * B. Capability-gated behavioral legs (soft-skip pre-implementation;
24
+ * REQUIRED once `ingestion.externalSources[]` advertises the source —
25
+ * advertise-only-what-you-honor, RFC 0127 §Negative example):
26
+ * - `POST /v1/host/sample/trigger-bridge/ingest` with a `stream` /
27
+ * `change` body → a run starts, the delivered `ctx.triggerData`
28
+ * matches `trigger-event.schema.json`, and the durable
29
+ * `trigger.delivery.attempted` is content-free (SR-1: no
30
+ * message/row body);
31
+ * - `POST /v1/host/sample/trigger-bridge/deliver` `{scenario:"dedup",
32
+ * source:"stream"}` → the same broker-coordinate dedup key delivered
33
+ * twice is effectively-once (§C-1 floor, `(topic,partition,offset)`
34
+ * keying).
35
+ *
36
+ * @see spec/v1/trigger-bridge.md §F.5
37
+ * @see RFCS/0127-streaming-and-cdc-trigger-sources.md
38
+ * @see RFCS/0099-external-event-trigger-ingestion.md (§F envelope, reused)
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
47
+ import { driver } from '../lib/driver.js';
48
+ import { behaviorGate } from '../lib/behavior-gate.js';
49
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
50
+ import { driveDelivery } from '../lib/triggerBridge.js';
51
+
52
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
53
+
54
+ const STREAM_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-stream.json');
55
+ const CHANGE_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-change.json');
56
+
57
+ function buildAjv(): Ajv2020 {
58
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
59
+ addFormats(ajv);
60
+ for (const name of ['trigger-subscription.schema.json']) {
61
+ ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')));
62
+ }
63
+ return ajv;
64
+ }
65
+
66
+ describe('trigger-stream-cdc: TriggerEvent schema for stream/change (always-on, server-free)', () => {
67
+ const ajv = buildAjv();
68
+ const validate = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
69
+
70
+ it('the canonical stream TriggerEvent fixture validates', () => {
71
+ const ev = JSON.parse(readFileSync(STREAM_FIXTURE, 'utf8'));
72
+ expect(
73
+ validate(ev),
74
+ `trigger-event.schema.json MUST accept a conforming stream TriggerEvent (RFC 0127 §2). Errors: ${JSON.stringify(validate.errors)}`,
75
+ ).toBe(true);
76
+ });
77
+
78
+ it('the canonical change TriggerEvent fixture validates (op + permittedPurposes present)', () => {
79
+ const ev = JSON.parse(readFileSync(CHANGE_FIXTURE, 'utf8'));
80
+ expect(
81
+ validate(ev),
82
+ `trigger-event.schema.json MUST accept a conforming change TriggerEvent (RFC 0127 §2 / RFC 0128 §1). Errors: ${JSON.stringify(validate.errors)}`,
83
+ ).toBe(true);
84
+ });
85
+
86
+ it('a change event WITHOUT op fails — ChangeEvent requires the operation discriminator', () => {
87
+ const ev = JSON.parse(readFileSync(CHANGE_FIXTURE, 'utf8'));
88
+ delete ev.change.op;
89
+ expect(
90
+ validate(ev),
91
+ 'RFC 0127 §2 — `op` (insert|update|delete) is REQUIRED on a change event (schema-enforced in the ChangeEvent $def)',
92
+ ).toBe(false);
93
+ });
94
+
95
+ it('an out-of-enum op fails', () => {
96
+ const ev = JSON.parse(readFileSync(CHANGE_FIXTURE, 'utf8'));
97
+ ev.change.op = 'upsert';
98
+ expect(
99
+ validate(ev),
100
+ 'RFC 0127 §2 — `op` MUST be one of insert|update|delete',
101
+ ).toBe(false);
102
+ });
103
+
104
+ it('the §F.1 exactly-one rule extends — a source:"stream" event carrying a change sub-object fails', () => {
105
+ const ev = JSON.parse(readFileSync(STREAM_FIXTURE, 'utf8'));
106
+ ev.change = { op: 'insert', table: 't' };
107
+ expect(
108
+ validate(ev),
109
+ 'trigger-bridge.md §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others (extends to stream/change per RFC 0127)',
110
+ ).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('trigger-stream-cdc: registration + capabilities vocabulary (always-on, server-free)', () => {
115
+ const ajv = buildAjv();
116
+ const validateReg = ajv.compile(
117
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json'), 'utf8')),
118
+ );
119
+
120
+ it('a registration accepts source:"stream" and source:"change"', () => {
121
+ for (const source of ['stream', 'change']) {
122
+ expect(
123
+ validateReg({ source, workflowId: 'wf_1' }),
124
+ `trigger-subscription-registration.schema.json MUST accept source:"${source}" (RFC 0127 §1). Errors: ${JSON.stringify(validateReg.errors)}`,
125
+ ).toBe(true);
126
+ }
127
+ });
128
+
129
+ it('the capabilities source enums include stream + change (regression pin)', () => {
130
+ const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'));
131
+ const tb = caps.properties?.triggerBridge?.properties ?? {};
132
+ const sources: string[] = tb.sources?.items?.enum ?? [];
133
+ const external: string[] = tb.ingestion?.properties?.externalSources?.items?.enum ?? [];
134
+ for (const v of ['stream', 'change']) {
135
+ expect(sources.includes(v), `RFC 0127 §1 — triggerBridge.sources[] enum MUST include "${v}"`).toBe(true);
136
+ expect(external.includes(v), `RFC 0127 §3 — ingestion.externalSources[] enum MUST include "${v}"`).toBe(true);
137
+ }
138
+ });
139
+ });
140
+
141
+ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (capability-gated)', () => {
142
+ it('a stream and a change event each ingest to a run with a schema-valid envelope and a content-free delivery event', async () => {
143
+ const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: string[] } }>('triggerBridge');
144
+ const external = tb?.ingestion?.externalSources ?? [];
145
+ const advertisesNew = external.includes('stream') || external.includes('change');
146
+ // Gate on the RFC 0099 ingestion surface existing at all; the new-source
147
+ // legs soft-skip on a pre-RFC-0127 host. Once the host ADVERTISES
148
+ // stream/change, a soft-skip is forbidden (advertise-only-what-you-honor).
149
+ if (!behaviorGate('triggerBridge.ingestion', (external.length ?? 0) > 0)) return;
150
+
151
+ const ajv = buildAjv();
152
+ const validate = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
153
+
154
+ const bodies: Record<string, unknown>[] = [
155
+ {
156
+ source: 'stream',
157
+ verification: { mode: 'none' },
158
+ stream: { topic: 'events', partition: 3, offset: '88412', key: 'user_77', message: { type: 'page_view', secretMarker: 'CANARY-STREAM-BODY' } },
159
+ },
160
+ {
161
+ source: 'change',
162
+ verification: { mode: 'none' },
163
+ change: { op: 'update', table: 'contacts', changelogId: '0/1C4F9D0', after: { id: 77, secretMarker: 'CANARY-CHANGE-BODY' } },
164
+ },
165
+ ];
166
+
167
+ for (const body of bodies) {
168
+ const res = await driver.post('/v1/host/sample/trigger-bridge/ingest', body);
169
+ if ((res.status === 404 || res.status === 405 || res.status === 400 || res.status === 422) && !advertisesNew) continue; // pre-0127 host — soft-skip this source
170
+ expect(
171
+ res.status < 400,
172
+ driver.describe(
173
+ 'trigger-bridge.md §F.5',
174
+ `a host advertising ingestion of "${body.source as string}" MUST ingest it (advertise-only-what-you-honor, RFC 0127 §Negative example) — got HTTP ${res.status}`,
175
+ ),
176
+ ).toBe(true);
177
+
178
+ const out = res.json as { triggerEvent?: Record<string, unknown>; deliveryEvent?: Record<string, unknown> } | undefined;
179
+ expect(
180
+ validate(out?.triggerEvent),
181
+ driver.describe(
182
+ 'trigger-bridge.md §F.5',
183
+ `the delivered ${body.source as string} envelope MUST validate against trigger-event.schema.json. Errors: ${JSON.stringify(validate.errors)}`,
184
+ ),
185
+ ).toBe(true);
186
+
187
+ // SR-1 — the durable delivery event MUST NOT carry the message/row body.
188
+ const durable = JSON.stringify(out?.deliveryEvent ?? {});
189
+ expect(
190
+ !durable.includes('CANARY-STREAM-BODY') && !durable.includes('CANARY-CHANGE-BODY'),
191
+ driver.describe(
192
+ 'trigger-bridge.md §F.5 (SR-1)',
193
+ 'the durable trigger.delivery.attempted MUST be content-free — the broker message / CDC row body has no slot on the event log',
194
+ ),
195
+ ).toBe(true);
196
+ }
197
+ });
198
+
199
+ it('stream dedup — the same broker coordinates delivered twice are effectively-once (§C-1 floor)', async () => {
200
+ const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: string[] } }>('triggerBridge');
201
+ const external = tb?.ingestion?.externalSources ?? [];
202
+ if (!behaviorGate('triggerBridge.ingestion', (external.length ?? 0) > 0)) return;
203
+
204
+ const first = await driveDelivery({ scenario: 'dedup', dedupKey: 'events:3:99001', source: 'stream' });
205
+ if (first === null) return; // delivery seam unwired — soft-skip
206
+ if (first.outcome === undefined && !external.includes('stream')) return; // pre-0127 host — soft-skip
207
+ expect(
208
+ first.deliveredCount === 1 || first.outcome === 'delivered',
209
+ driver.describe(
210
+ 'trigger-bridge.md §F.5 / §C-1',
211
+ 'a stream event dedup-keyed on (topic,partition,offset) redelivered within the window MUST be effectively-once — reuses the RFC 0083 §C-1 ≥24h floor unchanged',
212
+ ),
213
+ ).toBe(true);
214
+ });
215
+ });
@@ -184,6 +184,118 @@ describe('category: workflow-chain expansion — placeholder substitution', () =
184
184
  });
185
185
  });
186
186
 
187
+ describe('category: workflow-chain expansion — whole-value typed resolution', () => {
188
+ it('resolves a whole-value {{params.x}} token to the RAW typed value (object/array/number/boolean)', () => {
189
+ const chain: WorkflowChain = {
190
+ ...SAMPLE_CHAIN,
191
+ parameters: {
192
+ type: 'object',
193
+ properties: {
194
+ retryPolicy: { type: 'object' },
195
+ allowlist: { type: 'array' },
196
+ maxTokens: { type: 'number' },
197
+ streaming: { type: 'boolean' },
198
+ },
199
+ },
200
+ dag: {
201
+ nodes: [
202
+ {
203
+ id: 'n',
204
+ typeId: 'core.identity',
205
+ config: {
206
+ retryPolicy: '{{params.retryPolicy}}',
207
+ allowlist: '{{params.allowlist}}',
208
+ maxTokens: '{{params.maxTokens}}',
209
+ streaming: '{{params.streaming}}',
210
+ },
211
+ },
212
+ ],
213
+ edges: [],
214
+ },
215
+ };
216
+ const fragment = expandChain(chain, {
217
+ expansionId: 'wv1',
218
+ params: {
219
+ retryPolicy: { attempts: 3, backoff: 'exponential' },
220
+ allowlist: ['a', 'b'],
221
+ maxTokens: 4096,
222
+ streaming: true,
223
+ },
224
+ isTypeIdResolvable: RESOLVE_ALL,
225
+ });
226
+ const config = at(fragment.nodes, 0, 'fragment.nodes').config as {
227
+ retryPolicy: unknown;
228
+ allowlist: unknown;
229
+ maxTokens: unknown;
230
+ streaming: unknown;
231
+ };
232
+ expect(
233
+ config.retryPolicy,
234
+ 'Per workflow-chain-packs.md §"Parameter substitution": a value that is EXACTLY one `{{params.x}}` token MUST resolve to the raw typed value — an object param MUST NOT be stringified to "[object Object]".',
235
+ ).toEqual({ attempts: 3, backoff: 'exponential' });
236
+ expect(config.allowlist).toEqual(['a', 'b']);
237
+ expect(config.maxTokens).toBe(4096);
238
+ expect(config.streaming).toBe(true);
239
+ });
240
+
241
+ it('does literal string coercion for an EMBEDDED token in surrounding text', () => {
242
+ const chain: WorkflowChain = {
243
+ ...SAMPLE_CHAIN,
244
+ parameters: { type: 'object', properties: { count: { type: 'number' } } },
245
+ dag: {
246
+ nodes: [{ id: 'n', typeId: 'core.identity', config: { label: 'items: {{params.count}}' } }],
247
+ edges: [],
248
+ },
249
+ };
250
+ const fragment = expandChain(chain, {
251
+ expansionId: 'wv2',
252
+ params: { count: 42 },
253
+ isTypeIdResolvable: RESOLVE_ALL,
254
+ });
255
+ const config = at(fragment.nodes, 0, 'fragment.nodes').config as { label: string };
256
+ expect(
257
+ config.label,
258
+ 'A token embedded in surrounding text MUST do literal string substitution (the numeric param coerces to its string form).',
259
+ ).toBe('items: 42');
260
+ });
261
+ });
262
+
263
+ describe('category: workflow-chain expansion — inputs preservation', () => {
264
+ it('preserves a present node.inputs (PortValue references) through expansion', () => {
265
+ const chain: WorkflowChain = {
266
+ ...SAMPLE_CHAIN,
267
+ dag: {
268
+ nodes: [
269
+ {
270
+ id: 'n',
271
+ typeId: 'core.identity',
272
+ config: {},
273
+ inputs: {
274
+ prompt: { sourceNodeId: 'upstream', sourcePort: 'text' },
275
+ seed: '{{params.seed}}',
276
+ },
277
+ },
278
+ ],
279
+ edges: [],
280
+ },
281
+ };
282
+ const fragment = expandChain(chain, {
283
+ expansionId: 'ip1',
284
+ params: { seed: 'xyz' },
285
+ isTypeIdResolvable: RESOLVE_ALL,
286
+ });
287
+ const inputs = at(fragment.nodes, 0, 'fragment.nodes').inputs as {
288
+ prompt: unknown;
289
+ seed: unknown;
290
+ };
291
+ expect(
292
+ inputs.prompt,
293
+ 'Per workflow-chain-packs.md §"Parameter substitution": expansion MUST preserve a present `node.inputs` (PortValue references) verbatim — only `{{params.*}}` tokens inside its string leaves are substituted.',
294
+ ).toEqual({ sourceNodeId: 'upstream', sourcePort: 'text' });
295
+ expect(inputs.seed).toBe('xyz');
296
+ });
297
+ });
298
+
187
299
  describe('category: workflow-chain expansion — node id collision avoidance', () => {
188
300
  it('same chain expanded TWICE in one parent workflow produces non-colliding node ids', () => {
189
301
  const first = expandChain(SAMPLE_CHAIN, {