@alvera-ai/platform-sdk 0.13.0-next.g4d91b45 → 0.13.0-next.gbe3581f

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 (30) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +90 -0
  3. package/.agent/connected_apps.md +7 -2
  4. package/.agent/cookbook/action-status-updaters.md +59 -8
  5. package/.agent/cookbook/ai-agent-invoke.md +36 -0
  6. package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
  7. package/.agent/cookbook/birthday-greeting-sms-trigger.md +34 -0
  8. package/.agent/cookbook/bulk-ingest.md +48 -0
  9. package/.agent/cookbook/contact-us-triage-with-llm.md +35 -0
  10. package/.agent/cookbook/dunning-sms-for-delinquent.md +32 -0
  11. package/.agent/cookbook/generic-tables.md +40 -0
  12. package/.agent/cookbook/kyc-notification-on-account-activation.md +34 -0
  13. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  14. package/.agent/cookbook/paginated-restapi-poller.md +324 -0
  15. package/.agent/cookbook/rest-fetch.md +27 -0
  16. package/.agent/cookbook/sanctions-screening-with-agent-review.md +34 -0
  17. package/.agent/cookbook/score-leads-with-llm-categorization.md +35 -0
  18. package/.agent/cookbook/system-templates.md +36 -0
  19. package/.agent/cookbook/talk-to-data.md +39 -0
  20. package/.agent/cookbook/triage-prospects-by-priority.md +32 -0
  21. package/.agent/cookbook/welcome-sms-for-customers.md +32 -0
  22. package/.agent/datalakes.md +24 -0
  23. package/.agent/tool-call-configs.md +2 -1
  24. package/.agent/tools.md +46 -15
  25. package/.agent/type_naming.md +4 -0
  26. package/dist/index.d.mts +28 -10
  27. package/dist/index.d.mts.map +1 -1
  28. package/dist/index.mjs +1 -1
  29. package/dist/index.mjs.map +1 -1
  30. 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
@@ -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,6 +82,12 @@ 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
92
  unix milliseconds** — that is what the poll request takes. On
79
93
  these two fields `now` is a **variable already holding unix ms**,
@@ -123,6 +137,79 @@ events for messages this updater doesn't own.
123
137
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
124
138
  available here, as everywhere.
125
139
 
140
+ ### MMS delivery tracking uses a system `message_config` template
141
+
142
+ MMS delivery tracking is wired as a `cloud_watch` updater whose
143
+ `message_config` points at a system-defined template rather than a
144
+ hand-authored `custom` body — the system template lives at
145
+ `status_poller/end_user_messaging/mms_delivery_status`. Everything
146
+ else about the updater is ordinary: it still needs an
147
+ `action_log_config` like every other updater (see below) — the
148
+ system template covers `message_config` only.
149
+
150
+ ### Carrier information is available for SMS, not MMS
151
+
152
+ The recipient's mobile carrier is present on SMS delivery events but
153
+ not MMS. This is AWS behavior — verified against real delivery
154
+ events — not something the platform chooses to drop:
155
+
156
+ - **SMS via Amazon SNS** — the delivery-status log
157
+ (`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
158
+ under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
159
+ numeric `mcc`/`mnc`). The SNS status template maps it to
160
+ `sms_carrier`.
161
+ - **MMS via AWS End User Messaging (`sms-voice`)** — the delivery
162
+ event has no carrier field. Verified on **both** a config-set
163
+ CloudWatch destination and the EventBridge path: the terminal
164
+ `MEDIA_DELIVERED` event exposes only `totalCarrierFee` (a cost),
165
+ never `carrierName`/`mcc`/`mnc`. So `sms_carrier` stays unset for
166
+ MMS.
167
+
168
+ AWS's Nov-2024 EventBridge-integration announcement states MMS
169
+ delivery events carry carrier information in EventBridge, but the
170
+ real events do not — a documented doc-vs-behavior discrepancy. For
171
+ per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
172
+ `PhoneType`) at enrollment and join on destination/`messageId`; it is
173
+ not in the delivery stream.
174
+
175
+ ### `action_log_config` is required — same rendering contract as `message_config`
176
+
177
+ `action_log_config` is a **required** field on every action status
178
+ updater — `cloud_watch` and `restapi` alike — on **both `create()` and
179
+ `update()`**. There is no updater for which it is optional and no way
180
+ to omit it on a PUT.
181
+
182
+ It follows the same per-event rendering contract as `message_config`
183
+ (§2 above), but targets the **action_log** row instead of the message
184
+ row: it must render a JSON object containing `external_id` plus at
185
+ least one updatable action-log field — one of `status`, `sent_at`, or
186
+ `metadata`. A minimal valid value:
187
+
188
+ ```liquid
189
+ {"external_id": "{{ notification.messageId }}", "status": "delivered"}
190
+ ```
191
+
192
+ Two distinct rejections come off this field, and they read differently:
193
+
194
+ - **Missing entirely** — omitting `action_log_config` on create or
195
+ update is a 422 with `Missing field: action_log_config` at
196
+ `source.pointer: /action_log_config`.
197
+ - **Present but renders nothing** — a value that passes shallow
198
+ validation but renders `null` or an empty result at run time (e.g.
199
+ a `{"type": "null"}` config) is refused with a 422 whose detail
200
+ begins `must render a JSON object containing external_id`.
201
+
202
+ **Response nullability is lazy, not retroactive.** Updaters created
203
+ before this became required still read back `action_log_config: null`
204
+ — the platform does not backfill a template onto old rows. That
205
+ `null` persists until the row's **next edit** forces a real template
206
+ through, and "next edit" is broader than a plain field change: it
207
+ also includes resuming a halted updater by setting `status` back to
208
+ `'active'` (§7) and issuing a `refresh()` call with a one-shot
209
+ `updater_body` override (§7) — both now require `action_log_config`
210
+ to be present too, which is what completes the migration for an old
211
+ row.
212
+
126
213
  ### `cron_expression` is a standard 5-field cron string
127
214
 
128
215
  ```
@@ -201,6 +288,7 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
201
288
  datalake_id required UUID
202
289
  updater_body required embed — { updater_body_type, ... }
203
290
  message_config required embed — { type, body }
291
+ action_log_config required embed — { type, body } (required on create AND update)
204
292
  ```
205
293
 
206
294
  **Write-only (Request-only).** None.
@@ -216,6 +304,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
216
304
  | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
217
305
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
218
306
  | `/message_config/body` | empty when `type: 'custom'` |
307
+ | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
308
+ | (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
219
309
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
220
310
 
221
311
  ## 5. Lifecycle
@@ -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
  ```
@@ -110,6 +110,10 @@ variable holding unix milliseconds — pipe it through `minutes_ago`. The
110
110
  `message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
111
111
  assigns (there is no `events` list to loop), and emits a FLAT object: the
112
112
  `external_id` of the message to update plus the fields to set at the top level.
113
+ `action_log_config` is REQUIRED alongside it — same per-event assigns, rendered
114
+ into the action-log write shape: a JSON object with `external_id` plus at least
115
+ one of `status` / `sent_at` / `metadata`. Omitting it is a 422 (`Missing field:
116
+ action_log_config`).
113
117
 
114
118
  ```typescript
115
119
  const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
@@ -129,6 +133,10 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
129
133
  type: 'custom',
130
134
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
131
135
  },
136
+ action_log_config: {
137
+ type: 'custom',
138
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
139
+ },
132
140
  })
133
141
  actionStatusUpdaterId = asu.id!
134
142
  ```
@@ -162,6 +170,10 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
162
170
  type: 'custom',
163
171
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
164
172
  },
173
+ action_log_config: {
174
+ type: 'custom',
175
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
176
+ },
165
177
  })
166
178
  if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
167
179
  throw new Error('expected a checksum for the ASU body')
@@ -190,6 +202,35 @@ if (typeof detail !== 'string' || detail.length === 0) {
190
202
  }
191
203
  ```
192
204
 
205
+ ## 007 — write the integration test
206
+
207
+ End the build with a test you keep: one block that re-reads the reconciler
208
+ and asserts the facts the scenario depends on. This block runs live under
209
+ `make validate-cookbook`.
210
+
211
+ ```typescript
212
+ // Re-GET — the stored row echoes what was authored.
213
+ const { data: asuRow } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
214
+ if (asuRow.cron_expression !== '*/30 * * * *') {
215
+ throw new Error(`cron mismatch on read-back: ${asuRow.cron_expression}`)
216
+ }
217
+ // last_run_* is the ONLY run surface (there is no per-run log). On a
218
+ // fresh create it is legitimately null — assert the field EXISTS on the
219
+ // wire, never a value.
220
+ if (!('last_run_status' in asuRow)) {
221
+ throw new Error('ASU response carries no last_run_status field')
222
+ }
223
+ // Behavioural probe — the metadata surface an agent reads must render.
224
+ const { data: asuDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
225
+ if (typeof asuDetail !== 'string' || asuDetail.length === 0) {
226
+ throw new Error('ASU metadataDetails came back empty')
227
+ }
228
+ ```
229
+
230
+ If the test fails in production, escalate with the failing read's
231
+ evidence (the response body, `last_run_error`) — don't rewrite the config
232
+ blind.
233
+
193
234
  # Gotchas
194
235
 
195
236
  - **The poller tool's intent is `status_poller`, not the sender's intent.** The
@@ -199,14 +240,24 @@ if (typeof detail !== 'string' || detail.length === 0) {
199
240
  is the assigns — there is no `events` list to loop — and every top-level key
200
241
  that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
201
242
  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.
243
+ - **`action_log_config` is REQUIRED on both create and update (PUT), same as
244
+ `message_config`.** Omitting it is a 422 (`Missing field: action_log_config`).
245
+ It renders the same per-event assigns into the action-log write shape:
246
+ `external_id` plus at least one of `status` / `sent_at` / `metadata`.
247
+ - **The schedule is a cron, evaluated platform-side — and one poll cycle can
248
+ be fired on demand.** Creating the ASU registers the schedule. The
249
+ `updater_body` window (`{{ now | minutes_ago: 45 }}` … `{{ now }}`) bounds
250
+ each run's log query, and must render to unix milliseconds — `now` is a
251
+ variable here, not the ISO-8601 `now` filter. To trigger a cycle without
252
+ waiting for the cron tick, `api.actionStatusUpdaters.refresh(tenantSlug,
253
+ datalakeSlug, id)` answers `202` with the updater row AS-IS (the poll runs
254
+ async — the row still shows the PREVIOUS run's stamps; re-read later for
255
+ the outcome). See `action_status_updaters.md` §7.
256
+ - **If it doesn't reconcile, read `last_run_error` — refresh, don't guess.**
257
+ There is no per-run log (known limitation): the run surface is the
258
+ read-only `last_run_*` fields. Fire a `refresh`, re-read `last_run_status`
259
+ / `last_run_events_found` / `last_run_error`, and escalate with that
260
+ evidence — don't rewrite the config blind between cron ticks.
210
261
  - **`checksum` parity is your drift signal.** The fingerprint of the desired body
211
262
  must match the stored row's; a mismatch means the resource drifted.
212
263
 
@@ -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'`
@@ -533,6 +533,38 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
533
533
  }
534
534
  ```
535
535
 
536
+ ## 014 — write the integration test
537
+
538
+ End the build with a test you keep: re-read the workflow and prove the
539
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
540
+ never-matching selection runs the FULL pipeline (selection → filter →
541
+ decision) and intercepts only the final action call, so no message
542
+ leaves, yet the acknowledgement proves the workflow is runnable. This
543
+ block runs live under `make validate-cookbook`.
544
+
545
+ ```typescript
546
+ // Re-GET — the workflow must still be live, or nothing will run.
547
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
548
+ if (wfRow.status !== 'live') {
549
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
550
+ }
551
+ // Behavioural probe — a dry run against a selection no row can match:
552
+ // the pipeline executes end-to-end, the final action call is
553
+ // intercepted, and the acknowledgement carries the run-log id.
554
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
555
+ sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
556
+ mode: 'dry_run',
557
+ manual_override: false,
558
+ })
559
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
560
+ throw new Error('dry-run probe returned no workflow_run_log_id')
561
+ }
562
+ ```
563
+
564
+ If the probe fails in production, escalate with the run response as
565
+ evidence — don't flip the workflow's status or rewrite its configs to
566
+ chase the error.
567
+
536
568
  # Branches
537
569
 
538
570
  - **The unverified customer is filtered, not failed** — §011
@@ -175,6 +175,46 @@ if (!row || row.submission_id !== ctx.submissionId) {
175
175
  }
176
176
  ```
177
177
 
178
+ ## 007 — write the integration test
179
+
180
+ End the build with a test you keep: re-read the table and prove the two
181
+ facts every consumer of it depends on — the deploy completed (with the
182
+ server-derived physical name), and the ingest path accepts a row. The
183
+ probe row is `test-`-prefixed so it is unmistakably synthetic wherever it
184
+ surfaces. This block runs live under `make validate-cookbook`.
185
+
186
+ ```typescript
187
+ // Re-GET — deployed, with the server-derived alvera_custom_ name.
188
+ const { data: tableRow } = await api.genericTables.get(tenantSlug, datalakeSlug, genericTableId)
189
+ if (tableRow.status !== 'deployed') {
190
+ throw new Error(`generic table regressed from deployed: ${tableRow.status}`)
191
+ }
192
+ if (tableRow.name !== ctx.tableName) {
193
+ throw new Error(`physical name drifted on read-back: ${tableRow.name}`)
194
+ }
195
+ // Behavioural probe — one synthetic row through the auto-provisioned
196
+ // default client; ingest is async (202), so assert the batch
197
+ // acknowledgement, never synchronous row completion.
198
+ const { data: probeAck } = await api.dataActivationClients.ingest(
199
+ tenantSlug, datalakeSlug, ctx.defaultDacSlug,
200
+ {
201
+ data: {
202
+ submission_id: `test-CDS-probe-${runSuffix}`,
203
+ customer_name: 'test-Ada Lovelace',
204
+ email: 'test-ada@example.test',
205
+ message: 'test: integration-test probe row.',
206
+ source_channel: 'portal',
207
+ },
208
+ },
209
+ )
210
+ if (typeof probeAck.batch_id !== 'string' || probeAck.batch_id.length === 0) {
211
+ throw new Error('default-client ingest did not enqueue a batch')
212
+ }
213
+ ```
214
+
215
+ If the probe fails in production, escalate with the failing response —
216
+ don't re-create the table or hand-edit the physical schema.
217
+
178
218
  # Gotchas
179
219
 
180
220
  - **`privacy_requirement` is load-bearing.** It drives the regulated/unregulated
@@ -531,6 +531,40 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
531
531
  }
532
532
  ```
533
533
 
534
+ ## 014 — write the integration test
535
+
536
+ End the build with a test you keep: re-read the workflow and prove the
537
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
538
+ never-matching selection runs the FULL pipeline (selection → filter →
539
+ decision) and intercepts only the final action call, so no message
540
+ leaves, yet the acknowledgement proves the workflow is runnable. This
541
+ block runs live under `make validate-cookbook`.
542
+
543
+ ```typescript
544
+ // Re-GET — the workflow must still be live, or nothing will run.
545
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
546
+ if (wfRow.status !== 'live') {
547
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
548
+ }
549
+ // Behavioural probe — a dry run against a selection no row can match:
550
+ // the pipeline executes end-to-end, the final action call is
551
+ // intercepted, and the acknowledgement carries the run-log id. The
552
+ // clause must speak this workflow's selection dialect — the dataset
553
+ // alias is `rpa` here, the same alias the live run above uses.
554
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
555
+ sql_where_clause: "rpa.batch_id = 'test-never-matching-batch'",
556
+ mode: 'dry_run',
557
+ manual_override: false,
558
+ })
559
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
560
+ throw new Error('dry-run probe returned no workflow_run_log_id')
561
+ }
562
+ ```
563
+
564
+ If the probe fails in production, escalate with the run response as
565
+ evidence — don't flip the workflow's status or rewrite its configs to
566
+ chase the error.
567
+
534
568
  # Branches
535
569
 
536
570
  - **The suspended account is filtered, not failed** — §011