@alvera-ai/platform-sdk 0.13.0-next.gf3d2a4a → 0.13.0-next.gf8f28bd

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 (31) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +122 -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 +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/tool-call-configs.md +11 -0
  25. package/.agent/tools.md +54 -19
  26. package/.agent/type_naming.md +4 -0
  27. package/dist/index.d.mts +183 -17
  28. package/dist/index.d.mts.map +1 -1
  29. package/dist/index.mjs +3 -1
  30. package/dist/index.mjs.map +1 -1
  31. 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
@@ -363,25 +455,29 @@ run-outcome surface is four fields on the row itself, plus the halt
363
455
  flag:
364
456
 
365
457
  ```
366
- last_run_at when the poll worker last stamped a page
367
- last_run_status 'ok' | 'error'
368
- last_run_error why the last run failed (structured text)
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
369
461
  last_run_events_found events fetched so far in the current run
370
462
  status 'active' | 'cycle_detected'
371
463
  ```
372
464
 
373
- **A paginated run stamps `last_run_*` on EVERY page, not only the
374
- final one.** Two consequences for anything asserting on them:
375
-
376
- - `last_run_status: 'ok'` means *a page* succeeded — not that the run
377
- completed. Do not treat it as terminal.
378
- - `last_run_events_found` is a **running total** for the current run,
379
- so a mid-run read is legitimately smaller than the final count.
380
-
381
- Per-page stamping exists because the alternative hid a real incident:
382
- an updater that polled continuously for 45 minutes was
383
- indistinguishable from one that had never started, because only the
384
- terminal page stamped and no page was ever terminal.
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.
385
481
 
386
482
  **`status` is the halt flag.** The server sets `'cycle_detected'` when
387
483
  a run re-reads events it has already handled — the runtime backstop for
@@ -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 tick — see `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
@@ -229,6 +229,54 @@ if (rows.length < 4) {
229
229
  }
230
230
  ```
231
231
 
232
+ ## 009 — write the integration test
233
+
234
+ End the build with a test you keep: run the bulk path in miniature —
235
+ mint a link, PUT a tiny inline CSV, enqueue it — and assert each call's
236
+ own acknowledgement. The probe rows are `test-`-prefixed so they are
237
+ unmistakably synthetic wherever they surface, and the test asserts the
238
+ enqueue ack (`job_id`), never synchronous merge completion — the worker
239
+ owns that. This block runs live under `make validate-cookbook`.
240
+
241
+ ```typescript
242
+ // Re-GET — the client must still be there and carry its ingest slug.
243
+ const { data: dacRow } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
244
+ if (dacRow.slug !== ctx.dacSlug) {
245
+ throw new Error(`DAC slug drifted on read-back: ${dacRow.slug}`)
246
+ }
247
+ // Behavioural probe — the three-step bulk path, each step asserted on
248
+ // its own response.
249
+ const probeCsv =
250
+ 'customer_number,customer_type,status,name,email,currency\n' +
251
+ `test-PROBE-1-${runSuffix},individual,contracted,test-Grace Hopper,test-grace-${runSuffix}@example.com,USD\n`
252
+ const { data: probeLink } = await api.datalakes.createUploadLink(tenantSlug, datalakeSlug, {
253
+ content_type: 'text/csv',
254
+ filename: `test-bulk-probe-${runSuffix}.csv`,
255
+ })
256
+ if (!probeLink.url || !probeLink.key) {
257
+ throw new Error('createUploadLink returned no url/key')
258
+ }
259
+ const probePut = await fetch(probeLink.url, {
260
+ method: 'PUT',
261
+ headers: { 'Content-Type': 'text/csv' },
262
+ body: probeCsv,
263
+ })
264
+ if (probePut.status !== 200) {
265
+ throw new Error(`presigned PUT failed: ${probePut.status}`)
266
+ }
267
+ const { data: probeJob } = await api.dataActivationClients.ingestFile(tenantSlug, datalakeSlug, ctx.dacSlug, {
268
+ key: probeLink.key,
269
+ })
270
+ // job_id is a NUMBER on the wire (an Oban job id), status "scheduled".
271
+ if (probeJob.job_id === undefined || probeJob.job_id === null) {
272
+ throw new Error('ingestFile probe returned no job_id')
273
+ }
274
+ ```
275
+
276
+ If the probe fails in production, escalate with the failing call's
277
+ response — don't retry the enqueue in a loop or reach into the worker's
278
+ storage to "help it along".
279
+
232
280
  # Gotchas
233
281
 
234
282
  - **The file PUT is raw HTTP, not the SDK.** `createUploadLink` and `ingestFile`
@@ -543,6 +543,41 @@ for (const wel of ourWels) {
543
543
  }
544
544
  ```
545
545
 
546
+ ## 011 — write the integration test
547
+
548
+ End the build with a test you keep: re-read the workflow and prove the
549
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
550
+ never-matching selection runs the FULL pipeline (selection → filter →
551
+ decision) and intercepts only the final action call, so no message
552
+ leaves, yet the acknowledgement proves the workflow is runnable. This
553
+ block runs live under `make validate-cookbook`.
554
+
555
+ ```typescript
556
+ // Re-GET — the workflow must still be live, or nothing will run.
557
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
558
+ if (wfRow.status !== 'live') {
559
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
560
+ }
561
+ // Behavioural probe — a dry run against a selection no row can match:
562
+ // the pipeline executes end-to-end, the final action call is
563
+ // intercepted, and the acknowledgement carries the run-log id. The
564
+ // clause speaks this workflow's selection dialect: a GENERIC-TABLE
565
+ // dataset is addressed by its own columns (no `ra.` dataset alias —
566
+ // that alias exists only for system-dataset selections).
567
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
568
+ sql_where_clause: "submission_id = 'test-never-matching-submission'",
569
+ mode: 'dry_run',
570
+ manual_override: false,
571
+ })
572
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
573
+ throw new Error('dry-run probe returned no workflow_run_log_id')
574
+ }
575
+ ```
576
+
577
+ If the probe fails in production, escalate with the run response as
578
+ evidence — don't flip the workflow's status or rewrite its configs to
579
+ chase the error.
580
+
546
581
  # Branches
547
582
 
548
583
  - **The filter is permissive** — `filter_config.body: 'true'`