@alvera-ai/platform-sdk 0.10.0-rc.2 → 0.10.0-rc.21

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 (55) hide show
  1. package/.agent/AGENTS.md +440 -0
  2. package/.agent/account_management.md +455 -0
  3. package/.agent/action_status_updaters.md +262 -0
  4. package/.agent/ai_agents.md +423 -0
  5. package/.agent/ai_sandbox.md +265 -0
  6. package/.agent/async.md +111 -0
  7. package/.agent/connected_apps.md +407 -0
  8. package/.agent/cookbook/_fixtures/README.md +99 -0
  9. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
  10. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
  11. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  14. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  17. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  18. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  19. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  21. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  22. package/.agent/cookbook/_setup/foundation.md +277 -0
  23. package/.agent/cookbook/_setup/healthcare.md +279 -0
  24. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  25. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  26. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  27. package/.agent/cookbook/contact-us-triage-with-llm.md +603 -0
  28. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  29. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  30. package/.agent/cookbook/sanctions-screening-with-agent-review.md +711 -0
  31. package/.agent/cookbook/score-leads-with-llm-categorization.md +602 -0
  32. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  33. package/.agent/data_activation_clients.md +557 -0
  34. package/.agent/data_sources.md +234 -0
  35. package/.agent/datalakes.md +712 -0
  36. package/.agent/debugging.md +137 -0
  37. package/.agent/errors.md +196 -0
  38. package/.agent/generic_tables.md +351 -0
  39. package/.agent/interoperability_contracts.md +351 -0
  40. package/.agent/mdm.md +293 -0
  41. package/.agent/mutations.md +152 -0
  42. package/.agent/templates.md +98 -0
  43. package/.agent/tool-call-configs.md +90 -0
  44. package/.agent/tools.md +546 -0
  45. package/.agent/type_naming.md +131 -0
  46. package/.agent/workflows.md +601 -0
  47. package/README.md +46 -0
  48. package/dist/bin/platform-sdk.d.mts +1 -0
  49. package/dist/bin/platform-sdk.mjs +106 -0
  50. package/dist/bin/platform-sdk.mjs.map +1 -0
  51. package/dist/index.d.mts +1200 -43201
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +1859 -7319
  54. package/dist/index.mjs.map +1 -1
  55. package/package.json +19 -10
@@ -0,0 +1,619 @@
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 payment-risk 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: payment_risk
5
+ slug: kyc-notification-on-account-activation
6
+ vitest_source:
7
+ - integration-tests/tests/payment_risk/standard-workflow.test.ts
8
+ - integration-tests/tests/payment_risk/interoperability-contracts.test.ts
9
+ - integration-tests/tests/payment_risk/run-dac-single.test.ts
10
+ - integration-tests/tests/payment_risk/create-dac.test.ts
11
+ - integration-tests/tests/payment_risk/tools.test.ts
12
+ - integration-tests/tests/payment_risk/data-sources.test.ts
13
+ - integration-tests/tests/payment_risk/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 payment-risk `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/payment_risk/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/payment_risk.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/payment_risk.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 payment-risk 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
+ payment-risk 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
+ filter_config: {
164
+ type: 'custom',
165
+ body: FILTER_BODY,
166
+ output_schema: '{"type":"boolean"}',
167
+ },
168
+ decision_config: {
169
+ type: 'custom',
170
+ body: DECISION_BODY,
171
+ output_schema: DECISION_OUTPUT_SCHEMA,
172
+ },
173
+ context_datasets: [
174
+ {
175
+ dataset_type: 'message',
176
+ where_clause:
177
+ `rm.mdm_subject_id = '{{ payment_account.legal_entity_id }}' ` +
178
+ `AND rm.decision_key = '${DECISION_KEY}' ` +
179
+ "AND rm.sent_at > NOW() - INTERVAL '6 months'",
180
+ limit: 1,
181
+ position: 0,
182
+ },
183
+ ],
184
+ actions: [
185
+ {
186
+ action_type: 'sms',
187
+ tool_id: toolId,
188
+ decision_key: DECISION_KEY,
189
+ position: 0,
190
+ trigger_template: 'now',
191
+ idempotency_template: '{{ payment_account.id }}-{{ decision_key }}-{{ "" | uuid }}',
192
+ connected_app_id: connectedAppId,
193
+ connected_app_route: '/portal/kyc',
194
+ connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
195
+ tool_call: {
196
+ tool_call_type: 'sms_request',
197
+ to: { type: 'custom', body: '+15551234567' },
198
+ body: { type: 'custom', body: SMS_BODY_TEMPLATE },
199
+ sms_type: 'transactional',
200
+ },
201
+ },
202
+ ],
203
+ })
204
+ workflowId = workflowResp.data.id!
205
+ ctx.workflowSlug = workflowResp.data.slug!
206
+ ```
207
+
208
+ ## 004 — create the Atomic FI data source
209
+
210
+ A workflow runs on rows; rows arrive through the data-activation
211
+ chain. The chain's first link is a `DataSource` — a registration
212
+ of where the rows originate. The `uri` is the system-of-record
213
+ address; it flows into each ingested row's `source_uri`, which
214
+ the payment-account contract carries through to the resource.
215
+
216
+ ```typescript
217
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
218
+ name: `Cookbook Atomic FI Source ${runSuffix}`,
219
+ uri: 'api.atomic.fi',
220
+ description: 'Atomic FI banking API — origin of the payment-account rows the KYC workflow runs on.',
221
+ status: 'active',
222
+ is_default: false,
223
+ })
224
+ dataSourceId = dataSourceResp.data.id!
225
+ ```
226
+
227
+ ## 005 — create the Manual Upload tool
228
+
229
+ The Data Activation Client needs a tool. For inline-JSON ingest a
230
+ `manual_upload` tool is the minimal choice — `intent:
231
+ 'data_exchange'` distinguishes it from the SMS tool, and
232
+ `tool_body_type: 'manual_upload'` needs no endpoint or credential
233
+ wiring (the rows arrive in the ingest call body, not by the tool
234
+ fetching them).
235
+
236
+ ```typescript
237
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
238
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
239
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests Atomic FI payment-account rows.',
240
+ intent: 'data_exchange',
241
+ status: 'active',
242
+ datalake_id: ctx.datalakeId,
243
+ data_source_id: dataSourceId,
244
+ body: { tool_body_type: 'manual_upload' },
245
+ })
246
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
247
+ ```
248
+
249
+ ## 006 — create the Atomic FI Payment Account interoperability contract
250
+
251
+ The interoperability contract is the row-shaping rule: a Liquid
252
+ template that maps an inbound Atomic FI payment-account row into a
253
+ payment-risk `PaymentAccount` upsert. This cookbook loads the
254
+ production Atomic FI payment-account + MDM templates from the
255
+ vendored fixtures directory rather than inlining the Liquid.
256
+
257
+ Two template slots matter. `template_config` shapes the
258
+ PaymentAccount resource — including PCI-sensitive fields
259
+ (`account_number`, `iban`) the changeset tokenizes automatically.
260
+ `mdm_input_config` shapes the MDM input — the `account_holder_id`
261
+ the platform's master-data resolution keys on to find-or-create
262
+ the LegalEntity + AccountHolder pair the payment account belongs
263
+ to.
264
+
265
+ ```typescript
266
+ const { readFileSync } = await import('node:fs')
267
+ const { join } = await import('node:path')
268
+ const paTemplate = readFileSync(
269
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_payment_accounts_payment_risk_payment_account.liquid'),
270
+ 'utf8',
271
+ )
272
+ const mdmTemplate = readFileSync(
273
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_payment_accounts_payment_risk_mdm.liquid'),
274
+ 'utf8',
275
+ )
276
+
277
+ const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
278
+ name: `Cookbook Atomic FI Payment Account Contract ${runSuffix}`,
279
+ description: 'Atomic FI payment-accounts → PaymentRisk PaymentAccount (custom Liquid + MDM input).',
280
+ resource_type: 'payment_account',
281
+ template_config: { type: 'custom', body: paTemplate },
282
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
283
+ generic_table_id: null,
284
+ })
285
+ interopContractId = contractResp.data.id!
286
+ ```
287
+
288
+ ## 007 — create the manual-upload DAC
289
+
290
+ The Data Activation Client binds the three preceding pieces — the
291
+ manual-upload tool, the data source, and the interop contract —
292
+ into one ingestion endpoint. `tool_call.tool_call_type:
293
+ 'manual_upload'` selects the inline-JSON ingest path. The
294
+ server-derived `slug` is the handle §008 ingests rows against.
295
+
296
+ ```typescript
297
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
298
+ name: `Cookbook Atomic FI Payment Account DAC ${runSuffix}`,
299
+ description: 'Manual-upload DAC — ingests Atomic FI payment-account rows into PaymentAccount via the interop contract.',
300
+ tool_id: ctx.manualUploadToolId,
301
+ data_source_id: dataSourceId,
302
+ tool_call: { tool_call_type: 'manual_upload' },
303
+ interoperability_contract_ids: [interopContractId],
304
+ })
305
+ dacId = dacResp.data.id!
306
+ ctx.dacSlug = dacResp.data.slug!
307
+ ```
308
+
309
+ ## 008 — ingest two Atomic FI payment-account rows
310
+
311
+ Two rows, ingested as inline JSON through the manual-upload DAC.
312
+ They differ in the field the workflow filter cares about:
313
+ `status`. The first row is `active` — the filter will pass it.
314
+ The second is `suspended` — the filter must reject it. Each row
315
+ carries a distinct `account_holder_id` so MDM resolves each to its
316
+ own legal entity. Each ingest call gets its own batch id; both are
317
+ pinned for the run scope in §010.
318
+
319
+ ```typescript
320
+ const activeRow = {
321
+ payment_account_external_id: `PA-ACTIVE-${runSuffix}`,
322
+ payment_account_number: `PA-NUM-A-${runSuffix}`,
323
+ account_holder_id: `AH-KYC-${runSuffix}-active`,
324
+ status: 'active',
325
+ account_type: 'bank_account',
326
+ currency: 'USD',
327
+ routing_number: '121000358',
328
+ swift_bic: 'BOFAUS3N',
329
+ bank_name: 'Bank of America',
330
+ enabled_regimes: ['us_domestic'],
331
+ source_uri: 'api.atomic.fi',
332
+ }
333
+ const suspendedRow = {
334
+ payment_account_external_id: `PA-SUSPENDED-${runSuffix}`,
335
+ payment_account_number: `PA-NUM-S-${runSuffix}`,
336
+ account_holder_id: `AH-KYC-${runSuffix}-suspended`,
337
+ status: 'suspended',
338
+ account_type: 'bank_account',
339
+ currency: 'USD',
340
+ routing_number: '121000358',
341
+ bank_name: 'Bank of America',
342
+ enabled_regimes: ['us_domestic'],
343
+ source_uri: 'api.atomic.fi',
344
+ }
345
+
346
+ const [activeIngest, suspendedIngest] = await Promise.all([
347
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: activeRow }),
348
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: suspendedRow }),
349
+ ])
350
+ ctx.batchActive = activeIngest.data.batch_id!
351
+ ctx.batchSuspended = suspendedIngest.data.batch_id!
352
+ ```
353
+
354
+ ## 009 — wait for both ingest batches to reach steady-state
355
+
356
+ Ingestion is async — the DAC enqueues per-row jobs that the
357
+ `BatchMergeWorker` drains into the regulated payment-account
358
+ table. Poll the DAC's activation logs until both batches show a
359
+ row with `rows_ingested >= 1` and a non-empty `output_files`
360
+ array (the merged Parquet landed in object storage). Only then is
361
+ it safe to run the workflow against these rows.
362
+
363
+ ```typescript
364
+ const targetBatches = new Set([ctx.batchActive, ctx.batchSuspended])
365
+ const deadline = Date.now() + 90_000
366
+ let greenCount = 0
367
+ while (Date.now() < deadline) {
368
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
369
+ const green = new Set<string>()
370
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
371
+ const b = row.batch_id
372
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
373
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
374
+ const files = row.output_files
375
+ if (!Array.isArray(files) || files.length === 0) continue
376
+ green.add(b)
377
+ }
378
+ greenCount = green.size
379
+ if (greenCount === targetBatches.size) break
380
+ await new Promise((r) => setTimeout(r, 1_000))
381
+ }
382
+ if (greenCount !== targetBatches.size) {
383
+ throw new Error(`only ${greenCount}/2 payment-account batches reached steady-state within 90s`)
384
+ }
385
+ ```
386
+
387
+ ## 010 — run the workflow against the two batches
388
+
389
+ `workflows.run` with `manual_override: false` evaluates the
390
+ filter, so the `payment_account.status` filter genuinely routes
391
+ each row. The SQL where-clause scopes the run to exactly the two
392
+ batches §008 ingested (`rpa` is the regulated-payment-account
393
+ alias the run-query exposes).
394
+
395
+ The SMS action's `trigger_template: 'now'` dispatches the action
396
+ immediately rather than scheduling it, so the run reaches a
397
+ terminal status on its own — poll `batchLogs.refresh` until it
398
+ leaves `:pending`. A `:partial` status is expected and fine here:
399
+ one row passed and one was filtered.
400
+
401
+ ```typescript
402
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
403
+ sql_where_clause: `rpa.batch_id IN ('${ctx.batchActive}', '${ctx.batchSuspended}')`,
404
+ mode: 'live',
405
+ manual_override: false,
406
+ })
407
+ ctx.runLogId = runResp.data.workflow_run_log_id!
408
+ ctx.runBatchId = runResp.data.batch_id!
409
+
410
+ const deadline = Date.now() + 120_000
411
+ let status: string | null = null
412
+ while (Date.now() < deadline) {
413
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
414
+ status = log.status ?? null
415
+ if (status && status !== 'pending') break
416
+ await new Promise((r) => setTimeout(r, 2_000))
417
+ }
418
+ if (status === 'failed') throw new Error('workflow run reached :failed')
419
+ if (!status || status === 'pending') {
420
+ throw new Error('workflow run did not leave :pending within 120s')
421
+ }
422
+ ```
423
+
424
+ ## 011 — verify the filter routed: active passes, suspended is filtered
425
+
426
+ Each row produced a Workflow Execution Log. The active payment
427
+ account passes the filter, so its WEL is `:executing` or
428
+ `:completed`. The suspended payment account fails the filter, so
429
+ its WEL is `:filtered`. A run where both passed — or both were
430
+ filtered — would mean the filter is not actually evaluating
431
+ `payment_account.status`.
432
+
433
+ ```typescript
434
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
435
+ const ourWels = (wfLogs.data ?? []).filter(
436
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
437
+ )
438
+ if (ourWels.length !== 2) {
439
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
440
+ }
441
+
442
+ const byStatus: Record<string, number> = {}
443
+ for (const w of ourWels) {
444
+ const st = (w as { status?: string }).status ?? 'unknown'
445
+ byStatus[st] = (byStatus[st] ?? 0) + 1
446
+ }
447
+ const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
448
+ if (passCount !== 1) {
449
+ throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
450
+ }
451
+ if ((byStatus.filtered ?? 0) !== 1) {
452
+ throw new Error(
453
+ `expected 1 :filtered WEL (the suspended payment account) — distribution ${JSON.stringify(byStatus)}`,
454
+ )
455
+ }
456
+ ```
457
+
458
+ ## 012 — read the rendered notification SMS back from the message dataset
459
+
460
+ A `trigger_template: 'now'` action that passed the filter fires
461
+ immediately and persists a row in the regulated `message`
462
+ dataset, carrying the fully-rendered SMS body. Search that
463
+ dataset scoped to this workflow, poll until the row appears, and
464
+ extract the `/t/<token>` shortlink the platform baked into the
465
+ body when it minted the connected-app page token.
466
+
467
+ ```typescript
468
+ const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
469
+ search_query: `rm.workflow_id = '${workflowId}'`,
470
+ })
471
+ if (userSearch.status !== 'completed') {
472
+ throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
473
+ }
474
+
475
+ const deadline = Date.now() + 45_000
476
+ let messages: Array<Record<string, unknown>> = []
477
+ while (Date.now() < deadline && messages.length === 0) {
478
+ const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
479
+ userSearchId: userSearch.id!,
480
+ dataAccessMode: 'regulated',
481
+ })
482
+ messages = (data.data ?? []) as Array<Record<string, unknown>>
483
+ if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
484
+ }
485
+ if (messages.length === 0) {
486
+ throw new Error('no KYC-notification SMS message persisted for the workflow within 45s')
487
+ }
488
+
489
+ const withLink = messages
490
+ .map((m) => String(m.body ?? ''))
491
+ .find((body) => body.includes('/t/') && body.includes('Alvera PR notification'))
492
+ if (!withLink) {
493
+ throw new Error(`no rendered notification body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
494
+ }
495
+ const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
496
+ if (!tokenMatch) {
497
+ throw new Error(`no /t/<token> in rendered body: ${withLink}`)
498
+ }
499
+ ctx.kycShortPath = tokenMatch[1]!
500
+ ```
501
+
502
+ ## 013 — resolve the connected-app deep-link and post tracking
503
+
504
+ The `/t/<token>` shortlink the SMS carries resolves via
505
+ `connectedApps.resolvePage` — the `route_path` must match the
506
+ action's `connected_app_route`. Posting `opened_at` +
507
+ `form_submitted_at` via `updateMessageTracking` then mirrors what
508
+ the connected-app frontend does when the account holder opens the
509
+ KYC portal, closing the SMS → reply loop end-to-end.
510
+
511
+ ```typescript
512
+ const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
513
+ short_path: ctx.kycShortPath,
514
+ user_agent: 'cookbook-doctest/kyc-notification',
515
+ })
516
+ if (resolved.route_path !== '/portal/kyc') {
517
+ throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
518
+ }
519
+ if (!(resolved.message?.body ?? '').includes('Alvera PR notification')) {
520
+ throw new Error('resolved page message body missing the notification copy')
521
+ }
522
+
523
+ const now = new Date().toISOString()
524
+ const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
525
+ short_path: ctx.kycShortPath,
526
+ opened_at: now,
527
+ form_submitted_at: now,
528
+ })
529
+ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
530
+ throw new Error('message tracking did not persist opened_at + form_submitted_at')
531
+ }
532
+ ```
533
+
534
+ # Branches
535
+
536
+ - **The suspended account is filtered, not failed** — §011
537
+ asserts the suspended payment account's WEL is `:filtered`, a
538
+ distinct terminal status from `:failed`. A filtered row is a
539
+ *correct* outcome: the workflow looked at it, the
540
+ `payment_account.status == "active"` filter rendered empty, and
541
+ the platform recorded the row as intentionally skipped. No SMS
542
+ runs for a filtered row — exactly the business rule (never send
543
+ a "your account is active" notification for an account that is
544
+ not).
545
+ - **The bulk-batch variant** — the anchor vitest runs the whole
546
+ Atomic FI bulk batch in one go (three active rows and one
547
+ suspended) and asserts the same split: the active WELs route
548
+ past the filter, the suspended one records `:filtered`. This
549
+ cookbook walks the minimal two-row version; the polarity is
550
+ identical.
551
+ - **The six-month recency guard** — §003's `context_datasets`
552
+ entry queries the `message` dataset for a prior KYC
553
+ notification to the same legal entity. On this fresh tenant it
554
+ returns empty, so the action runs. On a tenant with history, a
555
+ recent prior message would make the action skip — the
556
+ platform's built-in anti-duplication guard, expressed as a
557
+ context query rather than application code.
558
+
559
+ # Rollback
560
+
561
+ The cookbook doctest harness does not currently tear down created
562
+ resources. The `_setup/payment_risk.md` setup file's
563
+ runSuffix-scoped tenant / datalake / user names mean each run is
564
+ naturally isolated; the seeded local DB is cheap to reset
565
+ (`mix ecto.reset` on the platform repo).
566
+
567
+ # Outcome
568
+
569
+ After this cookbook's thirteen steps run green:
570
+
571
+ - A payment-risk tenant exists with a payment-risk-domain
572
+ datalake
573
+ - An SMS tool, a KYC-portal connected app, and a KYC-notification
574
+ standard workflow (status `live`, `payment_account` dataset)
575
+ are registered
576
+ - An Atomic FI data source, a Manual Upload tool, an Atomic FI
577
+ Payment Account interop contract, and a manual-upload DAC form
578
+ a working ingestion chain
579
+ - Two payment accounts have been ingested through that chain —
580
+ one active, one suspended — each resolved to its own legal
581
+ entity by MDM
582
+ - Running the workflow routed them correctly: the active account
583
+ passed the filter and dispatched an SMS; the suspended account
584
+ was `:filtered`
585
+ - The rendered notification SMS is readable from the `message`
586
+ dataset, carrying the "Alvera PR notification" copy with the
587
+ activated account's external id and a `/t/` deep-link
588
+ - The connected-app deep-link the SMS carried resolves, and
589
+ message tracking records the open + form-submit timestamps
590
+
591
+ The business outcome — a KYC-notification SMS sent when a payment
592
+ account activates, with a working KYC-portal link — is
593
+ demonstrated end-to-end, not merely provisioned.
594
+
595
+ # See also
596
+
597
+ - `_setup/payment_risk.md` — the inlined bootstrap that
598
+ provisions the tenant + datalake this cookbook starts from
599
+ - `sanctions-screening-with-agent-review.md` — the agent-driven
600
+ payment-risk cookbook; an LLM agent disambiguates gray-zone
601
+ sanctions screenings
602
+ - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
603
+ manual-upload tool body shapes; intent classification
604
+ - `.agent/connected_apps.md` — connected-app registration + the
605
+ resolve-page / message-tracking reply loop
606
+ - `.agent/interoperability_contracts.md` — custom contract shape,
607
+ `template_config` vs `mdm_input_config`
608
+ - `.agent/data_activation_clients.md` — data source → tool →
609
+ interop contract → DAC ingestion chain
610
+ - `.agent/workflows.md` — standard workflow primitive (filter +
611
+ decision + actions), `context_datasets`, `workflows.run`
612
+ - `.agent/cookbook/_fixtures/payment_risk/` — the vendored Atomic
613
+ FI payment-account / MDM Liquid templates §006 loads
614
+ - `integration-tests/tests/payment_risk/standard-workflow.test.ts` —
615
+ the anchor green test (§1–§7) these snippets are lifted from
616
+ - `integration-tests/tests/payment_risk/interoperability-contracts.test.ts`,
617
+ `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
618
+ `data-sources.test.ts` — the per-resource create + ingest
619
+ snippets §004–§009 are lifted from