@alvera-ai/platform-sdk 0.13.0 → 0.14.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 +174 -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 +34 -0
  9. package/.agent/cookbook/bulk-ingest.md +48 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +35 -0
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +32 -0
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +34 -0
  14. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  15. package/.agent/cookbook/paginated-restapi-poller.md +329 -0
  16. package/.agent/cookbook/rest-fetch.md +27 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +34 -0
  18. package/.agent/cookbook/score-leads-with-llm-categorization.md +35 -0
  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 +32 -0
  22. package/.agent/cookbook/welcome-sms-for-customers.md +32 -0
  23. package/.agent/datalakes.md +24 -0
  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
 
@@ -123,6 +139,79 @@ events for messages this updater doesn't own.
123
139
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
124
140
  available here, as everywhere.
125
141
 
142
+ ### MMS delivery tracking uses a system `message_config` template
143
+
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.
151
+
152
+ ### Carrier information is available for SMS, not MMS
153
+
154
+ The recipient's mobile carrier is present on SMS delivery events but
155
+ not MMS. This is AWS behavior — verified against real delivery
156
+ events — not something the platform chooses to drop:
157
+
158
+ - **SMS via Amazon SNS** — the delivery-status log
159
+ (`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
160
+ under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
161
+ numeric `mcc`/`mnc`). The SNS status template maps it to
162
+ `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.
169
+
170
+ AWS's Nov-2024 EventBridge-integration announcement states MMS
171
+ delivery events carry carrier information in EventBridge, but the
172
+ real events do not — a documented doc-vs-behavior discrepancy. For
173
+ per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
174
+ `PhoneType`) at enrollment and join on destination/`messageId`; it is
175
+ not in the delivery stream.
176
+
177
+ ### `action_log_config` is required — same rendering contract as `message_config`
178
+
179
+ `action_log_config` is a **required** field on every action status
180
+ updater — `cloud_watch` and `restapi` alike — on **both `create()` and
181
+ `update()`**. There is no updater for which it is optional and no way
182
+ to omit it on a PUT.
183
+
184
+ It follows the same per-event rendering contract as `message_config`
185
+ (§2 above), but targets the **action_log** row instead of the message
186
+ row: it must render a JSON object containing `external_id` plus at
187
+ least one updatable action-log field — one of `status`, `sent_at`, or
188
+ `metadata`. A minimal valid value:
189
+
190
+ ```liquid
191
+ {"external_id": "{{ notification.messageId }}", "status": "delivered"}
192
+ ```
193
+
194
+ Two distinct rejections come off this field, and they read differently:
195
+
196
+ - **Missing entirely** — omitting `action_log_config` on create or
197
+ update is a 422 with `Missing field: action_log_config` at
198
+ `source.pointer: /action_log_config`.
199
+ - **Present but renders nothing** — a value that passes shallow
200
+ validation but renders `null` or an empty result at run time (e.g.
201
+ a `{"type": "null"}` config) is refused with a 422 whose detail
202
+ begins `must render a JSON object containing external_id`.
203
+
204
+ **Response nullability is lazy, not retroactive.** Updaters created
205
+ before this became required still read back `action_log_config: null`
206
+ — the platform does not backfill a template onto old rows. That
207
+ `null` persists until the row's **next edit** forces a real template
208
+ through, and "next edit" is broader than a plain field change: it
209
+ also includes resuming a halted updater by setting `status` back to
210
+ `'active'` (§7) and issuing a `refresh()` call with a one-shot
211
+ `updater_body` override (§7) — both now require `action_log_config`
212
+ to be present too, which is what completes the migration for an old
213
+ row.
214
+
126
215
  ### `cron_expression` is a standard 5-field cron string
127
216
 
128
217
  ```
@@ -201,6 +290,7 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
201
290
  datalake_id required UUID
202
291
  updater_body required embed — { updater_body_type, ... }
203
292
  message_config required embed — { type, body }
293
+ action_log_config required embed — { type, body } (required on create AND update)
204
294
  ```
205
295
 
206
296
  **Write-only (Request-only).** None.
@@ -216,6 +306,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
216
306
  | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
217
307
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
218
308
  | `/message_config/body` | empty when `type: 'custom'` |
309
+ | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
310
+ | (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
219
311
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
220
312
 
221
313
  ## 5. Lifecycle
@@ -318,7 +410,7 @@ check or retry for partial-delete states.
318
410
  The poll follows pages while `has_next` is true and stops on
319
411
  the first empty page.
320
412
 
321
- ## 7. Manual trigger via `refresh` — but still no per-run log
413
+ ## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
322
414
 
323
415
  An action status updater CAN be fired on demand:
324
416
 
@@ -328,28 +420,85 @@ const { data } = await api.actionStatusUpdaters.refresh(
328
420
  );
329
421
  ```
330
422
 
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>`.)
423
+ **`refresh` answers `202` with the updater row AS-IS the poll is
424
+ ENQUEUED, not run.** The `last_run_*` fields on that response are the
425
+ PREVIOUS run's stamps; reading them as the outcome of the refresh you
426
+ just issued is the single most common mistake here. An optional body
427
+ carries a one-shot `updater_body` override (e.g. a widened
428
+ `start_time`/`end_time` window for a backfill) that does not mutate
429
+ the persisted updater. Apply jobs are asynchronous too — observe
430
+ delivery status on the message rows, not in this response.
431
+ (CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
432
+ `refresh-updater`.)
433
+
434
+ The correct read is baseline-then-advance:
435
+
436
+ ```ts
437
+ const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
438
+ const baseline = before.data.last_run_at ?? null // BEFORE refreshing
439
+
440
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
441
+
442
+ let row = before.data
443
+ const deadline = Date.now() + 60_000
444
+ while (Date.now() < deadline) {
445
+ await new Promise((r) => setTimeout(r, 2_000))
446
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
447
+ if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
448
+ }
449
+ // row.last_run_status / last_run_error now describe THIS run
450
+ ```
335
451
 
336
452
  What the ASU still does NOT have is a **per-run log** (nothing like
337
453
  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:
454
+ run-outcome surface is four fields on the row itself, plus the halt
455
+ flag:
340
456
 
341
457
  ```
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
458
+ last_run_at when the poll worker last made progress
459
+ last_run_status 'ok' | 'partial' | 'error'
460
+ last_run_error why the last run failed, or why a partial run was truncated
461
+ last_run_events_found events fetched so far in the current run
462
+ status 'active' | 'cycle_detected'
346
463
  ```
347
464
 
465
+ **A poll run has three outcomes, and only `'ok'` means it reconciled its
466
+ whole time window:**
467
+
468
+ - `'ok'` — the run completed and read its entire window. This is the only
469
+ value that proves the reconciliation is complete.
470
+ - `'partial'` — the run completed but its provider fetch was **truncated**:
471
+ the window was not fully read, and the events it missed are the **newest**
472
+ ones. `last_run_error` carries the detail (it is **not** null on a
473
+ `partial` run). A client that treats `last_run_status !== 'error'` as
474
+ healthy silently accepts this incomplete reconciliation — check
475
+ `last_run_status === 'ok'` instead.
476
+ - `'error'` — the run failed; `last_run_error` says why.
477
+
478
+ `last_run_events_found` is the event count for the current run; because
479
+ `refresh` is asynchronous (below), a read taken before the run finishes can
480
+ legitimately show a smaller, in-progress figure than the final count.
481
+
482
+ **`status` is the halt flag.** The server sets `'cycle_detected'` when
483
+ a run re-reads events it has already handled — the runtime backstop for
484
+ a poll whose pagination does not advance (a provider cursor that never
485
+ drains, or a one-shot `updater_body` override). Every later job then
486
+ fails without calling the provider, and `last_run_error` carries the
487
+ diagnostic. **Nothing clears it but setting `status` back to
488
+ `'active'`.** It is also the cheapest deterministic failure to assert
489
+ on: a non-advancing config trips it within seconds, no provider
490
+ credential rejection required. Related: a `restapi` updater that
491
+ declares pagination but whose `path`/`params`/`body` never read
492
+ `msg.pagination_context` or `msg.page` is now refused at create/update
493
+ — see §2's template contract.
494
+
348
495
  **If a message's status is not reconciling, do this — in order:**
349
496
 
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.
497
+ 1. Call `refresh()`, wait for `last_run_at` to advance past the
498
+ baseline you captured, then read `last_run_status` /
499
+ `last_run_error` / `status`. They name the failing stage: window
500
+ render, source fetch, template render, JSON parse — or a halted
501
+ poll cycle.
353
502
  2. Check the config against §2 above. A config that violates the
354
503
  template contract is rejected at create, so a *deployed* updater's
355
504
  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
@@ -675,6 +675,38 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
675
675
  }
676
676
  ```
677
677
 
678
+ ## 016 — write the integration test
679
+
680
+ End the build with a test you keep: re-read the workflow and prove the
681
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
682
+ never-matching selection runs the FULL pipeline (selection → filter →
683
+ decision) and intercepts only the final action call, so no message
684
+ leaves, yet the acknowledgement proves the workflow is runnable. This
685
+ block runs live under `make validate-cookbook`.
686
+
687
+ ```typescript
688
+ // Re-GET — the workflow must still be live, or nothing will run.
689
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
690
+ if (wfRow.status !== 'live') {
691
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
692
+ }
693
+ // Behavioural probe — a dry run against a selection no row can match:
694
+ // the pipeline executes end-to-end, the final action call is
695
+ // intercepted, and the acknowledgement carries the run-log id.
696
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
697
+ sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
698
+ mode: 'dry_run',
699
+ manual_override: false,
700
+ })
701
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
702
+ throw new Error('dry-run probe returned no workflow_run_log_id')
703
+ }
704
+ ```
705
+
706
+ If the probe fails in production, escalate with the run response as
707
+ evidence — don't flip the workflow's status or rewrite its configs to
708
+ chase the error.
709
+
678
710
  # Branches
679
711
 
680
712
  - **The cancelled appointment is filtered, not failed** — §012
@@ -577,6 +577,40 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
577
577
  }
578
578
  ```
579
579
 
580
+ ## 015 — write the integration test
581
+
582
+ End the build with a test you keep: re-read the workflow and prove the
583
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
584
+ never-matching selection runs the FULL pipeline (selection → filter →
585
+ decision) and intercepts only the final action call, so no message
586
+ leaves, yet the acknowledgement proves the workflow is runnable. This
587
+ block runs live under `make validate-cookbook`.
588
+
589
+ ```typescript
590
+ // Re-GET — the workflow must still be live, or nothing will run.
591
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
592
+ if (wfRow.status !== 'live') {
593
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
594
+ }
595
+ // Behavioural probe — a dry run against a selection no row can match:
596
+ // the pipeline executes end-to-end, the final action call is
597
+ // intercepted, and the acknowledgement carries the run-log id. The
598
+ // clause must speak this workflow's selection dialect — the dataset
599
+ // alias is `rle` here, the same alias the live run above uses.
600
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
601
+ sql_where_clause: "rle.batch_id = 'test-never-matching-batch'",
602
+ mode: 'dry_run',
603
+ manual_override: false,
604
+ })
605
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
606
+ throw new Error('dry-run probe returned no workflow_run_log_id')
607
+ }
608
+ ```
609
+
610
+ If the probe fails in production, escalate with the run response as
611
+ evidence — don't flip the workflow's status or rewrite its configs to
612
+ chase the error.
613
+
580
614
  # Branches
581
615
 
582
616
  - **The no-DoB row is filtered, not failed** — §011 asserts the no-DoB