@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,1044 +0,0 @@
1
- ---
2
- title: Send an A/B marketing campaign across SMS and email
3
- summary: End-to-end campaign send over a generic-table audience — gate on suppression and reachability, split A/B inside the workflow's decision node, dispatch the chosen variant on both SMS and email in one run, carry a per-recipient short link, and re-attach the customer's reply to the customer who sent it. The gates and the split live in the workflow, not in app code.
4
- industry: foundation
5
- slug: marketing-campaign-send
6
- vitest_source:
7
- - integration-tests/tests/foundation/marketing-campaign-send.test.ts
8
- status: green
9
- ---
10
-
11
- # Problem
12
-
13
- A marketing platform runs campaigns on behalf of the businesses it
14
- serves. Each business has a roster entry; each business has its own
15
- end customers. A campaign must contact an end customer only if they
16
- are **reachable** (a phone and an email on file) and **not
17
- suppressed** (no do-not-contact flag). Every message must carry a
18
- link unique to its recipient, so a click is attributable to one
19
- person. The content is **A/B split**. And when someone replies, the
20
- reply must attach to the customer who sent it.
21
-
22
- The classic implementation puts all four of those in application
23
- code: a query with a `WHERE NOT suppressed`, an `if` on the phone
24
- column, a random-number bucket, and a hand-rolled short-link table.
25
- Every one of them is a place for the campaign to leak — a suppressed
26
- customer contacted by the one code path that forgot the check.
27
-
28
- The Alvera platform keeps all four **inside the workflow**. The
29
- suppression and reachability gates are the `filter_config`. The A/B
30
- split is the `decision_config` — a decision node that returns the
31
- decision keys of the variant it chose. The per-recipient link is
32
- minted by the platform when it resolves the recipient's MDM subject.
33
- The reply re-attaches by re-ingesting through a data activation
34
- client. None of it is application code, and the gates cannot be
35
- bypassed by a caller who forgot them.
36
-
37
- This cookbook walks the whole journey: two generic tables (the
38
- business roster and the end-customer audience), the ingest that
39
- resolves each row to its own legal entity, a campaign workflow with
40
- four channel/variant actions, one run that sends two recipients and
41
- filters two, the short link resolving per recipient, and the inbound
42
- reply landing back on its customer.
43
-
44
- The scenario is anchored to
45
- `platform/integration-tests/tests/foundation/marketing-campaign-send.test.ts`
46
- — a green end-to-end test this cookbook is a prose re-presentation
47
- of. The setup file `_setup/foundation.md` already provisioned the
48
- tenant, the datalake, and the tenant-scoped client; this cookbook
49
- starts from there.
50
-
51
- # Composition
52
-
53
- | Resource provisioned | Owner |
54
- |----------------------------------------------|----------|
55
- | Direct Customers generic table (the roster) | build |
56
- | End Customers generic table (the audience) | build |
57
- | Inbound Messages generic table | platform |
58
- | Campaign data source | build |
59
- | Manual Upload tool | build |
60
- | Roster contracts + roster activation client | build |
61
- | Audience contracts + audience activation client | build |
62
- | SMS tool (SNS-backed) | build |
63
- | Email tool (`provider: 'mock'`) | build |
64
- | Campaign Booking Form connected app | build |
65
- | Loyalty Campaign workflow | build |
66
- | Reply contract + reply activation client | build |
67
-
68
- Two things in that table are worth reading twice.
69
-
70
- `alvera_system_inbound_messages` is a **system** generic table. It
71
- ships with every foundation datalake — you do not create it, and §003
72
- proves it is already there.
73
-
74
- The datalake itself came from `_setup/foundation.md`, which gives it
75
- `timezone: 'America/New_York'`. The datalake timezone is a **closed
76
- whitelist of 8 US zones** — `America/New_York`, `America/Chicago`,
77
- `America/Denver`, `America/Los_Angeles`, `America/Anchorage`,
78
- `America/Adak`, `Pacific/Honolulu`, `America/Phoenix`. General IANA
79
- values are rejected, **including `UTC`**. The whitelist is the
80
- OpenAPI enum, so the generated `timezone` type carries exactly those
81
- eight and a wrong zone fails to compile rather than 422-ing at
82
- runtime.
83
-
84
- # Walkthrough
85
-
86
- ## 001 — create the Direct Customers table (the business roster)
87
-
88
- The roster is the list of businesses running campaigns. It is a
89
- generic table because the platform does not model "the business my
90
- customer is" — that is domain data. The server derives the table
91
- `name` (`alvera_custom_direct_customers`); you set only the `title`.
92
-
93
- Nothing here is PII — a business name and its shared sender identity
94
- are public record — so every column is `privacy_requirement: 'none'`.
95
-
96
- ```typescript
97
- const { data: roster } = await api.genericTables.create(tenantSlug, datalakeSlug, {
98
- title: 'Direct Customers',
99
- description: 'Direct customers — the businesses running marketing campaigns',
100
- columns: [
101
- { name: 'direct_customer_id', title: 'Direct Customer ID', type: 'string', description: 'Vendor-supplied unique direct-customer (business) id', is_unique: true, privacy_requirement: 'none' },
102
- { name: 'business_name', title: 'Business Name', type: 'string', description: 'Business display name (public record, not PII)', is_unique: false, privacy_requirement: 'none' },
103
- { name: 'sender_phone', title: 'Sender Phone', type: 'string', description: 'Shared sender number campaigns send SMS from', is_unique: false, privacy_requirement: 'none' },
104
- { name: 'sender_domain', title: 'Sender Domain', type: 'string', description: 'Shared sender domain campaigns send email from', is_unique: false, privacy_requirement: 'none' },
105
- ],
106
- })
107
- ctx.rosterTableId = roster.id!
108
- ctx.rosterTableName = roster.name!
109
-
110
- const deadline = Date.now() + 60_000
111
- let status: string | undefined
112
- while (Date.now() < deadline) {
113
- const { data: row } = await api.genericTables.get(tenantSlug, datalakeSlug, ctx.rosterTableId)
114
- status = row.status
115
- if (status === 'deployed') break
116
- await new Promise((r) => setTimeout(r, 1_000))
117
- }
118
- if (status !== 'deployed') {
119
- throw new Error(`Direct Customers did not reach :deployed within 60s (last: ${status})`)
120
- }
121
- ```
122
-
123
- ## 002 — create the End Customers table (the audience)
124
-
125
- The audience is the people a campaign may contact. Three columns
126
- carry the whole business rule:
127
-
128
- `suppressed` is the do-not-contact flag — the workflow filter reads
129
- it, so a suppressed customer is unselectable by *any* campaign, not
130
- merely hidden in a UI. `phone` and `email` are the two channel
131
- destinations, and their presence is the reachability gate. `bucket`
132
- is the A/B assignment, an integer 0–99 stamped at ingest (§006) and
133
- split on by the decision node (§009).
134
-
135
- The three PII columns are `tokenize`; `suppressed` and `bucket` are
136
- `none` — they are campaign mechanics, not personal data, and the
137
- workflow must read them in the unregulated view.
138
-
139
- ```typescript
140
- const { data: audience } = await api.genericTables.create(tenantSlug, datalakeSlug, {
141
- title: 'End Customers',
142
- description: 'End customers — the people a campaign may contact',
143
- columns: [
144
- { name: 'end_customer_id', title: 'End Customer ID', type: 'string', description: 'Vendor-supplied unique end-customer id', is_unique: true, privacy_requirement: 'none' },
145
- { name: 'direct_customer_id', title: 'Direct Customer ID', type: 'string', description: 'Direct customer (business) this end customer belongs to', is_unique: false, privacy_requirement: 'none' },
146
- { name: 'name', title: 'Name', type: 'string', description: 'Recipient full name', is_unique: false, privacy_requirement: 'tokenize' },
147
- { name: 'email', title: 'Email', type: 'string', description: 'Recipient email — email-channel destination', is_unique: false, privacy_requirement: 'tokenize' },
148
- { name: 'phone', title: 'Phone', type: 'string', description: 'Recipient phone — SMS-channel destination', is_unique: false, privacy_requirement: 'tokenize' },
149
- { name: 'suppressed', title: 'Suppressed', type: 'boolean', description: 'Do-not-contact flag — a suppressed recipient is never selected', is_unique: false, privacy_requirement: 'none' },
150
- { name: 'bucket', title: 'Bucket', type: 'integer', description: 'Stable 0-99 A/B bucket assigned at ingest; the decision node splits on it', is_unique: false, privacy_requirement: 'none' },
151
- ],
152
- })
153
- ctx.audienceTableId = audience.id!
154
- ctx.audienceTableName = audience.name!
155
-
156
- const deadline = Date.now() + 60_000
157
- let status: string | undefined
158
- while (Date.now() < deadline) {
159
- const { data: row } = await api.genericTables.get(tenantSlug, datalakeSlug, ctx.audienceTableId)
160
- status = row.status
161
- if (status === 'deployed') break
162
- await new Promise((r) => setTimeout(r, 1_000))
163
- }
164
- if (status !== 'deployed') {
165
- throw new Error(`End Customers did not reach :deployed within 60s (last: ${status})`)
166
- }
167
- ```
168
-
169
- ## 003 — find the Inbound Messages system table
170
-
171
- Replies land in `alvera_system_inbound_messages`. It is a **system**
172
- generic table: it ships with every foundation datalake, its `type` is
173
- `'system'` rather than `'custom'`, and **nobody creates it**. You find
174
- it by name in the table list.
175
-
176
- Trying to create it is the mistake this step exists to prevent — the
177
- name is reserved, and the physical table is already there.
178
-
179
- ```typescript
180
- const { data: tables } = await api.genericTables.list(tenantSlug, datalakeSlug)
181
- const inbound = (tables.data ?? []).find((t) => t.name === 'alvera_system_inbound_messages')
182
- if (!inbound) {
183
- throw new Error('alvera_system_inbound_messages must ship with every foundation datalake')
184
- }
185
- if (inbound.type !== 'system') {
186
- throw new Error(`inbound messages table must be a system table, got type=${inbound.type}`)
187
- }
188
- ctx.inboundTableId = inbound.id!
189
- ctx.inboundTableName = inbound.name!
190
- ```
191
-
192
- ## 004 — create the campaign data source and the manual-upload tool
193
-
194
- Three activation clients follow — roster, audience, reply — and **all
195
- three share this one data source**. That is not a convenience; it is
196
- load-bearing.
197
-
198
- The data source's `uri` flows into every ingested row as
199
- `source_uri`, and the contracts render it onto the MDM identifier's
200
- `system`. The reply (§012) re-attaches by looking up
201
- `(system, handle)`. If the reply arrived through a *different* data
202
- source, its `system` would differ, the lookup would miss, and the
203
- reply would attach to nobody. One capture system, one `uri`, one
204
- identity namespace.
205
-
206
- ```typescript
207
- const { ToolIntent } = await import('@alvera-ai/platform-sdk')
208
-
209
- const { data: source } = await api.dataSources.create(tenantSlug, datalakeSlug, {
210
- name: `Cookbook Campaign Source ${runSuffix}`,
211
- uri: 'crm.example.com',
212
- description: 'Campaign CRM — origin of the roster, the audience, and the inbound replies.',
213
- status: 'active',
214
- is_default: false,
215
- })
216
- dataSourceId = source.id!
217
-
218
- const { data: uploadTool } = await api.tools.create(tenantSlug, datalakeSlug, {
219
- name: `Cookbook Manual Upload Tool ${runSuffix}`,
220
- description: 'Manual-upload data-exchange tool — backs the roster, audience, and reply clients.',
221
- intent: ToolIntent.DATA_EXCHANGE,
222
- status: 'active',
223
- datalake_id: ctx.datalakeId,
224
- data_source_id: dataSourceId,
225
- body: { tool_body_type: 'manual_upload' },
226
- })
227
- ctx.uploadToolId = uploadTool.id!
228
- ```
229
-
230
- ## 005 — the roster contracts and the roster activation client
231
-
232
- Each roster row becomes **two** things: a row in the Direct Customers
233
- table, and a **business** legal entity in MDM. That takes two
234
- interoperability contracts on one client.
235
-
236
- The `legal_entity` contract shapes the MDM subject
237
- (`legal_entity_type: 'business'`). The `generic_table` contract shapes
238
- the table row — and its `mdm_input_config` is what tells the platform
239
- which subject this row belongs to. The platform then **stamps that
240
- subject's id onto the row** as `legal_entity_id`. That stamp is the
241
- whole point: §009's workflow resolves a row's MDM subject from the
242
- stamped FK, never from the row's own id.
243
-
244
- ```typescript
245
- const ROSTER_LE = `{% assign p = msg %}
246
- {
247
- "legal_entity_type": "business",
248
- "role": "direct",
249
- "business_name": "{{ p.business_name | json_escape }}",
250
- "identifications": [
251
- {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}", "id_number": "{{ p.direct_customer_id | json_escape }}"}
252
- ],
253
- "regulated_legal_entity": {
254
- "legal_entity_type": "business",
255
- "role": "direct",
256
- "business_name": "{{ p.business_name | json_escape }}",
257
- "identifications": [
258
- {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}", "id_number": "{{ p.direct_customer_id | json_escape }}"}
259
- ]
260
- }
261
- }`
262
-
263
- const ROSTER_GT = `{% assign p = msg %}
264
- {
265
- "direct_customer_id": "{{ p.direct_customer_id | json_escape }}",
266
- "business_name": "{{ p.business_name | json_escape }}",
267
- "sender_phone": "{{ p.sender_phone | json_escape }}",
268
- "sender_domain": "{{ p.sender_domain | json_escape }}"
269
- }`
270
-
271
- const ROSTER_MDM = `{% assign p = msg %}
272
- {
273
- "legal_entity_type": "business",
274
- "business_name": "{{ p.business_name | json_escape }}",
275
- "identifiers": [
276
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}", "type": "digital_identifier"}
277
- ]
278
- }`
279
-
280
- const { data: rosterLe } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
281
- name: `Cookbook Roster Business LE ${runSuffix}`,
282
- description: 'Roster row → business LegalEntity.',
283
- resource_type: 'legal_entity',
284
- type: 'identity',
285
- generic_table_id: null,
286
- template_config: { type: 'custom', body: ROSTER_LE },
287
- mdm_input_config: { type: 'null' },
288
- })
289
-
290
- const { data: rosterGt } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
291
- name: `Cookbook Roster GT ${runSuffix}`,
292
- description: 'Roster row → Direct Customers row, stamped with its business subject.',
293
- resource_type: 'generic_table',
294
- type: 'identity',
295
- generic_table_id: ctx.rosterTableId,
296
- template_config: { type: 'custom', body: ROSTER_GT },
297
- mdm_input_config: { type: 'custom', body: ROSTER_MDM },
298
- })
299
- interopContractId = rosterGt.id!
300
-
301
- const { data: rosterDac } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
302
- name: `Cookbook Roster Client ${runSuffix}`,
303
- description: 'Business roster ingest — resolves each business to a business LegalEntity.',
304
- tool_id: ctx.uploadToolId,
305
- data_source_id: dataSourceId,
306
- tool_call: { tool_call_type: 'manual_upload' },
307
- interoperability_contract_ids: [rosterLe.id!, rosterGt.id!],
308
- })
309
- ctx.rosterDacSlug = rosterDac.slug!
310
- ```
311
-
312
- ## 006 — the audience contracts and the audience activation client
313
-
314
- Same two-contract shape, with two differences that carry the
315
- campaign.
316
-
317
- First, each recipient resolves to an **individual** legal entity —
318
- their own MDM subject, keyed on their contact handle (phone if
319
- present, else email). That subject is what the per-recipient short
320
- link (§011) is minted against.
321
-
322
- Second, the generic-table contract assigns the **`bucket`**. It is
323
- derived from the last two digits of the vendor's own id
324
- (`| slice: -2, 2 | plus: 0`) — deterministic, not random. Re-ingest
325
- the same customer tomorrow and they land in the same bucket, so a
326
- re-ingest never re-rolls someone into the other variant mid-campaign.
327
- An A/B split that reshuffles on every ingest is not an A/B split.
328
-
329
- ```typescript
330
- const AUDIENCE_LE = `{% assign p = msg %}
331
- {% assign name_parts = p.name | default: "" | split: " " %}
332
- {% assign first_name = name_parts[0] | default: "" %}
333
- {% assign last_name_parts = name_parts | slice: 1, 9 %}
334
- {% assign last_name = last_name_parts | join: " " %}
335
- {% if p.phone and p.phone != "" %}{% assign handle = p.phone %}{% else %}{% assign handle = p.email %}{% endif %}
336
- {
337
- "legal_entity_type": "individual",
338
- "role": "direct",
339
- {% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
340
- {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
341
- {% if p.phone and p.phone != "" %}"phone_numbers": [{ "phone_number": "{{ p.phone | json_escape }}" }],{% endif %}
342
- "identifications": [
343
- {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}", "id_number": "{{ handle | json_escape }}"}
344
- ],
345
- "regulated_legal_entity": {
346
- "legal_entity_type": "individual",
347
- "role": "direct",
348
- {% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
349
- {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
350
- "identifications": [
351
- {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}", "id_number": "{{ handle | json_escape }}"}
352
- ]
353
- }
354
- }`
355
-
356
- const AUDIENCE_GT = `{% assign p = msg %}
357
- {% assign bucket = p.end_customer_id | slice: -2, 2 | plus: 0 %}
358
- {
359
- "end_customer_id": "{{ p.end_customer_id | json_escape }}",
360
- "direct_customer_id": "{{ p.direct_customer_id | json_escape }}",
361
- "name": "{{ p.name | default: "" | json_escape }}",
362
- "email": "{{ p.email | default: "" | json_escape }}",
363
- "phone": "{{ p.phone | default: "" | json_escape }}",
364
- "suppressed": {% if p.suppressed %}true{% else %}false{% endif %},
365
- "bucket": {{ bucket }}
366
- }`
367
-
368
- const AUDIENCE_MDM = `{% assign p = msg %}
369
- {% assign name_parts = p.name | default: "" | split: " " %}
370
- {% assign first_name = name_parts[0] | default: "" %}
371
- {% assign last_name_parts = name_parts | slice: 1, 9 %}
372
- {% assign last_name = last_name_parts | join: " " %}
373
- {% if p.phone and p.phone != "" %}{% assign handle = p.phone %}{% else %}{% assign handle = p.email %}{% endif %}
374
- {
375
- "legal_entity_type": "individual",
376
- {% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
377
- {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
378
- "identifiers": [
379
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}", "type": "digital_identifier"}
380
- ]
381
- }`
382
-
383
- const { data: audienceLe } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
384
- name: `Cookbook Audience LE ${runSuffix}`,
385
- description: 'Audience row → end-customer LegalEntity, keyed on the contact handle.',
386
- resource_type: 'legal_entity',
387
- type: 'identity',
388
- generic_table_id: null,
389
- template_config: { type: 'custom', body: AUDIENCE_LE },
390
- mdm_input_config: { type: 'null' },
391
- })
392
-
393
- const { data: audienceGt } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
394
- name: `Cookbook Audience GT ${runSuffix}`,
395
- description: 'Audience row → End Customers row, stamped with its subject and its stable A/B bucket.',
396
- resource_type: 'generic_table',
397
- type: 'identity',
398
- generic_table_id: ctx.audienceTableId,
399
- template_config: { type: 'custom', body: AUDIENCE_GT },
400
- mdm_input_config: { type: 'custom', body: AUDIENCE_MDM },
401
- })
402
-
403
- const { data: audienceDac } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
404
- name: `Cookbook Audience Client ${runSuffix}`,
405
- description: 'Audience ingest — resolves each recipient to an end-customer LegalEntity, assigns the A/B bucket.',
406
- tool_id: ctx.uploadToolId,
407
- data_source_id: dataSourceId,
408
- tool_call: { tool_call_type: 'manual_upload' },
409
- interoperability_contract_ids: [audienceLe.id!, audienceGt.id!],
410
- })
411
- dacId = audienceDac.id!
412
- ctx.audienceDacSlug = audienceDac.slug!
413
- ```
414
-
415
- ## 007 — ingest the roster and the four recipients
416
-
417
- One business, four end customers, chosen so that one run exercises
418
- every branch of the campaign at once:
419
-
420
- | Recipient | phone | email | suppressed | bucket | expected |
421
- |-----------|-------|-------|------------|--------|----------|
422
- | Ada | ✓ | ✓ | no | 10 | sent, variant A |
423
- | Grace | ✓ | ✓ | no | 80 | sent, variant B |
424
- | Sup | ✓ | ✓ | **yes** | 31 | filtered (suppressed) |
425
- | Nophone | — | ✓ | no | 42 | filtered (unreachable) |
426
-
427
- The buckets are not set by hand — they fall out of the ids
428
- (`…-10`, `…-80`, `…-31`, `…-42`), which is §006's Liquid at work.
429
-
430
- The read-back is the proof that MDM ran: every row carries a
431
- `legal_entity_id`, and the four are **distinct** — four recipients,
432
- four subjects. A shared subject would mean two people collapsed into
433
- one, and a short link that pointed at the wrong customer.
434
-
435
- ```typescript
436
- const p4 = String(Date.now()).slice(-4)
437
- ctx.adaPhone = `+1551${p4}`
438
-
439
- const rosterRow = {
440
- direct_customer_id: `DC-${runSuffix}`,
441
- business_name: 'Bloom Salon',
442
- sender_phone: '+15550001111',
443
- sender_domain: 'bloom-salon.example.com',
444
- }
445
- const audienceRows = [
446
- { end_customer_id: `EC-${runSuffix}-10`, direct_customer_id: rosterRow.direct_customer_id, name: 'Ada Lovelace', email: `ada-${runSuffix}@example.com`, phone: ctx.adaPhone, suppressed: false },
447
- { end_customer_id: `EC-${runSuffix}-80`, direct_customer_id: rosterRow.direct_customer_id, name: 'Grace Hopper', email: `grace-${runSuffix}@example.com`, phone: `+1552${p4}`, suppressed: false },
448
- { end_customer_id: `EC-${runSuffix}-31`, direct_customer_id: rosterRow.direct_customer_id, name: 'Sup Pressed', email: `sup-${runSuffix}@example.com`, phone: `+1553${p4}`, suppressed: true },
449
- { end_customer_id: `EC-${runSuffix}-42`, direct_customer_id: rosterRow.direct_customer_id, name: 'No Phone', email: `nophone-${runSuffix}@example.com`, phone: '', suppressed: false },
450
- ]
451
-
452
- await api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.rosterDacSlug, { data: rosterRow })
453
- await Promise.all(
454
- audienceRows.map((row) =>
455
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.audienceDacSlug, { data: row }),
456
- ),
457
- )
458
-
459
- const deadline = Date.now() + 120_000
460
- let rows: Array<Record<string, unknown>> = []
461
- while (Date.now() < deadline && rows.length < 4) {
462
- const { data: result } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
463
- sql: `SELECT end_customer_id, bucket, legal_entity_id
464
- FROM ${ctx.audienceTableName}
465
- WHERE end_customer_id LIKE 'EC-${runSuffix}-%'`,
466
- mode: 'unregulated',
467
- })
468
- if (typeof result !== 'string') {
469
- rows = result.data.map((r) => Object.fromEntries(result.meta.columns.map((c, i) => [c, r[i]])))
470
- }
471
- if (rows.length < 4) await new Promise((r) => setTimeout(r, 2_000))
472
- }
473
- if (rows.length !== 4) {
474
- throw new Error(`expected 4 audience rows within 120s, got ${rows.length}`)
475
- }
476
-
477
- const byId = new Map(rows.map((r) => [String(r.end_customer_id), r]))
478
- if (Number(byId.get(`EC-${runSuffix}-10`)!.bucket) !== 10) throw new Error('Ada must land in bucket 10')
479
- if (Number(byId.get(`EC-${runSuffix}-80`)!.bucket) !== 80) throw new Error('Grace must land in bucket 80')
480
-
481
- for (const row of rows) {
482
- if (!row.legal_entity_id) {
483
- throw new Error(`${row.end_customer_id} has no stamped subject — MDM did not resolve it`)
484
- }
485
- }
486
- const subjects = new Set(rows.map((r) => String(r.legal_entity_id)))
487
- if (subjects.size !== 4) {
488
- throw new Error(`four recipients must be four distinct subjects, got ${subjects.size}`)
489
- }
490
- ctx.adaSubjectId = String(byId.get(`EC-${runSuffix}-10`)!.legal_entity_id)
491
- ```
492
-
493
- ## 008 — the two sender tools and the connected app
494
-
495
- **Two sender tools, not one.** An SMS number and an email identity
496
- are different assets, provisioned and billed separately, so they are
497
- separate tools — and §009's actions bind each channel to its own.
498
-
499
- The email tool uses `provider: 'mock'`, which delivers in-process to
500
- the dev mailbox at `/dev/mailbox` — no credentials, no provider
501
- account. Note **where** that lives: it is a field on the **tool body**,
502
- which is data on the tool row, not an environment variable. The same
503
- manifest therefore runs against any server; you are not asking the
504
- deployment to be in "test mode". Swap `mock` for `ses` and the same
505
- workflow sends real email.
506
-
507
- The connected app is the campaign's booking form. It is registered
508
- here, and §009's actions deep-link into it.
509
-
510
- ```typescript
511
- const { ToolIntent } = await import('@alvera-ai/platform-sdk')
512
-
513
- const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
514
- name: `Cookbook Campaign SMS Sender ${runSuffix}`,
515
- description: 'SNS-backed SMS dispatcher for the campaign, wired to LocalStack.',
516
- intent: ToolIntent.SMS,
517
- status: 'active',
518
- datalake_id: ctx.datalakeId,
519
- body: {
520
- tool_body_type: 'sns',
521
- auth_method: 'access_key',
522
- region: 'us-east-1',
523
- phone_number: '+15550001111',
524
- endpoint_url: 'http://localhost:4566',
525
- access_key_id: 'test',
526
- secret_access_key: 'test',
527
- },
528
- })
529
- toolId = smsTool.id!
530
-
531
- const { data: emailTool } = await api.tools.create(tenantSlug, datalakeSlug, {
532
- name: `Cookbook Campaign Email Sender ${runSuffix}`,
533
- description: 'Campaign email dispatcher — the mock provider delivers to the dev mailbox.',
534
- intent: ToolIntent.EMAIL,
535
- status: 'active',
536
- datalake_id: ctx.datalakeId,
537
- body: {
538
- tool_body_type: 'email',
539
- provider: 'mock',
540
- from_email: 'campaigns@bloom-salon.example.com',
541
- from_name: 'Bloom Salon',
542
- },
543
- })
544
- ctx.emailToolId = emailTool.id!
545
-
546
- const { data: app } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
547
- name: `Cookbook Campaign Booking Form ${runSuffix}`,
548
- description: 'Booking form the campaign links to.',
549
- mode: 'self_hosted',
550
- urls: [{ url: 'https://campaign.example.com', is_primary: true, label: 'production' }],
551
- })
552
- connectedAppId = app.id!
553
- ctx.connectedAppSlug = app.slug!
554
- ```
555
-
556
- ## 009 — the campaign workflow
557
-
558
- This is where all four rules live.
559
-
560
- **The gates are the filter.** `{% unless event_dataset.suppressed %}`
561
- is the suppression gate; the two non-empty checks are the
562
- reachability gate. A row that fails either is `:filtered` — looked
563
- at, and deliberately not contacted. There is no code path around it.
564
-
565
- **The split is the decision node.** It returns an *array of decision
566
- keys*, and it returns **both channel keys of the chosen variant**:
567
- bucket under 50 gets `["sms_variant_a","email_variant_a"]`, otherwise
568
- the B pair. The four actions each declare a `decision_key`; the
569
- platform runs the two whose keys came back and marks the other two
570
- `:skipped`. That is how one run sends one recipient on two channels
571
- with one variant — the pairing is data, not two workflows.
572
-
573
- **`skip_mdm_resolution: false` is set explicitly, and must be.** A
574
- generic-table row is never its own MDM subject; the subject is the
575
- one §006 stamped onto the row. With resolution on, the platform loads
576
- that subject and mints a page token against it — which is what makes
577
- `{{ connected_app_form_url }}` a *per-recipient* link. Skip it and the
578
- subject is nil, the token is never minted, and the link renders empty.
579
-
580
- `output_schema` is a JSON-Schema **object**. The string form is
581
- rejected with a 422.
582
-
583
- ```typescript
584
- const CAMPAIGN_FILTER =
585
- '{% unless event_dataset.suppressed %}' +
586
- '{% if event_dataset.phone and event_dataset.phone != "" %}' +
587
- '{% if event_dataset.email and event_dataset.email != "" %}true{% endif %}' +
588
- '{% endif %}{% endunless %}'
589
- const AB_DECISION =
590
- '{% if event_dataset.bucket < 50 %}["sms_variant_a","email_variant_a"]' +
591
- '{% else %}["sms_variant_b","email_variant_b"]{% endif %}'
592
- const VARIANT_A = 'Hi {{ event_dataset.name }} — 20% off your next visit this week only. Book: {{ connected_app_form_url }}'
593
- const VARIANT_B = 'Hi {{ event_dataset.name }} — your loyalty reward is waiting. Claim: {{ connected_app_form_url }}'
594
- const ROUTE = '/forms/campaign'
595
- const META = '{"end_customer_id":"{{ event_dataset.end_customer_id }}","name":"{{ event_dataset.name }}"}'
596
- const IDEMPOTENCY = '{{ event_dataset.end_customer_id }}/{{ action_id }}'
597
-
598
- const linkage = {
599
- trigger_template: 'now',
600
- idempotency_template: IDEMPOTENCY,
601
- connected_app_id: connectedAppId,
602
- connected_app_route: ROUTE,
603
- connected_app_metadata_template: META,
604
- }
605
-
606
- const { data: workflow } = await api.workflows.create(tenantSlug, datalakeSlug, {
607
- name: `Cookbook Loyalty Campaign ${runSuffix}`,
608
- description: 'Loyalty campaign with A/B content variants across SMS and email.',
609
- dataset_type: 'generic_table',
610
- generic_table_id: ctx.audienceTableId,
611
- status: 'live',
612
- tags: ['marketing', 'loyalty'],
613
- skip_mdm_resolution: false,
614
- filter_config: { type: 'custom', body: CAMPAIGN_FILTER, output_schema: { type: 'boolean' } },
615
- decision_config: {
616
- type: 'custom',
617
- body: AB_DECISION,
618
- output_schema: { type: 'array', items: { type: 'string' } },
619
- },
620
- actions: [
621
- {
622
- ...linkage,
623
- action_type: 'sms', tool_id: toolId, decision_key: 'sms_variant_a', position: 0,
624
- tool_call: {
625
- tool_call_type: 'sms_request',
626
- to: { type: 'custom', body: '{{ event_dataset.phone }}' },
627
- body: { type: 'custom', body: VARIANT_A },
628
- sms_type: 'transactional',
629
- },
630
- },
631
- {
632
- ...linkage,
633
- action_type: 'sms', tool_id: toolId, decision_key: 'sms_variant_b', position: 1,
634
- tool_call: {
635
- tool_call_type: 'sms_request',
636
- to: { type: 'custom', body: '{{ event_dataset.phone }}' },
637
- body: { type: 'custom', body: VARIANT_B },
638
- sms_type: 'transactional',
639
- },
640
- },
641
- {
642
- ...linkage,
643
- action_type: 'email', tool_id: ctx.emailToolId, decision_key: 'email_variant_a', position: 2,
644
- tool_call: {
645
- tool_call_type: 'email_request',
646
- to: { type: 'custom', body: '{{ event_dataset.email }}' },
647
- subject: { type: 'custom', body: 'Your 20% off is here' },
648
- body: { type: 'custom', body: VARIANT_A },
649
- },
650
- },
651
- {
652
- ...linkage,
653
- action_type: 'email', tool_id: ctx.emailToolId, decision_key: 'email_variant_b', position: 3,
654
- tool_call: {
655
- tool_call_type: 'email_request',
656
- to: { type: 'custom', body: '{{ event_dataset.email }}' },
657
- subject: { type: 'custom', body: 'Your loyalty reward is waiting' },
658
- body: { type: 'custom', body: VARIANT_B },
659
- },
660
- },
661
- ],
662
- })
663
- workflowId = workflow.id!
664
- ctx.workflowSlug = workflow.slug!
665
-
666
- if (workflow.skip_mdm_resolution !== false) {
667
- throw new Error('a generic-table campaign workflow must keep MDM resolution ON')
668
- }
669
- if ((workflow.actions ?? []).length !== 4) {
670
- throw new Error(`expected 4 actions, got ${(workflow.actions ?? []).length}`)
671
- }
672
- ```
673
-
674
- ## 010 — run the campaign once
675
-
676
- One pass over all four recipients. Both gates and the split run in
677
- the same run — the two reachable, un-suppressed recipients send; the
678
- suppressed one and the unreachable one are `:filtered`.
679
-
680
- The per-recipient action logs are the review surface. Each sending
681
- recipient's execution log carries **four** action logs — one per
682
- action — of which exactly **two completed and two skipped**, and the
683
- two that completed are the *same variant on both channels*. That is
684
- the A/B split and the two-channel send, read straight off the log:
685
- group the completed logs by `decision_key` and you have per-variant
686
- performance.
687
-
688
- ```typescript
689
- const { data: run } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
690
- sql_where_clause: `end_customer_id LIKE 'EC-${runSuffix}-%'`,
691
- mode: 'live',
692
- manual_override: false,
693
- })
694
- // run-workflow only SCHEDULES the run. The log id is written when it
695
- // fires, so read it back via workflowRuns.get.
696
- const fired = await ctx.waitForFiredRun(datalakeSlug, run.workflow_run_id)
697
- ctx.runLogId = fired.workflowRunLogId
698
-
699
- const deadline = Date.now() + 180_000
700
- let byStatus: Record<string, number> = {}
701
- while (Date.now() < deadline) {
702
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
703
- if (log.status === 'failed') throw new Error('campaign run reached :failed')
704
-
705
- const { data: batch } = await api.workflows.batchLogs.get(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
706
- ctx.runBatchId = (batch as { batch_id?: string }).batch_id ?? ctx.runBatchId
707
-
708
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
709
- const ours = (wfLogs.data ?? []).filter((w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId)
710
- byStatus = {}
711
- for (const wel of ours) {
712
- const st = (wel as { status?: string }).status ?? 'unknown'
713
- byStatus[st] = (byStatus[st] ?? 0) + 1
714
- }
715
- if ((byStatus.completed ?? 0) >= 2 && (byStatus.filtered ?? 0) >= 2) break
716
- await new Promise((r) => setTimeout(r, 3_000))
717
- }
718
- if ((byStatus.completed ?? 0) !== 2 || (byStatus.filtered ?? 0) !== 2) {
719
- throw new Error(`expected 2 completed + 2 filtered, got ${JSON.stringify(byStatus)}`)
720
- }
721
-
722
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
723
- const sent = (wfLogs.data ?? []).filter((w) => {
724
- const wel = w as { batch_id?: string; status?: string }
725
- return wel.batch_id === ctx.runBatchId && wel.status === 'completed'
726
- })
727
-
728
- for (const wel of sent) {
729
- const aels = (wel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> }).action_execution_logs ?? []
730
- if (aels.length !== 4) throw new Error(`four actions → four action logs, got ${aels.length}`)
731
-
732
- const completed = aels.filter((a) => a.status === 'completed').map((a) => a.decision_key ?? '').sort()
733
- const skipped = aels.filter((a) => a.status === 'skipped')
734
- if (completed.length !== 2 || skipped.length !== 2) {
735
- throw new Error(`expected 2 completed + 2 skipped action logs, got ${JSON.stringify(aels)}`)
736
- }
737
- const isA = completed[0] === 'email_variant_a' && completed[1] === 'sms_variant_a'
738
- const isB = completed[0] === 'email_variant_b' && completed[1] === 'sms_variant_b'
739
- if (!isA && !isB) {
740
- throw new Error(`the completed pair must be one variant on both channels, got ${completed.join(', ')}`)
741
- }
742
- }
743
- ```
744
-
745
- ## 011 — the per-recipient short link
746
-
747
- Every message the campaign rendered carries a `/t/<hash>` link. It is
748
- not one campaign link shared by everyone: the platform minted a page
749
- token against **this recipient's own MDM subject** — the one §006
750
- stamped and §009 resolved — so the click is attributable to one
751
- person.
752
-
753
- `resolvePage` exchanges the hash for the page, and
754
- `updateMessageTracking` stamps `opened_at`. Together they are the
755
- click: a recipient opened *this* message.
756
-
757
- ```typescript
758
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
759
- search_query: `rm.workflow_id = '${workflowId}'`,
760
- })
761
- if (userSearch.status !== 'completed') {
762
- throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
763
- }
764
-
765
- const deadline = Date.now() + 60_000
766
- let shortPath: string | undefined
767
- while (Date.now() < deadline && !shortPath) {
768
- const { data: found } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
769
- userSearchId: userSearch.id!,
770
- dataAccessMode: 'regulated',
771
- })
772
- const bodies = ((found.data ?? []) as Array<Record<string, unknown>>).map((m) => String(m.body ?? ''))
773
- const withLink = bodies.find((b) => b.includes('/t/'))
774
- shortPath = withLink?.match(/\/t\/([A-Za-z0-9_-]+)/)?.[1]
775
- if (!shortPath) await new Promise((r) => setTimeout(r, 2_000))
776
- }
777
- if (!shortPath) {
778
- throw new Error('no rendered campaign message carried a /t/ short link within 60s')
779
- }
780
- ctx.shortPath = shortPath
781
-
782
- const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
783
- short_path: ctx.shortPath,
784
- user_agent: 'cookbook-doctest/marketing-campaign-send',
785
- })
786
- if (resolved.route_path !== '/forms/campaign') {
787
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
788
- }
789
-
790
- const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
791
- short_path: ctx.shortPath,
792
- opened_at: new Date().toISOString(),
793
- })
794
- if (!tracked.message?.opened_at) {
795
- throw new Error('message tracking did not persist opened_at')
796
- }
797
- ```
798
-
799
- ## 012 — the reply comes back, and attaches
800
-
801
- A reply arrives as an inbound message on the same handle the campaign
802
- sent to. It re-ingests through a reply activation client into the
803
- **system** inbound table, and its `mdm_input_config` keys on
804
- `(system, handle)` — the same namespace §006 wrote — so the platform
805
- resolves the handle straight back to the customer who replied and
806
- stamps their subject on the row.
807
-
808
- The client carries **only** a generic-table contract. It must not
809
- carry a legal-entity contract: a reply does not create a person, it
810
- finds one.
811
-
812
- **When a handle is ambiguous, the reply still attaches.** If two
813
- capture systems each registered a customer under the same phone,
814
- that handle maps to two legal entities. The platform attaches the
815
- reply to one of them — the row always lands, never errors, never
816
- sits withheld — and marks it **`potential_duplicate`** for a human to
817
- adjudicate. Flag, don't block: a dropped reply is a lost customer,
818
- while a flagged one is a two-minute review.
819
-
820
- The duplicate is *detectable* rather than guessed at. Tokenization is
821
- stable per value, so duplicate handles group on the shared token with
822
- no regulated access at all — this is the query a detection client
823
- runs on a schedule, and what it finds is what sets the flag:
824
-
825
- ```sql
826
- select (lei.id_number).token
827
- from legal_entity_identifications lei
828
- group by (lei.id_number).token
829
- having count(distinct lei.legal_entity_id) > 1
830
- ```
831
-
832
- Below, the reply lands and attaches to Ada; then the same
833
- `message_id` is re-ingested with the flag set. `message_id` is the
834
- table's unique column, so the second ingest **upserts** — one row,
835
- now flagged, not a second copy of the reply.
836
-
837
- ```typescript
838
- const REPLY_GT = `{% assign p = msg %}
839
- {
840
- "message_id": "{{ p.message_id | json_escape }}",
841
- "handle": "{{ p.handle | json_escape }}",
842
- "channel": "{{ p.channel | json_escape }}",
843
- "body": "{{ p.body | json_escape }}",
844
- "potential_duplicate": {% if p.potential_duplicate %}true{% else %}false{% endif %}
845
- }`
846
-
847
- const REPLY_MDM = `{% assign p = msg %}
848
- {
849
- "legal_entity_type": "individual",
850
- "identifiers": [
851
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}", "type": "digital_identifier"}
852
- ]
853
- }`
854
-
855
- const { data: replyGt } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
856
- name: `Cookbook Reply GT ${runSuffix}`,
857
- description: 'Inbound reply → Inbound Messages row, re-attached to the end customer who sent it.',
858
- resource_type: 'generic_table',
859
- type: 'identity',
860
- generic_table_id: ctx.inboundTableId,
861
- template_config: { type: 'custom', body: REPLY_GT },
862
- mdm_input_config: { type: 'custom', body: REPLY_MDM },
863
- })
864
-
865
- const { data: replyDac } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
866
- name: `Cookbook Reply Client ${runSuffix}`,
867
- description: 'Inbound reply ingest — re-attaches the sender handle to its end-customer subject.',
868
- tool_id: ctx.uploadToolId,
869
- data_source_id: dataSourceId,
870
- tool_call: { tool_call_type: 'manual_upload' },
871
- interoperability_contract_ids: [replyGt.id!],
872
- })
873
-
874
- const messageId = `IN-${runSuffix}`
875
- await api.dataActivationClients.ingest(tenantSlug, datalakeSlug, replyDac.slug!, {
876
- data: { message_id: messageId, handle: ctx.adaPhone, channel: 'sms', body: 'Yes! Book me for Friday.' },
877
- })
878
-
879
- const readReply = async (): Promise<Record<string, unknown> | undefined> => {
880
- const { data: result } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
881
- sql: `SELECT message_id, legal_entity_id, potential_duplicate
882
- FROM ${ctx.inboundTableName}
883
- WHERE message_id = '${messageId}'`,
884
- mode: 'unregulated',
885
- })
886
- if (typeof result === 'string' || result.data.length === 0) return undefined
887
- return Object.fromEntries(result.meta.columns.map((c, i) => [c, result.data[0][i]]))
888
- }
889
-
890
- const deadline = Date.now() + 120_000
891
- let reply: Record<string, unknown> | undefined
892
- while (Date.now() < deadline && !reply) {
893
- reply = await readReply()
894
- if (!reply) await new Promise((r) => setTimeout(r, 2_000))
895
- }
896
- if (!reply) throw new Error('the reply did not land in the inbound messages table within 120s')
897
- if (String(reply.legal_entity_id) !== ctx.adaSubjectId) {
898
- throw new Error('the reply must re-attach to the end customer who sent it')
899
- }
900
-
901
- // Same message_id, flag set — the unique column makes this an upsert.
902
- await api.dataActivationClients.ingest(tenantSlug, datalakeSlug, replyDac.slug!, {
903
- data: {
904
- message_id: messageId,
905
- handle: ctx.adaPhone,
906
- channel: 'sms',
907
- body: 'Yes! Book me for Friday.',
908
- potential_duplicate: true,
909
- },
910
- })
911
-
912
- const flagDeadline = Date.now() + 120_000
913
- let flagged: Record<string, unknown> | undefined
914
- while (Date.now() < flagDeadline) {
915
- const row = await readReply()
916
- if (row && row.potential_duplicate === true) {
917
- flagged = row
918
- break
919
- }
920
- await new Promise((r) => setTimeout(r, 2_000))
921
- }
922
- if (!flagged) throw new Error('the re-ingest did not flag the reply potential_duplicate within 120s')
923
- ```
924
-
925
- ## 013 — write the integration test
926
-
927
- End the build with a test you keep: re-read the workflow and prove the
928
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
929
- never-matching selection runs the FULL pipeline (selection → filter →
930
- decision) and intercepts only the final action call, so no message
931
- leaves, yet the acknowledgement proves the workflow is runnable. This
932
- block runs live under `make validate-cookbook`.
933
-
934
- ```typescript
935
- // Re-GET — the workflow must still be live, or nothing will run.
936
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
937
- if (wfRow.status !== 'live') {
938
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
939
- }
940
- // Behavioural probe — a dry run against a selection no row can match:
941
- // the pipeline executes end-to-end, the final action call is
942
- // intercepted, and the acknowledgement carries the scheduled run id. The
943
- // clause speaks this workflow's selection dialect: a GENERIC-TABLE
944
- // audience is addressed by its own columns (no `ra.` dataset alias —
945
- // that alias exists only for system-dataset selections).
946
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
947
- sql_where_clause: "end_customer_id = 'test-never-matching-customer'",
948
- mode: 'dry_run',
949
- manual_override: false,
950
- })
951
- // The run-log id does not exist until the run fires — wait, do not read a null.
952
- const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
953
- if (probeFired.workflowRunLogId.length === 0) {
954
- throw new Error('dry-run probe never produced a workflow_run_log_id')
955
- }
956
- ```
957
-
958
- If the probe fails in production, escalate with the run response as
959
- evidence — don't flip the workflow's status or rewrite its configs to
960
- chase the error.
961
-
962
- # Branches
963
-
964
- - **Suppressed and unreachable are `:filtered`, not `:failed`.** Both
965
- are *correct* outcomes: the workflow looked at the row, a gate
966
- rendered empty, and the platform recorded a deliberate skip. No
967
- action runs for a filtered row, on any channel. The suppression
968
- gate lives in workflow selection, so a suppressed customer is
969
- unselectable by *any* campaign — not merely filtered out of one
970
- UI's list.
971
- - **The variant pair, not the variant.** The decision node returns two
972
- keys, so a recipient gets one variant across both channels. Return
973
- one key and you would send SMS but not email. Return all four and
974
- you would send both variants to the same person — which is why the
975
- decision is a template over `bucket` and not a per-action condition.
976
- - **A re-ingest does not re-roll the bucket.** The bucket is derived
977
- from the vendor id, so a customer re-ingested tomorrow keeps their
978
- variant. Had it been random, a mid-campaign re-ingest would move
979
- people between arms and the A/B result would be unreadable.
980
- - **The ambiguous reply attaches and is flagged.** It is never
981
- withheld and never an error. §012's detection query is what finds
982
- the ambiguity; the flag is what routes it to a human. The platform
983
- does not silently pick a "best" match and hide the choice.
984
- - **`provider: 'mock'` is data, not a deploy mode.** It sits on the
985
- tool row, so the same manifest runs anywhere; the tool — not the
986
- environment — decides where email goes.
987
-
988
- # Rollback
989
-
990
- The cookbook doctest harness does not currently tear down created
991
- resources. `_setup/foundation.md`'s runSuffix-scoped tenant / datalake
992
- / user names mean each run is naturally isolated; the seeded local DB
993
- is cheap to reset (`mix ecto.reset` on the platform repo).
994
-
995
- # Outcome
996
-
997
- After this cookbook's twelve steps run green:
998
-
999
- - A business roster and an end-customer audience exist as generic
1000
- tables, and the foundation datalake's **system** inbound-messages
1001
- table was found, not created
1002
- - One data source, one manual-upload tool, and three activation
1003
- clients (roster, audience, reply) share one identity namespace
1004
- - Four recipients are ingested, each MDM-resolved to their **own**
1005
- legal entity, each carrying a **stable** A/B bucket derived from
1006
- their vendor id
1007
- - A campaign workflow holds the suppression gate, the reachability
1008
- gate, and the A/B split — none of them in application code
1009
- - One run sent two recipients on **both** SMS and email with the
1010
- variant their bucket chose, and filtered the suppressed and the
1011
- unreachable in the same pass
1012
- - Every send carried a `/t/` link minted against that recipient's own
1013
- subject; resolving it and stamping `opened_at` records the click
1014
- - A reply re-ingested on the sender's handle attached back to the
1015
- customer who sent it, and the duplicate-handle path flags rather
1016
- than blocks
1017
-
1018
- The business outcome — a compliant, attributable, A/B-split campaign
1019
- that can be replied to — is demonstrated end-to-end, not merely
1020
- provisioned.
1021
-
1022
- # See also
1023
-
1024
- - `_setup/foundation.md` — the inlined bootstrap that provisions the
1025
- tenant + datalake this cookbook starts from
1026
- - `generic-tables.md` — the capability walk for generic tables:
1027
- columns, `privacy_requirement`, deploy, read back with SQL
1028
- - `welcome-sms-for-customers.md` — the single-channel, no-split
1029
- sibling: one SMS action over a built-in dataset
1030
- - `score-leads-with-llm-categorization.md` — a decision node driven by
1031
- an agent instead of a Liquid template
1032
- - `.agent/workflows.md` — filter, decision node, actions,
1033
- `skip_mdm_resolution`, `workflows.run`
1034
- - `.agent/tools.md` — SMS (`sns`) and email tool bodies; the email
1035
- `provider` values including `mock`
1036
- - `.agent/connected_apps.md` — connected-app registration and the
1037
- resolve-page / message-tracking loop
1038
- - `.agent/interoperability_contracts.md` — `template_config` vs
1039
- `mdm_input_config`, and how a subject gets stamped on a row
1040
- - `.agent/data_activation_clients.md` — data source → tool → contract
1041
- → client ingestion chain
1042
- - `.agent/datalakes.md` — the 8-zone timezone whitelist
1043
- - `integration-tests/tests/foundation/marketing-campaign-send.test.ts`
1044
- — the anchor green test these snippets are lifted from