@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
@@ -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
@@ -918,6 +918,41 @@ while (Date.now() < flagDeadline) {
918
918
  if (!flagged) throw new Error('the re-ingest did not flag the reply potential_duplicate within 120s')
919
919
  ```
920
920
 
921
+ ## 013 — write the integration test
922
+
923
+ End the build with a test you keep: re-read the workflow and prove the
924
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
925
+ never-matching selection runs the FULL pipeline (selection → filter →
926
+ decision) and intercepts only the final action call, so no message
927
+ leaves, yet the acknowledgement proves the workflow is runnable. This
928
+ block runs live under `make validate-cookbook`.
929
+
930
+ ```typescript
931
+ // Re-GET — the workflow must still be live, or nothing will run.
932
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
933
+ if (wfRow.status !== 'live') {
934
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
935
+ }
936
+ // Behavioural probe — a dry run against a selection no row can match:
937
+ // the pipeline executes end-to-end, the final action call is
938
+ // intercepted, and the acknowledgement carries the run-log id. The
939
+ // clause speaks this workflow's selection dialect: a GENERIC-TABLE
940
+ // audience is addressed by its own columns (no `ra.` dataset alias —
941
+ // that alias exists only for system-dataset selections).
942
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
943
+ sql_where_clause: "end_customer_id = 'test-never-matching-customer'",
944
+ mode: 'dry_run',
945
+ manual_override: false,
946
+ })
947
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
948
+ throw new Error('dry-run probe returned no workflow_run_log_id')
949
+ }
950
+ ```
951
+
952
+ If the probe fails in production, escalate with the run response as
953
+ evidence — don't flip the workflow's status or rewrite its configs to
954
+ chase the error.
955
+
921
956
  # Branches
922
957
 
923
958
  - **Suppressed and unreachable are `:filtered`, not `:failed`.** Both
@@ -0,0 +1,329 @@
1
+ ---
2
+ title: "Capability: poll a paginated provider API for delivery status (Mailgun-style)"
3
+ summary: A capability walk for restapi action status updaters. Register a REST poller tool against a provider's paginated events API (Mailgun-shaped here) and an action status updater whose request templates FOLLOW the pagination cursor — page 1 renders the collection path, later pages ride `msg.pagination_context.next`. The platform refuses a poller whose request can never advance (`api.actionStatusUpdaters.create` 422), so the guard is walked first, then the accepted shape.
4
+ industry: foundation
5
+ slug: paginated-restapi-poller
6
+ vitest_source:
7
+ - integration-tests/tests/foundation/action-status-updaters.test.ts
8
+ - integration-tests/tests/foundation/bootstrap.test.ts
9
+ status: green
10
+ ---
11
+
12
+ # Capability
13
+
14
+ **What you get:** delivery outcomes pulled from a provider's REST events API —
15
+ paginated, cursor-driven — written back onto the messages you sent, on a cron,
16
+ with the platform enforcing that your poll can actually terminate.
17
+
18
+ A **restapi action status updater** polls an HTTP events endpoint instead of a
19
+ log group:
20
+
21
+ - a **REST poller tool** (`intent: 'status_poller'`, `tool_body_type:
22
+ 'rest_api'`) supplies the base URL + auth,
23
+ - the ASU's `updater_body` renders the request per page: `path`, `params`,
24
+ an `events_template` that extracts the page's events as a JSON array, and a
25
+ `pagination_context_template` that captures `has_next` + the provider's
26
+ cursor,
27
+ - on every page after the first, the driver binds what your pagination
28
+ template captured as `msg.pagination_context` (and the page number as
29
+ `msg.page`) into your `path`/`params` — **your templates must read one of
30
+ them, or the request is byte-identical for every page and the poll can
31
+ never advance. The platform rejects that config at create.**
32
+
33
+ Mailgun's events API is the shape walked here (`GET /v3/<domain>/events` with
34
+ a `paging.next` cursor link), but any cursor- or page-numbered API fits. See
35
+ `action_status_updaters.md` §7 for the wire reference.
36
+
37
+ # Walkthrough
38
+
39
+ The `_setup/foundation.md` bootstrap left `api`, `tenantSlug`, `datalakeSlug`,
40
+ and `ctx.datalakeId` populated.
41
+
42
+ ## 001 — register a data source for the tools
43
+
44
+ Both the sender and the poller attach to a data source — the origin
45
+ registration for the messaging provider.
46
+
47
+ ```typescript
48
+ const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
49
+ name: `Poller Source ${runSuffix}`,
50
+ uri: 'mailgun.local',
51
+ description: 'Messaging-provider origin for the paginated delivery poller.',
52
+ status: 'active',
53
+ is_default: false,
54
+ })
55
+ dataSourceId = ds.id!
56
+ ```
57
+
58
+ ## 002 — create the sender tool whose messages get reconciled
59
+
60
+ The ASU updates messages a sender produced; `sender_tool_ids` will point here.
61
+ An SNS-backed SMS sender (LocalStack locally) plays that role.
62
+
63
+ ```typescript
64
+ const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
65
+ name: `Poller SMS Sender ${runSuffix}`,
66
+ description: 'Sender whose delivery status the paginated poller reconciles.',
67
+ intent: 'sms',
68
+ status: 'active',
69
+ datalake_id: ctx.datalakeId,
70
+ data_source_id: dataSourceId,
71
+ body: {
72
+ tool_body_type: 'sns',
73
+ auth_method: 'access_key',
74
+ region: 'us-east-1',
75
+ phone_number: '+15551234567',
76
+ endpoint_url: 'http://localhost:4566',
77
+ access_key_id: 'test',
78
+ secret_access_key: 'test',
79
+ },
80
+ })
81
+ ctx.senderToolId = smsTool.id!
82
+ ```
83
+
84
+ ## 003 — create the REST poller tool
85
+
86
+ The poller supplies base URL + auth for the provider's events API —
87
+ Mailgun-shaped here (basic auth, `api` / API key). `intent:
88
+ 'status_poller'` tags it as a reconciler source, `tool_body_type: 'rest_api'`
89
+ makes the ASU's requests ride this tool's HTTP client.
90
+
91
+ ```typescript
92
+ const { data: restTool } = await api.tools.create(tenantSlug, datalakeSlug, {
93
+ name: `Mailgun Events Poller ${runSuffix}`,
94
+ description: 'REST poller — supplies auth for restapi ActionStatusUpdater delivery polling',
95
+ intent: 'status_poller',
96
+ status: 'active',
97
+ datalake_id: ctx.datalakeId,
98
+ data_source_id: dataSourceId,
99
+ body: {
100
+ tool_body_type: 'rest_api',
101
+ base_url: 'http://localhost:8080/mailgun/v3',
102
+ auth_method: 'basic',
103
+ username: 'api',
104
+ password: 'key-test',
105
+ request_type: 'json',
106
+ response_type: 'json',
107
+ timeout_ms: 30000,
108
+ },
109
+ })
110
+ ctx.restToolId = restTool.id!
111
+ ```
112
+
113
+ ## 004 — the pagination guard: a static request is refused at create
114
+
115
+ First, the shape that does NOT work — and why the platform refuses it. This
116
+ `path`/`params` pair reads neither `msg.pagination_context` nor `msg.page`,
117
+ so page 500's request would be byte-identical to page 1's: the window never
118
+ moves, `has_next` never goes false, and the run would re-apply the same
119
+ events forever. The create is a 422; the walk catches it to prove the gate.
120
+
121
+ ```typescript
122
+ let rejected = false
123
+ try {
124
+ await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
125
+ name: `Mailgun Delivery Poller ${runSuffix} non-advancing`,
126
+ cron_expression: '*/30 * * * *',
127
+ updater_type: 'restapi',
128
+ updater_tool_id: ctx.restToolId,
129
+ sender_tool_ids: [ctx.senderToolId],
130
+ datalake_id: ctx.datalakeId,
131
+ events_output_schema: { type: 'array' },
132
+ pagination_context_output_schema: {
133
+ type: 'object',
134
+ required: ['has_next'],
135
+ properties: { has_next: { type: 'boolean' } },
136
+ },
137
+ updater_body: {
138
+ updater_body_type: 'restapi_request',
139
+ method: 'get',
140
+ // Static on both — the cursor is captured below and never read back.
141
+ path: { type: 'custom', body: '/wiremock.domain/events' },
142
+ params: { type: 'custom', body: '{"event": "delivered"}' },
143
+ events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
144
+ pagination_context_template: {
145
+ type: 'custom',
146
+ body:
147
+ '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
148
+ '"next": "{{ response.paging.next }}"}',
149
+ },
150
+ },
151
+ message_config: {
152
+ type: 'custom',
153
+ body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
154
+ },
155
+ action_log_config: {
156
+ type: 'custom',
157
+ body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
158
+ },
159
+ })
160
+ } catch (err) {
161
+ const status = (err as { _httpStatus?: number })._httpStatus
162
+ if (status !== 422) throw err
163
+ rejected = true
164
+ }
165
+ if (!rejected) {
166
+ throw new Error('expected a 422 for a poller whose request never consumes the cursor')
167
+ }
168
+ ```
169
+
170
+ ## 005 — the accepted shape: the path follows the cursor
171
+
172
+ Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
173
+ with bounded query params; every later page rides the cursor link the
174
+ pagination template captured — and drops the params, because Mailgun's
175
+ `paging.next` is a complete URL that already carries them.
176
+
177
+ ```typescript
178
+ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
179
+ name: `Mailgun Delivery Poller ${runSuffix} advancing`,
180
+ cron_expression: '*/30 * * * *',
181
+ updater_type: 'restapi',
182
+ updater_tool_id: ctx.restToolId,
183
+ sender_tool_ids: [ctx.senderToolId],
184
+ datalake_id: ctx.datalakeId,
185
+ events_output_schema: { type: 'array' },
186
+ pagination_context_output_schema: {
187
+ type: 'object',
188
+ required: ['has_next'],
189
+ properties: { has_next: { type: 'boolean' } },
190
+ },
191
+ updater_body: {
192
+ updater_body_type: 'restapi_request',
193
+ method: 'get',
194
+ path: {
195
+ type: 'custom',
196
+ body:
197
+ '{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
198
+ '{% else %}/wiremock.domain/events{% endif %}',
199
+ },
200
+ params: {
201
+ type: 'custom',
202
+ body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
203
+ },
204
+ events_template: { type: 'custom', body: '{{ response.items | to_json }}' },
205
+ pagination_context_template: {
206
+ type: 'custom',
207
+ body:
208
+ '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
209
+ '"next": "{{ response.paging.next }}"}',
210
+ },
211
+ },
212
+ message_config: {
213
+ type: 'custom',
214
+ body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
215
+ },
216
+ action_log_config: {
217
+ type: 'custom',
218
+ body: '{"external_id": "{{ message.headers.message-id }}", "status": "delivered"}',
219
+ },
220
+ })
221
+ actionStatusUpdaterId = asu.id!
222
+ if (asu.status !== 'active') {
223
+ throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
224
+ }
225
+ ```
226
+
227
+ ## 006 — discover it (list + metadata)
228
+
229
+ The poller shows up in the paginated ASU list, and `metadataDetails` renders
230
+ the markdown an agent reads to understand the reconciler.
231
+
232
+ ```typescript
233
+ const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
234
+ if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
235
+ throw new Error('created restapi ASU not found in the list')
236
+ }
237
+
238
+ const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
239
+ if (typeof detail !== 'string' || detail.length === 0) {
240
+ throw new Error('expected non-empty ASU metadata details')
241
+ }
242
+ ```
243
+
244
+ ## 007 — write the integration test
245
+
246
+ End the build with a test you keep: re-read the poller and assert the
247
+ facts the scenario depends on — the create was accepted (so the
248
+ pagination guard passed), the row is free to poll, and the run surface is
249
+ on the wire. This block runs live under `make validate-cookbook`.
250
+
251
+ ```typescript
252
+ // Re-GET — the stored row echoes the authored cron.
253
+ const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
254
+ if (poller.cron_expression !== '*/30 * * * *') {
255
+ throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
256
+ }
257
+ // cycle_detected is only ever set at runtime by the poll driver —
258
+ // a fresh create MUST read active.
259
+ if (poller.status !== 'active') {
260
+ throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
261
+ }
262
+ // last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
263
+ // legitimately null. Assert the field EXISTS, never a value.
264
+ if (!('last_run_status' in poller)) {
265
+ throw new Error('poller response carries no last_run_status field')
266
+ }
267
+ // Behavioural probe — the metadata surface an agent reads must render.
268
+ const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
269
+ if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
270
+ throw new Error('poller metadataDetails came back empty')
271
+ }
272
+ ```
273
+
274
+ If the poller stops reconciling in production, refresh once, re-read
275
+ `last_run_error`, and escalate with that evidence — don't rewrite the
276
+ config and wait for another tick.
277
+
278
+ # Gotchas
279
+
280
+ - **The pagination guard is create-time and non-negotiable.** If neither
281
+ `path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
282
+ is a 422 — the request could never advance past page 1. The guard reads
283
+ your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
284
+ won't fool a reviewer even where it fools a regex; write the real cursor
285
+ read.
286
+ - **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
287
+ on the first page — `{% if msg.pagination_context %}…{% else %}<collection
288
+ path>{% endif %}` is the canonical shape. Bound page 1's `params`
289
+ with `{% unless msg.pagination_context %}` when the cursor link already
290
+ carries the query (Mailgun's `paging.next` does).
291
+ - **`has_next` decides termination — prefer the full-page heuristic in
292
+ production.** The walked shape (`response.items.size > 0`) terminates on
293
+ the first empty page, costing one extra request. Where the provider
294
+ documents a page size, `has_next: {% if response.items.size == 300 %}` (a
295
+ full page implies more) saves that call; a `paging.next` link that is
296
+ absent on the last page is an even stronger signal.
297
+ - **`events_template` must render a JSON ARRAY**, validated against
298
+ `events_output_schema` on every cycle; the pagination render is validated
299
+ against `pagination_context_output_schema` (which must require `has_next`).
300
+ Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
301
+ - **A newly created poller is `status: 'active'`.** `cycle_detected` is only
302
+ ever set at runtime by the poll driver — never by a caller; you cannot
303
+ create your way into it.
304
+ - **`action_log_config` is REQUIRED alongside `message_config`** on create
305
+ AND update (PUT) — same per-event assigns, rendered into the action-log
306
+ write shape (`external_id` + at least one of `status` / `sent_at` /
307
+ `metadata`).
308
+ - **One poll cycle can be fired on demand.**
309
+ `api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
310
+ cycle the cron would — `202` with the updater row AS-IS (the poll is
311
+ async; re-read `last_run_status` / `last_run_events_found` /
312
+ `last_run_error` for the outcome) — a day-two operate surface, not a
313
+ build step. That outcome has three values: `'ok'` (the whole window was
314
+ read), `'partial'` (the fetch was truncated — the newest events may be
315
+ missing, with `last_run_error` explaining), and `'error'` (the run
316
+ failed). Treat only `last_run_status === 'ok'` as a complete
317
+ reconciliation; a `!== 'error'` check silently accepts a truncated
318
+ `partial`.
319
+
320
+ # See also
321
+
322
+ - `action_status_updaters.md` §7 — restapi wire shape, the two output
323
+ schemas, `msg.*` assigns
324
+ - `tools.md` — `rest_api` poller body, `status_poller` intent
325
+ - `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
326
+ (log-group polling instead of HTTP pagination)
327
+ - `_setup/foundation.md` — the bootstrap this walk starts from
328
+ - `integration-tests/tests/foundation/action-status-updaters.test.ts` —
329
+ the green test these calls are lifted from (§3d–§3f)
@@ -220,6 +220,33 @@ if (!oauthTool.id) {
220
220
  }
221
221
  ```
222
222
 
223
+ ## 008 — write the integration test
224
+
225
+ End the build with a test you keep: trigger one pull and assert the
226
+ enqueue acknowledgement. `runManually` answers with the allocated
227
+ `batch_id` BEFORE the fetch runs — the HTTP call and the ingestion drain
228
+ on a background worker — so the durable test asserts on the call's own
229
+ response, never on polling the merge (that diagnosis walk lives in §006).
230
+ This block runs live under `make validate-cookbook`.
231
+
232
+ ```typescript
233
+ // Re-GET — the client must still be bound to the REST tool.
234
+ const { data: fetchDac } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
235
+ if (fetchDac.tool_id !== ctx.restToolId) {
236
+ throw new Error('DAC is no longer bound to the REST fetch tool')
237
+ }
238
+ // Behavioural probe — one manual pull; the ack carries the batch_id
239
+ // the worker will report under.
240
+ const { data: probePull } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
241
+ if (typeof probePull.batch_id !== 'string' || probePull.batch_id.length === 0) {
242
+ throw new Error('runManually probe returned no batch_id')
243
+ }
244
+ ```
245
+
246
+ If the probe fails in production, escalate with the response — and if
247
+ the enqueue succeeds but rows never land, the evidence to escalate with
248
+ is the batch's log row (§006's read), not a re-run storm.
249
+
223
250
  # Gotchas
224
251
 
225
252
  - **A draft tool is silently skipped at fetch time.** The worker only fetches
@@ -641,6 +641,40 @@ if (!verdictBody.includes('[verified]')) {
641
641
  }
642
642
  ```
643
643
 
644
+ ## 014 — write the integration test
645
+
646
+ End the build with a test you keep: re-read the workflow and prove the
647
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
648
+ never-matching selection runs the FULL pipeline (selection → filter →
649
+ decision) and intercepts only the final action call, so no message
650
+ leaves, yet the acknowledgement proves the workflow is runnable. This
651
+ block runs live under `make validate-cookbook`.
652
+
653
+ ```typescript
654
+ // Re-GET — the workflow must still be live, or nothing will run.
655
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
656
+ if (wfRow.status !== 'live') {
657
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
658
+ }
659
+ // Behavioural probe — a dry run against a selection no row can match:
660
+ // the pipeline executes end-to-end, the final action call is
661
+ // intercepted, and the acknowledgement carries the run-log id. The
662
+ // clause must speak this workflow's selection dialect — the dataset
663
+ // alias is `rcs` here, the same alias the live run above uses.
664
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
665
+ sql_where_clause: "rcs.batch_id = 'test-never-matching-batch'",
666
+ mode: 'dry_run',
667
+ manual_override: false,
668
+ })
669
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
670
+ throw new Error('dry-run probe returned no workflow_run_log_id')
671
+ }
672
+ ```
673
+
674
+ If the probe fails in production, escalate with the run response as
675
+ evidence — don't flip the workflow's status or rewrite its configs to
676
+ chase the error.
677
+
644
678
  # Branches
645
679
 
646
680
  - **The auto-clear screening is filtered, not failed** — §012