@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.
- package/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /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
|