@openwop/openwop-conformance 1.52.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,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, {