@alvera-ai/platform-sdk 0.14.0 → 0.15.0-next.g2f28e7b

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.
@@ -117,6 +117,10 @@ scope — do not loop.
117
117
  {"external_id": "{{ notification.messageId }}", "status": "delivered"}
118
118
  ```
119
119
 
120
+ > **Shape only — do not copy this mapping.** A hardcoded `"delivered"`
121
+ > records every event as delivered, failures included. Real bodies
122
+ > branch on the provider's status; see the SNS and MMS sections below.
123
+
120
124
  It must render a **flat** JSON object:
121
125
 
122
126
  ```
@@ -139,15 +143,107 @@ events for messages this updater doesn't own.
139
143
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
140
144
  available here, as everywhere.
141
145
 
142
- ### MMS delivery tracking uses a system `message_config` template
146
+ ### SMS-over-SNS delivery tracking two `custom` bodies
147
+
148
+ The SNS SMS delivery receipt is **binary and single-shot**: one terminal
149
+ outcome, `status` of `SUCCESS` or `FAILURE`. Its event shape is
150
+ **disjoint from EUM's** — `status`, `notification.messageId`,
151
+ `notification.timestamp`, `delivery.phoneCarrier`,
152
+ `delivery.providerResponse` all sit at the root. Do not carry an EUM
153
+ template over to SNS or vice versa.
154
+
155
+ Two convenient consequences, both unlike MMS:
156
+
157
+ - `delivered_at` is `notification.timestamp` — a UTC
158
+ `"YYYY-MM-DD HH:MM:SS.sss"` string Ecto casts directly, so **no date
159
+ filter is needed** (EUM needs `from_unix_ms` on its Unix-ms field).
160
+ - the DLR **does** surface the US carrier as `delivery.phoneCarrier`.
161
+
162
+ **`message_config`:**
163
+
164
+ ```liquid
165
+ {"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"{% if status == 'SUCCESS' and notification.timestamp %}, "delivered_at": "{{ notification.timestamp }}"{% endif %}{% unless status == 'SUCCESS' %}, "failure_reason": "{{ status | json_escape }}"{% endunless %}{% if delivery.phoneCarrier and delivery.phoneCarrier != '' %}, "sms_carrier": "{{ delivery.phoneCarrier | json_escape }}"{% endif %}}
166
+ ```
167
+
168
+ **`action_log_config`:**
169
+
170
+ ```liquid
171
+ {"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"}
172
+ ```
173
+
174
+ `status_description` is `delivery.providerResponse` verbatim on both
175
+ sides — the carrier's own text, on success and failure alike. Because
176
+ SNS's status is binary, `failure_reason` can only ever be `"FAILURE"`;
177
+ the granular carrier text stays in `status_description`.
178
+
179
+ **Note how the two SNS bodies differ from each other**: same status
180
+ mapping, fewer emitted fields on the action log (no `delivered_at`,
181
+ `sms_carrier`, or `failure_reason` — those are message concerns). That
182
+ is a *different* reason from MMS's, where the two also disagree on the
183
+ mapping itself. Both channels need two bodies; they need them for
184
+ different reasons.
185
+
186
+ ### MMS delivery tracking needs TWO different templates — author both as `custom`
187
+
188
+ MMS delivery tracking is a `cloud_watch` updater. Author **both**
189
+ `message_config` and `action_log_config` as `custom` bodies — the two
190
+ render *different* JSON from the same event, so one body cannot serve
191
+ both. This is the single most-missed thing about updaters.
192
+
193
+ **Why they differ.** A message's status vocabulary includes
194
+ `customer_rejected`; an action log's does not. A recipient or carrier
195
+ rejection (`SPAM` / `BLOCKED` / `CARRIER_BLOCKED`) is a message-level
196
+ outcome — the action *did* deliver — so the action-log render must
197
+ **NO-OP** on those events while the message render records
198
+ `customer_rejected`. The message side also carries `delivered_at`,
199
+ `failure_reason`, and `sms_carrier`; only `status` +
200
+ `status_description` are ever applied to an action log.
201
+
202
+ AWS End User Messaging (`sms-voice`) event fields: top-level
203
+ `messageId`, `eventType` (`MEDIA_*`), `messageStatus`,
204
+ `messageStatusDescription`, `eventTimestamp` (Unix ms), `isFinal`.
205
+
206
+ **`message_config`** — map every status, emit the extra fields:
207
+
208
+ ```liquid
209
+ {% case messageStatus %}
210
+ {% when 'DELIVERED' %}{% assign mapped_status = 'delivered' %}
211
+ {% when 'SUCCESSFUL' %}{% assign mapped_status = 'sent' %}
212
+ {% when 'PENDING' %}{% assign mapped_status = 'sent' %}
213
+ {% when 'QUEUED' %}{% assign mapped_status = 'queued' %}
214
+ {% when 'SPAM' %}{% assign mapped_status = 'customer_rejected' %}
215
+ {% when 'BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
216
+ {% when 'CARRIER_BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
217
+ {% else %}{% assign mapped_status = 'failed' %}
218
+ {% endcase %}
219
+ {"external_id": "{{ messageId }}", "status": "{{ mapped_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% if messageStatus == 'DELIVERED' and eventTimestamp %}, "delivered_at": "{{ eventTimestamp | from_unix_ms }}"{% endif %}{% if mapped_status == 'failed' or mapped_status == 'customer_rejected' %}, "failure_reason": "{{ messageStatus | json_escape }}"{% endif %}{% if carrierName and carrierName != '' %}, "sms_carrier": "{{ carrierName | json_escape }}"{% endif %}}
220
+ ```
221
+
222
+ **`action_log_config`** — the rejection statuses fall through to a
223
+ bare `external_id`, which applies nothing:
224
+
225
+ ```liquid
226
+ {% assign al_status = '' %}{% case messageStatus %}{% when 'DELIVERED' %}{% assign al_status = 'delivered' %}{% when 'SUCCESSFUL' %}{% assign al_status = 'sent' %}{% when 'PENDING' %}{% assign al_status = 'sent' %}{% when 'QUEUED' %}{% assign al_status = 'queued' %}{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}{% else %}{% assign al_status = 'failed' %}{% endcase %}{"external_id": "{{ messageId }}"{% if al_status != '' %}, "status": "{{ al_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% endif %}}
227
+ ```
143
228
 
144
- MMS delivery tracking is wired as a `cloud_watch` updater whose
145
- `message_config` points at a system-defined template rather than a
146
- hand-authored `custom` body the system template lives at
147
- `status_poller/end_user_messaging/mms_delivery_status`. Everything
148
- else about the updater is ordinary: it still needs an
149
- `action_log_config` like every other updater (see below) — the
150
- system template covers `message_config` only.
229
+ Note the `{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}` arm is
230
+ deliberately **empty** it leaves `al_status` blank, so the render
231
+ emits `external_id` alone and the action log is untouched.
232
+
233
+ **Capture every event, not just the terminal one.** 2–3 events arrive
234
+ per message interim `MEDIA_QUEUED` / `MEDIA_PENDING` /
235
+ `MEDIA_SUCCESSFUL` (`isFinal: false`), then one terminal event
236
+ (`isFinal: true`), up to 72h late and out of order. Apply is
237
+ idempotent and a monotonic guard only ever advances `status`, so a
238
+ late or interim lower-rank event can never demote a message that
239
+ already reached a higher state. Poll a window that overlaps
240
+ deliberately; nothing is dropped.
241
+
242
+ `queued_at` and `sent_at` are **your outbound pipeline's** times, set
243
+ when the platform enqueues and dispatches — a delivery poll never
244
+ overwrites them. Only `delivered_at` comes from the carrier event
245
+ (`DELIVERED`'s own `eventTimestamp` via `from_unix_ms`), so
246
+ re-polling a wide window does not drift it.
151
247
 
152
248
  ### Carrier information is available for SMS, not MMS
153
249
 
@@ -160,12 +256,15 @@ events — not something the platform chooses to drop:
160
256
  under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
161
257
  numeric `mcc`/`mnc`). The SNS status template maps it to
162
258
  `sms_carrier`.
163
- - **MMS via AWS End User Messaging (`sms-voice`)** — the delivery
164
- event has no carrier field. Verified on **both** a config-set
165
- CloudWatch destination and the EventBridge path: the terminal
166
- `MEDIA_DELIVERED` event exposes only `totalCarrierFee` (a cost),
167
- never `carrierName`/`mcc`/`mnc`. So `sms_carrier` stays unset for
168
- MMS.
259
+ - **MMS via AWS End User Messaging (`sms-voice`)** — the event *can*
260
+ carry `carrierName`, but it is DLR-populated and comes back
261
+ **absent for US destinations** (the US DLR does not surface it),
262
+ unlike the SNS path's `phoneCarrier`, whose US DLR does. So map it
263
+ when present — `{% if carrierName and carrierName != '' %}` and
264
+ expect `sms_carrier` to stay null for US long-code traffic.
265
+ Verified against real AWS delivery events: the terminal
266
+ `MEDIA_DELIVERED` event for a US destination exposes
267
+ `totalCarrierFee` (a cost) and no `carrierName`.
169
268
 
170
269
  AWS's Nov-2024 EventBridge-integration announcement states MMS
171
270
  delivery events carry carrier information in EventBridge, but the
@@ -210,6 +210,7 @@ const { data: workflow } = await api.workflows.create(
210
210
  {
211
211
  name: 'Contact-Us Triage',
212
212
  dataset_type: 'generic_table',
213
+ tags: [],
213
214
  // …other workflow fields…
214
215
  workflow_ai_agents: [
215
216
  {
@@ -159,6 +159,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
159
159
  description: 'Sends a review-request SMS with a connected-app form link after a fulfilled appointment.',
160
160
  dataset_type: 'appointment',
161
161
  status: 'live',
162
+ tags: ['appointments', 'review'],
162
163
  skip_mdm_resolution: false,
163
164
  filter_config: {
164
165
  type: 'custom',
@@ -157,6 +157,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
157
157
  description: "Sends a happy-birthday SMS on each contact's next birthday — pure-Liquid trigger does the year-roll math.",
158
158
  dataset_type: 'legal_entity',
159
159
  status: 'live',
160
+ tags: ['lifecycle', 'birthday'],
160
161
  skip_mdm_resolution: false,
161
162
  filter_config: {
162
163
  type: 'custom',
@@ -175,7 +176,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
175
176
  position: 0,
176
177
  trigger_template: TRIGGER_TEMPLATE,
177
178
  idempotency_template:
178
- '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}-{{ "" | uuid }}',
179
+ '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}',
179
180
  connected_app_id: connectedAppId,
180
181
  connected_app_route: '/forms/birthday-greeting',
181
182
  connected_app_metadata_template:
@@ -310,6 +310,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
310
310
  generic_table_id: genericTableId,
311
311
  skip_mdm_resolution: true,
312
312
  status: 'live',
313
+ tags: ['support', 'triage'],
313
314
  filter_config: {
314
315
  type: 'custom',
315
316
  body: 'true',
@@ -326,7 +327,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
326
327
  tool_id: toolId,
327
328
  position: 0,
328
329
  trigger_template: 'now',
329
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}-{{ "" | uuid }}`,
330
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}`,
330
331
  tool_call: {
331
332
  tool_call_type: 'sms_request',
332
333
  to: { type: 'custom', body: '+15551234567' },
@@ -162,6 +162,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
162
162
  description: 'Sends a payment-reminder SMS to delinquent contracted customers with a self-serve pay link.',
163
163
  dataset_type: 'customer',
164
164
  status: 'live',
165
+ tags: ['billing', 'dunning'],
165
166
  filter_config: {
166
167
  type: 'custom',
167
168
  body: FILTER_BODY,
@@ -189,7 +190,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
189
190
  decision_key: DECISION_KEY,
190
191
  position: 0,
191
192
  trigger_template: 'now',
192
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
193
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
193
194
  connected_app_id: connectedAppId,
194
195
  connected_app_route: '/portal/pay',
195
196
  connected_app_metadata_template:
@@ -160,6 +160,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
160
160
  description: 'Sends a KYC-notification SMS for newly activated payment accounts with a self-serve KYC portal link.',
161
161
  dataset_type: 'payment_account',
162
162
  status: 'live',
163
+ tags: ['compliance', 'kyc'],
163
164
  filter_config: {
164
165
  type: 'custom',
165
166
  body: FILTER_BODY,
@@ -188,7 +189,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
188
189
  decision_key: DECISION_KEY,
189
190
  position: 0,
190
191
  trigger_template: 'now',
191
- idempotency_template: '{{ payment_account.id }}-{{ decision_key }}-{{ "" | uuid }}',
192
+ idempotency_template: '{{ payment_account.id }}-{{ decision_key }}',
192
193
  connected_app_id: connectedAppId,
193
194
  connected_app_route: '/portal/kyc',
194
195
  connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
@@ -609,6 +609,7 @@ const { data: workflow } = await api.workflows.create(tenantSlug, datalakeSlug,
609
609
  dataset_type: 'generic_table',
610
610
  generic_table_id: ctx.audienceTableId,
611
611
  status: 'live',
612
+ tags: ['marketing', 'loyalty'],
612
613
  skip_mdm_resolution: false,
613
614
  filter_config: { type: 'custom', body: CAMPAIGN_FILTER, output_schema: { type: 'boolean' } },
614
615
  decision_config: {
@@ -128,7 +128,17 @@ try {
128
128
  updater_tool_id: ctx.restToolId,
129
129
  sender_tool_ids: [ctx.senderToolId],
130
130
  datalake_id: ctx.datalakeId,
131
- events_output_schema: { type: 'array' },
131
+ // FLOOR the platform enforces: an array whose items are objects listing
132
+ // `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
133
+ // create — every event has to name the message it reconciles.
134
+ events_output_schema: {
135
+ type: 'array',
136
+ items: {
137
+ type: 'object',
138
+ required: ['external_id'],
139
+ properties: { external_id: { type: 'string' } },
140
+ },
141
+ },
132
142
  pagination_context_output_schema: {
133
143
  type: 'object',
134
144
  required: ['has_next'],
@@ -140,7 +150,18 @@ try {
140
150
  // Static on both — the cursor is captured below and never read back.
141
151
  path: { type: 'custom', body: '/wiremock.domain/events' },
142
152
  params: { type: 'custom', body: '{"event": "delivered"}' },
143
- events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
153
+ // Providers name their own id `message-id` here, `messageId` / `sid`
154
+ // elsewhere. The events_template is where that becomes `external_id`:
155
+ // the apply path POPS that key off every rendered event to find the row
156
+ // it updates, so the render PROJECTS each event rather than passing the
157
+ // provider body through untouched.
158
+ events_template: {
159
+ type: 'custom',
160
+ body:
161
+ '[{% for item in response.items %}' +
162
+ '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
163
+ '{% unless forloop.last %},{% endunless %}{% endfor %}]',
164
+ },
144
165
  pagination_context_template: {
145
166
  type: 'custom',
146
167
  body:
@@ -150,11 +171,11 @@ try {
150
171
  },
151
172
  message_config: {
152
173
  type: 'custom',
153
- body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
174
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
154
175
  },
155
176
  action_log_config: {
156
177
  type: 'custom',
157
- body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
178
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
158
179
  },
159
180
  })
160
181
  } catch (err) {
@@ -182,7 +203,14 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
182
203
  updater_tool_id: ctx.restToolId,
183
204
  sender_tool_ids: [ctx.senderToolId],
184
205
  datalake_id: ctx.datalakeId,
185
- events_output_schema: { type: 'array' },
206
+ events_output_schema: {
207
+ type: 'array',
208
+ items: {
209
+ type: 'object',
210
+ required: ['external_id'],
211
+ properties: { external_id: { type: 'string' } },
212
+ },
213
+ },
186
214
  pagination_context_output_schema: {
187
215
  type: 'object',
188
216
  required: ['has_next'],
@@ -201,7 +229,16 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
201
229
  type: 'custom',
202
230
  body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
203
231
  },
204
- events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
232
+ // The provider's own id (`message-id`) becomes `external_id` HERE — the
233
+ // apply path pops that key to find the row it reconciles, so project each
234
+ // event instead of passing the provider body through untouched.
235
+ events_template: {
236
+ type: 'custom',
237
+ body:
238
+ '[{% for item in response.items %}' +
239
+ '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
240
+ '{% unless forloop.last %},{% endunless %}{% endfor %}]',
241
+ },
205
242
  pagination_context_template: {
206
243
  type: 'custom',
207
244
  body:
@@ -211,11 +248,11 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
211
248
  },
212
249
  message_config: {
213
250
  type: 'custom',
214
- body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
251
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
215
252
  },
216
253
  action_log_config: {
217
254
  type: 'custom',
218
- body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
255
+ body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
219
256
  },
220
257
  })
221
258
  actionStatusUpdaterId = asu.id!
@@ -298,6 +335,23 @@ config and wait for another tick.
298
335
  `events_output_schema` on every cycle; the pagination render is validated
299
336
  against `pagination_context_output_schema` (which must require `has_next`).
300
337
  Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
338
+ - **Each schema has a FLOOR the platform enforces, above which the contract is
339
+ yours.** `events_output_schema` must describe an **array whose `items` are
340
+ objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
341
+ 422 at create. `pagination_context_output_schema` must be an **object listing
342
+ `has_next` in `required`** — that key is what ends the page loop. Demand more
343
+ of your provider on top if you like; only the floor is checked.
344
+ - **The floor exists because `external_id` is how reconciliation finds the row.**
345
+ `apply_status_update` pops that key off every rendered event, so mapping the
346
+ provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
347
+ the **`events_template`'s job** — project each event, never pass the provider
348
+ body through untouched. A schema that satisfies the floor while the template
349
+ emits raw provider rows creates cleanly and then reconciles nothing on every
350
+ cycle. `message_config` / `action_log_config` then read the **mapped**
351
+ `{{ external_id }}`, not the provider's original key.
352
+ - **`action_log_config` is required alongside `message_config`** for every
353
+ updater type, and both are cast against the *same* pinned reconciliation
354
+ schema — so one template body satisfies both.
301
355
  - **A newly created poller is `status: 'active'`.** `cycle_detected` is only
302
356
  ever set at runtime by the poll driver — never by a caller; you cannot
303
357
  create your way into it.
@@ -270,6 +270,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
270
270
  description: 'Runs an LLM disambiguation pass on gray-zone-scored sanctions matches and fires a verdict-keyed SMS.',
271
271
  dataset_type: 'compliance_screening',
272
272
  status: 'live',
273
+ tags: ['compliance', 'sanctions'],
273
274
  filter_config: {
274
275
  type: 'custom',
275
276
  body: GRAY_ZONE_FILTER,
@@ -286,7 +287,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
286
287
  tool_id: toolId,
287
288
  position: 0,
288
289
  trigger_template: 'now',
289
- idempotency_template: `{{ compliance_screening.id }}-${verdict}-{{ "" | uuid }}`,
290
+ idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
290
291
  tool_call: {
291
292
  tool_call_type: 'sms_request',
292
293
  to: { type: 'custom', body: '+15550000000' },
@@ -321,6 +321,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
321
321
  generic_table_id: genericTableId,
322
322
  skip_mdm_resolution: true,
323
323
  status: 'live',
324
+ tags: ['leads', 'llm'],
324
325
  filter_config: {
325
326
  type: 'custom',
326
327
  body: 'true',
@@ -337,7 +338,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
337
338
  tool_id: toolId,
338
339
  position: 0,
339
340
  trigger_template: 'now',
340
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}-{{ "" | uuid }}`,
341
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
341
342
  tool_call: {
342
343
  tool_call_type: 'sms_request',
343
344
  to: { type: 'custom', body: '+15551234567' },
@@ -293,6 +293,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
293
293
  description: 'Bands AR customers into priority_high/medium/low via an LLM agent; one SMS action per band.',
294
294
  dataset_type: 'customer',
295
295
  status: 'live',
296
+ tags: ['ar', 'triage'],
296
297
  filter_config: {
297
298
  type: 'custom',
298
299
  body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
@@ -309,7 +310,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
309
310
  tool_id: toolId,
310
311
  position: 0,
311
312
  trigger_template: 'now',
312
- idempotency_template: `{{ customer_id }}-${band}-{{ "" | uuid }}`,
313
+ idempotency_template: `{{ customer_id }}-${band}`,
313
314
  tool_call: {
314
315
  tool_call_type: 'sms_request',
315
316
  to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
@@ -155,6 +155,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
155
155
  description: 'Sends a welcome SMS to newly contracted customers with a self-serve billing link.',
156
156
  dataset_type: 'customer',
157
157
  status: 'live',
158
+ tags: ['lifecycle', 'welcome'],
158
159
  filter_config: {
159
160
  type: 'custom',
160
161
  body: FILTER_BODY,
@@ -182,7 +183,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
182
183
  decision_key: DECISION_KEY,
183
184
  position: 0,
184
185
  trigger_template: 'now',
185
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
186
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
186
187
  connected_app_id: connectedAppId,
187
188
  connected_app_route: '/portal/welcome',
188
189
  connected_app_metadata_template: '{"customer_id":"{{ mdm_output.customer.id }}"}',
@@ -240,7 +240,7 @@ a hostname pattern; values like `"invalid_host!"` (with
240
240
  non-hostname punctuation) are rejected.
241
241
 
242
242
  This is the only field-format constraint surfaced before the
243
- synchronous reachability probe runs (see §6 Gotcha 9) — a
243
+ synchronous reachability probe runs (see §8 Gotcha 9) — a
244
244
  malformed identifier never gets as far as a connection attempt.
245
245
 
246
246
  ### Schema names must be unique within a database
@@ -576,7 +576,7 @@ agent-facing markdown, or the whole-domain catalog can be fetched
576
576
  in one shot via `api.datasets.metadata(tenantSlug, datalakeSlug)`. Pair `.systemDatasets()` with a
577
577
  `page_size: 1` probe per name as a post-migration smoke (proves
578
578
  each industry-built table is queryable; the post-ready probe
579
- code is in §6 Gotcha 3).
579
+ code is in §8 Gotcha 3).
580
580
 
581
581
  ```typescript
582
582
  const { data: catalog } = await api.datalakes.systemDatasets(
@@ -641,7 +641,48 @@ const { data: csv } = await api.datalakes.executeSql(
641
641
  ) // csv is a string, not the JSON envelope
642
642
  ```
643
643
 
644
- ## 6. Gotchas
644
+ ## 6. Logical name → physical table
645
+
646
+ The name you author is not the name you query. Datasets are addressed
647
+ **logically** everywhere in a manifest or SDK call, and **physically** in SQL:
648
+
649
+ | You author | You query in SQL | Schema |
650
+ |---|---|---|
651
+ | `message` | `regulated_messages` | `app_regulated` |
652
+ | `action_log` | `action_logs` | `app_unregulated` |
653
+ | a generic table `clients` | `regulated_alvera_custom_clients` | `app_regulated` |
654
+
655
+ Custom (generic) tables carry an `alvera_custom_` prefix, and the regulated side
656
+ adds a further `regulated_` prefix on top. The schema follows `execute-sql`'s
657
+ `--mode` (`regulated` → `app_regulated`, `unregulated` → `app_unregulated`).
658
+
659
+ **Don't derive these by hand — read them.** Guessing column and table names is
660
+ how six turns get spent on `column "error_message" does not exist` (the real
661
+ column is `failure_reason`):
662
+
663
+ - `alvera get-metadata dataset --only <type>` prints the **physical dataset
664
+ name** plus every column, from the Ecto struct itself.
665
+ - `alvera get-metadata generic-table --only <slug>` prints the exact **primary
666
+ table alias** to use in a `WHERE` clause, with a worked `SELECT`.
667
+
668
+ An `execute-sql` failure on an undefined column, relation, table, or schema now
669
+ names these verbs in its error, so the correction path is one command away.
670
+
671
+ ## 7. The roster owns the dedupe uri — don't inject it per source
672
+
673
+ MDM matches on the full identification triple, so a `source_uri` injected
674
+ per data source participates in matching. Two sources describing the same
675
+ subject then produce two different triples, and the same subject is written
676
+ **twice** instead of being reconciled onto one entity.
677
+
678
+ Derive the dedupe uri **once, at the roster**, and let every data source inherit
679
+ it. Never compute a per-source `source_uri` and expect MDM to see through it.
680
+
681
+ This divergence is **not visible through `get-metadata`**: the roster value and
682
+ the per-source values each look well-formed in isolation, and only the duplicate
683
+ rows downstream reveal the mismatch. Check the derivation, not the metadata.
684
+
685
+ ## 8. Gotchas
645
686
 
646
687
  1. **`create()` does NOT auto-enqueue migration.** The create response
647
688
  returns immediately with `status: 'new'`; Datalake-DB-resident
package/.agent/errors.md CHANGED
@@ -125,11 +125,17 @@ body):
125
125
  { "source": { "pointer": "/name" },
126
126
  "detail": "Missing field: name" },
127
127
  { "source": { "pointer": "/dataset_type" },
128
- "detail": "Missing field: dataset_type" }
128
+ "detail": "Missing field: dataset_type" },
129
+ { "source": { "pointer": "/tags" },
130
+ "detail": "Missing field: tags" }
129
131
  ]
130
132
  }
131
133
  ```
132
134
 
135
+ `tags` is the easy one to miss: it is required with no default, so
136
+ omitting it 422s even though an empty list is a perfectly ordinary
137
+ workflow. Send `tags: []` (see `workflows.md` §2).
138
+
133
139
  **Layer 1 — invalid enum value** (`dataset_type: "nonexistent_type"`):
134
140
 
135
141
  ```json
@@ -49,6 +49,7 @@ const { data: created } = await api.workflows.create(
49
49
  description: 'Send a review-request SMS after a fulfilled appointment',
50
50
  dataset_type: 'appointment', // the dataset this workflow listens on
51
51
  status: 'live', // 'live' | 'draft' | 'manual' (see §5)
52
+ tags: ['appointments', 'sms'], // REQUIRED on every write — [] if untagged (see §2)
52
53
  filter_config: {
53
54
  type: 'custom',
54
55
  body: `{% if appointment.source_uri == "12345.example.com" %}true{% endif %}`,
@@ -138,6 +139,25 @@ authors are Complex) — don't assume every `{ type, body }`-shaped config
138
139
  also carries a request-writable `output_schema`; check whether the field
139
140
  is pinned or caller-authored for that specific config before authoring one.
140
141
 
142
+ ### `tags` is required on every write, and has no default
143
+
144
+ `tags` is a `string[]` on both the request and the response, and it is
145
+ in `required:`. **Every `create()` / `update()` without it is a 422.**
146
+ Send `tags: []` for an untagged workflow — the empty array is how you
147
+ say "no tags", not something you can omit.
148
+
149
+ The missing default is deliberate. `update()` is full-replacement (there
150
+ is no PATCH, and no add-tag / remove-tag endpoint), so an omitted key
151
+ cannot mean "leave tags alone". With a default, omitting it would have
152
+ silently emptied a workflow's tags and returned 200. A 422 beats losing
153
+ state the caller never mentioned.
154
+
155
+ The labels are free text — no taxonomy, no shared vocabulary, and the
156
+ execution pipeline does not read them. But they **do** participate in the
157
+ workflow checksum, so retagging shifts the drift fingerprint. Re-tag and
158
+ `checksum()` returns a different value even though nothing executable
159
+ changed.
160
+
141
161
  ### `update()` replaces inline arrays whole
142
162
 
143
163
  `actions` and `context_datasets` are replaced whole by `update()`.
@@ -322,6 +342,7 @@ are removed, and re-supplied ones are repositioned.
322
342
  await api.workflows.create(tenantSlug, datalakeSlug, {
323
343
  name: 'Contact-Us Triage',
324
344
  dataset_type: 'generic_table',
345
+ tags: [],
325
346
  // …other workflow fields…
326
347
  workflow_ai_agents: [
327
348
  {
@@ -412,6 +433,8 @@ name required string
412
433
  description optional string
413
434
  dataset_type required string — the dataset this listens on
414
435
  status required enum — 'live' | 'draft' | 'manual' (§5 Create)
436
+ tags required array — string[]; NO default, send [] if
437
+ untagged; feeds the checksum (§2)
415
438
  generic_table_id optional UUID — required iff dataset_type='generic_table'
416
439
  skip_mdm_resolution optional bool — default false; a deliberate
417
440
  choice, NOT a generic-table default (§3)