@alvera-ai/platform-sdk 0.13.0 → 0.15.0-next.g09659bd

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 (33) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +339 -26
  3. package/.agent/ai_sandbox.md +4 -2
  4. package/.agent/connected_apps.md +7 -2
  5. package/.agent/cookbook/action-status-updaters.md +112 -14
  6. package/.agent/cookbook/ai-agent-invoke.md +36 -0
  7. package/.agent/cookbook/appointment-review-sms-workflow.md +41 -0
  8. package/.agent/cookbook/birthday-greeting-sms-trigger.md +44 -1
  9. package/.agent/cookbook/bulk-ingest.md +57 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +42 -1
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +44 -1
  14. package/.agent/cookbook/marketing-campaign-send.md +65 -3
  15. package/.agent/cookbook/paginated-restapi-poller.md +400 -0
  16. package/.agent/cookbook/rest-fetch.md +36 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +44 -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 +42 -1
  22. package/.agent/cookbook/welcome-sms-for-customers.md +42 -1
  23. package/.agent/datalakes.md +68 -3
  24. package/.agent/interoperability_contracts.md +29 -0
  25. package/.agent/mdm.md +45 -2
  26. package/.agent/tool-call-configs.md +11 -0
  27. package/.agent/tools.md +103 -36
  28. package/.agent/type_naming.md +4 -0
  29. package/dist/index.d.mts +548 -27
  30. package/dist/index.d.mts.map +1 -1
  31. package/dist/index.mjs +64 -2
  32. package/dist/index.mjs.map +1 -1
  33. 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,69 @@ 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
+ **Four poll-contract fields are required on EVERY updater type**, and
86
+ they live at the **root of the create body** — not inside
87
+ `updater_body`:
88
+
89
+ ```
90
+ events_template REQUIRED embed — { type, body } — event-list
91
+ extraction; maps the provider's
92
+ own id into `external_id`
93
+ pagination_context_template REQUIRED embed — { type, body } — must render JSON
94
+ carrying a boolean `has_next`
95
+ events_output_schema REQUIRED object — JSON Schema; FLOORED at an array
96
+ whose items list `external_id`
97
+ in `required`
98
+ pagination_context_output_schema REQUIRED object — JSON Schema; FLOORED at an object
99
+ listing `has_next` in `required`
100
+ ```
101
+
102
+ **Mind the level.** These sit at the ASU root, deliberately — the
103
+ `restapi_request` body embed is shared with the data-activation
104
+ `tool_call`, and putting them there would force every DAC to supply
105
+ them too. A `restapi_request` body carries its own `events_template`;
106
+ that is a different field at a different level, and supplying it does
107
+ **not** satisfy the root requirement.
108
+
109
+ This was `restapi`-only while CloudWatch was a separate loop with no
110
+ templates of its own. It is not one any more: every source now renders
111
+ through `events_template` + `pagination_context_template` and
112
+ terminates on the same `has_next`, so a `cloud_watch` updater declares
113
+ the same contract a REST one does — the platform just ships the
114
+ templates it normally points at. An older `cloud_watch` manifest that
115
+ predates the change 422s on its next create.
116
+
117
+ > ⚠ **The OpenAPI schema is behind the changeset on two of these.**
118
+ > `events_output_schema` and `pagination_context_output_schema` are
119
+ > generated **optional**, but `validate_expected_output_schemas/1`
120
+ > requires them unconditionally. Omit them and the type-checker is
121
+ > happy while the server returns 422. Treat all four as required until
122
+ > the spec catches up.
123
+
124
+ The floors exist for a reason. `external_id` is what the apply path
125
+ pops off each rendered event to find the message and action-log rows to
126
+ update — an events contract that does not demand it accepts a poll that
127
+ fetches pages forever and reconciles nothing. `has_next` is the key the
128
+ page loop ends on.
129
+
77
130
  `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:
131
+ unix milliseconds** — that is what the poll request takes. Use the
132
+ **`now_msec`** variable (unix ms) and pipe it through the
133
+ relative-time filters. **Not the bare `now`** — that is a DateTime,
134
+ and feeding it a millisecond filter now raises at render:
81
135
 
82
136
  ```
83
- start_time: '{{ now | minutes_ago: 45 }}' // 45 minutes back
84
- end_time: '{{ now }}' // right now
137
+ start_time: '{{ now_msec | minutes_ago: 45 }}' // 45 minutes back
138
+ end_time: '{{ now_msec }}' // right now
85
139
  ```
86
140
 
87
141
  Also available: `hours_ago: N`.
88
142
 
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.
143
+ **Do not use the bare `now` variable or the ISO-8601 `now` filter
144
+ here.** The `now` variable is a DateTime and `{{ "" | now }}`
145
+ renders a datetime *string* neither is a valid unix-ms window,
146
+ and the platform rejects both. An ISO 8601 literal is not valid on
147
+ these fields either.
93
148
 
94
149
  ### `message_config.body` renders ONCE PER EVENT
95
150
 
@@ -101,6 +156,10 @@ scope — do not loop.
101
156
  {"external_id": "{{ notification.messageId }}", "status": "delivered"}
102
157
  ```
103
158
 
159
+ > **Shape only — do not copy this mapping.** A hardcoded `"delivered"`
160
+ > records every event as delivered, failures included. Real bodies
161
+ > branch on the provider's status; see the SNS and MMS sections below.
162
+
104
163
  It must render a **flat** JSON object:
105
164
 
106
165
  ```
@@ -123,6 +182,174 @@ events for messages this updater doesn't own.
123
182
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
124
183
  available here, as everywhere.
125
184
 
185
+ ### SMS-over-SNS delivery tracking — two `custom` bodies
186
+
187
+ The SNS SMS delivery receipt is **binary and single-shot**: one terminal
188
+ outcome, `status` of `SUCCESS` or `FAILURE`. Its event shape is
189
+ **disjoint from EUM's** — `status`, `notification.messageId`,
190
+ `notification.timestamp`, `delivery.phoneCarrier`,
191
+ `delivery.providerResponse` all sit at the root. Do not carry an EUM
192
+ template over to SNS or vice versa.
193
+
194
+ Two convenient consequences, both unlike MMS:
195
+
196
+ - `delivered_at` is `notification.timestamp` — a UTC
197
+ `"YYYY-MM-DD HH:MM:SS.sss"` string Ecto casts directly, so **no date
198
+ filter is needed** (EUM needs `from_unix_ms` on its Unix-ms field).
199
+ - the DLR **does** surface the US carrier as `delivery.phoneCarrier`.
200
+
201
+ **`message_config`:**
202
+
203
+ ```liquid
204
+ {"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 %}}
205
+ ```
206
+
207
+ **`action_log_config`:**
208
+
209
+ ```liquid
210
+ {"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"}
211
+ ```
212
+
213
+ `status_description` is `delivery.providerResponse` verbatim on both
214
+ sides — the carrier's own text, on success and failure alike. Because
215
+ SNS's status is binary, `failure_reason` can only ever be `"FAILURE"`;
216
+ the granular carrier text stays in `status_description`.
217
+
218
+ **Note how the two SNS bodies differ from each other**: same status
219
+ mapping, fewer emitted fields on the action log (no `delivered_at`,
220
+ `sms_carrier`, or `failure_reason` — those are message concerns). That
221
+ is a *different* reason from MMS's, where the two also disagree on the
222
+ mapping itself. Both channels need two bodies; they need them for
223
+ different reasons.
224
+
225
+ ### MMS delivery tracking needs TWO different templates — author both as `custom`
226
+
227
+ MMS delivery tracking is a `cloud_watch` updater. Author **both**
228
+ `message_config` and `action_log_config` as `custom` bodies — the two
229
+ render *different* JSON from the same event, so one body cannot serve
230
+ both. This is the single most-missed thing about updaters.
231
+
232
+ **Why they differ.** A message's status vocabulary includes
233
+ `customer_rejected`; an action log's does not. A recipient or carrier
234
+ rejection (`SPAM` / `BLOCKED` / `CARRIER_BLOCKED`) is a message-level
235
+ outcome — the action *did* deliver — so the action-log render must
236
+ **NO-OP** on those events while the message render records
237
+ `customer_rejected`. The message side also carries `delivered_at`,
238
+ `failure_reason`, and `sms_carrier`; only `status` +
239
+ `status_description` are ever applied to an action log.
240
+
241
+ AWS End User Messaging (`sms-voice`) event fields: top-level
242
+ `messageId`, `eventType` (`MEDIA_*`), `messageStatus`,
243
+ `messageStatusDescription`, `eventTimestamp` (Unix ms), `isFinal`.
244
+
245
+ **`message_config`** — map every status, emit the extra fields:
246
+
247
+ ```liquid
248
+ {% case messageStatus %}
249
+ {% when 'DELIVERED' %}{% assign mapped_status = 'delivered' %}
250
+ {% when 'SUCCESSFUL' %}{% assign mapped_status = 'sent' %}
251
+ {% when 'PENDING' %}{% assign mapped_status = 'sent' %}
252
+ {% when 'QUEUED' %}{% assign mapped_status = 'queued' %}
253
+ {% when 'SPAM' %}{% assign mapped_status = 'customer_rejected' %}
254
+ {% when 'BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
255
+ {% when 'CARRIER_BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
256
+ {% else %}{% assign mapped_status = 'failed' %}
257
+ {% endcase %}
258
+ {"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 %}}
259
+ ```
260
+
261
+ **`action_log_config`** — the rejection statuses fall through to a
262
+ bare `external_id`, which applies nothing:
263
+
264
+ ```liquid
265
+ {% 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 %}}
266
+ ```
267
+
268
+ Note the `{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}` arm is
269
+ deliberately **empty** — it leaves `al_status` blank, so the render
270
+ emits `external_id` alone and the action log is untouched.
271
+
272
+ **Capture every event, not just the terminal one.** 2–3 events arrive
273
+ per message — interim `MEDIA_QUEUED` / `MEDIA_PENDING` /
274
+ `MEDIA_SUCCESSFUL` (`isFinal: false`), then one terminal event
275
+ (`isFinal: true`), up to 72h late and out of order. Apply is
276
+ idempotent and a monotonic guard only ever advances `status`, so a
277
+ late or interim lower-rank event can never demote a message that
278
+ already reached a higher state. Poll a window that overlaps
279
+ deliberately; nothing is dropped.
280
+
281
+ `queued_at` and `sent_at` are **your outbound pipeline's** times, set
282
+ when the platform enqueues and dispatches — a delivery poll never
283
+ overwrites them. Only `delivered_at` comes from the carrier event
284
+ (`DELIVERED`'s own `eventTimestamp` via `from_unix_ms`), so
285
+ re-polling a wide window does not drift it.
286
+
287
+ ### Carrier information is available for SMS, not MMS
288
+
289
+ The recipient's mobile carrier is present on SMS delivery events but
290
+ not MMS. This is AWS behavior — verified against real delivery
291
+ events — not something the platform chooses to drop:
292
+
293
+ - **SMS via Amazon SNS** — the delivery-status log
294
+ (`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
295
+ under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
296
+ numeric `mcc`/`mnc`). The SNS status template maps it to
297
+ `sms_carrier`.
298
+ - **MMS via AWS End User Messaging (`sms-voice`)** — the event *can*
299
+ carry `carrierName`, but it is DLR-populated and comes back
300
+ **absent for US destinations** (the US DLR does not surface it),
301
+ unlike the SNS path's `phoneCarrier`, whose US DLR does. So map it
302
+ when present — `{% if carrierName and carrierName != '' %}` — and
303
+ expect `sms_carrier` to stay null for US long-code traffic.
304
+ Verified against real AWS delivery events: the terminal
305
+ `MEDIA_DELIVERED` event for a US destination exposes
306
+ `totalCarrierFee` (a cost) and no `carrierName`.
307
+
308
+ AWS's Nov-2024 EventBridge-integration announcement states MMS
309
+ delivery events carry carrier information in EventBridge, but the
310
+ real events do not — a documented doc-vs-behavior discrepancy. For
311
+ per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
312
+ `PhoneType`) at enrollment and join on destination/`messageId`; it is
313
+ not in the delivery stream.
314
+
315
+ ### `action_log_config` is required — same rendering contract as `message_config`
316
+
317
+ `action_log_config` is a **required** field on every action status
318
+ updater — `cloud_watch` and `restapi` alike — on **both `create()` and
319
+ `update()`**. There is no updater for which it is optional and no way
320
+ to omit it on a PUT.
321
+
322
+ It follows the same per-event rendering contract as `message_config`
323
+ (§2 above), but targets the **action_log** row instead of the message
324
+ row: it must render a JSON object containing `external_id` plus at
325
+ least one updatable action-log field — one of `status`, `sent_at`, or
326
+ `metadata`. A minimal valid value:
327
+
328
+ ```liquid
329
+ {"external_id": "{{ notification.messageId }}", "status": "delivered"}
330
+ ```
331
+
332
+ Two distinct rejections come off this field, and they read differently:
333
+
334
+ - **Missing entirely** — omitting `action_log_config` on create or
335
+ update is a 422 with `Missing field: action_log_config` at
336
+ `source.pointer: /action_log_config`.
337
+ - **Present but renders nothing** — a value that passes shallow
338
+ validation but renders `null` or an empty result at run time (e.g.
339
+ a `{"type": "null"}` config) is refused with a 422 whose detail
340
+ begins `must render a JSON object containing external_id`.
341
+
342
+ **Response nullability is lazy, not retroactive.** Updaters created
343
+ before this became required still read back `action_log_config: null`
344
+ — the platform does not backfill a template onto old rows. That
345
+ `null` persists until the row's **next edit** forces a real template
346
+ through, and "next edit" is broader than a plain field change: it
347
+ also includes resuming a halted updater by setting `status` back to
348
+ `'active'` (§7) and issuing a `refresh()` call with a one-shot
349
+ `updater_body` override (§7) — both now require `action_log_config`
350
+ to be present too, which is what completes the migration for an old
351
+ row.
352
+
126
353
  ### `cron_expression` is a standard 5-field cron string
127
354
 
128
355
  ```
@@ -201,8 +428,17 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
201
428
  datalake_id required UUID
202
429
  updater_body required embed — { updater_body_type, ... }
203
430
  message_config required embed — { type, body }
431
+ action_log_config required embed — { type, body } (required on create AND update)
432
+
433
+ events_template required embed — EVERY updater type, not just restapi
434
+ pagination_context_template required embed — EVERY updater type
435
+ events_output_schema required object — JSON Schema
436
+ pagination_context_output_schema required object — JSON Schema, floored at
437
+ `has_next` in `required`
204
438
  ```
205
439
 
440
+ The last four widened from `restapi`-only to universal — see §2.
441
+
206
442
  **Write-only (Request-only).** None.
207
443
 
208
444
  ## 4. Error envelopes
@@ -216,6 +452,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
216
452
  | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
217
453
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
218
454
  | `/message_config/body` | empty when `type: 'custom'` |
455
+ | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
456
+ | (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
219
457
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
220
458
 
221
459
  ## 5. Lifecycle
@@ -318,7 +556,7 @@ check or retry for partial-delete states.
318
556
  The poll follows pages while `has_next` is true and stops on
319
557
  the first empty page.
320
558
 
321
- ## 7. Manual trigger via `refresh` — but still no per-run log
559
+ ## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
322
560
 
323
561
  An action status updater CAN be fired on demand:
324
562
 
@@ -328,28 +566,103 @@ const { data } = await api.actionStatusUpdaters.refresh(
328
566
  );
329
567
  ```
330
568
 
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>`.)
569
+ **`refresh` answers `202` with the updater row AS-IS the poll is
570
+ ENQUEUED, not run.** The `last_run_*` fields on that response are the
571
+ PREVIOUS run's stamps; reading them as the outcome of the refresh you
572
+ just issued is the single most common mistake here. An optional body
573
+ carries a one-shot `updater_body` override (e.g. a widened
574
+ `start_time`/`end_time` window for a backfill) that does not mutate
575
+ the persisted updater. Apply jobs are asynchronous too — observe
576
+ delivery status on the message rows, not in this response.
577
+ (CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
578
+ `refresh-updater`.)
335
579
 
336
- What the ASU still does NOT have is a **per-run log** (nothing like
337
- 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:
580
+ The correct read is baseline-then-advance:
581
+
582
+ ```ts
583
+ const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
584
+ const baseline = before.data.last_run_at ?? null // BEFORE refreshing
585
+
586
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
587
+
588
+ let row = before.data
589
+ const deadline = Date.now() + 60_000
590
+ while (Date.now() < deadline) {
591
+ await new Promise((r) => setTimeout(r, 2_000))
592
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
593
+ if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
594
+ }
595
+ // row.last_run_status / last_run_error now describe THIS run
596
+ ```
597
+
598
+ The ASU now **does** have a per-run log — the sibling of a data
599
+ activation client's `logs` and a workflow's `batchLogs`. There are two
600
+ run-outcome surfaces and they answer different questions:
601
+
602
+ ```
603
+ actionStatusUpdaters.runLogs.list(tenantSlug, datalakeSlug, updaterId, query?)
604
+ actionStatusUpdaters.runLogs.get(tenantSlug, datalakeSlug, updaterId, logId)
605
+ ```
606
+
607
+ `.list` is Flop-paginated like every other list — same `page` /
608
+ `page_size` / `filters` / `order_by` vocabulary (see `async.md`).
609
+
610
+ **The summary surface — four fields on the updater row, plus the halt
611
+ flag:**
340
612
 
341
613
  ```
342
- last_run_at when the poll worker last completed a run
614
+ last_run_at when the poll worker last made progress
343
615
  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
616
+ last_run_error why the last run failed
617
+ last_run_events_found events fetched so far in the current run
618
+ status 'active' | 'cycle_detected'
346
619
  ```
347
620
 
621
+ **The per-run surface — one row per poll run, from `runLogs`:**
622
+
623
+ ```
624
+ status 'ok' | 'partial' | 'error' ← the outcome of THIS page
625
+ ```
626
+
627
+ **`'partial'` lives on the run log, NOT on the updater — this is the
628
+ trap.** A truncated fetch means the window was not fully read and the
629
+ events it missed are the **newest** ones. The updater's own
630
+ `last_run_status` cannot tell you that: it carries `'ok' | 'error'`
631
+ only, so a truncated run reads as `'ok'` there. If completeness of
632
+ reconciliation matters to you, read the run log — checking
633
+ `last_run_status === 'ok'` on the updater is **not** sufficient, and a
634
+ client that treats it as sufficient silently accepts an incomplete
635
+ reconciliation.
636
+
637
+ - `'ok'` — the page completed and read its whole slice of the window.
638
+ - `'partial'` — the page completed but its provider fetch was
639
+ **truncated**. Only visible on the run-log row.
640
+ - `'error'` — the run failed; `last_run_error` on the updater says why.
641
+
642
+ `last_run_events_found` is the event count for the current run; because
643
+ `refresh` is asynchronous (below), a read taken before the run finishes can
644
+ legitimately show a smaller, in-progress figure than the final count.
645
+
646
+ **`status` is the halt flag.** The server sets `'cycle_detected'` when
647
+ a run re-reads events it has already handled — the runtime backstop for
648
+ a poll whose pagination does not advance (a provider cursor that never
649
+ drains, or a one-shot `updater_body` override). Every later job then
650
+ fails without calling the provider, and `last_run_error` carries the
651
+ diagnostic. **Nothing clears it but setting `status` back to
652
+ `'active'`.** It is also the cheapest deterministic failure to assert
653
+ on: a non-advancing config trips it within seconds, no provider
654
+ credential rejection required. Related: a `restapi` updater that
655
+ declares pagination but whose `path`/`params`/`body` never read
656
+ `msg.pagination_context` or `msg.page` is now refused at create/update
657
+ — see §2's template contract.
658
+
348
659
  **If a message's status is not reconciling, do this — in order:**
349
660
 
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.
661
+ 1. Call `refresh()`, wait for `last_run_at` to advance past the
662
+ baseline you captured, then read `last_run_status` /
663
+ `last_run_error` / `status`. They name the failing stage: window
664
+ render, source fetch, template render, JSON parse — or a halted
665
+ poll cycle.
353
666
  2. Check the config against §2 above. A config that violates the
354
667
  template contract is rejected at create, so a *deployed* updater's
355
668
  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
  ```