@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.
- package/.agent/account_management.md +31 -0
- package/.agent/action_status_updaters.md +90 -0
- package/.agent/connected_apps.md +7 -2
- package/.agent/cookbook/action-status-updaters.md +59 -8
- package/.agent/cookbook/ai-agent-invoke.md +36 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +34 -0
- package/.agent/cookbook/bulk-ingest.md +48 -0
- package/.agent/cookbook/contact-us-triage-with-llm.md +35 -0
- package/.agent/cookbook/dunning-sms-for-delinquent.md +32 -0
- package/.agent/cookbook/generic-tables.md +40 -0
- package/.agent/cookbook/kyc-notification-on-account-activation.md +34 -0
- package/.agent/cookbook/marketing-campaign-send.md +35 -0
- package/.agent/cookbook/paginated-restapi-poller.md +324 -0
- package/.agent/cookbook/rest-fetch.md +27 -0
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +34 -0
- package/.agent/cookbook/score-leads-with-llm-categorization.md +35 -0
- package/.agent/cookbook/system-templates.md +36 -0
- package/.agent/cookbook/talk-to-data.md +39 -0
- package/.agent/cookbook/triage-prospects-by-priority.md +32 -0
- package/.agent/cookbook/welcome-sms-for-customers.md +32 -0
- package/.agent/datalakes.md +24 -0
- package/.agent/tool-call-configs.md +2 -1
- package/.agent/tools.md +46 -15
- package/.agent/type_naming.md +4 -0
- package/dist/index.d.mts +28 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- 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
|
package/.agent/connected_apps.md
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|