@alvera-ai/platform-sdk 0.17.0 → 0.18.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 (83) hide show
  1. package/.agent/AGENTS.md +82 -144
  2. package/.agent/account_management.md +2 -2
  3. package/.agent/action_logs.md +4 -4
  4. package/.agent/ai_agents.md +28 -21
  5. package/.agent/ai_sandbox.md +49 -39
  6. package/.agent/connected_apps.md +3 -3
  7. package/.agent/cookbook/_fixtures/README.md +1 -1
  8. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
  9. package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
  10. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
  11. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
  12. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
  13. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
  14. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
  15. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
  16. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
  17. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
  18. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
  19. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
  20. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
  21. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
  22. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
  23. package/.agent/cookbook/organic-marketing.md +2801 -0
  24. package/.agent/cookbook/payments-compliance.md +2180 -0
  25. package/.agent/cookbook/primary-care.md +2175 -0
  26. package/.agent/cookbook/subscription-saas.md +2403 -0
  27. package/.agent/data_activation_clients.md +65 -52
  28. package/.agent/datalakes.md +338 -171
  29. package/.agent/errors.md +3 -3
  30. package/.agent/generic_tables.md +151 -62
  31. package/.agent/interoperability_contracts.md +57 -22
  32. package/.agent/mdm.md +136 -153
  33. package/.agent/messages.md +36 -34
  34. package/.agent/mock-services.md +1 -1
  35. package/.agent/mutations.md +2 -2
  36. package/.agent/templates.md +14 -13
  37. package/.agent/tools.md +63 -21
  38. package/.agent/type_naming.md +13 -13
  39. package/.agent/workflows.md +99 -53
  40. package/README.md +2 -2
  41. package/dist/bin/platform-sdk.mjs +33 -47
  42. package/dist/bin/platform-sdk.mjs.map +1 -1
  43. package/dist/index.d.mts +565 -379
  44. package/dist/index.d.mts.map +1 -1
  45. package/dist/index.mjs +494 -59
  46. package/dist/index.mjs.map +1 -1
  47. package/package.json +4 -3
  48. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
  49. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
  50. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
  51. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
  52. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
  53. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
  54. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
  55. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
  56. package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
  57. package/.agent/cookbook/_setup/foundation.md +0 -359
  58. package/.agent/cookbook/_setup/healthcare.md +0 -361
  59. package/.agent/cookbook/_setup/payments.md +0 -365
  60. package/.agent/cookbook/_setup/subscription.md +0 -364
  61. package/.agent/cookbook/action-status-updaters.md +0 -278
  62. package/.agent/cookbook/ai-agent-invoke.md +0 -279
  63. package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
  64. package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
  65. package/.agent/cookbook/bulk-ingest.md +0 -302
  66. package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
  67. package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
  68. package/.agent/cookbook/generic-tables.md +0 -244
  69. package/.agent/cookbook/invite-team.md +0 -200
  70. package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
  71. package/.agent/cookbook/marketing-campaign-send.md +0 -1044
  72. package/.agent/cookbook/paginated-restapi-poller.md +0 -383
  73. package/.agent/cookbook/rest-fetch.md +0 -273
  74. package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
  75. package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
  76. package/.agent/cookbook/system-templates.md +0 -165
  77. package/.agent/cookbook/talk-to-data.md +0 -178
  78. package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
  79. package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
  80. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  81. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  82. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  83. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
@@ -1,661 +0,0 @@
1
- ---
2
- title: Send a KYC-notification SMS when a payment account becomes active
3
- summary: End-to-end standard workflow — provision a KYC-notification workflow over the payments payment_account dataset, ingest two Atomic FI payment-account rows (one active, one suspended) through a payment_account interop contract, run the workflow so the status filter routes each row, read the rendered SMS back from the message dataset, and close the connected-app reply loop. No agent, no LLM.
4
- industry: payments
5
- slug: kyc-notification-on-account-activation
6
- vitest_source:
7
- - integration-tests/tests/payments/standard-workflow.test.ts
8
- - integration-tests/tests/payments/interoperability-contracts.test.ts
9
- - integration-tests/tests/payments/run-dac-single.test.ts
10
- - integration-tests/tests/payments/create-dac.test.ts
11
- - integration-tests/tests/payments/tools.test.ts
12
- - integration-tests/tests/payments/data-sources.test.ts
13
- - integration-tests/tests/payments/bootstrap.test.ts
14
- status: green
15
- ---
16
-
17
- # Problem
18
-
19
- When a payment account transitions to `active`, the risk-ops team
20
- wants to send the account holder a notification SMS with a link
21
- to a self-serve KYC portal — so they can complete or review their
22
- know-your-customer details. An account that is still `suspended`
23
- should never trigger that message.
24
-
25
- The classic implementation queries the payment-accounts table on
26
- a status-change hook, branches on the new status, and hands the
27
- active ones to an SMS sender — with the status gate living in
28
- application code.
29
-
30
- The Alvera platform's standard workflow primitive keeps that gate
31
- inside the platform. A payments `payment_account` dataset is
32
- the workflow's event source; a pure-Liquid filter gates on
33
- `payment_account.status == "active"`; a single SMS action carries
34
- a deep-link to a connected-app KYC portal. The
35
- activated-or-not decision is a one-line filter, not a code branch.
36
-
37
- This cookbook walks the **whole** scenario, not just the
38
- provisioning: it creates the workflow, ingests two Atomic FI-shaped
39
- payment-account rows through the production data-activation chain
40
- (one active, one suspended), runs the workflow so the filter
41
- routes each row, reads the rendered notification SMS back out of
42
- the message dataset, and finally resolves the connected-app
43
- deep-link the SMS carries — closing the SMS → reply loop.
44
-
45
- The scenario is anchored to
46
- `platform/integration-tests/tests/payments/standard-workflow.test.ts`
47
- — a green end-to-end test (§1–§7) that exercises this exact shape.
48
- This cookbook is a prose re-presentation of what that test walks.
49
- The setup file `_setup/payments.md` already provisioned the
50
- tenant, datalake, and industry-admin client; this cookbook starts
51
- from there.
52
-
53
- # Composition
54
-
55
- | Resource provisioned | Owner |
56
- |--------------------------------------|-------------|
57
- | SMS tool (SNS-backed) | build |
58
- | KYC-portal connected app | build |
59
- | KYC-notification workflow | build |
60
- | Atomic FI data source | build |
61
- | Manual Upload tool | build |
62
- | Atomic FI Payment Account interop contract | build |
63
- | Manual-upload DAC | build |
64
-
65
- The setup file `_setup/payments.md` already provisioned the
66
- tenant + datalake + tenant-scoped client; this cookbook starts
67
- from there.
68
-
69
- # Walkthrough
70
-
71
- ## 001 — create the SMS tool
72
-
73
- The KYC-notification workflow's action invokes an SMS tool. The
74
- tool's `body.tool_body_type: 'sns'` means it routes via AWS SNS;
75
- local dev points it at LocalStack on `http://localhost:4566` via
76
- `endpoint_url` so no real AWS credentials are needed. The
77
- `intent: 'sms'` tags this tool for workflow actions that send SMS
78
- (versus `data_exchange` for ingestion tools).
79
-
80
- ```typescript
81
- const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
82
- name: `Cookbook SMS Tool ${runSuffix}`,
83
- description: 'SNS-backed SMS dispatcher for the KYC-notification workflow, wired to LocalStack.',
84
- intent: 'sms',
85
- status: 'active',
86
- datalake_id: ctx.datalakeId,
87
- body: {
88
- tool_body_type: 'sns',
89
- auth_method: 'access_key',
90
- region: 'us-east-1',
91
- phone_number: '+15551234567',
92
- endpoint_url: 'http://localhost:4566',
93
- access_key_id: 'test',
94
- secret_access_key: 'test',
95
- },
96
- })
97
- toolId = smsToolResp.data.id!
98
- ```
99
-
100
- ## 002 — create the KYC-portal connected app
101
-
102
- The workflow's SMS action carries a deep-link to a connected-app
103
- page so the account holder can open the self-serve KYC portal.
104
- The connected app is a thin registration of the portal's URL and
105
- mode; the actual page is hosted outside the platform
106
- (`mode: 'self_hosted'`). The server-derived `slug` is captured for
107
- the resolve-page call in §013.
108
-
109
- ```typescript
110
- const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
111
- name: `Cookbook KYC Portal ${runSuffix}`,
112
- description: 'Self-serve KYC portal linked from the outbound notification SMS.',
113
- mode: 'self_hosted',
114
- urls: [
115
- {
116
- url: 'https://kyc.example.local',
117
- is_primary: true,
118
- label: 'production',
119
- },
120
- ],
121
- })
122
- connectedAppId = connectedAppResp.data.id!
123
- ctx.connectedAppSlug = connectedAppResp.data.slug!
124
- ```
125
-
126
- ## 003 — create the KYC-notification workflow
127
-
128
- The workflow has the standard shape: a filter (Liquid; passes
129
- payment accounts whose `status` is `active`), a decision (a
130
- literal Liquid array naming one decision key), and one SMS
131
- action. The SMS body references the payment account's external id
132
- so the recipient knows which account activated, and
133
- `{{ connected_app_form_url }}` so the platform can bake in the
134
- `/t/<token>` KYC-portal link.
135
-
136
- `dataset_type: 'payment_account'` makes the payments payment
137
- account dataset the event source; the workflow context injects
138
- the payment account row under the `payment_account` key, so the
139
- filter reads `payment_account.status` directly. A
140
- `context_datasets` entry queries the `message` dataset for any
141
- KYC-notification SMS already sent to the same legal entity in the
142
- last six months — the platform's built-in guard against
143
- re-notifying. On a fresh tenant that lookup returns empty, which
144
- is harmless. The SMS `to` is a fixed inbox number because a
145
- payments legal entity's contact fields are tokenized and not
146
- resolved into the SMS templating context here.
147
-
148
- ```typescript
149
- const FILTER_BODY = '{% if payment_account.status == "active" %}true{% endif %}'
150
- const DECISION_KEY = 'send_pr_notification_sms'
151
- const DECISION_BODY = `["${DECISION_KEY}"]`
152
- const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
153
- const SMS_BODY_TEMPLATE =
154
- 'Alvera PR notification — payment_account ' +
155
- '{{ payment_account.payment_account_external_id }} has been activated. ' +
156
- 'Manage KYC: {{ connected_app_form_url }}'
157
-
158
- const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
159
- name: `Cookbook KYC Notification Workflow ${runSuffix}`,
160
- description: 'Sends a KYC-notification SMS for newly activated payment accounts with a self-serve KYC portal link.',
161
- dataset_type: 'payment_account',
162
- status: 'live',
163
- tags: ['compliance', 'kyc'],
164
- filter_config: {
165
- type: 'custom',
166
- body: FILTER_BODY,
167
- output_schema: { type: 'boolean' },
168
- },
169
- decision_config: {
170
- type: 'custom',
171
- body: DECISION_BODY,
172
- output_schema: DECISION_OUTPUT_SCHEMA,
173
- },
174
- context_datasets: [
175
- {
176
- dataset_type: 'message',
177
- where_clause:
178
- `rm.mdm_subject_id = '{{ payment_account.legal_entity_id }}' ` +
179
- `AND rm.decision_key = '${DECISION_KEY}' ` +
180
- "AND rm.sent_at > NOW() - INTERVAL '6 months'",
181
- limit: 1,
182
- position: 0,
183
- },
184
- ],
185
- actions: [
186
- {
187
- action_type: 'sms',
188
- tool_id: toolId,
189
- decision_key: DECISION_KEY,
190
- position: 0,
191
- trigger_template: 'now',
192
- idempotency_template: '{{ payment_account.id }}-{{ decision_key }}',
193
- connected_app_id: connectedAppId,
194
- connected_app_route: '/portal/kyc',
195
- connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
196
- tool_call: {
197
- tool_call_type: 'sms_request',
198
- to: { type: 'custom', body: '+15551234567' },
199
- body: { type: 'custom', body: SMS_BODY_TEMPLATE },
200
- sms_type: 'transactional',
201
- },
202
- },
203
- ],
204
- })
205
- workflowId = workflowResp.data.id!
206
- ctx.workflowSlug = workflowResp.data.slug!
207
- ```
208
-
209
- ## 004 — create the Atomic FI data source
210
-
211
- A workflow runs on rows; rows arrive through the data-activation
212
- chain. The chain's first link is a `DataSource` — a registration
213
- of where the rows originate. The `uri` is the system-of-record
214
- address; it flows into each ingested row's `source_uri`, which
215
- the payment-account contract carries through to the resource.
216
-
217
- ```typescript
218
- const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
219
- name: `Cookbook Atomic FI Source ${runSuffix}`,
220
- uri: 'api.atomic.fi',
221
- description: 'Atomic FI banking API — origin of the payment-account rows the KYC workflow runs on.',
222
- status: 'active',
223
- is_default: false,
224
- })
225
- dataSourceId = dataSourceResp.data.id!
226
- ```
227
-
228
- ## 005 — create the Manual Upload tool
229
-
230
- The Data Activation Client needs a tool. For inline-JSON ingest a
231
- `manual_upload` tool is the minimal choice — `intent:
232
- 'data_exchange'` distinguishes it from the SMS tool, and
233
- `tool_body_type: 'manual_upload'` needs no endpoint or credential
234
- wiring (the rows arrive in the ingest call body, not by the tool
235
- fetching them).
236
-
237
- ```typescript
238
- const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
239
- name: `Cookbook Manual Upload Tool ${runSuffix}`,
240
- description: 'Manual-upload data-exchange tool — backs the DAC that ingests Atomic FI payment-account rows.',
241
- intent: 'data_exchange',
242
- status: 'active',
243
- datalake_id: ctx.datalakeId,
244
- data_source_id: dataSourceId,
245
- body: { tool_body_type: 'manual_upload' },
246
- })
247
- ctx.manualUploadToolId = manualUploadToolResp.data.id!
248
- ```
249
-
250
- ## 006 — create the Atomic FI Payment Account interoperability contract
251
-
252
- The interoperability contract is the row-shaping rule: a Liquid
253
- template that maps an inbound Atomic FI payment-account row into a
254
- payments `PaymentAccount` upsert. This cookbook loads the
255
- production Atomic FI payment-account + MDM templates from the
256
- vendored fixtures directory rather than inlining the Liquid.
257
-
258
- Two template slots matter. `template_config` shapes the
259
- PaymentAccount resource — including PCI-sensitive fields
260
- (`account_number`, `iban`) the changeset tokenizes automatically.
261
- `mdm_input_config` shapes the MDM input — the `account_holder_id`
262
- the platform's master-data resolution keys on to find-or-create
263
- the LegalEntity + AccountHolder pair the payment account belongs
264
- to.
265
-
266
- ```typescript
267
- const { readFileSync } = await import('node:fs')
268
- const { join } = await import('node:path')
269
- const paTemplate = readFileSync(
270
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_payment_accounts_payments_payment_account.liquid'),
271
- 'utf8',
272
- )
273
- const mdmTemplate = readFileSync(
274
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_payment_accounts_payments_mdm.liquid'),
275
- 'utf8',
276
- )
277
-
278
- const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
279
- name: `Cookbook Atomic FI Payment Account Contract ${runSuffix}`,
280
- description: 'Atomic FI payment-accounts → Payments PaymentAccount (custom Liquid + MDM input).',
281
- resource_type: 'payment_account',
282
- template_config: { type: 'custom', body: paTemplate },
283
- mdm_input_config: { type: 'custom', body: mdmTemplate },
284
- generic_table_id: null,
285
- })
286
- interopContractId = contractResp.data.id!
287
- ```
288
-
289
- ## 007 — create the manual-upload DAC
290
-
291
- The Data Activation Client binds the three preceding pieces — the
292
- manual-upload tool, the data source, and the interop contract —
293
- into one ingestion endpoint. `tool_call.tool_call_type:
294
- 'manual_upload'` selects the inline-JSON ingest path. The
295
- server-derived `slug` is the handle §008 ingests rows against.
296
-
297
- ```typescript
298
- const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
299
- name: `Cookbook Atomic FI Payment Account DAC ${runSuffix}`,
300
- description: 'Manual-upload DAC — ingests Atomic FI payment-account rows into PaymentAccount via the interop contract.',
301
- tool_id: ctx.manualUploadToolId,
302
- data_source_id: dataSourceId,
303
- tool_call: { tool_call_type: 'manual_upload' },
304
- interoperability_contract_ids: [interopContractId],
305
- })
306
- dacId = dacResp.data.id!
307
- ctx.dacSlug = dacResp.data.slug!
308
- ```
309
-
310
- ## 008 — ingest two Atomic FI payment-account rows
311
-
312
- Two rows, ingested as inline JSON through the manual-upload DAC.
313
- They differ in the field the workflow filter cares about:
314
- `status`. The first row is `active` — the filter will pass it.
315
- The second is `suspended` — the filter must reject it. Each row
316
- carries a distinct `account_holder_id` so MDM resolves each to its
317
- own legal entity. Each ingest call gets its own batch id; both are
318
- pinned for the run scope in §010.
319
-
320
- ```typescript
321
- const activeRow = {
322
- payment_account_external_id: `PA-ACTIVE-${runSuffix}`,
323
- payment_account_number: `PA-NUM-A-${runSuffix}`,
324
- account_holder_id: `AH-KYC-${runSuffix}-active`,
325
- status: 'active',
326
- account_type: 'bank_account',
327
- currency: 'USD',
328
- routing_number: '121000358',
329
- swift_bic: 'BOFAUS3N',
330
- bank_name: 'Bank of America',
331
- enabled_regimes: ['us_domestic'],
332
- source_uri: 'api.atomic.fi',
333
- }
334
- const suspendedRow = {
335
- payment_account_external_id: `PA-SUSPENDED-${runSuffix}`,
336
- payment_account_number: `PA-NUM-S-${runSuffix}`,
337
- account_holder_id: `AH-KYC-${runSuffix}-suspended`,
338
- status: 'suspended',
339
- account_type: 'bank_account',
340
- currency: 'USD',
341
- routing_number: '121000358',
342
- bank_name: 'Bank of America',
343
- enabled_regimes: ['us_domestic'],
344
- source_uri: 'api.atomic.fi',
345
- }
346
-
347
- const [activeIngest, suspendedIngest] = await Promise.all([
348
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: activeRow }),
349
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: suspendedRow }),
350
- ])
351
- ctx.batchActive = activeIngest.data.batch_id!
352
- ctx.batchSuspended = suspendedIngest.data.batch_id!
353
- ```
354
-
355
- ## 009 — wait for both ingest batches to reach steady-state
356
-
357
- Ingestion is async — the DAC enqueues per-row jobs that the
358
- `BatchMergeWorker` drains into the regulated payment-account
359
- table. Poll the DAC's activation logs until both batches show a
360
- row with `rows_ingested >= 1` and a non-empty `output_files`
361
- array (the merged Parquet landed in object storage). Only then is
362
- it safe to run the workflow against these rows.
363
-
364
- ```typescript
365
- const targetBatches = new Set([ctx.batchActive, ctx.batchSuspended])
366
- const deadline = Date.now() + 90_000
367
- let greenCount = 0
368
- while (Date.now() < deadline) {
369
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
370
- const green = new Set<string>()
371
- for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
372
- const b = row.batch_id
373
- if (typeof b !== 'string' || !targetBatches.has(b)) continue
374
- if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
375
- const files = row.output_files
376
- if (!Array.isArray(files) || files.length === 0) continue
377
- green.add(b)
378
- }
379
- greenCount = green.size
380
- if (greenCount === targetBatches.size) break
381
- await new Promise((r) => setTimeout(r, 1_000))
382
- }
383
- if (greenCount !== targetBatches.size) {
384
- throw new Error(`only ${greenCount}/2 payment-account batches reached steady-state within 90s`)
385
- }
386
- ```
387
-
388
- ## 010 — run the workflow against the two batches
389
-
390
- `workflows.run` with `manual_override: false` evaluates the
391
- filter, so the `payment_account.status` filter genuinely routes
392
- each row. The SQL where-clause scopes the run to exactly the two
393
- batches §008 ingested (`rpa` is the regulated-payment-account
394
- alias the run-query exposes).
395
-
396
- The SMS action's `trigger_template: 'now'` dispatches the action
397
- immediately rather than deferring it, so once the run fires it
398
- reaches a terminal status on its own — poll `batchLogs.refresh`
399
- until it leaves `:pending`. The run itself is still scheduled:
400
- `workflows.run` records it and returns, which is why the setup
401
- file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
402
- one row passed and one was filtered.
403
-
404
- ```typescript
405
- const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
406
- sql_where_clause: `rpa.batch_id IN ('${ctx.batchActive}', '${ctx.batchSuspended}')`,
407
- mode: 'live',
408
- manual_override: false,
409
- })
410
- // run-workflow only SCHEDULES the run. The log id and batch id are
411
- // written when it fires, so read them back via workflowRuns.get.
412
- const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
413
- ctx.runLogId = fired.workflowRunLogId
414
- ctx.runBatchId = fired.batchId!
415
-
416
- const deadline = Date.now() + 120_000
417
- let status: string | null = null
418
- while (Date.now() < deadline) {
419
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
420
- status = log.status ?? null
421
- if (status && status !== 'pending') break
422
- await new Promise((r) => setTimeout(r, 2_000))
423
- }
424
- if (status === 'failed') throw new Error('workflow run reached :failed')
425
- if (!status || status === 'pending') {
426
- throw new Error('workflow run did not leave :pending within 120s')
427
- }
428
- ```
429
-
430
- ## 011 — verify the filter routed: active passes, suspended is filtered
431
-
432
- Each row produced a Workflow Execution Log. The active payment
433
- account passes the filter, so its WEL is `:executing` or
434
- `:completed`. The suspended payment account fails the filter, so
435
- its WEL is `:filtered`. A run where both passed — or both were
436
- filtered — would mean the filter is not actually evaluating
437
- `payment_account.status`.
438
-
439
- ```typescript
440
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
441
- const ourWels = (wfLogs.data ?? []).filter(
442
- (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
443
- )
444
- if (ourWels.length !== 2) {
445
- throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
446
- }
447
-
448
- const byStatus: Record<string, number> = {}
449
- for (const w of ourWels) {
450
- const st = (w as { status?: string }).status ?? 'unknown'
451
- byStatus[st] = (byStatus[st] ?? 0) + 1
452
- }
453
- const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
454
- if (passCount !== 1) {
455
- throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
456
- }
457
- if ((byStatus.filtered ?? 0) !== 1) {
458
- throw new Error(
459
- `expected 1 :filtered WEL (the suspended payment account) — distribution ${JSON.stringify(byStatus)}`,
460
- )
461
- }
462
- ```
463
-
464
- ## 012 — read the rendered notification SMS back from the message dataset
465
-
466
- A `trigger_template: 'now'` action that passed the filter fires
467
- immediately and persists a row in the regulated `message`
468
- dataset, carrying the fully-rendered SMS body. Search that
469
- dataset scoped to this workflow, poll until the row appears, and
470
- extract the `/t/<token>` shortlink the platform baked into the
471
- body when it minted the connected-app page token.
472
-
473
- ```typescript
474
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
475
- search_query: `rm.workflow_id = '${workflowId}'`,
476
- })
477
- if (userSearch.status !== 'completed') {
478
- throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
479
- }
480
-
481
- const deadline = Date.now() + 45_000
482
- let messages: Array<Record<string, unknown>> = []
483
- while (Date.now() < deadline && messages.length === 0) {
484
- const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
485
- userSearchId: userSearch.id!,
486
- dataAccessMode: 'regulated',
487
- })
488
- messages = (data.data ?? []) as Array<Record<string, unknown>>
489
- if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
490
- }
491
- if (messages.length === 0) {
492
- throw new Error('no KYC-notification SMS message persisted for the workflow within 45s')
493
- }
494
-
495
- const withLink = messages
496
- .map((m) => String(m.body ?? ''))
497
- .find((body) => body.includes('/t/') && body.includes('Alvera PR notification'))
498
- if (!withLink) {
499
- throw new Error(`no rendered notification body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
500
- }
501
- const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
502
- if (!tokenMatch) {
503
- throw new Error(`no /t/<token> in rendered body: ${withLink}`)
504
- }
505
- ctx.kycShortPath = tokenMatch[1]!
506
- ```
507
-
508
- ## 013 — resolve the connected-app deep-link and post tracking
509
-
510
- The `/t/<token>` shortlink the SMS carries resolves via
511
- `connectedApps.resolvePage` — the `route_path` must match the
512
- action's `connected_app_route`. Posting `opened_at` +
513
- `form_submitted_at` via `updateMessageTracking` then mirrors what
514
- the connected-app frontend does when the account holder opens the
515
- KYC portal, closing the SMS → reply loop end-to-end.
516
-
517
- ```typescript
518
- const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
519
- short_path: ctx.kycShortPath,
520
- user_agent: 'cookbook-doctest/kyc-notification',
521
- })
522
- if (resolved.route_path !== '/portal/kyc') {
523
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
524
- }
525
- if (!(resolved.message?.body ?? '').includes('Alvera PR notification')) {
526
- throw new Error('resolved page message body missing the notification copy')
527
- }
528
-
529
- const now = new Date().toISOString()
530
- const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
531
- short_path: ctx.kycShortPath,
532
- opened_at: now,
533
- form_submitted_at: now,
534
- })
535
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
536
- throw new Error('message tracking did not persist opened_at + form_submitted_at')
537
- }
538
- ```
539
-
540
- ## 014 — write the integration test
541
-
542
- End the build with a test you keep: re-read the workflow and prove the
543
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
544
- never-matching selection runs the FULL pipeline (selection → filter →
545
- decision) and intercepts only the final action call, so no message
546
- leaves, yet the acknowledgement proves the workflow is runnable. This
547
- block runs live under `make validate-cookbook`.
548
-
549
- ```typescript
550
- // Re-GET — the workflow must still be live, or nothing will run.
551
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
552
- if (wfRow.status !== 'live') {
553
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
554
- }
555
- // Behavioural probe — a dry run against a selection no row can match:
556
- // the pipeline executes end-to-end, the final action call is
557
- // intercepted, and the acknowledgement carries the scheduled run id. The
558
- // clause must speak this workflow's selection dialect — the dataset
559
- // alias is `rpa` here, the same alias the live run above uses.
560
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
561
- sql_where_clause: "rpa.batch_id = 'test-never-matching-batch'",
562
- mode: 'dry_run',
563
- manual_override: false,
564
- })
565
- // The run-log id does not exist until the run fires — wait, do not read a null.
566
- const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
567
- if (probeFired.workflowRunLogId.length === 0) {
568
- throw new Error('dry-run probe never produced a workflow_run_log_id')
569
- }
570
- ```
571
-
572
- If the probe fails in production, escalate with the run response as
573
- evidence — don't flip the workflow's status or rewrite its configs to
574
- chase the error.
575
-
576
- # Branches
577
-
578
- - **The suspended account is filtered, not failed** — §011
579
- asserts the suspended payment account's WEL is `:filtered`, a
580
- distinct terminal status from `:failed`. A filtered row is a
581
- *correct* outcome: the workflow looked at it, the
582
- `payment_account.status == "active"` filter rendered empty, and
583
- the platform recorded the row as intentionally skipped. No SMS
584
- runs for a filtered row — exactly the business rule (never send
585
- a "your account is active" notification for an account that is
586
- not).
587
- - **The bulk-batch variant** — the anchor vitest runs the whole
588
- Atomic FI bulk batch in one go (three active rows and one
589
- suspended) and asserts the same split: the active WELs route
590
- past the filter, the suspended one records `:filtered`. This
591
- cookbook walks the minimal two-row version; the polarity is
592
- identical.
593
- - **The six-month recency guard** — §003's `context_datasets`
594
- entry queries the `message` dataset for a prior KYC
595
- notification to the same legal entity. On this fresh tenant it
596
- returns empty, so the action runs. On a tenant with history, a
597
- recent prior message would make the action skip — the
598
- platform's built-in anti-duplication guard, expressed as a
599
- context query rather than application code.
600
-
601
- # Rollback
602
-
603
- The cookbook doctest harness does not currently tear down created
604
- resources. The `_setup/payments.md` setup file's
605
- runSuffix-scoped tenant / datalake / user names mean each run is
606
- naturally isolated; the seeded local DB is cheap to reset
607
- (`mix ecto.reset` on the platform repo).
608
-
609
- # Outcome
610
-
611
- After this cookbook's thirteen steps run green:
612
-
613
- - A payments tenant exists with a payments-domain
614
- datalake
615
- - An SMS tool, a KYC-portal connected app, and a KYC-notification
616
- standard workflow (status `live`, `payment_account` dataset)
617
- are registered
618
- - An Atomic FI data source, a Manual Upload tool, an Atomic FI
619
- Payment Account interop contract, and a manual-upload DAC form
620
- a working ingestion chain
621
- - Two payment accounts have been ingested through that chain —
622
- one active, one suspended — each resolved to its own legal
623
- entity by MDM
624
- - Running the workflow routed them correctly: the active account
625
- passed the filter and dispatched an SMS; the suspended account
626
- was `:filtered`
627
- - The rendered notification SMS is readable from the `message`
628
- dataset, carrying the "Alvera PR notification" copy with the
629
- activated account's external id and a `/t/` deep-link
630
- - The connected-app deep-link the SMS carried resolves, and
631
- message tracking records the open + form-submit timestamps
632
-
633
- The business outcome — a KYC-notification SMS sent when a payment
634
- account activates, with a working KYC-portal link — is
635
- demonstrated end-to-end, not merely provisioned.
636
-
637
- # See also
638
-
639
- - `_setup/payments.md` — the inlined bootstrap that
640
- provisions the tenant + datalake this cookbook starts from
641
- - `sanctions-screening-with-agent-review.md` — the agent-driven
642
- payments cookbook; an LLM agent disambiguates gray-zone
643
- sanctions screenings
644
- - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
645
- manual-upload tool body shapes; intent classification
646
- - `.agent/connected_apps.md` — connected-app registration + the
647
- resolve-page / message-tracking reply loop
648
- - `.agent/interoperability_contracts.md` — custom contract shape,
649
- `template_config` vs `mdm_input_config`
650
- - `.agent/data_activation_clients.md` — data source → tool →
651
- interop contract → DAC ingestion chain
652
- - `.agent/workflows.md` — standard workflow primitive (filter +
653
- decision + actions), `context_datasets`, `workflows.run`
654
- - `.agent/cookbook/_fixtures/payments/` — the vendored Atomic
655
- FI payment-account / MDM Liquid templates §006 loads
656
- - `integration-tests/tests/payments/standard-workflow.test.ts` —
657
- the anchor green test (§1–§7) these snippets are lifted from
658
- - `integration-tests/tests/payments/interoperability-contracts.test.ts`,
659
- `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
660
- `data-sources.test.ts` — the per-resource create + ingest
661
- snippets §004–§009 are lifted from