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

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 (55) hide show
  1. package/.agent/AGENTS.md +121 -19
  2. package/.agent/account_management.md +79 -38
  3. package/.agent/action_logs.md +144 -0
  4. package/.agent/action_status_updaters.md +237 -53
  5. package/.agent/ai_agents.md +149 -53
  6. package/.agent/ai_sandbox.md +11 -4
  7. package/.agent/async.md +16 -6
  8. package/.agent/connected_apps.md +116 -4
  9. package/.agent/cookbook/_fixtures/README.md +9 -9
  10. package/.agent/cookbook/_fixtures/{payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid → payments/_compliance_screenings_payments_compliance_screening.liquid} +1 -1
  11. package/.agent/cookbook/_fixtures/{payment_risk/_compliance_screenings_payment_risk_mdm.liquid → payments/_compliance_screenings_payments_mdm.liquid} +1 -1
  12. package/.agent/cookbook/_fixtures/{payment_risk/_payment_accounts_payment_risk_mdm.liquid → payments/_payment_accounts_payments_mdm.liquid} +1 -1
  13. package/.agent/cookbook/_fixtures/{payment_risk/_payment_accounts_payment_risk_payment_account.liquid → payments/_payment_accounts_payments_payment_account.liquid} +1 -1
  14. package/.agent/cookbook/_fixtures/{accounts_receivable/_customers_accounts_receivable_customer.liquid → subscription/_customers_subscription_customer.liquid} +1 -1
  15. package/.agent/cookbook/_fixtures/{accounts_receivable/_customers_accounts_receivable_mdm.liquid → subscription/_customers_subscription_mdm.liquid} +1 -1
  16. package/.agent/cookbook/_setup/foundation.md +24 -4
  17. package/.agent/cookbook/_setup/healthcare.md +24 -4
  18. package/.agent/cookbook/_setup/{payment_risk.md → payments.md} +51 -31
  19. package/.agent/cookbook/_setup/{accounts_receivable.md → subscription.md} +51 -31
  20. package/.agent/cookbook/action-status-updaters.md +25 -18
  21. package/.agent/cookbook/bulk-ingest.md +15 -15
  22. package/.agent/cookbook/dunning-sms-for-delinquent.md +21 -21
  23. package/.agent/cookbook/{custom-tables.md → generic-tables.md} +44 -41
  24. package/.agent/cookbook/invite-team.md +19 -13
  25. package/.agent/cookbook/kyc-notification-on-account-activation.md +26 -26
  26. package/.agent/cookbook/marketing-campaign-send.md +1003 -0
  27. package/.agent/cookbook/rest-fetch.md +9 -9
  28. package/.agent/cookbook/sanctions-screening-with-agent-review.md +22 -22
  29. package/.agent/cookbook/system-templates.md +15 -15
  30. package/.agent/cookbook/triage-prospects-by-priority.md +17 -17
  31. package/.agent/cookbook/welcome-sms-for-customers.md +21 -21
  32. package/.agent/data_activation_clients.md +177 -42
  33. package/.agent/data_sources.md +55 -37
  34. package/.agent/datalakes.md +30 -17
  35. package/.agent/debugging.md +48 -14
  36. package/.agent/errors.md +39 -17
  37. package/.agent/generic_tables.md +150 -51
  38. package/.agent/interoperability_contracts.md +114 -15
  39. package/.agent/mdm.md +171 -93
  40. package/.agent/messages.md +252 -0
  41. package/.agent/templates.md +21 -4
  42. package/.agent/tool-call-configs.md +1 -1
  43. package/.agent/tools.md +150 -60
  44. package/.agent/type_naming.md +79 -18
  45. package/.agent/workflows.md +408 -62
  46. package/LICENSE +21 -93
  47. package/README.md +2 -2
  48. package/dist/bin/platform-sdk.mjs +53 -1
  49. package/dist/bin/platform-sdk.mjs.map +1 -1
  50. package/dist/index.d.mts +582 -66
  51. package/dist/index.d.mts.map +1 -1
  52. package/dist/index.mjs +654 -105
  53. package/dist/index.mjs.map +1 -1
  54. package/package.json +10 -6
  55. /package/.agent/cookbook/_fixtures/{accounts_receivable → subscription}/stripe_customers_batch1.csv +0 -0
@@ -44,13 +44,12 @@ const { data: created } = await api.actionStatusUpdaters.create(
44
44
  end_time: '{{ now }}',
45
45
  },
46
46
 
47
- // Liquid template emitting JSON that maps poll events to message updates
47
+ // Liquid, rendered ONCE PER EVENT — the event IS the assigns, so there is no
48
+ // `events` list to loop. Emits a FLAT object: the message to update, plus the
49
+ // fields to set at the top level.
48
50
  message_config: {
49
51
  type: 'custom',
50
- body:
51
- '{% for event in events %}' +
52
- '{"external_id": "{{ event.message_id }}", "set_params": {"status": "{{ event.status }}"}}' +
53
- '{% endfor %}',
52
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
54
53
  },
55
54
  },
56
55
  )
@@ -75,29 +74,54 @@ updater_body_type: 'cloud_watch_request'
75
74
  follow the same `<verb>_request` naming and carry the fields
76
75
  their target API requires.)
77
76
 
78
- `start_time` / `end_time` accept Liquid expressions using the
79
- `now` variable plus relative-time filters (`minutes_ago: N`,
80
- `hours_ago: N`, ...). A literal ISO 8601 string also works for
81
- fixed windows.
77
+ `start_time` / `end_time` are Liquid, and they **must render to
78
+ unix milliseconds** — that is what the poll request takes. On
79
+ these two fields `now` is a **variable already holding unix ms**,
80
+ so pipe it through the relative-time filters:
82
81
 
83
- ### `message_config.body` emits a JSON event stream
82
+ ```
83
+ start_time: '{{ now | minutes_ago: 45 }}' // 45 minutes back
84
+ end_time: '{{ now }}' // right now
85
+ ```
86
+
87
+ Also available: `hours_ago: N`.
88
+
89
+ **Do not use the ISO-8601 `now` filter here.** `{{ "" | now }}` is
90
+ a different thing — it renders a datetime *string*, which is not a
91
+ valid window, and the platform rejects it at create. An ISO 8601
92
+ literal is not valid on these fields either.
93
+
94
+ ### `message_config.body` renders ONCE PER EVENT
95
+
96
+ The template is rendered **once for each fetched event, with that
97
+ event as the entire assigns**. There is no `events` collection in
98
+ scope — do not loop.
99
+
100
+ ```liquid
101
+ {"external_id": "{{ notification.messageId }}", "status": "delivered"}
102
+ ```
84
103
 
85
- The template runs against an `events` array — the parsed
86
- response from the polling tool. Each iteration must render
87
- one JSON object with two required fields:
104
+ It must render a **flat** JSON object:
88
105
 
89
106
  ```
90
- external_id string — the message identifier to update (typically
91
- the provider's id, matching the recipient
92
- record's external_id column)
93
- set_params object — fields to update on the matching message row
94
- (e.g. { status, delivered_at, error_code })
107
+ external_id string — the message to update (matches the message row's
108
+ external_id, i.e. the provider's id)
109
+ <field> any — each remaining TOP-LEVEL key is a field to set:
110
+ status, sent_at, delivered_at, read_at, opened_at
95
111
  ```
96
112
 
97
- The platform parses the rendered output, joins each event to a
98
- message row by `external_id`, and applies `set_params` as the
99
- update. Events that don't match any message are silently
100
- discarded.
113
+ There is **no `set_params` wrapper**. Every key that is not
114
+ `external_id` *is* the update. Nesting the fields under
115
+ `set_params` renders an object the platform's field allowlist
116
+ drops, so the update silently applies to nothing.
117
+
118
+ The platform joins each rendered `external_id` to a message row
119
+ and applies the remaining fields. Events that don't match any
120
+ message are silently discarded — a provider poll routinely sees
121
+ events for messages this updater doesn't own.
122
+
123
+ Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
124
+ available here, as everywhere.
101
125
 
102
126
  ### `cron_expression` is a standard 5-field cron string
103
127
 
@@ -111,7 +135,7 @@ Sub-minute cadences are NOT supported. The minimum interval
111
135
  the scheduler honors depends on platform-level configuration;
112
136
  default minimum is 5 minutes.
113
137
 
114
- ### `updater_type` and `updater_body.updater_body_type` must correspond
138
+ ### `updater_type` and `updater_body.updater_body_type` — set them to agree, but only one is load-bearing
115
139
 
116
140
  The body carries TWO discriminator fields that name the same
117
141
  choice from different angles:
@@ -123,25 +147,35 @@ choice from different angles:
123
147
  body embed
124
148
  ```
125
149
 
126
- They must agree according to a fixed mapping:
150
+ The intended pairing is a fixed mapping:
127
151
 
128
152
  ```
129
153
  updater_type: "cloud_watch" ↔ updater_body_type: "cloud_watch_request"
130
154
  updater_type: "restapi" ↔ updater_body_type: "restapi_request"
131
155
  ```
132
156
 
133
- A mismatched pair (e.g. `updater_type: "cloud_watch"` +
134
- `updater_body_type: "restapi_request"`) returns 422 — typically
135
- on `/updater_type` with a "type / body type mismatch" message.
136
- The full mapping is exported by the SDK; the `updater_type` enum
137
- and the `updater_body_type` enum are kept in lockstep by the
138
- generated types.
157
+ **But the platform does NOT cross-validate the pair — a mismatch is
158
+ NOT rejected.** The two fields are cast independently; there is no
159
+ create-time check that `updater_type` matches
160
+ `updater_body_type`, so a mismatched-but-well-formed pair (e.g.
161
+ `updater_type: "cloud_watch"` + a `restapi_request` body) **persists
162
+ silently**. At runtime the platform dispatches on the polymorphic
163
+ **body** struct, so `updater_body_type` is the one that matters and
164
+ `updater_type` is effectively informational. Set them to agree
165
+ anyway — a future release may start enforcing it, and a lying
166
+ `updater_type` is a debugging trap — but don't expect a 422 to catch
167
+ a mismatch for you today.
139
168
 
140
169
  ### `updater_tool_id` and `sender_tool_ids` serve different roles
141
170
 
142
171
  - `updater_tool_id` (single) — the tool the updater uses to
143
- ISSUE the poll. Must be a tool whose `intent` is
144
- `status_poller` (e.g. a `cloud_watch_log_group` body type).
172
+ ISSUE the poll. By convention this is a tool whose `intent` is
173
+ `status_poller` (e.g. a `cloud_watch_log_group` body type) — but
174
+ the platform does **not** verify the intent at create time (it
175
+ checks only that the tool exists and belongs to the tenant). A
176
+ wrong-intent tool is accepted and only fails later, when the poll
177
+ actually runs. Wire a `status_poller` tool anyway; just don't rely
178
+ on a create-time 422 to enforce it.
145
179
  - `sender_tool_ids` (array) — the tools whose sent messages
146
180
  this updater is responsible for tracking. The match logic
147
181
  joins each polled event back to a message row that was
@@ -178,10 +212,9 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
178
212
  | `source.pointer` | Cause |
179
213
  |-------------------------------------|----------------------------------------------------|
180
214
  | `/cron_expression` | invalid cron syntax or below minimum interval |
181
- | `/updater_tool_id` | tool's intent isn't `status_poller` |
215
+ | `/updater_tool_id` | tool id doesn't exist or wrong datalake (intent is NOT checked — see §2) |
182
216
  | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
183
- | `/updater_body/updater_body_type` | enum mismatch |
184
- | `/updater_type` | mismatch with `updater_body.updater_body_type` |
217
+ | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
185
218
  | `/message_config/body` | empty when `type: 'custom'` |
186
219
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
187
220
 
@@ -204,11 +237,12 @@ branch.
204
237
  ### Read shapes
205
238
 
206
239
  ```
207
- .list(tenantSlug, datalakeSlug) Paged: { data, meta }
208
- .get(tenantSlug, datalakeSlug, idOrSlug) One row
240
+ .list(tenantSlug, datalakeSlug, query?) Paged: { data, meta }
241
+ (full Flop query — type_naming.md)
242
+ .get(tenantSlug, datalakeSlug, id) One row — UUID only
209
243
  .metadata(tenantSlug, datalakeSlug) Markdown — catalog
210
244
  .metadataDetails(tenantSlug, datalakeSlug, Markdown — one updater
211
- idOrSlug)
245
+ id) (UUID only)
212
246
  ```
213
247
 
214
248
  ### Update
@@ -232,17 +266,23 @@ check or retry for partial-delete states.
232
266
 
233
267
  ## 6. Gotchas
234
268
 
235
- 1. **The polling tool's `intent` must be `status_poller`.**
236
- A tool with `intent: 'sms'` or any other non-poller intent
237
- is rejected at create time. The poller intent gates the
238
- tool's eligibility as an `updater_tool_id`.
239
-
240
- 2. **`message_config.body` must emit valid JSON.** The
241
- template is rendered against the polling tool's parsed
242
- response and the output is then JSON-parsed. A render that
243
- produces malformed JSON (e.g. trailing comma, unquoted
244
- string) makes the entire poll cycle silently no-op for
245
- that updater — events stay unprocessed.
269
+ 1. **The polling tool should be `status_poller` — but nothing
270
+ enforces it at create.** By convention `updater_tool_id` points
271
+ at a `status_poller`-intent tool, and the runtime poll assumes
272
+ that shape. The create endpoint, however, does not check the
273
+ tool's intent (only existence + tenant), so an `intent: 'sms'`
274
+ tool is accepted and fails only when the poll runs. Wire the
275
+ right tool; don't count on a create-time rejection to catch a
276
+ wrong one.
277
+
278
+ 2. **`message_config.body` must render one flat JSON object per
279
+ event.** The two authoring mistakes that cannot work:
280
+ looping a `{% for event in events %}` collection (there is
281
+ none — the event *is* the assigns, so the loop renders
282
+ nothing), and nesting the fields under a `set_params` key
283
+ (the platform's allowlist drops them, so the update applies
284
+ to nothing). Both are now **rejected at create**. A render
285
+ that fails at run time is reported in `last_run_error`.
246
286
 
247
287
  3. **`external_id` join is exact-match.** Events whose
248
288
  `external_id` doesn't match a message row are discarded
@@ -251,10 +291,11 @@ check or retry for partial-delete states.
251
291
  the bare id, transform inside the Liquid template (string
252
292
  filters) so the rendered `external_id` matches.
253
293
 
254
- 4. **`set_params` keys must be writable columns on the
255
- message row.** Unknown keys are silently dropped during
256
- the update. Refer to the message dataset's schema to
257
- confirm which columns are writable from updater output.
294
+ 4. **The fields you set must be writable columns on the
295
+ message row** — `status`, `sent_at`, `delivered_at`,
296
+ `read_at`, `opened_at`. Unknown keys are silently dropped
297
+ during the update. They go at the TOP LEVEL of the rendered
298
+ object, alongside `external_id`.
258
299
 
259
300
  5. **Cron windows + polling-window overlap.** Choose
260
301
  `start_time` / `end_time` to overlap consecutive cron
@@ -262,3 +303,146 @@ check or retry for partial-delete states.
262
303
  transient delivery delays don't fall between two polls.
263
304
  The defaults in the wire-shape example illustrate this
264
305
  pattern: 30-minute cron, 45-minute window.
306
+
307
+ 6. **Key `has_next` off the page being NON-EMPTY, not off the
308
+ cursor's presence.** Real providers (Mailgun is one) return a
309
+ `paging.next` URL on EVERY page — including the final, empty
310
+ one — so a pagination template that emits `"has_next": true`
311
+ whenever the cursor exists loops until the runaway cap. The
312
+ canonical template keys off content instead:
313
+
314
+ ```liquid
315
+ "has_next": {% if items.size > 0 %}true{% else %}false{% endif %}
316
+ ```
317
+
318
+ The poll follows pages while `has_next` is true and stops on
319
+ the first empty page.
320
+
321
+ ## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
322
+
323
+ An action status updater CAN be fired on demand:
324
+
325
+ ```ts
326
+ const { data } = await api.actionStatusUpdaters.refresh(
327
+ tenantSlug, datalakeSlug, updaterId,
328
+ );
329
+ ```
330
+
331
+ **`refresh` answers `202` with the updater row AS-IS — the poll is
332
+ ENQUEUED, not run.** The `last_run_*` fields on that response are the
333
+ PREVIOUS run's stamps; reading them as the outcome of the refresh you
334
+ just issued is the single most common mistake here. An optional body
335
+ carries a one-shot `updater_body` override (e.g. a widened
336
+ `start_time`/`end_time` window for a backfill) that does not mutate
337
+ the persisted updater. Apply jobs are asynchronous too — observe
338
+ delivery status on the message rows, not in this response.
339
+ (CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
340
+ `refresh-updater`.)
341
+
342
+ The correct read is baseline-then-advance:
343
+
344
+ ```ts
345
+ const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
346
+ const baseline = before.data.last_run_at ?? null // BEFORE refreshing
347
+
348
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
349
+
350
+ let row = before.data
351
+ const deadline = Date.now() + 60_000
352
+ while (Date.now() < deadline) {
353
+ await new Promise((r) => setTimeout(r, 2_000))
354
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
355
+ if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
356
+ }
357
+ // row.last_run_status / last_run_error now describe THIS run
358
+ ```
359
+
360
+ What the ASU still does NOT have is a **per-run log** (nothing like
361
+ a data activation client's `logs` or a workflow's `batchLogs`). The
362
+ run-outcome surface is four fields on the row itself, plus the halt
363
+ flag:
364
+
365
+ ```
366
+ last_run_at when the poll worker last stamped a page
367
+ last_run_status 'ok' | 'error'
368
+ last_run_error why the last run failed (structured text)
369
+ last_run_events_found events fetched so far in the current run
370
+ status 'active' | 'cycle_detected'
371
+ ```
372
+
373
+ **A paginated run stamps `last_run_*` on EVERY page, not only the
374
+ final one.** Two consequences for anything asserting on them:
375
+
376
+ - `last_run_status: 'ok'` means *a page* succeeded — not that the run
377
+ completed. Do not treat it as terminal.
378
+ - `last_run_events_found` is a **running total** for the current run,
379
+ so a mid-run read is legitimately smaller than the final count.
380
+
381
+ Per-page stamping exists because the alternative hid a real incident:
382
+ an updater that polled continuously for 45 minutes was
383
+ indistinguishable from one that had never started, because only the
384
+ terminal page stamped and no page was ever terminal.
385
+
386
+ **`status` is the halt flag.** The server sets `'cycle_detected'` when
387
+ a run re-reads events it has already handled — the runtime backstop for
388
+ a poll whose pagination does not advance (a provider cursor that never
389
+ drains, or a one-shot `updater_body` override). Every later job then
390
+ fails without calling the provider, and `last_run_error` carries the
391
+ diagnostic. **Nothing clears it but setting `status` back to
392
+ `'active'`.** It is also the cheapest deterministic failure to assert
393
+ on: a non-advancing config trips it within seconds, no provider
394
+ credential rejection required. Related: a `restapi` updater that
395
+ declares pagination but whose `path`/`params`/`body` never read
396
+ `msg.pagination_context` or `msg.page` is now refused at create/update
397
+ — see §2's template contract.
398
+
399
+ **If a message's status is not reconciling, do this — in order:**
400
+
401
+ 1. Call `refresh()`, wait for `last_run_at` to advance past the
402
+ baseline you captured, then read `last_run_status` /
403
+ `last_run_error` / `status`. They name the failing stage: window
404
+ render, source fetch, template render, JSON parse — or a halted
405
+ poll cycle.
406
+ 2. Check the config against §2 above. A config that violates the
407
+ template contract is rejected at create, so a *deployed* updater's
408
+ templates are structurally sound — the fault is more likely the
409
+ event shape or the `external_id` join.
410
+ 3. **If those don't explain it, ESCALATE TO A HUMAN.** Report the
411
+ updater id, `last_run_error`, and the event shape you expected.
412
+
413
+ A wrong config guess is worse than no change: replacing the unix-ms
414
+ poll window with the ISO-8601 `now` filter, for example, stops the
415
+ updater polling at all — verify each `refresh` outcome before
416
+ touching the config again.
417
+
418
+ ## 8. Where delivery truth comes from (consumer view)
419
+
420
+ If you only READ the platform (an operator UI, a dashboard), this
421
+ is the part that matters:
422
+
423
+ ```
424
+ dispatch writes: messages row, status 'sent',
425
+ external_id = provider's id
426
+ THIS updater writes: 'delivered' or 'failed' +
427
+ failure_reason (+ delivered_at)
428
+ nothing else writes those.
429
+ ```
430
+
431
+ - **No updater configured → no delivery truth.** Rows stay `sent`
432
+ forever; a delivery-rate number computed from them is fiction.
433
+ Updaters are created manually (this page §1) — the platform does
434
+ not auto-create them per sender tool.
435
+ - **The join is `{external_id, set_params}`,** exact-match, silent
436
+ on misses (gotcha 3). "Eventually honest": a just-sent row is
437
+ neither delivered nor failed yet — show the in-between state.
438
+ - **Retry is not here.** The updater only reports. Re-sending a
439
+ failed message is `workflows.execute` for that subject +
440
+ decision (`mode: 'live'`, `manual_override: true`) — the same
441
+ call as any manual dispatch; there is no retry endpoint. See
442
+ `workflows.md` §8.
443
+ - **Engagement is not here either.** `opened_at` /
444
+ `form_submitted_at` arrive via
445
+ `connectedApps.updateMessageTracking` (magic-link seam,
446
+ `connected_apps.md` §6) and never touch `status`.
447
+ - Field-by-field semantics of the rows being updated:
448
+ `messages.md`, `action_logs.md`.
@@ -30,7 +30,7 @@ const { data: created } = await api.aiAgents.create(
30
30
 
31
31
  tool_id: chatCompletionToolId, // see §2 — must be a tool with intent: 'llm_enrichment'
32
32
 
33
- model: 'llama3.2:3b', // provider-specific model identifier
33
+ model: 'qwen3-vl:8b-instruct', // provider-specific model identifier
34
34
  data_access: 'unregulated', // 'unregulated' | 'regulated' (compliance gate)
35
35
  temperature: 0.0,
36
36
  max_tokens: 2048,
@@ -139,15 +139,27 @@ data_access: 'regulated' — the LLM call receives raw values;
139
139
  screening, manual review, KYC.
140
140
  ```
141
141
 
142
- The setting is **immutable post-create**. Flipping the access
143
- tier requires recreating the agent (delete + create).
144
-
145
- ### `enabled` gates whether workflows can invoke the agent
146
-
147
- A workflow that references a `disabled` agent fails at run time
148
- with `error_code: 'ai_agent_disabled'` on the workflow execution
149
- log. `enabled: false` is the operator's kill switch for an agent
150
- without removing it from the workflow's `ai_agents` array.
142
+ **The platform does NOT enforce `data_access` as immutable — a PUT
143
+ `update()` can change it today.** The field is cast by the same
144
+ changeset that `update()` uses and carries no immutability guard (and
145
+ isn't `readOnly` on the wire), so an update can flip `regulated ↔
146
+ unregulated`, which changes whether the agent sees raw or tokenized
147
+ values. **Treat it as immutable by discipline** — a data source
148
+ built expecting one tier shouldn't be silently re-tiered under it —
149
+ but know the platform won't stop a flip, so guard it in your own
150
+ change-control (or recreate the agent) rather than relying on a
151
+ rejected update.
152
+
153
+ ### `enabled` gates whether a nested agent runs
154
+
155
+ At enrichment time the workflow filters its `workflow_ai_agents`
156
+ to the enabled ones and runs only those — a `disabled` agent is
157
+ silently **skipped**, not run-and-failed. There is no
158
+ `ai_agent_disabled` error code. `enabled: false` is the
159
+ operator's kill switch: it takes the agent out of the enrichment
160
+ set without detaching it from the parent's `*_ai_agents` array.
161
+ If every nested agent is disabled, the enrichment stage lands
162
+ `{ status: 'skipped' }` (see §7).
151
163
 
152
164
  ### Numeric clamps on `temperature` and `max_tokens`
153
165
 
@@ -166,23 +178,20 @@ value the provider doesn't support (e.g. `temperature: 1.5` on
166
178
  a model that caps at 1.0) surfaces later as a provider error in
167
179
  the workflow execution log, not at create.
168
180
 
169
- ### Name uniqueness is per-tenant, not per-datalake
181
+ ### Name uniqueness is per-datalake
170
182
 
171
183
  The agent's `name` (and the server-derived `slug`) must be
172
- unique within the tenant — collisions across **different
173
- datalakes of the same tenant** are rejected with a 422 on
174
- `/name` or `/slug`. This is unusual: most other resources scoped
175
- under a datalake (tools, data sources, interoperability
176
- contracts) carry per-datalake uniqueness. AI agents are the
177
- exception because their `slug` is the workflow-template
178
- reference key (`additional_context.<slug>`) and the platform
179
- needs a stable cross-datalake namespace for it.
180
-
181
- Practical consequence: if you want the same logical agent in
182
- two datalakes (e.g. dev + prod inside one tenant), give them
183
- distinct names. A naming convention like `"<purpose>-<env>"` or
184
- including the datalake slug in the agent name keeps the
185
- namespace clean.
184
+ unique within the **datalake** — the uniqueness scope is
185
+ `[slug, datalake_id]`, and a collision is rejected with a 422 on
186
+ `/name` carrying "name must be unique within a datalake". This
187
+ matches the other datalake-scoped resources (tools, data
188
+ sources, interoperability contracts).
189
+
190
+ The same logical name can therefore be reused across different
191
+ datalakes of one tenant. A `slug` is only ever dereferenced
192
+ within its own datalake's workflow templates
193
+ (`additional_context.<slug>`), so cross-datalake reuse is safe —
194
+ no environment-suffix naming convention is required.
186
195
 
187
196
  ## 3. Workflow & contract nesting
188
197
 
@@ -319,7 +328,7 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
319
328
  | `/data_access` | value not in enum |
320
329
  | `/temperature` | outside the inclusive range [0.0, 2.0] |
321
330
  | `/max_tokens` | outside the inclusive range [1, 100_000] |
322
- | `/name` or `/slug` | uniqueness collision — scope is per-tenant |
331
+ | `/name` | name/slug collision — unique within the datalake (`[slug, datalake_id]`) |
323
332
 
324
333
  ## 6. Lifecycle
325
334
 
@@ -331,12 +340,15 @@ once `enabled: true`.
331
340
  ### Read shapes
332
341
 
333
342
  ```
334
- .list(tenantSlug, datalakeSlug) Paged: { data, meta }
343
+ .list(tenantSlug, datalakeSlug, query?) Paged: { data, meta }
344
+ (full Flop query — type_naming.md)
335
345
  .get(tenantSlug, datalakeSlug, id) One row, BY ID (UUID — not slug)
336
346
  .metadata(tenantSlug, datalakeSlug) Markdown catalog
337
347
  .metadataDetails(tenantSlug, datalakeSlug, Markdown for one agent;
338
- id) section headings: Model,
339
- Bound tool, Prompt config
348
+ id) section headings: Wrapper,
349
+ Model, Bound tool, Prompt
350
+ config, Input schema, LLM
351
+ response schema
340
352
  ```
341
353
 
342
354
  `.get` and `.metadataDetails` accept the agent's UUID `id` as
@@ -354,10 +366,75 @@ Omitting any returns a 422.
354
366
 
355
367
  ### Delete
356
368
 
357
- `delete()` removes the row. Workflows that still reference the
358
- agent in their `ai_agents` array fail at run time with
359
- `error_code: 'ai_agent_not_found'`; clean up workflow
360
- references first.
369
+ `delete()` removes the row — but only if nothing references it.
370
+ The delete is **blocked** (rejected as a 422) while the agent is
371
+ still nested on any workflow (`workflow_ai_agents`) or
372
+ interoperability contract (`interoperability_contract_ai_agents`),
373
+ or while it has invocation `usage_logs`. Detach it from every
374
+ parent first (re-supply the parent's `*_ai_agents` array without
375
+ it — see §3), then delete. There is no "referenced agent silently
376
+ deleted, workflow fails later" path: the association constraint
377
+ stops the delete up front, so a live workflow never loses an agent
378
+ out from under it.
379
+
380
+ ### Invoke (direct, outside a workflow)
381
+
382
+ `invoke()` runs the agent once, synchronously, against a
383
+ caller-supplied input map — the direct path for reading an
384
+ uploaded file or doing ad-hoc extraction without nesting the
385
+ agent on a workflow or contract.
386
+
387
+ ```
388
+ .invoke(tenantSlug, datalakeSlug, id, body) POST .../ai-agents/:id/invoke
389
+ ```
390
+
391
+ The body is `AiAgentInvokeRequest`:
392
+
393
+ ```
394
+ input required object — the prompt vars; validated against the
395
+ agent's input_schema BEFORE the model is
396
+ called (a violation is a 422)
397
+ files optional array — [{ key, content_type }] attachments for
398
+ multimodal (vision) invocation. `key` is a
399
+ storage key from datalakes.createUploadLink;
400
+ content_type ∈ application/pdf | image/png |
401
+ image/jpeg | image/webp. The platform
402
+ downloads each file from the agent's datalake,
403
+ Base64-encodes it, and hands it to the model
404
+ as a content part.
405
+ ```
406
+
407
+ The response (`AiAgentInvokeResponse`):
408
+
409
+ ```
410
+ output object — the parsed LLM JSON, matching llm_response_schema
411
+ explanation string? — the model's reasoning trace, when the tool's
412
+ response_extractor mapped one out; null otherwise
413
+ usage object — { model, input_tokens, output_tokens, total_tokens,
414
+ latency_ms } telemetry for the call
415
+ ```
416
+
417
+ Invoke passes NO `context_mapping_config` — `input` is validated
418
+ directly against `input_schema` (unlike the nested workflow path,
419
+ which remaps host context first). See the `ai-agent-invoke`
420
+ cookbook for the full upload → tool → agent → invoke walk.
421
+
422
+ ### Drift checksum
423
+
424
+ `checksum()` computes the SHA-256 drift fingerprint a
425
+ create/update WOULD stamp for a given body, without persisting —
426
+ the stateless plan-time primitive `alvera plan` uses.
427
+
428
+ ```
429
+ .checksum(tenantSlug, datalakeSlug, body) POST .../ai-agents/checksum
430
+ ```
431
+
432
+ Compare the result against a deployed agent's server-computed
433
+ `checksum` (from `get()`) to detect config drift. The fingerprint
434
+ covers the authored fields only — `name`, `model`, `data_access`,
435
+ `enabled`, `temperature`, `max_tokens`, `tool_id`, `description`,
436
+ `input_schema`, `llm_response_schema`, `prompt_config` — server
437
+ bookkeeping (`slug`, `id`, timestamps, FKs) is excluded.
361
438
 
362
439
  ## 7. Run-time failure modes
363
440
 
@@ -368,26 +445,41 @@ agent-specific codes:
368
445
 
369
446
  ```
370
447
  tool_execution_failed — the bound chat-completion tool's
371
- HTTP call failed (404, refused,
372
- timeout). error.json carries the
373
- rich detail (URL, status); the
374
- workflow execution log carries
375
- a coarse error_message of the
376
- form "AI enrichment failed: <agent_name>"
377
- json_decode_failed — provider returned a response that
378
- didn't parse as JSON (typically
379
- when llm_response_schema wasn't
380
- wired through to the provider's
381
- structured-output mode)
382
- context_mapping_failed — context_mapping_config.body
383
- rendered output that didn't
384
- validate against the agent's
385
- input_schema
386
- ai_agent_disabled — agent's enabled flag is false
387
- ai_agent_not_found — agent was deleted but workflow
388
- still references it
448
+ HTTP call failed (non-2xx, refused,
449
+ timeout). Returned as a rich
450
+ {code, message}: error.json's `detail`
451
+ carries the URL + status; the workflow
452
+ execution log carries a coarse
453
+ error_message of the form
454
+ "AI enrichment failed: <agent_name>"
455
+ json_decode_failed — the model's content didn't parse as
456
+ JSON (typically llm_response_schema
457
+ wasn't wired through to the provider's
458
+ structured-output mode, so the model
459
+ wrapped JSON in prose / code fences)
460
+ response_extraction_failed — the tool's response_extractor template
461
+ failed to render/decode the provider
462
+ envelope, or its output violated the
463
+ extractor's own output_schema
464
+ context_mapping_failed — the nested join's
465
+ context_mapping_config.body failed to
466
+ render or JSON-decode
467
+ context_mapping_not_a_map — context_mapping_config rendered valid
468
+ JSON that wasn't an object
469
+ input_schema_validation_failed — the effective prompt vars (post
470
+ context-mapping) didn't validate
471
+ against the agent's input_schema
472
+ tool_call_type_not_supported_for_llm_enrichment
473
+ — the bound tool's body can't host a model
474
+ call (only rest_api and aws_lambda
475
+ bodies can)
389
476
  ```
390
477
 
478
+ There is no `ai_agent_disabled` or `ai_agent_not_found` runtime
479
+ code: a disabled agent is silently filtered out of the enrichment
480
+ set and never runs (§2), and an agent still nested on a
481
+ workflow/contract cannot be deleted in the first place (§6, Delete).
482
+
391
483
  The `error.json` shape:
392
484
 
393
485
  ```
@@ -448,8 +540,12 @@ with `{ status: 'skipped' }` and no per-agent entries.
448
540
  partial body silently drops fields and returns 422 on the
449
541
  missing required field.
450
542
 
451
- 6. **`data_access` is immutable post-create.** Flipping the
452
- compliance tier requires delete + recreate.
543
+ 6. **`data_access` is immutable by convention only — the platform
544
+ won't stop a PUT from flipping it.** There's no immutability
545
+ guard in the changeset, so an `update()` can change the
546
+ compliance tier (`regulated ↔ unregulated`) and re-tier what the
547
+ agent sees. Enforce it in your own change-control (or delete +
548
+ recreate); don't rely on the platform to reject the flip (§2).
453
549
 
454
550
  7. **`prompt_config.body` field references must resolve in
455
551
  `input_schema`.** A `{{ field }}` that isn't declared in