@alvera-ai/platform-sdk 0.13.0 → 0.15.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.
Files changed (32) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +273 -25
  3. package/.agent/ai_sandbox.md +4 -2
  4. package/.agent/connected_apps.md +7 -2
  5. package/.agent/cookbook/action-status-updaters.md +73 -14
  6. package/.agent/cookbook/ai-agent-invoke.md +36 -0
  7. package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
  8. package/.agent/cookbook/birthday-greeting-sms-trigger.md +35 -1
  9. package/.agent/cookbook/bulk-ingest.md +48 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +33 -1
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +35 -1
  14. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  15. package/.agent/cookbook/paginated-restapi-poller.md +383 -0
  16. package/.agent/cookbook/rest-fetch.md +27 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +35 -1
  18. package/.agent/cookbook/score-leads-with-llm-categorization.md +36 -1
  19. package/.agent/cookbook/system-templates.md +36 -0
  20. package/.agent/cookbook/talk-to-data.md +39 -0
  21. package/.agent/cookbook/triage-prospects-by-priority.md +33 -1
  22. package/.agent/cookbook/welcome-sms-for-customers.md +33 -1
  23. package/.agent/datalakes.md +68 -3
  24. package/.agent/interoperability_contracts.md +29 -0
  25. package/.agent/tool-call-configs.md +11 -0
  26. package/.agent/tools.md +103 -36
  27. package/.agent/type_naming.md +4 -0
  28. package/dist/index.d.mts +189 -19
  29. package/dist/index.d.mts.map +1 -1
  30. package/dist/index.mjs +3 -1
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +2 -2
@@ -0,0 +1,383 @@
1
+ ---
2
+ title: "Capability: poll a paginated provider API for delivery status (Mailgun-style)"
3
+ summary: A capability walk for restapi action status updaters. Register a REST poller tool against a provider's paginated events API (Mailgun-shaped here) and an action status updater whose request templates FOLLOW the pagination cursor — page 1 renders the collection path, later pages ride `msg.pagination_context.next`. The platform refuses a poller whose request can never advance (`api.actionStatusUpdaters.create` 422), so the guard is walked first, then the accepted shape.
4
+ industry: foundation
5
+ slug: paginated-restapi-poller
6
+ vitest_source:
7
+ - integration-tests/tests/foundation/action-status-updaters.test.ts
8
+ - integration-tests/tests/foundation/bootstrap.test.ts
9
+ status: green
10
+ ---
11
+
12
+ # Capability
13
+
14
+ **What you get:** delivery outcomes pulled from a provider's REST events API —
15
+ paginated, cursor-driven — written back onto the messages you sent, on a cron,
16
+ with the platform enforcing that your poll can actually terminate.
17
+
18
+ A **restapi action status updater** polls an HTTP events endpoint instead of a
19
+ log group:
20
+
21
+ - a **REST poller tool** (`intent: 'status_poller'`, `tool_body_type:
22
+ 'rest_api'`) supplies the base URL + auth,
23
+ - the ASU's `updater_body` renders the request per page: `path`, `params`,
24
+ an `events_template` that extracts the page's events as a JSON array, and a
25
+ `pagination_context_template` that captures `has_next` + the provider's
26
+ cursor,
27
+ - on every page after the first, the driver binds what your pagination
28
+ template captured as `msg.pagination_context` (and the page number as
29
+ `msg.page`) into your `path`/`params` — **your templates must read one of
30
+ them, or the request is byte-identical for every page and the poll can
31
+ never advance. The platform rejects that config at create.**
32
+
33
+ Mailgun's events API is the shape walked here (`GET /v3/<domain>/events` with
34
+ a `paging.next` cursor link), but any cursor- or page-numbered API fits. See
35
+ `action_status_updaters.md` §7 for the wire reference.
36
+
37
+ # Walkthrough
38
+
39
+ The `_setup/foundation.md` bootstrap left `api`, `tenantSlug`, `datalakeSlug`,
40
+ and `ctx.datalakeId` populated.
41
+
42
+ ## 001 — register a data source for the tools
43
+
44
+ Both the sender and the poller attach to a data source — the origin
45
+ registration for the messaging provider.
46
+
47
+ ```typescript
48
+ const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
49
+ name: `Poller Source ${runSuffix}`,
50
+ uri: 'mailgun.local',
51
+ description: 'Messaging-provider origin for the paginated delivery poller.',
52
+ status: 'active',
53
+ is_default: false,
54
+ })
55
+ dataSourceId = ds.id!
56
+ ```
57
+
58
+ ## 002 — create the sender tool whose messages get reconciled
59
+
60
+ The ASU updates messages a sender produced; `sender_tool_ids` will point here.
61
+ An SNS-backed SMS sender (LocalStack locally) plays that role.
62
+
63
+ ```typescript
64
+ const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
65
+ name: `Poller SMS Sender ${runSuffix}`,
66
+ description: 'Sender whose delivery status the paginated poller reconciles.',
67
+ intent: 'sms',
68
+ status: 'active',
69
+ datalake_id: ctx.datalakeId,
70
+ data_source_id: dataSourceId,
71
+ body: {
72
+ tool_body_type: 'sns',
73
+ auth_method: 'access_key',
74
+ region: 'us-east-1',
75
+ phone_number: '+15551234567',
76
+ endpoint_url: 'http://localhost:4566',
77
+ access_key_id: 'test',
78
+ secret_access_key: 'test',
79
+ },
80
+ })
81
+ ctx.senderToolId = smsTool.id!
82
+ ```
83
+
84
+ ## 003 — create the REST poller tool
85
+
86
+ The poller supplies base URL + auth for the provider's events API —
87
+ Mailgun-shaped here (basic auth, `api` / API key). `intent:
88
+ 'status_poller'` tags it as a reconciler source, `tool_body_type: 'rest_api'`
89
+ makes the ASU's requests ride this tool's HTTP client.
90
+
91
+ ```typescript
92
+ const { data: restTool } = await api.tools.create(tenantSlug, datalakeSlug, {
93
+ name: `Mailgun Events Poller ${runSuffix}`,
94
+ description: 'REST poller — supplies auth for restapi ActionStatusUpdater delivery polling',
95
+ intent: 'status_poller',
96
+ status: 'active',
97
+ datalake_id: ctx.datalakeId,
98
+ data_source_id: dataSourceId,
99
+ body: {
100
+ tool_body_type: 'rest_api',
101
+ base_url: 'http://localhost:8080/mailgun/v3',
102
+ auth_method: 'basic',
103
+ username: 'api',
104
+ password: 'key-test',
105
+ request_type: 'json',
106
+ response_type: 'json',
107
+ timeout_ms: 30000,
108
+ },
109
+ })
110
+ ctx.restToolId = restTool.id!
111
+ ```
112
+
113
+ ## 004 — the pagination guard: a static request is refused at create
114
+
115
+ First, the shape that does NOT work — and why the platform refuses it. This
116
+ `path`/`params` pair reads neither `msg.pagination_context` nor `msg.page`,
117
+ so page 500's request would be byte-identical to page 1's: the window never
118
+ moves, `has_next` never goes false, and the run would re-apply the same
119
+ events forever. The create is a 422; the walk catches it to prove the gate.
120
+
121
+ ```typescript
122
+ let rejected = false
123
+ try {
124
+ await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
125
+ name: `Mailgun Delivery Poller ${runSuffix} non-advancing`,
126
+ cron_expression: '*/30 * * * *',
127
+ updater_type: 'restapi',
128
+ updater_tool_id: ctx.restToolId,
129
+ sender_tool_ids: [ctx.senderToolId],
130
+ datalake_id: ctx.datalakeId,
131
+ // FLOOR the platform enforces: an array whose items are objects listing
132
+ // `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
133
+ // create — every event has to name the message it reconciles.
134
+ events_output_schema: {
135
+ type: 'array',
136
+ items: {
137
+ type: 'object',
138
+ required: ['external_id'],
139
+ properties: { external_id: { type: 'string' } },
140
+ },
141
+ },
142
+ pagination_context_output_schema: {
143
+ type: 'object',
144
+ required: ['has_next'],
145
+ properties: { has_next: { type: 'boolean' } },
146
+ },
147
+ updater_body: {
148
+ updater_body_type: 'restapi_request',
149
+ method: 'get',
150
+ // Static on both — the cursor is captured below and never read back.
151
+ path: { type: 'custom', body: '/wiremock.domain/events' },
152
+ params: { type: 'custom', body: '{"event": "delivered"}' },
153
+ // Providers name their own id — `message-id` here, `messageId` / `sid`
154
+ // elsewhere. The events_template is where that becomes `external_id`:
155
+ // the apply path POPS that key off every rendered event to find the row
156
+ // it updates, so the render PROJECTS each event rather than passing the
157
+ // provider body through untouched.
158
+ events_template: {
159
+ type: 'custom',
160
+ body:
161
+ '[{% for item in response.items %}' +
162
+ '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
163
+ '{% unless forloop.last %},{% endunless %}{% endfor %}]',
164
+ },
165
+ pagination_context_template: {
166
+ type: 'custom',
167
+ body:
168
+ '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
169
+ '"next": "{{ response.paging.next }}"}',
170
+ },
171
+ },
172
+ message_config: {
173
+ type: 'custom',
174
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
175
+ },
176
+ action_log_config: {
177
+ type: 'custom',
178
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
179
+ },
180
+ })
181
+ } catch (err) {
182
+ const status = (err as { _httpStatus?: number })._httpStatus
183
+ if (status !== 422) throw err
184
+ rejected = true
185
+ }
186
+ if (!rejected) {
187
+ throw new Error('expected a 422 for a poller whose request never consumes the cursor')
188
+ }
189
+ ```
190
+
191
+ ## 005 — the accepted shape: the path follows the cursor
192
+
193
+ Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
194
+ with bounded query params; every later page rides the cursor link the
195
+ pagination template captured — and drops the params, because Mailgun's
196
+ `paging.next` is a complete URL that already carries them.
197
+
198
+ ```typescript
199
+ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
200
+ name: `Mailgun Delivery Poller ${runSuffix} advancing`,
201
+ cron_expression: '*/30 * * * *',
202
+ updater_type: 'restapi',
203
+ updater_tool_id: ctx.restToolId,
204
+ sender_tool_ids: [ctx.senderToolId],
205
+ datalake_id: ctx.datalakeId,
206
+ events_output_schema: {
207
+ type: 'array',
208
+ items: {
209
+ type: 'object',
210
+ required: ['external_id'],
211
+ properties: { external_id: { type: 'string' } },
212
+ },
213
+ },
214
+ pagination_context_output_schema: {
215
+ type: 'object',
216
+ required: ['has_next'],
217
+ properties: { has_next: { type: 'boolean' } },
218
+ },
219
+ updater_body: {
220
+ updater_body_type: 'restapi_request',
221
+ method: 'get',
222
+ path: {
223
+ type: 'custom',
224
+ body:
225
+ '{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
226
+ '{% else %}/wiremock.domain/events{% endif %}',
227
+ },
228
+ params: {
229
+ type: 'custom',
230
+ body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
231
+ },
232
+ // The provider's own id (`message-id`) becomes `external_id` HERE — the
233
+ // apply path pops that key to find the row it reconciles, so project each
234
+ // event instead of passing the provider body through untouched.
235
+ events_template: {
236
+ type: 'custom',
237
+ body:
238
+ '[{% for item in response.items %}' +
239
+ '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
240
+ '{% unless forloop.last %},{% endunless %}{% endfor %}]',
241
+ },
242
+ pagination_context_template: {
243
+ type: 'custom',
244
+ body:
245
+ '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
246
+ '"next": "{{ response.paging.next }}"}',
247
+ },
248
+ },
249
+ message_config: {
250
+ type: 'custom',
251
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
252
+ },
253
+ action_log_config: {
254
+ type: 'custom',
255
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
256
+ },
257
+ })
258
+ actionStatusUpdaterId = asu.id!
259
+ if (asu.status !== 'active') {
260
+ throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
261
+ }
262
+ ```
263
+
264
+ ## 006 — discover it (list + metadata)
265
+
266
+ The poller shows up in the paginated ASU list, and `metadataDetails` renders
267
+ the markdown an agent reads to understand the reconciler.
268
+
269
+ ```typescript
270
+ const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
271
+ if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
272
+ throw new Error('created restapi ASU not found in the list')
273
+ }
274
+
275
+ const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
276
+ if (typeof detail !== 'string' || detail.length === 0) {
277
+ throw new Error('expected non-empty ASU metadata details')
278
+ }
279
+ ```
280
+
281
+ ## 007 — write the integration test
282
+
283
+ End the build with a test you keep: re-read the poller and assert the
284
+ facts the scenario depends on — the create was accepted (so the
285
+ pagination guard passed), the row is free to poll, and the run surface is
286
+ on the wire. This block runs live under `make validate-cookbook`.
287
+
288
+ ```typescript
289
+ // Re-GET — the stored row echoes the authored cron.
290
+ const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
291
+ if (poller.cron_expression !== '*/30 * * * *') {
292
+ throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
293
+ }
294
+ // cycle_detected is only ever set at runtime by the poll driver —
295
+ // a fresh create MUST read active.
296
+ if (poller.status !== 'active') {
297
+ throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
298
+ }
299
+ // last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
300
+ // legitimately null. Assert the field EXISTS, never a value.
301
+ if (!('last_run_status' in poller)) {
302
+ throw new Error('poller response carries no last_run_status field')
303
+ }
304
+ // Behavioural probe — the metadata surface an agent reads must render.
305
+ const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
306
+ if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
307
+ throw new Error('poller metadataDetails came back empty')
308
+ }
309
+ ```
310
+
311
+ If the poller stops reconciling in production, refresh once, re-read
312
+ `last_run_error`, and escalate with that evidence — don't rewrite the
313
+ config and wait for another tick.
314
+
315
+ # Gotchas
316
+
317
+ - **The pagination guard is create-time and non-negotiable.** If neither
318
+ `path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
319
+ is a 422 — the request could never advance past page 1. The guard reads
320
+ your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
321
+ won't fool a reviewer even where it fools a regex; write the real cursor
322
+ read.
323
+ - **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
324
+ on the first page — `{% if msg.pagination_context %}…{% else %}<collection
325
+ path>{% endif %}` is the canonical shape. Bound page 1's `params`
326
+ with `{% unless msg.pagination_context %}` when the cursor link already
327
+ carries the query (Mailgun's `paging.next` does).
328
+ - **`has_next` decides termination — prefer the full-page heuristic in
329
+ production.** The walked shape (`response.items.size > 0`) terminates on
330
+ the first empty page, costing one extra request. Where the provider
331
+ documents a page size, `has_next: {% if response.items.size == 300 %}` (a
332
+ full page implies more) saves that call; a `paging.next` link that is
333
+ absent on the last page is an even stronger signal.
334
+ - **`events_template` must render a JSON ARRAY**, validated against
335
+ `events_output_schema` on every cycle; the pagination render is validated
336
+ against `pagination_context_output_schema` (which must require `has_next`).
337
+ Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
338
+ - **Each schema has a FLOOR the platform enforces, above which the contract is
339
+ yours.** `events_output_schema` must describe an **array whose `items` are
340
+ objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
341
+ 422 at create. `pagination_context_output_schema` must be an **object listing
342
+ `has_next` in `required`** — that key is what ends the page loop. Demand more
343
+ of your provider on top if you like; only the floor is checked.
344
+ - **The floor exists because `external_id` is how reconciliation finds the row.**
345
+ `apply_status_update` pops that key off every rendered event, so mapping the
346
+ provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
347
+ the **`events_template`'s job** — project each event, never pass the provider
348
+ body through untouched. A schema that satisfies the floor while the template
349
+ emits raw provider rows creates cleanly and then reconciles nothing on every
350
+ cycle. `message_config` / `action_log_config` then read the **mapped**
351
+ `{{ external_id }}`, not the provider's original key.
352
+ - **`action_log_config` is required alongside `message_config`** for every
353
+ updater type, and both are cast against the *same* pinned reconciliation
354
+ schema — so one template body satisfies both.
355
+ - **A newly created poller is `status: 'active'`.** `cycle_detected` is only
356
+ ever set at runtime by the poll driver — never by a caller; you cannot
357
+ create your way into it.
358
+ - **`action_log_config` is REQUIRED alongside `message_config`** on create
359
+ AND update (PUT) — same per-event assigns, rendered into the action-log
360
+ write shape (`external_id` + at least one of `status` / `sent_at` /
361
+ `metadata`).
362
+ - **One poll cycle can be fired on demand.**
363
+ `api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
364
+ cycle the cron would — `202` with the updater row AS-IS (the poll is
365
+ async; re-read `last_run_status` / `last_run_events_found` /
366
+ `last_run_error` for the outcome) — a day-two operate surface, not a
367
+ build step. That outcome has three values: `'ok'` (the whole window was
368
+ read), `'partial'` (the fetch was truncated — the newest events may be
369
+ missing, with `last_run_error` explaining), and `'error'` (the run
370
+ failed). Treat only `last_run_status === 'ok'` as a complete
371
+ reconciliation; a `!== 'error'` check silently accepts a truncated
372
+ `partial`.
373
+
374
+ # See also
375
+
376
+ - `action_status_updaters.md` §7 — restapi wire shape, the two output
377
+ schemas, `msg.*` assigns
378
+ - `tools.md` — `rest_api` poller body, `status_poller` intent
379
+ - `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
380
+ (log-group polling instead of HTTP pagination)
381
+ - `_setup/foundation.md` — the bootstrap this walk starts from
382
+ - `integration-tests/tests/foundation/action-status-updaters.test.ts` —
383
+ the green test these calls are lifted from (§3d–§3f)
@@ -220,6 +220,33 @@ if (!oauthTool.id) {
220
220
  }
221
221
  ```
222
222
 
223
+ ## 008 — write the integration test
224
+
225
+ End the build with a test you keep: trigger one pull and assert the
226
+ enqueue acknowledgement. `runManually` answers with the allocated
227
+ `batch_id` BEFORE the fetch runs — the HTTP call and the ingestion drain
228
+ on a background worker — so the durable test asserts on the call's own
229
+ response, never on polling the merge (that diagnosis walk lives in §006).
230
+ This block runs live under `make validate-cookbook`.
231
+
232
+ ```typescript
233
+ // Re-GET — the client must still be bound to the REST tool.
234
+ const { data: fetchDac } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
235
+ if (fetchDac.tool_id !== ctx.restToolId) {
236
+ throw new Error('DAC is no longer bound to the REST fetch tool')
237
+ }
238
+ // Behavioural probe — one manual pull; the ack carries the batch_id
239
+ // the worker will report under.
240
+ const { data: probePull } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
241
+ if (typeof probePull.batch_id !== 'string' || probePull.batch_id.length === 0) {
242
+ throw new Error('runManually probe returned no batch_id')
243
+ }
244
+ ```
245
+
246
+ If the probe fails in production, escalate with the response — and if
247
+ the enqueue succeeds but rows never land, the evidence to escalate with
248
+ is the batch's log row (§006's read), not a re-run storm.
249
+
223
250
  # Gotchas
224
251
 
225
252
  - **A draft tool is silently skipped at fetch time.** The worker only fetches
@@ -286,7 +286,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
286
286
  tool_id: toolId,
287
287
  position: 0,
288
288
  trigger_template: 'now',
289
- idempotency_template: `{{ compliance_screening.id }}-${verdict}-{{ "" | uuid }}`,
289
+ idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
290
290
  tool_call: {
291
291
  tool_call_type: 'sms_request',
292
292
  to: { type: 'custom', body: '+15550000000' },
@@ -641,6 +641,40 @@ if (!verdictBody.includes('[verified]')) {
641
641
  }
642
642
  ```
643
643
 
644
+ ## 014 — write the integration test
645
+
646
+ End the build with a test you keep: re-read the workflow and prove the
647
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
648
+ never-matching selection runs the FULL pipeline (selection → filter →
649
+ decision) and intercepts only the final action call, so no message
650
+ leaves, yet the acknowledgement proves the workflow is runnable. This
651
+ block runs live under `make validate-cookbook`.
652
+
653
+ ```typescript
654
+ // Re-GET — the workflow must still be live, or nothing will run.
655
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
656
+ if (wfRow.status !== 'live') {
657
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
658
+ }
659
+ // Behavioural probe — a dry run against a selection no row can match:
660
+ // the pipeline executes end-to-end, the final action call is
661
+ // intercepted, and the acknowledgement carries the run-log id. The
662
+ // clause must speak this workflow's selection dialect — the dataset
663
+ // alias is `rcs` here, the same alias the live run above uses.
664
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
665
+ sql_where_clause: "rcs.batch_id = 'test-never-matching-batch'",
666
+ mode: 'dry_run',
667
+ manual_override: false,
668
+ })
669
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
670
+ throw new Error('dry-run probe returned no workflow_run_log_id')
671
+ }
672
+ ```
673
+
674
+ If the probe fails in production, escalate with the run response as
675
+ evidence — don't flip the workflow's status or rewrite its configs to
676
+ chase the error.
677
+
644
678
  # Branches
645
679
 
646
680
  - **The auto-clear screening is filtered, not failed** — §012
@@ -337,7 +337,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
337
337
  tool_id: toolId,
338
338
  position: 0,
339
339
  trigger_template: 'now',
340
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}-{{ "" | uuid }}`,
340
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
341
341
  tool_call: {
342
342
  tool_call_type: 'sms_request',
343
343
  to: { type: 'custom', body: '+15551234567' },
@@ -547,6 +547,41 @@ for (const wel of ourWels) {
547
547
  }
548
548
  ```
549
549
 
550
+ ## 011 — write the integration test
551
+
552
+ End the build with a test you keep: re-read the workflow and prove the
553
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
554
+ never-matching selection runs the FULL pipeline (selection → filter →
555
+ decision) and intercepts only the final action call, so no message
556
+ leaves, yet the acknowledgement proves the workflow is runnable. This
557
+ block runs live under `make validate-cookbook`.
558
+
559
+ ```typescript
560
+ // Re-GET — the workflow must still be live, or nothing will run.
561
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
562
+ if (wfRow.status !== 'live') {
563
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
564
+ }
565
+ // Behavioural probe — a dry run against a selection no row can match:
566
+ // the pipeline executes end-to-end, the final action call is
567
+ // intercepted, and the acknowledgement carries the run-log id. The
568
+ // clause speaks this workflow's selection dialect: a GENERIC-TABLE
569
+ // dataset is addressed by its own columns (no `ra.` dataset alias —
570
+ // that alias exists only for system-dataset selections).
571
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
572
+ sql_where_clause: "submission_id = 'test-never-matching-submission'",
573
+ mode: 'dry_run',
574
+ manual_override: false,
575
+ })
576
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
577
+ throw new Error('dry-run probe returned no workflow_run_log_id')
578
+ }
579
+ ```
580
+
581
+ If the probe fails in production, escalate with the run response as
582
+ evidence — don't flip the workflow's status or rewrite its configs to
583
+ chase the error.
584
+
550
585
  # Branches
551
586
 
552
587
  - **The filter is permissive** — `filter_config.body: 'true'` passes
@@ -103,6 +103,42 @@ if (typeof detail !== 'string' || detail.length === 0) {
103
103
  }
104
104
  ```
105
105
 
106
+ ## 004 — write the integration test
107
+
108
+ End the walk with a test you keep: anything that references a system
109
+ template by path (`template_config: { type: 'system', path: … }`) depends
110
+ on that path staying in this datalake's scope — so the durable test
111
+ re-asserts the anchor is listed, the domain filter holds, and the detail
112
+ surface renders. This block runs live under `make validate-cookbook`.
113
+
114
+ ```typescript
115
+ const { data: tplList } = await api.templates.systemTemplates(tenantSlug, datalakeSlug)
116
+ const tplPaths = (tplList.data ?? []).map((t) => t.path)
117
+ const anchor = 'data_activation/interoperability/subscription/stripe/customers_subscription_customer'
118
+ if (!tplPaths.some((p) => p.includes(anchor))) {
119
+ throw new Error(`anchor template ${anchor} vanished from the domain-scoped list`)
120
+ }
121
+ const foreign = ['healthcare', 'payments', 'foundation', 'core_banking', 'service_commerce', 'trading']
122
+ const leakedPaths = tplPaths.filter((p) => foreign.some((d) => p.split('/').includes(d)))
123
+ if (leakedPaths.length > 0) {
124
+ throw new Error(`cross-domain templates leaked: ${leakedPaths.join(', ')}`)
125
+ }
126
+ // Behavioural probe — the detail surface must render for the anchor.
127
+ const { data: anchorDetail } = await api.templates.metadataDetails(
128
+ tenantSlug,
129
+ datalakeSlug,
130
+ 'customers_subscription_customer',
131
+ 'data_activation_interoperability',
132
+ )
133
+ if (typeof anchorDetail !== 'string' || anchorDetail.length === 0) {
134
+ throw new Error('anchor template metadataDetails came back empty')
135
+ }
136
+ ```
137
+
138
+ If the anchor disappears in production, escalate with the listing
139
+ evidence — a `type: system` reference that stops resolving is a platform
140
+ regression, not something to paper over by inlining the template body.
141
+
106
142
  # Gotchas
107
143
 
108
144
  - **The list is domain-scoped, not global.** `systemTemplates` only returns
@@ -103,6 +103,45 @@ if (!/\bn\b/.test(csv) || !csv.includes('1')) {
103
103
  }
104
104
  ```
105
105
 
106
+ ## 004 — write the integration test
107
+
108
+ End the walk with a test you keep: both surfaces are read-only, so the
109
+ durable test is the pair of deterministic boundary probes — the
110
+ round-trip works, and the read-only wall holds. Neither depends on an
111
+ LLM provider or seeded data, which is what makes the test durable. This
112
+ block runs live under `make validate-cookbook`.
113
+
114
+ ```typescript
115
+ // Round-trip probe — deterministic, data-independent.
116
+ const probe = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
117
+ sql: 'SELECT 1 AS ok',
118
+ mode: 'unregulated',
119
+ })
120
+ if (typeof probe.data === 'string' || probe.data.data[0]?.[0] !== 1) {
121
+ throw new Error('executeSql round-trip probe failed')
122
+ }
123
+ // Boundary probe — write SQL must be rejected with a 422; the
124
+ // read-only wall is the security property this capability rests on.
125
+ let walled = false
126
+ try {
127
+ await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
128
+ sql: "INSERT INTO legal_entities (legal_name) VALUES ('test-should-never-land')",
129
+ mode: 'unregulated',
130
+ })
131
+ } catch (err) {
132
+ const status = (err as { _httpStatus?: number })._httpStatus
133
+ if (status !== 422) throw err
134
+ walled = true
135
+ }
136
+ if (!walled) {
137
+ throw new Error('executeSql accepted a write statement — the read-only wall is down')
138
+ }
139
+ ```
140
+
141
+ If the write probe ever lands in production, stop and escalate
142
+ immediately with the accepted statement as evidence — a read-only
143
+ boundary failure is a security regression, never a config problem.
144
+
106
145
  # Branches
107
146
 
108
147
  - **Generation failure.** If every configured LLM provider fails, `textToSql`
@@ -309,7 +309,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
309
309
  tool_id: toolId,
310
310
  position: 0,
311
311
  trigger_template: 'now',
312
- idempotency_template: `{{ customer_id }}-${band}-{{ "" | uuid }}`,
312
+ idempotency_template: `{{ customer_id }}-${band}`,
313
313
  tool_call: {
314
314
  tool_call_type: 'sms_request',
315
315
  to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
@@ -480,6 +480,38 @@ for (const wel of ourWels) {
480
480
  }
481
481
  ```
482
482
 
483
+ ## 013 — write the integration test
484
+
485
+ End the build with a test you keep: re-read the workflow and prove the
486
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
487
+ never-matching selection runs the FULL pipeline (selection → filter →
488
+ decision) and intercepts only the final action call, so no message
489
+ leaves, yet the acknowledgement proves the workflow is runnable. This
490
+ block runs live under `make validate-cookbook`.
491
+
492
+ ```typescript
493
+ // Re-GET — the workflow must still be live, or nothing will run.
494
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
495
+ if (wfRow.status !== 'live') {
496
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
497
+ }
498
+ // Behavioural probe — a dry run against a selection no row can match:
499
+ // the pipeline executes end-to-end, the final action call is
500
+ // intercepted, and the acknowledgement carries the run-log id.
501
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
502
+ sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
503
+ mode: 'dry_run',
504
+ manual_override: false,
505
+ })
506
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
507
+ throw new Error('dry-run probe returned no workflow_run_log_id')
508
+ }
509
+ ```
510
+
511
+ If the probe fails in production, escalate with the run response as
512
+ evidence — don't flip the workflow's status or rewrite its configs to
513
+ chase the error.
514
+
483
515
  # Branches
484
516
 
485
517
  - **Agent emits an out-of-enum band** — the response schema's