@alvera-ai/platform-sdk 0.10.0-rc.2 → 0.10.0-rc.21

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 +440 -0
  2. package/.agent/account_management.md +455 -0
  3. package/.agent/action_status_updaters.md +262 -0
  4. package/.agent/ai_agents.md +423 -0
  5. package/.agent/ai_sandbox.md +265 -0
  6. package/.agent/async.md +111 -0
  7. package/.agent/connected_apps.md +407 -0
  8. package/.agent/cookbook/_fixtures/README.md +99 -0
  9. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
  10. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
  11. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  14. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  17. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  18. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  19. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  21. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  22. package/.agent/cookbook/_setup/foundation.md +277 -0
  23. package/.agent/cookbook/_setup/healthcare.md +279 -0
  24. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  25. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  26. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  27. package/.agent/cookbook/contact-us-triage-with-llm.md +603 -0
  28. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  29. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  30. package/.agent/cookbook/sanctions-screening-with-agent-review.md +711 -0
  31. package/.agent/cookbook/score-leads-with-llm-categorization.md +602 -0
  32. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  33. package/.agent/data_activation_clients.md +557 -0
  34. package/.agent/data_sources.md +234 -0
  35. package/.agent/datalakes.md +712 -0
  36. package/.agent/debugging.md +137 -0
  37. package/.agent/errors.md +196 -0
  38. package/.agent/generic_tables.md +351 -0
  39. package/.agent/interoperability_contracts.md +351 -0
  40. package/.agent/mdm.md +293 -0
  41. package/.agent/mutations.md +152 -0
  42. package/.agent/templates.md +98 -0
  43. package/.agent/tool-call-configs.md +90 -0
  44. package/.agent/tools.md +546 -0
  45. package/.agent/type_naming.md +131 -0
  46. package/.agent/workflows.md +601 -0
  47. package/README.md +46 -0
  48. package/dist/bin/platform-sdk.d.mts +1 -0
  49. package/dist/bin/platform-sdk.mjs +106 -0
  50. package/dist/bin/platform-sdk.mjs.map +1 -0
  51. package/dist/index.d.mts +1200 -43201
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +1859 -7319
  54. package/dist/index.mjs.map +1 -1
  55. package/package.json +19 -10
@@ -0,0 +1,711 @@
1
+ ---
2
+ title: Disambiguate gray-zone sanctions screenings via an LLM agent
3
+ summary: End-to-end agent-driven workflow — a chat-completion LLM agent disambiguates gray-zone-scored sanctions screenings into a verified/blocked verdict, the workflow's decision interpolates that verdict, two SMS actions (one per verdict) fan out, and the run is verified row-by-row. A clearly-clear screening is filtered out before the agent ever sees it.
4
+ industry: payment_risk
5
+ slug: sanctions-screening-with-agent-review
6
+ vitest_source:
7
+ - integration-tests/tests/payment_risk/sanctions-review-workflow.test.ts
8
+ - integration-tests/tests/payment_risk/interoperability-contracts.test.ts
9
+ - integration-tests/tests/payment_risk/run-dac-single.test.ts
10
+ - integration-tests/tests/payment_risk/create-dac.test.ts
11
+ - integration-tests/tests/payment_risk/tools.test.ts
12
+ - integration-tests/tests/payment_risk/data-sources.test.ts
13
+ - integration-tests/tests/payment_risk/bootstrap.test.ts
14
+ status: green
15
+ ---
16
+
17
+ # Problem
18
+
19
+ A sanctions-screening provider returns a match score for every
20
+ name it checks against a watchlist. The extremes are easy: a
21
+ score near zero is a clean pass, a score near one is a confirmed
22
+ hit. The middle band — a name close enough to a sanctioned entity
23
+ to be plausible, different enough to be a likely false positive —
24
+ is where a compliance analyst spends their day.
25
+
26
+ A fixed threshold cannot resolve the gray zone: set it low and
27
+ every near-miss blocks a legitimate customer; set it high and a
28
+ real match slips through. The disambiguation is a judgement call
29
+ on the specific name, not a number comparison.
30
+
31
+ The Alvera platform's agent-driven workflow primitive turns that
32
+ judgement call into an LLM enrichment plus a Liquid
33
+ interpolation. A filter narrows the workflow to *only* gray-zone
34
+ scores; an AI agent receives the screening's entity name and
35
+ score via a context-mapping template, returns a JSON object whose
36
+ `verdict` field is `verified` or `blocked`; and the workflow's
37
+ decision_config interpolates that verdict into a one-element
38
+ decision array. Two SMS actions are registered against the
39
+ workflow, one per verdict; the agent's output picks which one
40
+ runs. Auto-clear and auto-block screenings never reach the agent
41
+ — the filter rejects them.
42
+
43
+ This cookbook walks the **whole** scenario: it provisions the
44
+ agent and workflow, ingests two compliance-screening events
45
+ through the production data-activation chain (one gray-zone, one
46
+ clearly clear), runs the workflow so the gray-zone filter routes
47
+ each row, and verifies the routing — the gray-zone screening
48
+ reaches the agent and fans out to a verdict-keyed SMS, the
49
+ clearly-clear screening is filtered out before any agent call.
50
+
51
+ The scenario is anchored to
52
+ `platform/integration-tests/tests/payment_risk/sanctions-review-workflow.test.ts`
53
+ — a green end-to-end test. The setup file `_setup/payment_risk.md`
54
+ already provisioned the tenant + datalake + tenant-scoped client;
55
+ this cookbook starts from there.
56
+
57
+ # Composition
58
+
59
+ | Resource provisioned | Owner |
60
+ |-------------------------------------|-------------|
61
+ | SMS tool (SNS-backed) | build |
62
+ | LLM tool (Ollama chat completion) | build |
63
+ | Sanctions Review AI agent | build |
64
+ | Sanctions Review workflow | build |
65
+ | Atomic FI data source | build |
66
+ | Manual Upload tool | build |
67
+ | Compliance Screening interop contract | build |
68
+ | Manual-upload DAC | build |
69
+
70
+ The setup file `_setup/payment_risk.md` already provisioned the
71
+ tenant + datalake + tenant-scoped client; this cookbook starts
72
+ from there.
73
+
74
+ # Walkthrough
75
+
76
+ ## 001 — create the SMS tool
77
+
78
+ The Sanctions Review workflow's actions invoke an SMS tool. The
79
+ tool's `body.tool_body_type: 'sns'` means it routes via AWS SNS;
80
+ local dev points it at LocalStack on `http://localhost:4566` via
81
+ `endpoint_url` so no real AWS credentials are needed. The
82
+ `intent: 'sms'` tags this tool for workflow actions that send SMS
83
+ (versus `data_exchange` for ingestion tools).
84
+
85
+ ```typescript
86
+ const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
87
+ name: `Cookbook SMS Tool ${runSuffix}`,
88
+ description: 'SNS-backed SMS dispatcher for the Sanctions Review workflow, wired to LocalStack.',
89
+ intent: 'sms',
90
+ status: 'active',
91
+ datalake_id: ctx.datalakeId,
92
+ body: {
93
+ tool_body_type: 'sns',
94
+ auth_method: 'access_key',
95
+ region: 'us-east-1',
96
+ phone_number: '+15551234567',
97
+ endpoint_url: 'http://localhost:4566',
98
+ access_key_id: 'test',
99
+ secret_access_key: 'test',
100
+ },
101
+ })
102
+ toolId = smsToolResp.data.id!
103
+ ```
104
+
105
+ ## 002 — create the LLM tool
106
+
107
+ The Sanctions Review agent calls a chat-completion endpoint to
108
+ disambiguate each gray-zone screening. The tool's
109
+ `intent: 'llm_enrichment'` distinguishes it from the SMS tool
110
+ above; `tool_body_type: 'rest_api'` plus an OpenAI-compatible
111
+ `base_url` lets the platform route Ollama, Anthropic, OpenAI, or
112
+ any other compatible inference server through the same wire
113
+ format. Local dev points at Ollama's OpenAI-compat shim on
114
+ `http://localhost:11434/v1`. The `api_key`/`auth_method` pair is
115
+ required by the REST tool's discriminated-union schema even
116
+ though Ollama ignores the header.
117
+
118
+ ```typescript
119
+ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
120
+ name: `Cookbook LLM Tool ${runSuffix}`,
121
+ description: 'Ollama-backed chat-completion endpoint for sanctions-screening disambiguation.',
122
+ intent: 'llm_enrichment',
123
+ status: 'active',
124
+ datalake_id: ctx.datalakeId,
125
+ body: {
126
+ tool_body_type: 'rest_api',
127
+ base_url: 'http://localhost:11434/v1',
128
+ auth_method: 'api_key',
129
+ api_key: 'ollama-noop',
130
+ api_key_name: 'Authorization',
131
+ api_key_location: 'header',
132
+ request_type: 'json',
133
+ response_type: 'json',
134
+ timeout_ms: 60_000,
135
+ },
136
+ })
137
+ ctx.llmToolId = llmToolResp.data.id!
138
+ ```
139
+
140
+ ## 003 — create the Sanctions Review AI agent
141
+
142
+ The agent binds three things: the model name, an input schema the
143
+ workflow's context-mapping must satisfy, and a response schema the
144
+ agent's output must match. The response schema's
145
+ `enum: ['verified','blocked']` constraint guards the downstream
146
+ decision interpolation from emitting a verdict the workflow has no
147
+ SMS action for. `temperature: 0.0` removes sampling noise.
148
+
149
+ `data_access: 'regulated'` is load-bearing here: the screened
150
+ entity's name is a `TokenizedDataType` field — tokenized on the
151
+ unregulated side. The agent needs the plaintext name to make a
152
+ judgement call, so it reads the *regulated* projection of the
153
+ compliance-screening row.
154
+
155
+ ```typescript
156
+ const AGENT_INPUT_SCHEMA = {
157
+ type: 'object',
158
+ properties: {
159
+ screening_id: { type: 'string' },
160
+ screened_entity_name: { type: 'string' },
161
+ match_score: { type: 'number' },
162
+ },
163
+ required: ['screening_id', 'screened_entity_name', 'match_score'],
164
+ }
165
+
166
+ const AGENT_RESPONSE_SCHEMA = {
167
+ type: 'object',
168
+ properties: {
169
+ verdict: { type: 'string', enum: ['verified', 'blocked'] },
170
+ comments: { type: 'string' },
171
+ },
172
+ required: ['verdict', 'comments'],
173
+ }
174
+
175
+ const AGENT_PROMPT_BODY = `You are a sanctions-review disambiguation assistant. A name-match against a sanctions list has come in with a gray-zone score (close enough to be plausible, ambiguous enough to need a human-style judgement call). Decide whether this is a true match ("blocked") or a false positive on a similar name ("verified").
176
+
177
+ Screening id: {{ screening_id }}
178
+ Screened entity name: {{ screened_entity_name }}
179
+ Match score: {{ match_score }}
180
+
181
+ Respond with JSON: {"verdict": "verified" | "blocked", "comments": "<one-sentence rationale>"}
182
+
183
+ For this cookbook fixture, the entity name is a deliberately disambiguating non-sanctioned identity — respond with "verified".`
184
+
185
+ const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
186
+ name: `Cookbook Sanctions Review Agent ${runSuffix}`,
187
+ tool_id: ctx.llmToolId,
188
+ model: 'gemma3:1b',
189
+ data_access: 'regulated',
190
+ temperature: 0.0,
191
+ max_tokens: 256,
192
+ enabled: true,
193
+ input_schema: AGENT_INPUT_SCHEMA,
194
+ llm_response_schema: AGENT_RESPONSE_SCHEMA,
195
+ prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
196
+ })
197
+ aiAgentId = agentResp.data.id!
198
+ ctx.agentSlug = agentResp.data.slug!
199
+ ```
200
+
201
+ ## 004 — create the Sanctions Review workflow
202
+
203
+ The workflow has the standard shape — filter, decision, actions —
204
+ plus an `ai_agents` array binding the Sanctions Review agent into
205
+ the enrichment phase. Three pieces are worth noting.
206
+
207
+ The **filter** is the gray-zone band: it passes only screenings
208
+ whose `screening_score` is at least `0.80` and below `0.95`. A
209
+ score below `0.80` is an auto-clear and a score at/above `0.95` is
210
+ an auto-block — neither needs the agent, so the filter rejects
211
+ them before any LLM call.
212
+
213
+ The **context_mapping** projects each screening into the agent's
214
+ input schema. `match_score` is interpolated *without* surrounding
215
+ quotes so it renders as a raw JSON number — the input schema
216
+ declares it `number`, and a quoted `"0.88"` would fail schema
217
+ validation. `screened_entity_name` is read from the regulated
218
+ projection so the agent sees the plaintext name.
219
+
220
+ The **decision_config** interpolates the agent's `verdict` (read
221
+ from `additional_context["<agent-slug>"].verdict`) into a
222
+ single-element decision array. The two `actions` are keyed
223
+ `decision_key: verified|blocked`; whichever verdict the agent
224
+ emits picks the action that fires, leaving the other `:skipped`.
225
+ Bracket access is used because the agent slug contains hyphens.
226
+
227
+ ```typescript
228
+ const VERDICTS = ['verified', 'blocked'] as const
229
+
230
+ const GRAY_ZONE_FILTER =
231
+ '{% if compliance_screening.screening_score >= 0.80 ' +
232
+ 'and compliance_screening.screening_score < 0.95 %}true{% endif %}'
233
+
234
+ const CONTEXT_MAPPING_BODY =
235
+ '{' +
236
+ '"screening_id": "{{ compliance_screening.id }}",' +
237
+ '"screened_entity_name": "{{ regulated_compliance_screening.screened_entity_name }}",' +
238
+ '"match_score": {{ compliance_screening.screening_score }}' +
239
+ '}'
240
+
241
+ const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].verdict }}"]`
242
+ const DECISION_OUTPUT_SCHEMA = '{"type":"array","items":{"type":"string"}}'
243
+
244
+ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
245
+ name: `Cookbook Sanctions Review Workflow ${runSuffix}`,
246
+ description: 'Runs an LLM disambiguation pass on gray-zone-scored sanctions matches and fires a verdict-keyed SMS.',
247
+ dataset_type: 'compliance_screening',
248
+ status: 'live',
249
+ filter_config: {
250
+ type: 'custom',
251
+ body: GRAY_ZONE_FILTER,
252
+ output_schema: '{"type":"boolean"}',
253
+ },
254
+ decision_config: {
255
+ type: 'custom',
256
+ body: DECISION_CONFIG_BODY,
257
+ output_schema: DECISION_OUTPUT_SCHEMA,
258
+ },
259
+ ai_agents: [
260
+ {
261
+ ai_agent_id: aiAgentId,
262
+ position: 0,
263
+ context_mapping_config: {
264
+ type: 'custom',
265
+ body: CONTEXT_MAPPING_BODY,
266
+ output_schema: '{"type":"object"}',
267
+ },
268
+ },
269
+ ],
270
+ actions: VERDICTS.map((verdict) => ({
271
+ decision_key: verdict,
272
+ action_type: 'sms',
273
+ tool_id: toolId,
274
+ position: 0,
275
+ trigger_template: 'now',
276
+ idempotency_template: `{{ compliance_screening.id }}-${verdict}-{{ "" | uuid }}`,
277
+ tool_call: {
278
+ tool_call_type: 'sms_request',
279
+ to: { type: 'custom', body: '+15550000000' },
280
+ body: {
281
+ type: 'custom',
282
+ body: `[${verdict}] {{ additional_context["${ctx.agentSlug}"].comments }}`,
283
+ },
284
+ sms_type: 'transactional',
285
+ },
286
+ })),
287
+ })
288
+ workflowId = workflowResp.data.id!
289
+ ctx.workflowSlug = workflowResp.data.slug!
290
+ ```
291
+
292
+ ## 005 — create the Atomic FI data source
293
+
294
+ A workflow runs on rows; rows arrive through the data-activation
295
+ chain. The chain's first link is a `DataSource` — a registration
296
+ of where the rows originate. The `uri` is the system-of-record
297
+ address; it flows into each ingested row's `source_uri`.
298
+
299
+ ```typescript
300
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
301
+ name: `Cookbook Atomic FI Source ${runSuffix}`,
302
+ uri: 'api.atomic.fi',
303
+ description: 'Atomic FI compliance API — origin of the compliance-screening rows the workflow runs on.',
304
+ status: 'active',
305
+ is_default: false,
306
+ })
307
+ dataSourceId = dataSourceResp.data.id!
308
+ ```
309
+
310
+ ## 006 — create the Manual Upload tool
311
+
312
+ The Data Activation Client needs a tool. For inline-JSON ingest a
313
+ `manual_upload` tool is the minimal choice — `intent:
314
+ 'data_exchange'` distinguishes it from the SMS tool, and
315
+ `tool_body_type: 'manual_upload'` needs no endpoint or credential
316
+ wiring (the rows arrive in the ingest call body, not by the tool
317
+ fetching them).
318
+
319
+ ```typescript
320
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
321
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
322
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests compliance-screening rows.',
323
+ intent: 'data_exchange',
324
+ status: 'active',
325
+ datalake_id: ctx.datalakeId,
326
+ data_source_id: dataSourceId,
327
+ body: { tool_body_type: 'manual_upload' },
328
+ })
329
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
330
+ ```
331
+
332
+ ## 007 — create the Compliance Screening interoperability contract
333
+
334
+ The interoperability contract is the row-shaping rule: a Liquid
335
+ template that maps an inbound Atomic FI compliance-screening row
336
+ into a payment-risk `ComplianceScreening` upsert. This cookbook
337
+ loads the production Atomic FI compliance-screening + MDM
338
+ templates from the vendored fixtures directory.
339
+
340
+ `template_config` shapes the ComplianceScreening resource —
341
+ including the `screened_entity_name` field the changeset
342
+ tokenizes automatically. `mdm_input_config` shapes the MDM input
343
+ — the `account_holder_id` plus the entity name the platform's
344
+ master-data resolution keys on to find-or-create the LegalEntity
345
+ + AccountHolder pair the screening belongs to.
346
+
347
+ ```typescript
348
+ const { readFileSync } = await import('node:fs')
349
+ const { join } = await import('node:path')
350
+ const csTemplate = readFileSync(
351
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid'),
352
+ 'utf8',
353
+ )
354
+ const mdmTemplate = readFileSync(
355
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_compliance_screenings_payment_risk_mdm.liquid'),
356
+ 'utf8',
357
+ )
358
+
359
+ const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
360
+ name: `Cookbook Atomic FI Compliance Screening Contract ${runSuffix}`,
361
+ description: 'Atomic FI compliance-screenings → PaymentRisk ComplianceScreening (custom Liquid + MDM input).',
362
+ resource_type: 'compliance_screening',
363
+ template_config: { type: 'custom', body: csTemplate },
364
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
365
+ generic_table_id: null,
366
+ })
367
+ interopContractId = contractResp.data.id!
368
+ ```
369
+
370
+ ## 008 — create the manual-upload DAC
371
+
372
+ The Data Activation Client binds the three preceding pieces — the
373
+ manual-upload tool, the data source, and the interop contract —
374
+ into one ingestion endpoint. `tool_call.tool_call_type:
375
+ 'manual_upload'` selects the inline-JSON ingest path. The
376
+ server-derived `slug` is the handle §009 ingests rows against.
377
+
378
+ ```typescript
379
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
380
+ name: `Cookbook Compliance Screening DAC ${runSuffix}`,
381
+ description: 'Manual-upload DAC — ingests compliance-screening events into ComplianceScreening via the interop contract.',
382
+ tool_id: ctx.manualUploadToolId,
383
+ data_source_id: dataSourceId,
384
+ tool_call: { tool_call_type: 'manual_upload' },
385
+ interoperability_contract_ids: [interopContractId],
386
+ })
387
+ dacId = dacResp.data.id!
388
+ ctx.dacSlug = dacResp.data.slug!
389
+ ```
390
+
391
+ ## 009 — ingest two compliance-screening events
392
+
393
+ Two rows, ingested as inline JSON through the manual-upload DAC.
394
+ They differ in the field the workflow filter cares about:
395
+ `screening_score`. The first row scores `0.88` — squarely in the
396
+ gray zone `[0.80, 0.95)`, so the filter passes it to the agent.
397
+ The second scores `0.20` — an auto-clear, so the filter rejects
398
+ it before any agent call. Each row carries a distinct
399
+ `account_holder_id` so MDM resolves each to its own legal entity.
400
+ Each ingest call gets its own batch id; both are pinned for the
401
+ run scope in §011.
402
+
403
+ ```typescript
404
+ const grayZoneRow = {
405
+ compliance_screening_number: `CS-GRAY-${runSuffix}`,
406
+ account_holder_id: `AH-SR-${runSuffix}-gray`,
407
+ scope: 'account_holder',
408
+ screening_type: 'sanctions',
409
+ screening_status: 'pending',
410
+ screened_entity_type: 'individual',
411
+ sanctions_screening_status: 'match',
412
+ screening_score: 0.88,
413
+ match_count: 1,
414
+ screened_entity_name: 'Jane Cookbook-Disambiguating-Doe',
415
+ source_uri: 'api.atomic.fi',
416
+ }
417
+ const autoClearRow = {
418
+ compliance_screening_number: `CS-CLEAR-${runSuffix}`,
419
+ account_holder_id: `AH-SR-${runSuffix}-clear`,
420
+ scope: 'account_holder',
421
+ screening_type: 'sanctions',
422
+ screening_status: 'pass',
423
+ screened_entity_type: 'individual',
424
+ sanctions_screening_status: 'cleared',
425
+ screening_score: 0.2,
426
+ match_count: 0,
427
+ screened_entity_name: 'John Cookbook-Clear-Smith',
428
+ source_uri: 'api.atomic.fi',
429
+ }
430
+
431
+ const [grayZoneIngest, autoClearIngest] = await Promise.all([
432
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: grayZoneRow }),
433
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: autoClearRow }),
434
+ ])
435
+ ctx.batchGrayZone = grayZoneIngest.data.batch_id!
436
+ ctx.batchAutoClear = autoClearIngest.data.batch_id!
437
+ ```
438
+
439
+ ## 010 — wait for both ingest batches to reach steady-state
440
+
441
+ Ingestion is async — the DAC enqueues per-row jobs that the
442
+ `BatchMergeWorker` drains into the regulated compliance-screening
443
+ table. Poll the DAC's activation logs until both batches show a
444
+ row with `rows_ingested >= 1` and a non-empty `output_files`
445
+ array (the merged Parquet landed in object storage). Only then is
446
+ it safe to run the workflow against these rows.
447
+
448
+ ```typescript
449
+ const targetBatches = new Set([ctx.batchGrayZone, ctx.batchAutoClear])
450
+ const deadline = Date.now() + 90_000
451
+ let greenCount = 0
452
+ while (Date.now() < deadline) {
453
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
454
+ const green = new Set<string>()
455
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
456
+ const b = row.batch_id
457
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
458
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
459
+ const files = row.output_files
460
+ if (!Array.isArray(files) || files.length === 0) continue
461
+ green.add(b)
462
+ }
463
+ greenCount = green.size
464
+ if (greenCount === targetBatches.size) break
465
+ await new Promise((r) => setTimeout(r, 1_000))
466
+ }
467
+ if (greenCount !== targetBatches.size) {
468
+ throw new Error(`only ${greenCount}/2 compliance-screening batches reached steady-state within 90s`)
469
+ }
470
+ ```
471
+
472
+ ## 011 — run the workflow against the two batches
473
+
474
+ `workflows.run` with `manual_override: false` evaluates the
475
+ filter, so the gray-zone filter genuinely routes each row. The
476
+ SQL where-clause scopes the run to exactly the two batches §009
477
+ ingested (`rcs` is the regulated-compliance-screening alias the
478
+ run-query exposes).
479
+
480
+ For the gray-zone row the run drives the full agent-driven
481
+ pipeline: filter → agent enrichment (the Ollama call that returns
482
+ the verdict) → decision (the verdict interpolated into the
483
+ decision array) → action fan-out. The auto-clear row is rejected
484
+ at the filter and never reaches the agent. Poll
485
+ `batchLogs.refresh` until the run leaves `:pending`; the window
486
+ is generous because the gray-zone row's enrichment is a live LLM
487
+ inference call.
488
+
489
+ ```typescript
490
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
491
+ sql_where_clause: `rcs.batch_id IN ('${ctx.batchGrayZone}', '${ctx.batchAutoClear}')`,
492
+ mode: 'live',
493
+ manual_override: false,
494
+ })
495
+ ctx.runLogId = runResp.data.workflow_run_log_id!
496
+ ctx.runBatchId = runResp.data.batch_id!
497
+
498
+ const deadline = Date.now() + 240_000
499
+ let status: string | null = null
500
+ while (Date.now() < deadline) {
501
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
502
+ status = log.status ?? null
503
+ if (status && status !== 'pending') break
504
+ await new Promise((r) => setTimeout(r, 2_000))
505
+ }
506
+ if (status === 'failed') throw new Error('agent-driven workflow run reached :failed')
507
+ if (!status || status === 'pending') {
508
+ throw new Error('workflow run did not leave :pending within 240s')
509
+ }
510
+ ```
511
+
512
+ ## 012 — verify the gray-zone filter routed and the agent steered the fan-out
513
+
514
+ Each row produced a Workflow Execution Log. The gray-zone
515
+ screening passes the filter, reaches the agent, and its WEL is
516
+ `:executing` or `:completed`. The auto-clear screening fails the
517
+ filter, so its WEL is `:filtered` — it never reached the agent. A
518
+ run where both passed — or both were filtered — would mean the
519
+ gray-zone band filter is not actually evaluating the score.
520
+
521
+ The pass-branch WEL carries exactly **two** action execution logs
522
+ — one per verdict — and exactly **one** is matched (`:pending` or
523
+ `:completed`, the verdict the agent emitted) while the other is
524
+ `:skipped`. That is the proof the agent's verdict actually steered
525
+ the decision rather than firing every action.
526
+
527
+ ```typescript
528
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
529
+ const ourWels = (wfLogs.data ?? []).filter(
530
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
531
+ )
532
+ if (ourWels.length !== 2) {
533
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
534
+ }
535
+
536
+ const byStatus: Record<string, number> = {}
537
+ for (const w of ourWels) {
538
+ const st = (w as { status?: string }).status ?? 'unknown'
539
+ byStatus[st] = (byStatus[st] ?? 0) + 1
540
+ }
541
+ if ((byStatus.filtered ?? 0) !== 1) {
542
+ throw new Error(
543
+ `expected 1 :filtered WEL (the auto-clear screening) — distribution ${JSON.stringify(byStatus)}`,
544
+ )
545
+ }
546
+ const passWel = ourWels.find((w) => {
547
+ const st = (w as { status?: string }).status
548
+ return st === 'executing' || st === 'completed'
549
+ })
550
+ if (!passWel) {
551
+ throw new Error(`no pass-branch WEL — distribution ${JSON.stringify(byStatus)}`)
552
+ }
553
+
554
+ const aels =
555
+ (passWel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
556
+ .action_execution_logs ?? []
557
+ if (aels.length !== 2) {
558
+ throw new Error(`expected 2 AELs (one per verdict) on the gray-zone WEL, got ${aels.length}`)
559
+ }
560
+ const aelByStatus: Record<string, number> = {}
561
+ for (const ael of aels) {
562
+ const st = ael.status ?? 'unknown'
563
+ aelByStatus[st] = (aelByStatus[st] ?? 0) + 1
564
+ }
565
+ const matched = (aelByStatus.pending ?? 0) + (aelByStatus.completed ?? 0)
566
+ if (matched !== 1) {
567
+ throw new Error(`expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(aelByStatus)}`)
568
+ }
569
+ if ((aelByStatus.skipped ?? 0) !== 1) {
570
+ throw new Error(`expected 1 :skipped AEL — ${JSON.stringify(aelByStatus)}`)
571
+ }
572
+ const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
573
+ if (!routed?.decision_key || !/^(verified|blocked)$/.test(routed.decision_key)) {
574
+ throw new Error(`matched AEL has unexpected decision_key ${routed?.decision_key}`)
575
+ }
576
+ ```
577
+
578
+ ## 013 — confirm the verdict-keyed SMS rendered
579
+
580
+ The matched action fired immediately (`trigger_template: 'now'`)
581
+ and persisted a row in the regulated `message` dataset, carrying
582
+ the fully-rendered SMS body. The body is prefixed with the
583
+ verdict decision_key — `[verified]` or `[blocked]` — followed by
584
+ the agent's free-text `comments`. For this cookbook's fixture
585
+ entity name the agent is prompted toward `verified`, so the
586
+ persisted SMS is prefixed `[verified]`. Search the message
587
+ dataset scoped to this workflow and assert the prefix.
588
+
589
+ ```typescript
590
+ const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
591
+ search_query: `rm.workflow_id = '${workflowId}'`,
592
+ })
593
+ if (userSearch.status !== 'completed') {
594
+ throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
595
+ }
596
+
597
+ const deadline = Date.now() + 45_000
598
+ let messages: Array<Record<string, unknown>> = []
599
+ while (Date.now() < deadline && messages.length === 0) {
600
+ const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
601
+ userSearchId: userSearch.id!,
602
+ dataAccessMode: 'regulated',
603
+ })
604
+ messages = (data.data ?? []) as Array<Record<string, unknown>>
605
+ if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
606
+ }
607
+ if (messages.length === 0) {
608
+ throw new Error('no verdict SMS message persisted for the workflow within 45s')
609
+ }
610
+
611
+ const verdictBody = messages
612
+ .map((m) => String(m.body ?? ''))
613
+ .find((body) => body.includes('[verified]') || body.includes('[blocked]'))
614
+ if (!verdictBody) {
615
+ throw new Error(`no verdict-prefixed SMS body — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
616
+ }
617
+ if (!verdictBody.includes('[verified]')) {
618
+ throw new Error(`expected the agent verdict to render [verified] — got: ${verdictBody}`)
619
+ }
620
+ ```
621
+
622
+ # Branches
623
+
624
+ - **The auto-clear screening is filtered, not failed** — §012
625
+ asserts the `0.20`-scored screening's WEL is `:filtered`, a
626
+ distinct terminal status from `:failed`. A filtered row is a
627
+ *correct* outcome: the workflow looked at it, the gray-zone
628
+ filter rendered empty because the score was below `0.80`, and
629
+ the platform recorded the row as intentionally skipped. The
630
+ agent is never called for a filtered row — which is the whole
631
+ point of the band filter: do not spend an LLM inference on a
632
+ screening a threshold already settles.
633
+ - **The blocked verdict** — the agent's response schema admits
634
+ `blocked` as well as `verified`. A gray-zone screening on a
635
+ genuinely sanctioned entity would have the agent emit `blocked`,
636
+ the decision interpolate `["blocked"]`, and the blocked-keyed
637
+ SMS fire instead. This cookbook's fixture entity name is
638
+ deliberately disambiguating so the run is deterministic; a
639
+ production deployment lets the model's judgement pick the
640
+ branch.
641
+ - **Agent emits an out-of-enum verdict** — the LLM response
642
+ schema's `enum` constraint guards the decision interpolation.
643
+ If the model returned an unexpected string the workflow row's
644
+ execution log would land `:failed`. Production deployments
645
+ either tighten the prompt or add a fallback action.
646
+
647
+ # Rollback
648
+
649
+ The cookbook doctest harness does not currently tear down created
650
+ resources. The `_setup/payment_risk.md` setup file's
651
+ runSuffix-scoped tenant / datalake / user names mean each run is
652
+ naturally isolated; the seeded local DB is cheap to reset
653
+ (`mix ecto.reset` on the platform repo).
654
+
655
+ # Outcome
656
+
657
+ After this cookbook's thirteen steps run green:
658
+
659
+ - A payment-risk tenant exists with a payment-risk-domain
660
+ datalake
661
+ - An SMS tool, a chat-completion LLM tool, a Sanctions Review AI
662
+ agent (gemma3:1b, verified/blocked response schema), and a
663
+ Sanctions Review agent-driven workflow are all registered
664
+ - An Atomic FI data source, a Manual Upload tool, a Compliance
665
+ Screening interop contract, and a manual-upload DAC form a
666
+ working ingestion chain
667
+ - Two compliance screenings have been ingested through that chain
668
+ — one gray-zone-scored, one auto-clear
669
+ - Running the workflow routed them correctly: the gray-zone
670
+ screening passed the band filter, reached the agent, and fanned
671
+ out to a verdict-keyed SMS; the auto-clear screening was
672
+ `:filtered` before any agent call
673
+ - The pass-branch WEL is verified row-by-row — two AELs, exactly
674
+ one matched and one `:skipped`, proving the agent's verdict
675
+ steered the decision
676
+ - The rendered SMS in the `message` dataset carries the
677
+ `[verified]` verdict prefix
678
+
679
+ The business outcome — gray-zone sanctions screenings
680
+ disambiguated by an LLM and routed to a verdict-specific SMS,
681
+ with clear-cut screenings filtered out of the agent path
682
+ entirely — is demonstrated end-to-end, not merely provisioned.
683
+
684
+ # See also
685
+
686
+ - `_setup/payment_risk.md` — the inlined bootstrap that
687
+ provisions the tenant + datalake this cookbook starts from
688
+ - `kyc-notification-on-account-activation.md` — the
689
+ static-decision payment-risk cookbook; same SMS wiring, full
690
+ data-activation chain, no agent
691
+ - `score-leads-with-llm-categorization.md`,
692
+ `contact-us-triage-with-llm.md` — the foundation and healthcare
693
+ agent-driven cookbooks; same agent + decision-interpolation
694
+ shape
695
+ - `.agent/tools.md` — SMS and chat-completion tool body shapes;
696
+ intent classification
697
+ - `.agent/ai_agents.md` — AI agent registration shape; input +
698
+ response schemas; `data_access` regulated vs unregulated
699
+ - `.agent/workflows.md` — standard and agent-driven workflow
700
+ primitives; `ai_agents`, `context_mapping_config`, and
701
+ agent-output decision interpolation
702
+ - `.agent/interoperability_contracts.md` — custom contract shape,
703
+ `template_config` vs `mdm_input_config`
704
+ - `.agent/cookbook/_fixtures/payment_risk/` — the vendored Atomic
705
+ FI compliance-screening / MDM Liquid templates §007 loads
706
+ - `integration-tests/tests/payment_risk/sanctions-review-workflow.test.ts` —
707
+ the anchor green test these snippets are lifted from
708
+ - `integration-tests/tests/payment_risk/interoperability-contracts.test.ts`,
709
+ `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
710
+ `data-sources.test.ts` — the per-resource create + ingest
711
+ snippets §005–§010 are lifted from