@alvera-ai/platform-sdk 0.14.0 → 0.15.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.
@@ -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
@@ -175,7 +175,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
175
175
  position: 0,
176
176
  trigger_template: TRIGGER_TEMPLATE,
177
177
  idempotency_template:
178
- '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}-{{ "" | uuid }}',
178
+ '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}',
179
179
  connected_app_id: connectedAppId,
180
180
  connected_app_route: '/forms/birthday-greeting',
181
181
  connected_app_metadata_template:
@@ -326,7 +326,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
326
326
  tool_id: toolId,
327
327
  position: 0,
328
328
  trigger_template: 'now',
329
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}-{{ "" | uuid }}`,
329
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}`,
330
330
  tool_call: {
331
331
  tool_call_type: 'sms_request',
332
332
  to: { type: 'custom', body: '+15551234567' },
@@ -189,7 +189,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
189
189
  decision_key: DECISION_KEY,
190
190
  position: 0,
191
191
  trigger_template: 'now',
192
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
192
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
193
193
  connected_app_id: connectedAppId,
194
194
  connected_app_route: '/portal/pay',
195
195
  connected_app_metadata_template:
@@ -188,7 +188,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
188
188
  decision_key: DECISION_KEY,
189
189
  position: 0,
190
190
  trigger_template: 'now',
191
- idempotency_template: '{{ payment_account.id }}-{{ decision_key }}-{{ "" | uuid }}',
191
+ idempotency_template: '{{ payment_account.id }}-{{ decision_key }}',
192
192
  connected_app_id: connectedAppId,
193
193
  connected_app_route: '/portal/kyc',
194
194
  connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
@@ -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.
@@ -286,7 +286,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
286
286
  tool_id: toolId,
287
287
  position: 0,
288
288
  trigger_template: 'now',
289
- idempotency_template: `{{ compliance_screening.id }}-${verdict}-{{ "" | uuid }}`,
289
+ idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
290
290
  tool_call: {
291
291
  tool_call_type: 'sms_request',
292
292
  to: { type: 'custom', body: '+15550000000' },
@@ -337,7 +337,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
337
337
  tool_id: toolId,
338
338
  position: 0,
339
339
  trigger_template: 'now',
340
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}-{{ "" | uuid }}`,
340
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
341
341
  tool_call: {
342
342
  tool_call_type: 'sms_request',
343
343
  to: { type: 'custom', body: '+15551234567' },
@@ -309,7 +309,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
309
309
  tool_id: toolId,
310
310
  position: 0,
311
311
  trigger_template: 'now',
312
- idempotency_template: `{{ customer_id }}-${band}-{{ "" | uuid }}`,
312
+ idempotency_template: `{{ customer_id }}-${band}`,
313
313
  tool_call: {
314
314
  tool_call_type: 'sms_request',
315
315
  to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
@@ -182,7 +182,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
182
182
  decision_key: DECISION_KEY,
183
183
  position: 0,
184
184
  trigger_template: 'now',
185
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
185
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
186
186
  connected_app_id: connectedAppId,
187
187
  connected_app_route: '/portal/welcome',
188
188
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alvera-ai/platform-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Typed SDK for the Alvera platform API — manage data sources, tools, generic tables, AI agents, and action status updaters.",
5
5
  "type": "module",
6
6
  "sideEffects": false,