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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,11 +82,50 @@ updater_body_type: 'cloud_watch_request'
82
82
  follow the same `<verb>_request` naming and carry the fields
83
83
  their target API requires.)
84
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.
85
+ **Four poll-contract fields are required on EVERY updater type**, and
86
+ they live at the **root of the create body** not inside
87
+ `updater_body`:
88
+
89
+ ```
90
+ events_template REQUIRED embed — { type, body } — event-list
91
+ extraction; maps the provider's
92
+ own id into `external_id`
93
+ pagination_context_template REQUIRED embed — { type, body } — must render JSON
94
+ carrying a boolean `has_next`
95
+ events_output_schema REQUIRED object — JSON Schema; FLOORED at an array
96
+ whose items list `external_id`
97
+ in `required`
98
+ pagination_context_output_schema REQUIRED object — JSON Schema; FLOORED at an object
99
+ listing `has_next` in `required`
100
+ ```
101
+
102
+ **Mind the level.** These sit at the ASU root, deliberately — the
103
+ `restapi_request` body embed is shared with the data-activation
104
+ `tool_call`, and putting them there would force every DAC to supply
105
+ them too. A `restapi_request` body carries its own `events_template`;
106
+ that is a different field at a different level, and supplying it does
107
+ **not** satisfy the root requirement.
108
+
109
+ This was `restapi`-only while CloudWatch was a separate loop with no
110
+ templates of its own. It is not one any more: every source now renders
111
+ through `events_template` + `pagination_context_template` and
112
+ terminates on the same `has_next`, so a `cloud_watch` updater declares
113
+ the same contract a REST one does — the platform just ships the
114
+ templates it normally points at. An older `cloud_watch` manifest that
115
+ predates the change 422s on its next create.
116
+
117
+ > ⚠ **The OpenAPI schema is behind the changeset on two of these.**
118
+ > `events_output_schema` and `pagination_context_output_schema` are
119
+ > generated **optional**, but `validate_expected_output_schemas/1`
120
+ > requires them unconditionally. Omit them and the type-checker is
121
+ > happy while the server returns 422. Treat all four as required until
122
+ > the spec catches up.
123
+
124
+ The floors exist for a reason. `external_id` is what the apply path
125
+ pops off each rendered event to find the message and action-log rows to
126
+ update — an events contract that does not demand it accepts a poll that
127
+ fetches pages forever and reconciles nothing. `has_next` is the key the
128
+ page loop ends on.
90
129
 
91
130
  `start_time` / `end_time` are Liquid, and they **must render to
92
131
  unix milliseconds** — that is what the poll request takes. Use the
@@ -117,6 +156,10 @@ scope — do not loop.
117
156
  {"external_id": "{{ notification.messageId }}", "status": "delivered"}
118
157
  ```
119
158
 
159
+ > **Shape only — do not copy this mapping.** A hardcoded `"delivered"`
160
+ > records every event as delivered, failures included. Real bodies
161
+ > branch on the provider's status; see the SNS and MMS sections below.
162
+
120
163
  It must render a **flat** JSON object:
121
164
 
122
165
  ```
@@ -139,15 +182,107 @@ events for messages this updater doesn't own.
139
182
  Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
140
183
  available here, as everywhere.
141
184
 
142
- ### MMS delivery tracking uses a system `message_config` template
185
+ ### SMS-over-SNS delivery tracking two `custom` bodies
186
+
187
+ The SNS SMS delivery receipt is **binary and single-shot**: one terminal
188
+ outcome, `status` of `SUCCESS` or `FAILURE`. Its event shape is
189
+ **disjoint from EUM's** — `status`, `notification.messageId`,
190
+ `notification.timestamp`, `delivery.phoneCarrier`,
191
+ `delivery.providerResponse` all sit at the root. Do not carry an EUM
192
+ template over to SNS or vice versa.
193
+
194
+ Two convenient consequences, both unlike MMS:
195
+
196
+ - `delivered_at` is `notification.timestamp` — a UTC
197
+ `"YYYY-MM-DD HH:MM:SS.sss"` string Ecto casts directly, so **no date
198
+ filter is needed** (EUM needs `from_unix_ms` on its Unix-ms field).
199
+ - the DLR **does** surface the US carrier as `delivery.phoneCarrier`.
200
+
201
+ **`message_config`:**
202
+
203
+ ```liquid
204
+ {"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"{% if status == 'SUCCESS' and notification.timestamp %}, "delivered_at": "{{ notification.timestamp }}"{% endif %}{% unless status == 'SUCCESS' %}, "failure_reason": "{{ status | json_escape }}"{% endunless %}{% if delivery.phoneCarrier and delivery.phoneCarrier != '' %}, "sms_carrier": "{{ delivery.phoneCarrier | json_escape }}"{% endif %}}
205
+ ```
206
+
207
+ **`action_log_config`:**
208
+
209
+ ```liquid
210
+ {"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"}
211
+ ```
212
+
213
+ `status_description` is `delivery.providerResponse` verbatim on both
214
+ sides — the carrier's own text, on success and failure alike. Because
215
+ SNS's status is binary, `failure_reason` can only ever be `"FAILURE"`;
216
+ the granular carrier text stays in `status_description`.
217
+
218
+ **Note how the two SNS bodies differ from each other**: same status
219
+ mapping, fewer emitted fields on the action log (no `delivered_at`,
220
+ `sms_carrier`, or `failure_reason` — those are message concerns). That
221
+ is a *different* reason from MMS's, where the two also disagree on the
222
+ mapping itself. Both channels need two bodies; they need them for
223
+ different reasons.
224
+
225
+ ### MMS delivery tracking needs TWO different templates — author both as `custom`
226
+
227
+ MMS delivery tracking is a `cloud_watch` updater. Author **both**
228
+ `message_config` and `action_log_config` as `custom` bodies — the two
229
+ render *different* JSON from the same event, so one body cannot serve
230
+ both. This is the single most-missed thing about updaters.
143
231
 
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.
232
+ **Why they differ.** A message's status vocabulary includes
233
+ `customer_rejected`; an action log's does not. A recipient or carrier
234
+ rejection (`SPAM` / `BLOCKED` / `CARRIER_BLOCKED`) is a message-level
235
+ outcome — the action *did* deliver — so the action-log render must
236
+ **NO-OP** on those events while the message render records
237
+ `customer_rejected`. The message side also carries `delivered_at`,
238
+ `failure_reason`, and `sms_carrier`; only `status` +
239
+ `status_description` are ever applied to an action log.
240
+
241
+ AWS End User Messaging (`sms-voice`) event fields: top-level
242
+ `messageId`, `eventType` (`MEDIA_*`), `messageStatus`,
243
+ `messageStatusDescription`, `eventTimestamp` (Unix ms), `isFinal`.
244
+
245
+ **`message_config`** — map every status, emit the extra fields:
246
+
247
+ ```liquid
248
+ {% case messageStatus %}
249
+ {% when 'DELIVERED' %}{% assign mapped_status = 'delivered' %}
250
+ {% when 'SUCCESSFUL' %}{% assign mapped_status = 'sent' %}
251
+ {% when 'PENDING' %}{% assign mapped_status = 'sent' %}
252
+ {% when 'QUEUED' %}{% assign mapped_status = 'queued' %}
253
+ {% when 'SPAM' %}{% assign mapped_status = 'customer_rejected' %}
254
+ {% when 'BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
255
+ {% when 'CARRIER_BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
256
+ {% else %}{% assign mapped_status = 'failed' %}
257
+ {% endcase %}
258
+ {"external_id": "{{ messageId }}", "status": "{{ mapped_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% if messageStatus == 'DELIVERED' and eventTimestamp %}, "delivered_at": "{{ eventTimestamp | from_unix_ms }}"{% endif %}{% if mapped_status == 'failed' or mapped_status == 'customer_rejected' %}, "failure_reason": "{{ messageStatus | json_escape }}"{% endif %}{% if carrierName and carrierName != '' %}, "sms_carrier": "{{ carrierName | json_escape }}"{% endif %}}
259
+ ```
260
+
261
+ **`action_log_config`** — the rejection statuses fall through to a
262
+ bare `external_id`, which applies nothing:
263
+
264
+ ```liquid
265
+ {% assign al_status = '' %}{% case messageStatus %}{% when 'DELIVERED' %}{% assign al_status = 'delivered' %}{% when 'SUCCESSFUL' %}{% assign al_status = 'sent' %}{% when 'PENDING' %}{% assign al_status = 'sent' %}{% when 'QUEUED' %}{% assign al_status = 'queued' %}{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}{% else %}{% assign al_status = 'failed' %}{% endcase %}{"external_id": "{{ messageId }}"{% if al_status != '' %}, "status": "{{ al_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% endif %}}
266
+ ```
267
+
268
+ Note the `{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}` arm is
269
+ deliberately **empty** — it leaves `al_status` blank, so the render
270
+ emits `external_id` alone and the action log is untouched.
271
+
272
+ **Capture every event, not just the terminal one.** 2–3 events arrive
273
+ per message — interim `MEDIA_QUEUED` / `MEDIA_PENDING` /
274
+ `MEDIA_SUCCESSFUL` (`isFinal: false`), then one terminal event
275
+ (`isFinal: true`), up to 72h late and out of order. Apply is
276
+ idempotent and a monotonic guard only ever advances `status`, so a
277
+ late or interim lower-rank event can never demote a message that
278
+ already reached a higher state. Poll a window that overlaps
279
+ deliberately; nothing is dropped.
280
+
281
+ `queued_at` and `sent_at` are **your outbound pipeline's** times, set
282
+ when the platform enqueues and dispatches — a delivery poll never
283
+ overwrites them. Only `delivered_at` comes from the carrier event
284
+ (`DELIVERED`'s own `eventTimestamp` via `from_unix_ms`), so
285
+ re-polling a wide window does not drift it.
151
286
 
152
287
  ### Carrier information is available for SMS, not MMS
153
288
 
@@ -160,12 +295,15 @@ events — not something the platform chooses to drop:
160
295
  under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
161
296
  numeric `mcc`/`mnc`). The SNS status template maps it to
162
297
  `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.
298
+ - **MMS via AWS End User Messaging (`sms-voice`)** — the event *can*
299
+ carry `carrierName`, but it is DLR-populated and comes back
300
+ **absent for US destinations** (the US DLR does not surface it),
301
+ unlike the SNS path's `phoneCarrier`, whose US DLR does. So map it
302
+ when present — `{% if carrierName and carrierName != '' %}` and
303
+ expect `sms_carrier` to stay null for US long-code traffic.
304
+ Verified against real AWS delivery events: the terminal
305
+ `MEDIA_DELIVERED` event for a US destination exposes
306
+ `totalCarrierFee` (a cost) and no `carrierName`.
169
307
 
170
308
  AWS's Nov-2024 EventBridge-integration announcement states MMS
171
309
  delivery events carry carrier information in EventBridge, but the
@@ -291,8 +429,16 @@ datalake_id required UUID
291
429
  updater_body required embed — { updater_body_type, ... }
292
430
  message_config required embed — { type, body }
293
431
  action_log_config required embed — { type, body } (required on create AND update)
432
+
433
+ events_template required embed — EVERY updater type, not just restapi
434
+ pagination_context_template required embed — EVERY updater type
435
+ events_output_schema required object — JSON Schema
436
+ pagination_context_output_schema required object — JSON Schema, floored at
437
+ `has_next` in `required`
294
438
  ```
295
439
 
440
+ The last four widened from `restapi`-only to universal — see §2.
441
+
296
442
  **Write-only (Request-only).** None.
297
443
 
298
444
  ## 4. Error envelopes
@@ -449,31 +595,49 @@ while (Date.now() < deadline) {
449
595
  // row.last_run_status / last_run_error now describe THIS run
450
596
  ```
451
597
 
452
- What the ASU still does NOT have is a **per-run log** (nothing like
453
- a data activation client's `logs` or a workflow's `batchLogs`). The
454
- run-outcome surface is four fields on the row itself, plus the halt
455
- flag:
598
+ The ASU now **does** have a per-run log the sibling of a data
599
+ activation client's `logs` and a workflow's `batchLogs`. There are two
600
+ run-outcome surfaces and they answer different questions:
601
+
602
+ ```
603
+ actionStatusUpdaters.runLogs.list(tenantSlug, datalakeSlug, updaterId, query?)
604
+ actionStatusUpdaters.runLogs.get(tenantSlug, datalakeSlug, updaterId, logId)
605
+ ```
606
+
607
+ `.list` is Flop-paginated like every other list — same `page` /
608
+ `page_size` / `filters` / `order_by` vocabulary (see `async.md`).
609
+
610
+ **The summary surface — four fields on the updater row, plus the halt
611
+ flag:**
456
612
 
457
613
  ```
458
614
  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
615
+ last_run_status 'ok' | 'error'
616
+ last_run_error why the last run failed
461
617
  last_run_events_found events fetched so far in the current run
462
618
  status 'active' | 'cycle_detected'
463
619
  ```
464
620
 
465
- **A poll run has three outcomes, and only `'ok'` means it reconciled its
466
- whole time window:**
621
+ **The per-run surface one row per poll run, from `runLogs`:**
622
+
623
+ ```
624
+ status 'ok' | 'partial' | 'error' ← the outcome of THIS page
625
+ ```
467
626
 
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.
627
+ **`'partial'` lives on the run log, NOT on the updater this is the
628
+ trap.** A truncated fetch means the window was not fully read and the
629
+ events it missed are the **newest** ones. The updater's own
630
+ `last_run_status` cannot tell you that: it carries `'ok' | 'error'`
631
+ only, so a truncated run reads as `'ok'` there. If completeness of
632
+ reconciliation matters to you, read the run log checking
633
+ `last_run_status === 'ok'` on the updater is **not** sufficient, and a
634
+ client that treats it as sufficient silently accepts an incomplete
635
+ reconciliation.
636
+
637
+ - `'ok'` — the page completed and read its whole slice of the window.
638
+ - `'partial'` — the page completed but its provider fetch was
639
+ **truncated**. Only visible on the run-log row.
640
+ - `'error'` — the run failed; `last_run_error` on the updater says why.
477
641
 
478
642
  `last_run_events_found` is the event count for the current run; because
479
643
  `refresh` is asynchronous (below), a read taken before the run finishes can
@@ -130,6 +130,28 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
130
130
  start_time: '{{ now_msec | minutes_ago: 45 }}',
131
131
  end_time: '{{ now_msec }}',
132
132
  },
133
+ // The poll contract — REQUIRED on every updater type, and at the body ROOT,
134
+ // not inside `updater_body`. For an AWS poller, point at the templates the
135
+ // platform already ships rather than authoring Liquid: `events_template`
136
+ // projects each log event to `{ external_id, ... }`, and
137
+ // `pagination_context_template` renders the `has_next` the page loop ends on.
138
+ events_template: { type: 'system', path: 'status_poller/aws_sns/sms_events' },
139
+ pagination_context_template: {
140
+ type: 'system',
141
+ path: 'status_poller/aws_cloudwatch/pagination',
142
+ },
143
+ // The two floors. `external_id` is what the apply path pops off each rendered
144
+ // event to find the row it updates; `has_next` is what terminates paging.
145
+ // Demand more of your provider if you know it — you may not demand less.
146
+ events_output_schema: {
147
+ type: 'array',
148
+ items: { type: 'object', required: ['external_id'] },
149
+ },
150
+ pagination_context_output_schema: {
151
+ type: 'object',
152
+ required: ['has_next'],
153
+ properties: { has_next: { type: 'boolean' } },
154
+ },
133
155
  message_config: {
134
156
  type: 'custom',
135
157
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
@@ -167,6 +189,23 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
167
189
  start_time: '{{ now_msec | minutes_ago: 45 }}',
168
190
  end_time: '{{ now_msec }}',
169
191
  },
192
+ // Byte-identical to the create body above — the checksum is a fingerprint of
193
+ // the whole body, so omitting the poll contract here would compute a
194
+ // different one and read as drift on a resource that never changed.
195
+ events_template: { type: 'system', path: 'status_poller/aws_sns/sms_events' },
196
+ pagination_context_template: {
197
+ type: 'system',
198
+ path: 'status_poller/aws_cloudwatch/pagination',
199
+ },
200
+ events_output_schema: {
201
+ type: 'array',
202
+ items: { type: 'object', required: ['external_id'] },
203
+ },
204
+ pagination_context_output_schema: {
205
+ type: 'object',
206
+ required: ['has_next'],
207
+ properties: { has_next: { type: 'boolean' } },
208
+ },
170
209
  message_config: {
171
210
  type: 'custom',
172
211
  body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
@@ -356,6 +356,15 @@ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug,
356
356
  tool_id: ctx.manualUploadToolId,
357
357
  data_source_id: dataSourceId,
358
358
  tool_call: { tool_call_type: 'manual_upload' },
359
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
360
+ // manual_upload client is push-based — it never fetches and never pages —
361
+ // but the poll contract is declared uniformly, and `has_next` is the key
362
+ // the fetch loop would terminate on for a protocol that does page.
363
+ pagination_context_output_schema: {
364
+ type: 'object',
365
+ required: ['has_next'],
366
+ properties: { has_next: { type: 'boolean' } },
367
+ },
359
368
  interoperability_contract_ids: [interopContractId, ctx.appointmentContractId],
360
369
  })
361
370
  dacId = dacResp.data.id!
@@ -175,7 +175,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
175
175
  position: 0,
176
176
  trigger_template: TRIGGER_TEMPLATE,
177
177
  idempotency_template:
178
- '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}-{{ "" | uuid }}',
178
+ '{{ subject_id }}-{{ workflow_id }}-{{ action_id }}',
179
179
  connected_app_id: connectedAppId,
180
180
  connected_app_route: '/forms/birthday-greeting',
181
181
  connected_app_metadata_template:
@@ -285,6 +285,15 @@ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug,
285
285
  tool_id: ctx.manualUploadToolId,
286
286
  data_source_id: dataSourceId,
287
287
  tool_call: { tool_call_type: 'manual_upload' },
288
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
289
+ // manual_upload client is push-based — it never fetches and never pages —
290
+ // but the poll contract is declared uniformly, and `has_next` is the key
291
+ // the fetch loop would terminate on for a protocol that does page.
292
+ pagination_context_output_schema: {
293
+ type: 'object',
294
+ required: ['has_next'],
295
+ properties: { has_next: { type: 'boolean' } },
296
+ },
288
297
  interoperability_contract_ids: [interopContractId],
289
298
  })
290
299
  dacId = dacResp.data.id!
@@ -113,6 +113,15 @@ const { data: dac } = await api.dataActivationClients.create(tenantSlug, datalak
113
113
  tool_id: ctx.manualUploadToolId,
114
114
  data_source_id: dataSourceId,
115
115
  tool_call: { tool_call_type: 'manual_upload' },
116
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
117
+ // manual_upload client is push-based — it never fetches and never pages —
118
+ // but the poll contract is declared uniformly, and `has_next` is the key
119
+ // the fetch loop would terminate on for a protocol that does page.
120
+ pagination_context_output_schema: {
121
+ type: 'object',
122
+ required: ['has_next'],
123
+ properties: { has_next: { type: 'boolean' } },
124
+ },
116
125
  interoperability_contract_ids: [interopContractId],
117
126
  })
118
127
  dacId = dac.id!
@@ -326,7 +326,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
326
326
  tool_id: toolId,
327
327
  position: 0,
328
328
  trigger_template: 'now',
329
- idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}-{{ "" | uuid }}`,
329
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}`,
330
330
  tool_call: {
331
331
  tool_call_type: 'sms_request',
332
332
  to: { type: 'custom', body: '+15551234567' },
@@ -189,7 +189,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
189
189
  decision_key: DECISION_KEY,
190
190
  position: 0,
191
191
  trigger_template: 'now',
192
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
192
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
193
193
  connected_app_id: connectedAppId,
194
194
  connected_app_route: '/portal/pay',
195
195
  connected_app_metadata_template:
@@ -303,6 +303,15 @@ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug,
303
303
  tool_id: ctx.manualUploadToolId,
304
304
  data_source_id: dataSourceId,
305
305
  tool_call: { tool_call_type: 'manual_upload' },
306
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
307
+ // manual_upload client is push-based — it never fetches and never pages —
308
+ // but the poll contract is declared uniformly, and `has_next` is the key
309
+ // the fetch loop would terminate on for a protocol that does page.
310
+ pagination_context_output_schema: {
311
+ type: 'object',
312
+ required: ['has_next'],
313
+ properties: { has_next: { type: 'boolean' } },
314
+ },
306
315
  interoperability_contract_ids: [interopContractId],
307
316
  })
308
317
  dacId = dacResp.data.id!
@@ -188,7 +188,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
188
188
  decision_key: DECISION_KEY,
189
189
  position: 0,
190
190
  trigger_template: 'now',
191
- idempotency_template: '{{ payment_account.id }}-{{ decision_key }}-{{ "" | uuid }}',
191
+ idempotency_template: '{{ payment_account.id }}-{{ decision_key }}',
192
192
  connected_app_id: connectedAppId,
193
193
  connected_app_route: '/portal/kyc',
194
194
  connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
@@ -300,6 +300,15 @@ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug,
300
300
  tool_id: ctx.manualUploadToolId,
301
301
  data_source_id: dataSourceId,
302
302
  tool_call: { tool_call_type: 'manual_upload' },
303
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
304
+ // manual_upload client is push-based — it never fetches and never pages —
305
+ // but the poll contract is declared uniformly, and `has_next` is the key
306
+ // the fetch loop would terminate on for a protocol that does page.
307
+ pagination_context_output_schema: {
308
+ type: 'object',
309
+ required: ['has_next'],
310
+ properties: { has_next: { type: 'boolean' } },
311
+ },
303
312
  interoperability_contract_ids: [interopContractId],
304
313
  })
305
314
  dacId = dacResp.data.id!
@@ -273,7 +273,7 @@ const ROSTER_MDM = `{% assign p = msg %}
273
273
  "legal_entity_type": "business",
274
274
  "business_name": "{{ p.business_name | json_escape }}",
275
275
  "identifiers": [
276
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}"}
276
+ {"system": "{{ p.source_uri | json_escape }}", "type": "digital_identifier", "value": "{{ p.direct_customer_id | json_escape }}"}
277
277
  ]
278
278
  }`
279
279
 
@@ -304,6 +304,15 @@ const { data: rosterDac } = await api.dataActivationClients.create(tenantSlug, d
304
304
  tool_id: ctx.uploadToolId,
305
305
  data_source_id: dataSourceId,
306
306
  tool_call: { tool_call_type: 'manual_upload' },
307
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
308
+ // manual_upload client is push-based — it never fetches and never pages —
309
+ // but the poll contract is declared uniformly, and `has_next` is the key
310
+ // the fetch loop would terminate on for a protocol that does page.
311
+ pagination_context_output_schema: {
312
+ type: 'object',
313
+ required: ['has_next'],
314
+ properties: { has_next: { type: 'boolean' } },
315
+ },
307
316
  interoperability_contract_ids: [rosterLe.id!, rosterGt.id!],
308
317
  })
309
318
  ctx.rosterDacSlug = rosterDac.slug!
@@ -376,7 +385,7 @@ const AUDIENCE_MDM = `{% assign p = msg %}
376
385
  {% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
377
386
  {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
378
387
  "identifiers": [
379
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}"}
388
+ {"system": "{{ p.source_uri | json_escape }}", "type": "digital_identifier", "value": "{{ handle | json_escape }}"}
380
389
  ]
381
390
  }`
382
391
 
@@ -406,6 +415,15 @@ const { data: audienceDac } = await api.dataActivationClients.create(tenantSlug,
406
415
  tool_id: ctx.uploadToolId,
407
416
  data_source_id: dataSourceId,
408
417
  tool_call: { tool_call_type: 'manual_upload' },
418
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
419
+ // manual_upload client is push-based — it never fetches and never pages —
420
+ // but the poll contract is declared uniformly, and `has_next` is the key
421
+ // the fetch loop would terminate on for a protocol that does page.
422
+ pagination_context_output_schema: {
423
+ type: 'object',
424
+ required: ['has_next'],
425
+ properties: { has_next: { type: 'boolean' } },
426
+ },
409
427
  interoperability_contract_ids: [audienceLe.id!, audienceGt.id!],
410
428
  })
411
429
  dacId = audienceDac.id!
@@ -844,7 +862,7 @@ const REPLY_MDM = `{% assign p = msg %}
844
862
  {
845
863
  "legal_entity_type": "individual",
846
864
  "identifiers": [
847
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}"}
865
+ {"system": "{{ p.source_uri | json_escape }}", "type": "digital_identifier", "value": "{{ p.handle | json_escape }}"}
848
866
  ]
849
867
  }`
850
868
 
@@ -864,6 +882,15 @@ const { data: replyDac } = await api.dataActivationClients.create(tenantSlug, da
864
882
  tool_id: ctx.uploadToolId,
865
883
  data_source_id: dataSourceId,
866
884
  tool_call: { tool_call_type: 'manual_upload' },
885
+ // REQUIRED on every DAC create, whatever the `tool_call` protocol. A
886
+ // manual_upload client is push-based — it never fetches and never pages —
887
+ // but the poll contract is declared uniformly, and `has_next` is the key
888
+ // the fetch loop would terminate on for a protocol that does page.
889
+ pagination_context_output_schema: {
890
+ type: 'object',
891
+ required: ['has_next'],
892
+ properties: { has_next: { type: 'boolean' } },
893
+ },
867
894
  interoperability_contract_ids: [replyGt.id!],
868
895
  })
869
896