@alvera-ai/platform-sdk 0.13.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.
Files changed (32) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +273 -25
  3. package/.agent/ai_sandbox.md +4 -2
  4. package/.agent/connected_apps.md +7 -2
  5. package/.agent/cookbook/action-status-updaters.md +73 -14
  6. package/.agent/cookbook/ai-agent-invoke.md +36 -0
  7. package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
  8. package/.agent/cookbook/birthday-greeting-sms-trigger.md +35 -1
  9. package/.agent/cookbook/bulk-ingest.md +48 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +33 -1
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +35 -1
  14. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  15. package/.agent/cookbook/paginated-restapi-poller.md +383 -0
  16. package/.agent/cookbook/rest-fetch.md +27 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +35 -1
  18. package/.agent/cookbook/score-leads-with-llm-categorization.md +36 -1
  19. package/.agent/cookbook/system-templates.md +36 -0
  20. package/.agent/cookbook/talk-to-data.md +39 -0
  21. package/.agent/cookbook/triage-prospects-by-priority.md +33 -1
  22. package/.agent/cookbook/welcome-sms-for-customers.md +33 -1
  23. package/.agent/datalakes.md +68 -3
  24. package/.agent/interoperability_contracts.md +29 -0
  25. package/.agent/tool-call-configs.md +11 -0
  26. package/.agent/tools.md +103 -36
  27. package/.agent/type_naming.md +4 -0
  28. package/dist/index.d.mts +189 -19
  29. package/dist/index.d.mts.map +1 -1
  30. package/dist/index.mjs +3 -1
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +2 -2
@@ -102,6 +102,37 @@ app authenticates **tenant-scoped against exactly one tenant**,
102
102
  with that tenant's publishable key; connected apps in particular
103
103
  are single-tenant by construction (`connected_apps.md`).
104
104
 
105
+ ### Key-only clients — machine-to-machine on the publishable key
106
+
107
+ `sessionToken` is optional on the client config. A deployed
108
+ connected app that authenticates purely on its tenant's publishable
109
+ key constructs with just the key — no session mint, no fabricated
110
+ empty token:
111
+
112
+ ```typescript
113
+ const api = createIsolatedPlatformApi({ baseUrl, apiKey })
114
+ ```
115
+
116
+ Such a client sends `X-API-Key` and **no `Authorization` header**,
117
+ and reaches exactly the `public_api` (publishable-key) allowlist —
118
+ five calls:
119
+
120
+ ```
121
+ api.connectedApps.resolvePage mint/read a delivered page
122
+ api.connectedApps.updateMessageTracking opened_at / form_submitted_at
123
+ api.dataActivationClients.ingest submit rows
124
+ api.dataActivationClients.ingestFile submit a file
125
+ api.mdm.verify resolve an identity
126
+ ```
127
+
128
+ (The same allowlist `connected_apps.md` documents — that guide owns
129
+ the runtime-call details.) Every call outside these five returns
130
+ 401; that ceiling is the point, not an error to work around.
131
+ Minting a session and passing `sessionToken` unlocks the full
132
+ tenant-scoped API exactly as before — the two credentials are
133
+ complementary (the key identifies the client; the Bearer
134
+ authorizes), never substitutes.
135
+
105
136
  ### Explicit teardown — `revokeSession`
106
137
 
107
138
  The inverse of `createSession`. Revokes the current Bearer on
@@ -40,8 +40,8 @@ const { data: created } = await api.actionStatusUpdaters.create(
40
40
  updater_body: {
41
41
  updater_body_type: 'cloud_watch_request',
42
42
  log_group_name: 'sns/us-east-1/...',
43
- start_time: '{{ now | minutes_ago: 45 }}', // Liquid window
44
- end_time: '{{ now }}',
43
+ start_time: '{{ now_msec | minutes_ago: 45 }}', // Liquid window
44
+ end_time: '{{ now_msec }}',
45
45
  },
46
46
 
47
47
  // Liquid, rendered ONCE PER EVENT — the event IS the assigns, so there is no
@@ -51,6 +51,14 @@ const { data: created } = await api.actionStatusUpdaters.create(
51
51
  type: 'custom',
52
52
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
53
53
  },
54
+
55
+ // REQUIRED on every updater, both types, on create AND update. Same
56
+ // per-event rendering contract as `message_config`, but targets the
57
+ // action_log row instead of the message row.
58
+ action_log_config: {
59
+ type: 'custom',
60
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
61
+ },
54
62
  },
55
63
  )
56
64
  ```
@@ -74,22 +82,30 @@ updater_body_type: 'cloud_watch_request'
74
82
  follow the same `<verb>_request` naming and carry the fields
75
83
  their target API requires.)
76
84
 
85
+ `updater_body_type: 'restapi_request'` additionally **requires an
86
+ `events_template` field**. Without it the REST poll fails with
87
+ `missing_events_template`. `events_template` is specific to the REST
88
+ poll's event-list extraction — it is not read by data-activation
89
+ fetches.
90
+
77
91
  `start_time` / `end_time` are Liquid, and they **must render to
78
- unix milliseconds** — that is what the poll request takes. On
79
- these two fields `now` is a **variable already holding unix ms**,
80
- so pipe it through the relative-time filters:
92
+ unix milliseconds** — that is what the poll request takes. Use the
93
+ **`now_msec`** variable (unix ms) and pipe it through the
94
+ relative-time filters. **Not the bare `now`** — that is a DateTime,
95
+ and feeding it a millisecond filter now raises at render:
81
96
 
82
97
  ```
83
- start_time: '{{ now | minutes_ago: 45 }}' // 45 minutes back
84
- end_time: '{{ now }}' // right now
98
+ start_time: '{{ now_msec | minutes_ago: 45 }}' // 45 minutes back
99
+ end_time: '{{ now_msec }}' // right now
85
100
  ```
86
101
 
87
102
  Also available: `hours_ago: N`.
88
103
 
89
- **Do not use the ISO-8601 `now` filter here.** `{{ "" | now }}` is
90
- a different thing it renders a datetime *string*, which is not a
91
- valid window, and the platform rejects it at create. An ISO 8601
92
- literal is not valid on these fields either.
104
+ **Do not use the bare `now` variable or the ISO-8601 `now` filter
105
+ here.** The `now` variable is a DateTime and `{{ "" | now }}`
106
+ renders a datetime *string* neither is a valid unix-ms window,
107
+ and the platform rejects both. An ISO 8601 literal is not valid on
108
+ these fields either.
93
109
 
94
110
  ### `message_config.body` renders ONCE PER EVENT
95
111
 
@@ -101,6 +117,10 @@ scope — do not loop.
101
117
  {"external_id": "{{ notification.messageId }}", "status": "delivered"}
102
118
  ```
103
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
+
104
124
  It must render a **flat** JSON object:
105
125
 
106
126
  ```
@@ -123,6 +143,174 @@ events for messages this updater doesn't own.
123
143
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
124
144
  available here, as everywhere.
125
145
 
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
+ ```
228
+
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.
247
+
248
+ ### Carrier information is available for SMS, not MMS
249
+
250
+ The recipient's mobile carrier is present on SMS delivery events but
251
+ not MMS. This is AWS behavior — verified against real delivery
252
+ events — not something the platform chooses to drop:
253
+
254
+ - **SMS via Amazon SNS** — the delivery-status log
255
+ (`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
256
+ under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
257
+ numeric `mcc`/`mnc`). The SNS status template maps it to
258
+ `sms_carrier`.
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`.
268
+
269
+ AWS's Nov-2024 EventBridge-integration announcement states MMS
270
+ delivery events carry carrier information in EventBridge, but the
271
+ real events do not — a documented doc-vs-behavior discrepancy. For
272
+ per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
273
+ `PhoneType`) at enrollment and join on destination/`messageId`; it is
274
+ not in the delivery stream.
275
+
276
+ ### `action_log_config` is required — same rendering contract as `message_config`
277
+
278
+ `action_log_config` is a **required** field on every action status
279
+ updater — `cloud_watch` and `restapi` alike — on **both `create()` and
280
+ `update()`**. There is no updater for which it is optional and no way
281
+ to omit it on a PUT.
282
+
283
+ It follows the same per-event rendering contract as `message_config`
284
+ (§2 above), but targets the **action_log** row instead of the message
285
+ row: it must render a JSON object containing `external_id` plus at
286
+ least one updatable action-log field — one of `status`, `sent_at`, or
287
+ `metadata`. A minimal valid value:
288
+
289
+ ```liquid
290
+ {"external_id": "{{ notification.messageId }}", "status": "delivered"}
291
+ ```
292
+
293
+ Two distinct rejections come off this field, and they read differently:
294
+
295
+ - **Missing entirely** — omitting `action_log_config` on create or
296
+ update is a 422 with `Missing field: action_log_config` at
297
+ `source.pointer: /action_log_config`.
298
+ - **Present but renders nothing** — a value that passes shallow
299
+ validation but renders `null` or an empty result at run time (e.g.
300
+ a `{"type": "null"}` config) is refused with a 422 whose detail
301
+ begins `must render a JSON object containing external_id`.
302
+
303
+ **Response nullability is lazy, not retroactive.** Updaters created
304
+ before this became required still read back `action_log_config: null`
305
+ — the platform does not backfill a template onto old rows. That
306
+ `null` persists until the row's **next edit** forces a real template
307
+ through, and "next edit" is broader than a plain field change: it
308
+ also includes resuming a halted updater by setting `status` back to
309
+ `'active'` (§7) and issuing a `refresh()` call with a one-shot
310
+ `updater_body` override (§7) — both now require `action_log_config`
311
+ to be present too, which is what completes the migration for an old
312
+ row.
313
+
126
314
  ### `cron_expression` is a standard 5-field cron string
127
315
 
128
316
  ```
@@ -201,6 +389,7 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
201
389
  datalake_id required UUID
202
390
  updater_body required embed — { updater_body_type, ... }
203
391
  message_config required embed — { type, body }
392
+ action_log_config required embed — { type, body } (required on create AND update)
204
393
  ```
205
394
 
206
395
  **Write-only (Request-only).** None.
@@ -216,6 +405,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
216
405
  | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
217
406
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
218
407
  | `/message_config/body` | empty when `type: 'custom'` |
408
+ | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
409
+ | (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
219
410
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
220
411
 
221
412
  ## 5. Lifecycle
@@ -318,7 +509,7 @@ check or retry for partial-delete states.
318
509
  The poll follows pages while `has_next` is true and stops on
319
510
  the first empty page.
320
511
 
321
- ## 7. Manual trigger via `refresh` — but still no per-run log
512
+ ## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
322
513
 
323
514
  An action status updater CAN be fired on demand:
324
515
 
@@ -328,28 +519,85 @@ const { data } = await api.actionStatusUpdaters.refresh(
328
519
  );
329
520
  ```
330
521
 
331
- `refresh` runs one poll cycle synchronously and returns the updater
332
- row with its `last_run_*` fields freshly stamped. Apply jobs are
333
- asynchronous observe delivery status on the message rows, not in
334
- this response. (CLI: `alvera refresh-updater <id>`.)
522
+ **`refresh` answers `202` with the updater row AS-IS the poll is
523
+ ENQUEUED, not run.** The `last_run_*` fields on that response are the
524
+ PREVIOUS run's stamps; reading them as the outcome of the refresh you
525
+ just issued is the single most common mistake here. An optional body
526
+ carries a one-shot `updater_body` override (e.g. a widened
527
+ `start_time`/`end_time` window for a backfill) that does not mutate
528
+ the persisted updater. Apply jobs are asynchronous too — observe
529
+ delivery status on the message rows, not in this response.
530
+ (CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
531
+ `refresh-updater`.)
532
+
533
+ The correct read is baseline-then-advance:
534
+
535
+ ```ts
536
+ const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
537
+ const baseline = before.data.last_run_at ?? null // BEFORE refreshing
538
+
539
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
540
+
541
+ let row = before.data
542
+ const deadline = Date.now() + 60_000
543
+ while (Date.now() < deadline) {
544
+ await new Promise((r) => setTimeout(r, 2_000))
545
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
546
+ if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
547
+ }
548
+ // row.last_run_status / last_run_error now describe THIS run
549
+ ```
335
550
 
336
551
  What the ASU still does NOT have is a **per-run log** (nothing like
337
552
  a data activation client's `logs` or a workflow's `batchLogs`). The
338
- only run-outcome surface is four fields on the row itself,
339
- describing the most recent poll only:
553
+ run-outcome surface is four fields on the row itself, plus the halt
554
+ flag:
340
555
 
341
556
  ```
342
- last_run_at when the poll worker last completed a run
343
- last_run_status 'ok' | 'error'
344
- last_run_error why the last run failed (structured text)
345
- last_run_events_found how many events that run fetched
557
+ last_run_at when the poll worker last made progress
558
+ last_run_status 'ok' | 'partial' | 'error'
559
+ last_run_error why the last run failed, or why a partial run was truncated
560
+ last_run_events_found events fetched so far in the current run
561
+ status 'active' | 'cycle_detected'
346
562
  ```
347
563
 
564
+ **A poll run has three outcomes, and only `'ok'` means it reconciled its
565
+ whole time window:**
566
+
567
+ - `'ok'` — the run completed and read its entire window. This is the only
568
+ value that proves the reconciliation is complete.
569
+ - `'partial'` — the run completed but its provider fetch was **truncated**:
570
+ the window was not fully read, and the events it missed are the **newest**
571
+ ones. `last_run_error` carries the detail (it is **not** null on a
572
+ `partial` run). A client that treats `last_run_status !== 'error'` as
573
+ healthy silently accepts this incomplete reconciliation — check
574
+ `last_run_status === 'ok'` instead.
575
+ - `'error'` — the run failed; `last_run_error` says why.
576
+
577
+ `last_run_events_found` is the event count for the current run; because
578
+ `refresh` is asynchronous (below), a read taken before the run finishes can
579
+ legitimately show a smaller, in-progress figure than the final count.
580
+
581
+ **`status` is the halt flag.** The server sets `'cycle_detected'` when
582
+ a run re-reads events it has already handled — the runtime backstop for
583
+ a poll whose pagination does not advance (a provider cursor that never
584
+ drains, or a one-shot `updater_body` override). Every later job then
585
+ fails without calling the provider, and `last_run_error` carries the
586
+ diagnostic. **Nothing clears it but setting `status` back to
587
+ `'active'`.** It is also the cheapest deterministic failure to assert
588
+ on: a non-advancing config trips it within seconds, no provider
589
+ credential rejection required. Related: a `restapi` updater that
590
+ declares pagination but whose `path`/`params`/`body` never read
591
+ `msg.pagination_context` or `msg.page` is now refused at create/update
592
+ — see §2's template contract.
593
+
348
594
  **If a message's status is not reconciling, do this — in order:**
349
595
 
350
- 1. Call `refresh()` and read `last_run_status` / `last_run_error`
351
- off the response. They name the failing stage: window render,
352
- source fetch, template render, or JSON parse.
596
+ 1. Call `refresh()`, wait for `last_run_at` to advance past the
597
+ baseline you captured, then read `last_run_status` /
598
+ `last_run_error` / `status`. They name the failing stage: window
599
+ render, source fetch, template render, JSON parse — or a halted
600
+ poll cycle.
353
601
  2. Check the config against §2 above. A config that violates the
354
602
  template contract is rejected at create, so a *deployed* updater's
355
603
  templates are structurally sound — the fault is more likely the
@@ -103,10 +103,12 @@ consumers cannot extend the set:
103
103
 
104
104
  minutes_ago Unix ms N minutes before a unix-ms input. Bounds
105
105
  the start of an ActionStatusUpdater poll window.
106
- Example: {{ now | minutes_ago: 45 }} → 1773585000000
106
+ `now_msec` is the injected unix-ms variable (the
107
+ bare `now` is a DateTime — feeding it here raises).
108
+ Example: {{ now_msec | minutes_ago: 45 }} → 1773585000000
107
109
 
108
110
  hours_ago Unix ms N hours before a unix-ms input.
109
- Example: {{ now | hours_ago: 2 }} → 1773578400000
111
+ Example: {{ now_msec | hours_ago: 2 }} → 1773578400000
110
112
 
111
113
  uuid Generate a new v4 UUID.
112
114
  Example: {{ '' | uuid }} → a1b2c3d4-…
@@ -64,7 +64,8 @@ const { data: created } = await api.connectedApps.create(
64
64
  )
65
65
  // created.id, created.slug — server-derived
66
66
  // created.status === 'pending' (transient) → 'synced' after route fetch
67
- // created.api_key_id — auto-provisioned M2M key id (see §2)
67
+ // (an M2M key IS auto-provisioned at create (see §2), but its id is NOT
68
+ // echoed on the resource — no response carries an api_key_id field)
68
69
  // created.routes — array populated from .well-known/routes.json
69
70
  // (empty until first sync)
70
71
  // created.last_synced_at — set when routes successfully validated
@@ -191,9 +192,13 @@ last_synced_at string — ISO 8601; set at successful route sync
191
192
  error string — populated when status === 'error'
192
193
  routes array — discovered from .well-known/routes.json
193
194
  (each: { name, path, description? })
194
- api_key_id UUID — id of the auto-provisioned M2M key
195
195
  ```
196
196
 
197
+ (The auto-provisioned M2M key's id is **not** among these — neither
198
+ `.get` nor `get-metadata` returns an `api_key_id`. Don't assert on it
199
+ in a contract hook; the key itself reaches the deployment as
200
+ `ALVERA_API_KEY`, §2.)
201
+
197
202
  **Caller-supplied (round-trip).**
198
203
 
199
204
  ```
@@ -105,11 +105,16 @@ ctx.cwToolId = cwTool.id!
105
105
 
106
106
  The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
107
107
  (the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
108
- naming the log group + the time window. On `start_time`/`end_time`, `now` is a
109
- variable holding unix milliseconds — pipe it through `minutes_ago`. The
108
+ naming the log group + the time window. On `start_time`/`end_time`, use
109
+ `now_msec` — the injected variable holding unix milliseconds — piped through
110
+ `minutes_ago`; the bare `now` is a DateTime and raises here. The
110
111
  `message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
111
112
  assigns (there is no `events` list to loop), and emits a FLAT object: the
112
113
  `external_id` of the message to update plus the fields to set at the top level.
114
+ `action_log_config` is REQUIRED alongside it — same per-event assigns, rendered
115
+ into the action-log write shape: a JSON object with `external_id` plus at least
116
+ one of `status` / `sent_at` / `metadata`. Omitting it is a 422 (`Missing field:
117
+ action_log_config`).
113
118
 
114
119
  ```typescript
115
120
  const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
@@ -122,13 +127,17 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
122
127
  updater_body: {
123
128
  updater_body_type: 'cloud_watch_request',
124
129
  log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
125
- start_time: '{{ now | minutes_ago: 45 }}',
126
- end_time: '{{ now }}',
130
+ start_time: '{{ now_msec | minutes_ago: 45 }}',
131
+ end_time: '{{ now_msec }}',
127
132
  },
128
133
  message_config: {
129
134
  type: 'custom',
130
135
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
131
136
  },
137
+ action_log_config: {
138
+ type: 'custom',
139
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
140
+ },
132
141
  })
133
142
  actionStatusUpdaterId = asu.id!
134
143
  ```
@@ -155,13 +164,17 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
155
164
  updater_body: {
156
165
  updater_body_type: 'cloud_watch_request',
157
166
  log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
158
- start_time: '{{ now | minutes_ago: 45 }}',
159
- end_time: '{{ now }}',
167
+ start_time: '{{ now_msec | minutes_ago: 45 }}',
168
+ end_time: '{{ now_msec }}',
160
169
  },
161
170
  message_config: {
162
171
  type: 'custom',
163
172
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
164
173
  },
174
+ action_log_config: {
175
+ type: 'custom',
176
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
177
+ },
165
178
  })
166
179
  if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
167
180
  throw new Error('expected a checksum for the ASU body')
@@ -190,6 +203,35 @@ if (typeof detail !== 'string' || detail.length === 0) {
190
203
  }
191
204
  ```
192
205
 
206
+ ## 007 — write the integration test
207
+
208
+ End the build with a test you keep: one block that re-reads the reconciler
209
+ and asserts the facts the scenario depends on. This block runs live under
210
+ `make validate-cookbook`.
211
+
212
+ ```typescript
213
+ // Re-GET — the stored row echoes what was authored.
214
+ const { data: asuRow } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
215
+ if (asuRow.cron_expression !== '*/30 * * * *') {
216
+ throw new Error(`cron mismatch on read-back: ${asuRow.cron_expression}`)
217
+ }
218
+ // last_run_* is the ONLY run surface (there is no per-run log). On a
219
+ // fresh create it is legitimately null — assert the field EXISTS on the
220
+ // wire, never a value.
221
+ if (!('last_run_status' in asuRow)) {
222
+ throw new Error('ASU response carries no last_run_status field')
223
+ }
224
+ // Behavioural probe — the metadata surface an agent reads must render.
225
+ const { data: asuDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
226
+ if (typeof asuDetail !== 'string' || asuDetail.length === 0) {
227
+ throw new Error('ASU metadataDetails came back empty')
228
+ }
229
+ ```
230
+
231
+ If the test fails in production, escalate with the failing read's
232
+ evidence (the response body, `last_run_error`) — don't rewrite the config
233
+ blind.
234
+
193
235
  # Gotchas
194
236
 
195
237
  - **The poller tool's intent is `status_poller`, not the sender's intent.** The
@@ -199,14 +241,31 @@ if (typeof detail !== 'string' || detail.length === 0) {
199
241
  is the assigns — there is no `events` list to loop — and every top-level key
200
242
  that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
201
243
  no `set_params` wrapper. Both mistakes are rejected at create.
202
- - **The schedule is a cron, evaluated platform-side, and you CANNOT trigger it.**
203
- Creating the ASU registers the schedule. The `updater_body` window
204
- (`{{ now | minutes_ago: 45 }}` `{{ now }}`) bounds each run's log query, and
205
- must render to unix milliseconds `now` is a variable here, not the ISO-8601
206
- `now` filter.
207
- - **If it doesn't reconcile, read `last_run_error` and then ESCALATE.** There is
208
- no manual trigger and no per-run log (known limitation). Don't rewrite the
209
- config and wait for another cron ticksee `action_status_updaters.md` §7.
244
+ - **`action_log_config` is REQUIRED on both create and update (PUT), same as
245
+ `message_config`.** Omitting it is a 422 (`Missing field: action_log_config`).
246
+ It renders the same per-event assigns into the action-log write shape:
247
+ `external_id` plus at least one of `status` / `sent_at` / `metadata`.
248
+ - **The schedule is a cron, evaluated platform-side — and one poll cycle can
249
+ be fired on demand.** Creating the ASU registers the schedule. The
250
+ `updater_body` window (`{{ now_msec | minutes_ago: 45 }}` `{{ now_msec }}`)
251
+ bounds each run's log query, and must render to unix milliseconds — `now_msec`
252
+ is the unix-ms variable, not the bare `now` (a DateTime) or the ISO-8601 `now`
253
+ filter. To trigger a cycle without
254
+ waiting for the cron tick, `api.actionStatusUpdaters.refresh(tenantSlug,
255
+ datalakeSlug, id)` answers `202` with the updater row AS-IS (the poll runs
256
+ async — the row still shows the PREVIOUS run's stamps; re-read later for
257
+ the outcome). See `action_status_updaters.md` §7.
258
+ - **If it doesn't reconcile, read `last_run_error` — refresh, don't guess.**
259
+ There is no per-run log (known limitation): the run surface is the
260
+ read-only `last_run_*` fields. Fire a `refresh`, re-read `last_run_status`
261
+ / `last_run_events_found` / `last_run_error`, and escalate with that
262
+ evidence — don't rewrite the config blind between cron ticks.
263
+ - **`last_run_status` has three values — only `'ok'` means done.** `'ok'`
264
+ is the run that read its whole time window; `'partial'` completed but its
265
+ provider fetch was truncated, so the newest events may be missing and
266
+ `last_run_error` says so (it is not null on a `partial`); `'error'` failed.
267
+ Check `last_run_status === 'ok'` — a `!== 'error'` check silently accepts
268
+ a truncated `partial` as if it had reconciled.
210
269
  - **`checksum` parity is your drift signal.** The fingerprint of the desired body
211
270
  must match the stored row's; a mismatch means the resource drifted.
212
271
 
@@ -214,6 +214,42 @@ if (!rejected) {
214
214
  }
215
215
  ```
216
216
 
217
+ ## 006 — write the integration test
218
+
219
+ End the build with a test you keep: re-read the agent and probe the one
220
+ behaviour that is provable deterministically — the input gate. A real
221
+ extraction (§004) needs a live provider behind the tool; the schema gate
222
+ does not, which is exactly what makes it the durable test. This block
223
+ runs live under `make validate-cookbook`.
224
+
225
+ ```typescript
226
+ // Re-GET — the agent must be enabled, or every invoke will refuse.
227
+ const { data: agentRow } = await api.aiAgents.get(tenantSlug, datalakeSlug, aiAgentId)
228
+ if (agentRow.enabled !== true) {
229
+ throw new Error('expected enabled=true on agent read-back')
230
+ }
231
+ // Behavioural probe — the input_schema gate fires BEFORE the model:
232
+ // a malformed input must 422 even with no working provider.
233
+ let gateFired = false
234
+ try {
235
+ await api.aiAgents.invoke(tenantSlug, datalakeSlug, aiAgentId, {
236
+ input: { wrong_field: 'test — no instructions here' },
237
+ files: [],
238
+ })
239
+ } catch (err) {
240
+ const status = (err as { _httpStatus?: number })._httpStatus
241
+ if (status !== 422) throw err
242
+ gateFired = true
243
+ }
244
+ if (!gateFired) {
245
+ throw new Error('agent accepted input that violates its input_schema (expected 422)')
246
+ }
247
+ ```
248
+
249
+ If the gate stops firing in production, escalate with the
250
+ accepted-payload evidence — don't loosen the `input_schema` to make the
251
+ error go away.
252
+
217
253
  # Gotchas
218
254
 
219
255
  - **Uploading a file is not ingesting it.** The upload + `invoke` here makes the