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