@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,659 +0,0 @@
1
- ---
2
- title: Send a payment-reminder SMS to delinquent customers with a pay-invoice link
3
- summary: End-to-end standard workflow — provision a Dunning SMS workflow over the AR customer dataset, ingest two Stripe customer rows (one phone + tax-id verified, one missing tax-id) through a customer interop contract, run the workflow so the SMS-reachable-and-KYC-complete 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: subscription
5
- slug: dunning-sms-for-delinquent
6
- vitest_source:
7
- - integration-tests/tests/subscription/dunning-sms-workflow.test.ts
8
- - integration-tests/tests/subscription/interoperability-contracts.test.ts
9
- - integration-tests/tests/subscription/run-dac-single.test.ts
10
- - integration-tests/tests/subscription/create-dac.test.ts
11
- - integration-tests/tests/subscription/tools.test.ts
12
- - integration-tests/tests/subscription/data-sources.test.ts
13
- - integration-tests/tests/subscription/bootstrap.test.ts
14
- status: green
15
- ---
16
-
17
- # Problem
18
-
19
- When an invoice goes unpaid, the billing team wants to send a
20
- payment-reminder ("dunning") SMS with a self-serve pay link — but
21
- only to customers it is both *allowed* and *able* to contact. The
22
- customer must be SMS-reachable (a phone on file) and KYC-complete
23
- (a verified tax id). A customer missing either precondition should
24
- be skipped, not errored, and certainly not chased.
25
-
26
- The classic implementation queries the customers table, branches
27
- on phone-present and tax-id-present, and hands the survivors to an
28
- SMS sender — with both preconditions living in application code.
29
-
30
- The Alvera platform's standard workflow primitive keeps both
31
- checks inside the platform. An AR `customer` dataset is the
32
- workflow's event source; a pure-Liquid filter gates on
33
- `customer.phone and customer.tax_id`; a single SMS action carries
34
- a deep-link to a connected-app payment portal. The
35
- reachable-and-verified decision is a one-line filter, not a code
36
- branch.
37
-
38
- This cookbook walks the **whole** scenario, not just the
39
- provisioning: it creates the workflow, ingests two Stripe-shaped
40
- customer rows through the production data-activation chain (one
41
- phone + tax-id verified, one missing the tax id), runs the
42
- workflow so the filter routes each row, reads the rendered dunning
43
- SMS back out of the message dataset, and finally resolves the
44
- connected-app deep-link the SMS carries — closing the SMS → reply
45
- loop.
46
-
47
- The scenario is anchored to
48
- `platform/integration-tests/tests/subscription/dunning-sms-workflow.test.ts`
49
- — a green end-to-end test (§1–§8) that exercises this exact shape.
50
- This cookbook is a prose re-presentation of what that test walks.
51
- The setup file `_setup/subscription.md` already provisioned
52
- the tenant, datalake, and industry-admin client; this cookbook
53
- starts from there.
54
-
55
- # Composition
56
-
57
- | Resource provisioned | Owner |
58
- |----------------------------------|-------------|
59
- | SMS tool (SNS-backed) | build |
60
- | Dunning payment-portal connected app | build |
61
- | Dunning SMS workflow | build |
62
- | Stripe data source | build |
63
- | Manual Upload tool | build |
64
- | Stripe Customer interop contract | build |
65
- | Manual-upload DAC | build |
66
-
67
- The setup file `_setup/subscription.md` already
68
- provisioned the tenant + datalake + tenant-scoped client; this
69
- cookbook starts from there.
70
-
71
- # Walkthrough
72
-
73
- ## 001 — create the SMS tool
74
-
75
- The Dunning SMS workflow's action invokes an SMS tool. The tool's
76
- `body.tool_body_type: 'sns'` means it routes via AWS SNS; local
77
- dev points it at LocalStack on `http://localhost:4566` via
78
- `endpoint_url` so no real AWS credentials are needed. The
79
- `intent: 'sms'` tags this tool for workflow actions that send SMS
80
- (versus `data_exchange` for ingestion tools).
81
-
82
- ```typescript
83
- const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
84
- name: `Cookbook SMS Tool ${runSuffix}`,
85
- description: 'SNS-backed SMS dispatcher for the Dunning SMS workflow, wired to LocalStack.',
86
- intent: 'sms',
87
- status: 'active',
88
- datalake_id: ctx.datalakeId,
89
- body: {
90
- tool_body_type: 'sns',
91
- auth_method: 'access_key',
92
- region: 'us-east-1',
93
- phone_number: '+15551234567',
94
- endpoint_url: 'http://localhost:4566',
95
- access_key_id: 'test',
96
- secret_access_key: 'test',
97
- },
98
- })
99
- toolId = smsToolResp.data.id!
100
- ```
101
-
102
- ## 002 — create the Dunning payment-portal connected app
103
-
104
- The workflow's SMS action carries a deep-link to a connected-app
105
- page so the customer can open the self-serve payment portal. The
106
- connected app is a thin registration of the portal's URL and
107
- mode; the actual page is hosted outside the platform
108
- (`mode: 'self_hosted'`). The server-derived `slug` is captured for
109
- the resolve-page call in §013.
110
-
111
- ```typescript
112
- const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
113
- name: `Cookbook Dunning Payment Portal ${runSuffix}`,
114
- description: 'Self-serve invoice payment portal linked from the outbound dunning SMS.',
115
- mode: 'self_hosted',
116
- urls: [
117
- {
118
- url: 'https://dunning.example.local',
119
- is_primary: true,
120
- label: 'production',
121
- },
122
- ],
123
- })
124
- connectedAppId = connectedAppResp.data.id!
125
- ctx.connectedAppSlug = connectedAppResp.data.slug!
126
- ```
127
-
128
- ## 003 — create the Dunning SMS workflow
129
-
130
- The workflow has the standard shape: a filter (Liquid; passes
131
- customers who are both SMS-reachable *and* KYC-complete —
132
- `customer.phone and customer.tax_id`), a decision (a literal
133
- Liquid array naming one decision key), and one SMS action. The
134
- SMS body references `{{ customer.customer_number }}` so the
135
- recipient sees which account is overdue, and
136
- `{{ connected_app_form_url }}` so the platform can bake in the
137
- `/t/<token>` pay-link.
138
-
139
- Two details matter. `dataset_type: 'customer'` makes the AR
140
- customer dataset the event source; the workflow context injects
141
- the regulated customer row under the `customer` key, so the
142
- filter reads `customer.phone` and `customer.tax_id` directly. A
143
- `context_datasets` entry queries the `message` dataset for any
144
- dunning SMS already sent to this customer in the last thirty days
145
- — the platform's built-in guard against dunning the same customer
146
- twice in a billing cycle. On a fresh tenant that lookup returns
147
- empty, which is harmless.
148
-
149
- ```typescript
150
- const FILTER_BODY = '{% if customer.phone and customer.tax_id %}true{% endif %}'
151
- const DECISION_KEY = 'send_dunning_sms'
152
- const DECISION_BODY = `["${DECISION_KEY}"]`
153
- const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
154
- const SMS_TO_TEMPLATE = '{{ mdm_output.regulated_customer.phone }}'
155
- const SMS_BODY_TEMPLATE =
156
- 'Hi {{ mdm_output.regulated_customer.name }}, an invoice on account ' +
157
- '{{ customer.customer_number }} needs your attention. ' +
158
- 'Pay now: {{ connected_app_form_url }}'
159
-
160
- const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
161
- name: `Cookbook Dunning SMS Workflow ${runSuffix}`,
162
- description: 'Sends a payment-reminder SMS to delinquent contracted customers with a self-serve pay link.',
163
- dataset_type: 'customer',
164
- status: 'live',
165
- tags: ['billing', 'dunning'],
166
- filter_config: {
167
- type: 'custom',
168
- body: FILTER_BODY,
169
- output_schema: { type: 'boolean' },
170
- },
171
- decision_config: {
172
- type: 'custom',
173
- body: DECISION_BODY,
174
- output_schema: DECISION_OUTPUT_SCHEMA,
175
- },
176
- context_datasets: [
177
- {
178
- dataset_type: 'message',
179
- where_clause:
180
- `rm.customer_id = '{{ customer_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
181
- "AND rm.sent_at > NOW() - INTERVAL '30 days'",
182
- limit: 1,
183
- position: 0,
184
- },
185
- ],
186
- actions: [
187
- {
188
- action_type: 'sms',
189
- tool_id: toolId,
190
- decision_key: DECISION_KEY,
191
- position: 0,
192
- trigger_template: 'now',
193
- idempotency_template: '{{ customer_id }}-{{ decision_key }}',
194
- connected_app_id: connectedAppId,
195
- connected_app_route: '/portal/pay',
196
- connected_app_metadata_template:
197
- '{"customer_id":"{{ mdm_output.customer.id }}","customer_number":"{{ customer.customer_number }}"}',
198
- tool_call: {
199
- tool_call_type: 'sms_request',
200
- to: { type: 'custom', body: SMS_TO_TEMPLATE },
201
- body: { type: 'custom', body: SMS_BODY_TEMPLATE },
202
- sms_type: 'transactional',
203
- },
204
- },
205
- ],
206
- })
207
- workflowId = workflowResp.data.id!
208
- ctx.workflowSlug = workflowResp.data.slug!
209
- ```
210
-
211
- ## 004 — create the Stripe data source
212
-
213
- A workflow runs on rows; rows arrive through the data-activation
214
- chain. The chain's first link is a `DataSource` — a registration
215
- of where the rows originate. The `uri` is the system-of-record
216
- address; it flows into each ingested row's `source_uri`, which
217
- the customer template renders onto the MDM identifier `system`
218
- URN.
219
-
220
- ```typescript
221
- const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
222
- name: `Cookbook Stripe Source ${runSuffix}`,
223
- uri: 'stripe.example.com',
224
- description: 'Stripe billing system — origin of the customer rows the dunning workflow runs on.',
225
- status: 'active',
226
- is_default: false,
227
- })
228
- dataSourceId = dataSourceResp.data.id!
229
- ```
230
-
231
- ## 005 — create the Manual Upload tool
232
-
233
- The Data Activation Client needs a tool. For inline-JSON ingest a
234
- `manual_upload` tool is the minimal choice — `intent:
235
- 'data_exchange'` distinguishes it from the SMS tool, and
236
- `tool_body_type: 'manual_upload'` needs no endpoint or credential
237
- wiring (the rows arrive in the ingest call body, not by the tool
238
- fetching them).
239
-
240
- ```typescript
241
- const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
242
- name: `Cookbook Manual Upload Tool ${runSuffix}`,
243
- description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
244
- intent: 'data_exchange',
245
- status: 'active',
246
- datalake_id: ctx.datalakeId,
247
- data_source_id: dataSourceId,
248
- body: { tool_body_type: 'manual_upload' },
249
- })
250
- ctx.manualUploadToolId = manualUploadToolResp.data.id!
251
- ```
252
-
253
- ## 006 — create the Stripe Customer interoperability contract
254
-
255
- The interoperability contract is the row-shaping rule: a Liquid
256
- template that maps an inbound Stripe customer row into an AR
257
- `Customer` upsert. This cookbook loads the production Stripe
258
- customer + MDM templates from the vendored fixtures directory
259
- rather than inlining the Liquid.
260
-
261
- Two template slots matter. `template_config` shapes the Customer
262
- resource — including the nested `regulated_customer` object that
263
- carries the PII fields (`name`, `email`, `phone`, `tax_id`).
264
- `mdm_input_config` shapes the MDM input — the identifier
265
- (`customer_number`), name, and contact fields the platform's
266
- master-data resolution keys on to decide whether this is a new
267
- customer or an existing one.
268
-
269
- ```typescript
270
- const { readFileSync } = await import('node:fs')
271
- const { join } = await import('node:path')
272
- const customerTemplate = readFileSync(
273
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
274
- 'utf8',
275
- )
276
- const mdmTemplate = readFileSync(
277
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
278
- 'utf8',
279
- )
280
-
281
- const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
282
- name: `Cookbook Stripe Customer Contract ${runSuffix}`,
283
- description: 'Stripe customers → Subscription Customer (custom Liquid + MDM input).',
284
- resource_type: 'customer',
285
- template_config: { type: 'custom', body: customerTemplate },
286
- mdm_input_config: { type: 'custom', body: mdmTemplate },
287
- generic_table_id: null,
288
- })
289
- interopContractId = contractResp.data.id!
290
- ```
291
-
292
- ## 007 — create the manual-upload DAC
293
-
294
- The Data Activation Client binds the three preceding pieces — the
295
- manual-upload tool, the data source, and the interop contract —
296
- into one ingestion endpoint. `tool_call.tool_call_type:
297
- 'manual_upload'` selects the inline-JSON ingest path. The
298
- server-derived `slug` is the handle §008 ingests rows against.
299
-
300
- ```typescript
301
- const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
302
- name: `Cookbook Stripe Customer DAC ${runSuffix}`,
303
- description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
304
- tool_id: ctx.manualUploadToolId,
305
- data_source_id: dataSourceId,
306
- tool_call: { tool_call_type: 'manual_upload' },
307
- interoperability_contract_ids: [interopContractId],
308
- })
309
- dacId = dacResp.data.id!
310
- ctx.dacSlug = dacResp.data.slug!
311
- ```
312
-
313
- ## 008 — ingest two Stripe customer rows
314
-
315
- Two rows, ingested as inline JSON through the manual-upload DAC.
316
- Both carry a phone number; they differ in `tax_id`. The first row
317
- is KYC-complete — it has a verified tax id — so the filter
318
- (`customer.phone and customer.tax_id`) will pass it. The second
319
- omits `tax_id` entirely; even though it has a phone, the filter
320
- must reject it. Each ingest call gets its own batch id; both are
321
- pinned for the run scope in §010.
322
-
323
- ```typescript
324
- const phone4 = String(Date.now()).slice(-4)
325
-
326
- const verifiedRow = {
327
- customer_number: `CUST-KYC-${runSuffix}`,
328
- customer_type: 'individual',
329
- status: 'contracted',
330
- name: 'Priya Anand',
331
- email: `priya-${runSuffix}@example.com`,
332
- phone: `+1202555${phone4}`,
333
- tax_id: '123-45-6789',
334
- currency: 'USD',
335
- delinquent: 'true',
336
- }
337
- const unverifiedRow = {
338
- customer_number: `CUST-NOKYC-${runSuffix}`,
339
- customer_type: 'individual',
340
- status: 'contracted',
341
- name: 'Daniel Foster',
342
- email: `daniel-${runSuffix}@example.com`,
343
- phone: `+1203555${phone4}`,
344
- currency: 'USD',
345
- delinquent: 'true',
346
- // tax_id intentionally absent — the workflow filter must reject this row
347
- }
348
-
349
- const [verifiedIngest, unverifiedIngest] = await Promise.all([
350
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: verifiedRow }),
351
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: unverifiedRow }),
352
- ])
353
- ctx.batchVerified = verifiedIngest.data.batch_id!
354
- ctx.batchUnverified = unverifiedIngest.data.batch_id!
355
- ```
356
-
357
- ## 009 — wait for both ingest batches to reach steady-state
358
-
359
- Ingestion is async — the DAC enqueues per-row jobs that the
360
- `BatchMergeWorker` drains into the regulated customer table. Poll
361
- the DAC's activation logs until both batches show a row with
362
- `rows_ingested >= 1` and a non-empty `output_files` array (the
363
- merged Parquet landed in object storage). Only then is it safe to
364
- run the workflow against these rows.
365
-
366
- ```typescript
367
- const targetBatches = new Set([ctx.batchVerified, ctx.batchUnverified])
368
- const deadline = Date.now() + 90_000
369
- let greenCount = 0
370
- while (Date.now() < deadline) {
371
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
372
- const green = new Set<string>()
373
- for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
374
- const b = row.batch_id
375
- if (typeof b !== 'string' || !targetBatches.has(b)) continue
376
- if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
377
- const files = row.output_files
378
- if (!Array.isArray(files) || files.length === 0) continue
379
- green.add(b)
380
- }
381
- greenCount = green.size
382
- if (greenCount === targetBatches.size) break
383
- await new Promise((r) => setTimeout(r, 1_000))
384
- }
385
- if (greenCount !== targetBatches.size) {
386
- throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
387
- }
388
- ```
389
-
390
- ## 010 — run the workflow against the two batches
391
-
392
- `workflows.run` with `manual_override: false` evaluates the
393
- filter, so the `customer.phone and customer.tax_id` filter
394
- genuinely routes each row. The SQL where-clause scopes the run to
395
- exactly the two batches §008 ingested (`ra` is the
396
- regulated-customer alias the run-query exposes).
397
-
398
- The SMS action's `trigger_template: 'now'` dispatches the action
399
- immediately rather than deferring it, so once the run fires it
400
- reaches a terminal status on its own — poll `batchLogs.refresh`
401
- until it leaves `:pending`. The run itself is still scheduled:
402
- `workflows.run` records it and returns, which is why the setup
403
- file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
404
- one row passed and one was filtered.
405
-
406
- ```typescript
407
- const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
408
- sql_where_clause: `ra.batch_id IN ('${ctx.batchVerified}', '${ctx.batchUnverified}')`,
409
- mode: 'live',
410
- manual_override: false,
411
- })
412
- // run-workflow only SCHEDULES the run. The log id and batch id are
413
- // written when it fires, so read them back via workflowRuns.get.
414
- const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
415
- ctx.runLogId = fired.workflowRunLogId
416
- ctx.runBatchId = fired.batchId!
417
-
418
- const deadline = Date.now() + 120_000
419
- let status: string | null = null
420
- while (Date.now() < deadline) {
421
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
422
- status = log.status ?? null
423
- if (status && status !== 'pending') break
424
- await new Promise((r) => setTimeout(r, 2_000))
425
- }
426
- if (status === 'failed') throw new Error('workflow run reached :failed')
427
- if (!status || status === 'pending') {
428
- throw new Error('workflow run did not leave :pending within 120s')
429
- }
430
- ```
431
-
432
- ## 011 — verify the filter routed: verified passes, unverified is filtered
433
-
434
- Each row produced a Workflow Execution Log. The KYC-complete
435
- customer (phone + tax id) passes the filter, so its WEL is
436
- `:executing` or `:completed`. The customer missing the tax id
437
- fails the filter, so its WEL is `:filtered`. A run where both
438
- passed — or both were filtered — would mean the filter is not
439
- actually evaluating both `customer.phone` and `customer.tax_id`.
440
-
441
- ```typescript
442
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
443
- const ourWels = (wfLogs.data ?? []).filter(
444
- (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
445
- )
446
- if (ourWels.length !== 2) {
447
- throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
448
- }
449
-
450
- const byStatus: Record<string, number> = {}
451
- for (const w of ourWels) {
452
- const st = (w as { status?: string }).status ?? 'unknown'
453
- byStatus[st] = (byStatus[st] ?? 0) + 1
454
- }
455
- const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
456
- if (passCount !== 1) {
457
- throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
458
- }
459
- if ((byStatus.filtered ?? 0) !== 1) {
460
- throw new Error(
461
- `expected 1 :filtered WEL (the tax-id-missing customer) — distribution ${JSON.stringify(byStatus)}`,
462
- )
463
- }
464
- ```
465
-
466
- ## 012 — read the rendered dunning SMS back from the message dataset
467
-
468
- A `trigger_template: 'now'` action that passed the filter fires
469
- immediately and persists a row in the regulated `message`
470
- dataset, carrying the fully-rendered SMS body. Search that
471
- dataset scoped to this workflow, poll until the row appears, and
472
- extract the `/t/<token>` shortlink the platform baked into the
473
- body when it minted the connected-app page token.
474
-
475
- ```typescript
476
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
477
- search_query: `rm.workflow_id = '${workflowId}'`,
478
- })
479
- if (userSearch.status !== 'completed') {
480
- throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
481
- }
482
-
483
- const deadline = Date.now() + 45_000
484
- let messages: Array<Record<string, unknown>> = []
485
- while (Date.now() < deadline && messages.length === 0) {
486
- const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
487
- userSearchId: userSearch.id!,
488
- dataAccessMode: 'regulated',
489
- })
490
- messages = (data.data ?? []) as Array<Record<string, unknown>>
491
- if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
492
- }
493
- if (messages.length === 0) {
494
- throw new Error('no dunning SMS message persisted for the workflow within 45s')
495
- }
496
-
497
- const withLink = messages
498
- .map((m) => String(m.body ?? ''))
499
- .find((body) => body.includes('/t/') && body.includes('needs your attention'))
500
- if (!withLink) {
501
- throw new Error(`no rendered dunning body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
502
- }
503
- const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
504
- if (!tokenMatch) {
505
- throw new Error(`no /t/<token> in rendered body: ${withLink}`)
506
- }
507
- ctx.dunningShortPath = tokenMatch[1]!
508
- ```
509
-
510
- ## 013 — resolve the connected-app deep-link and post tracking
511
-
512
- The `/t/<token>` shortlink the SMS carries resolves via
513
- `connectedApps.resolvePage` — the `route_path` must match the
514
- action's `connected_app_route`. Posting `opened_at` +
515
- `form_submitted_at` via `updateMessageTracking` then mirrors what
516
- the connected-app frontend does when the customer opens the
517
- payment portal, closing the SMS → reply loop end-to-end.
518
-
519
- ```typescript
520
- const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
521
- short_path: ctx.dunningShortPath,
522
- user_agent: 'cookbook-doctest/dunning-sms',
523
- })
524
- if (resolved.route_path !== '/portal/pay') {
525
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
526
- }
527
- if (!(resolved.message?.body ?? '').includes('needs your attention')) {
528
- throw new Error('resolved page message body missing the dunning copy')
529
- }
530
-
531
- const now = new Date().toISOString()
532
- const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
533
- short_path: ctx.dunningShortPath,
534
- opened_at: now,
535
- form_submitted_at: now,
536
- })
537
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
538
- throw new Error('message tracking did not persist opened_at + form_submitted_at')
539
- }
540
- ```
541
-
542
- ## 014 — write the integration test
543
-
544
- End the build with a test you keep: re-read the workflow and prove the
545
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
546
- never-matching selection runs the FULL pipeline (selection → filter →
547
- decision) and intercepts only the final action call, so no message
548
- leaves, yet the acknowledgement proves the workflow is runnable. This
549
- block runs live under `make validate-cookbook`.
550
-
551
- ```typescript
552
- // Re-GET — the workflow must still be live, or nothing will run.
553
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
554
- if (wfRow.status !== 'live') {
555
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
556
- }
557
- // Behavioural probe — a dry run against a selection no row can match:
558
- // the pipeline executes end-to-end, the final action call is
559
- // intercepted, and the acknowledgement carries the scheduled run id.
560
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
561
- sql_where_clause: "ra.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 unverified customer is filtered, not failed** — §011
579
- asserts the tax-id-missing customer'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
- `customer.phone and customer.tax_id` filter rendered empty
583
- because one conjunct was falsy, and the platform recorded the
584
- row as intentionally skipped. No SMS runs for a filtered row —
585
- exactly the business rule (never dun a customer whose KYC is
586
- incomplete).
587
- - **Both conjuncts are load-bearing** — the filter is an `and` of
588
- two preconditions. The §008 unverified row has a phone, so it
589
- is the *tax-id* conjunct that fails it. A row missing the phone
590
- instead would fail the same filter on the other conjunct. The
591
- anchor vitest's §8 proves the opposite extreme: a filter gated
592
- on a sentinel no customer can satisfy filters *every* row.
593
- - **The thirty-day recency guard** — §003's `context_datasets`
594
- entry queries the `message` dataset for a prior dunning SMS to
595
- the same customer in the last 30 days. On this fresh tenant it
596
- returns empty, so the action runs. On a tenant with history, a
597
- recent prior dunning message would make the action skip — the
598
- platform's built-in guard against dunning the same customer
599
- repeatedly within one billing cycle.
600
-
601
- # Rollback
602
-
603
- The cookbook doctest harness does not currently tear down created
604
- resources. The `_setup/subscription.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
- - An subscription tenant exists with an
614
- subscription-domain datalake
615
- - An SMS tool, a Dunning payment-portal connected app, and a
616
- Dunning SMS standard workflow (status `live`, `customer`
617
- dataset) are registered
618
- - A Stripe data source, a Manual Upload tool, a Stripe Customer
619
- interop contract, and a manual-upload DAC form a working
620
- ingestion chain
621
- - Two customers have been ingested through that chain — one
622
- KYC-complete, one missing a tax id
623
- - Running the workflow routed them correctly: the verified
624
- customer passed the phone + tax-id filter and dispatched an
625
- SMS; the unverified customer was `:filtered`
626
- - The rendered dunning SMS is readable from the `message`
627
- dataset, carrying the "needs your attention" copy with the
628
- overdue account number and a `/t/` deep-link
629
- - The connected-app deep-link the SMS carried resolves, and
630
- message tracking records the open + form-submit timestamps
631
-
632
- The business outcome — a payment-reminder SMS sent to a reachable
633
- and KYC-complete delinquent customer, with a working pay-invoice
634
- link — is demonstrated end-to-end, not merely provisioned.
635
-
636
- # See also
637
-
638
- - `_setup/subscription.md` — the inlined bootstrap that
639
- provisions the tenant + datalake this cookbook starts from
640
- - `welcome-sms-for-customers.md` — the sibling AR cookbook; same
641
- customer-dataset shape, a simpler phone-only filter
642
- - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
643
- manual-upload tool body shapes; intent classification
644
- - `.agent/connected_apps.md` — connected-app registration + the
645
- resolve-page / message-tracking reply loop
646
- - `.agent/interoperability_contracts.md` — custom contract shape,
647
- `template_config` vs `mdm_input_config`
648
- - `.agent/data_activation_clients.md` — data source → tool →
649
- interop contract → DAC ingestion chain
650
- - `.agent/workflows.md` — standard workflow primitive (filter +
651
- decision + actions), `context_datasets`, `workflows.run`
652
- - `.agent/cookbook/_fixtures/subscription/` — the vendored
653
- Stripe customer / MDM Liquid templates §006 loads
654
- - `integration-tests/tests/subscription/dunning-sms-workflow.test.ts` —
655
- the anchor green test (§1–§8) these snippets are lifted from
656
- - `integration-tests/tests/subscription/interoperability-contracts.test.ts`,
657
- `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
658
- `data-sources.test.ts` — the per-resource create + ingest
659
- snippets §004–§009 are lifted from