@alvera-ai/platform-sdk 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agent/account_management.md +31 -0
  2. package/.agent/action_status_updaters.md +174 -25
  3. package/.agent/ai_sandbox.md +4 -2
  4. package/.agent/connected_apps.md +7 -2
  5. package/.agent/cookbook/action-status-updaters.md +73 -14
  6. package/.agent/cookbook/ai-agent-invoke.md +36 -0
  7. package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
  8. package/.agent/cookbook/birthday-greeting-sms-trigger.md +34 -0
  9. package/.agent/cookbook/bulk-ingest.md +48 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +35 -0
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +32 -0
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +34 -0
  14. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  15. package/.agent/cookbook/paginated-restapi-poller.md +329 -0
  16. package/.agent/cookbook/rest-fetch.md +27 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +34 -0
  18. package/.agent/cookbook/score-leads-with-llm-categorization.md +35 -0
  19. package/.agent/cookbook/system-templates.md +36 -0
  20. package/.agent/cookbook/talk-to-data.md +39 -0
  21. package/.agent/cookbook/triage-prospects-by-priority.md +32 -0
  22. package/.agent/cookbook/welcome-sms-for-customers.md +32 -0
  23. package/.agent/datalakes.md +24 -0
  24. package/.agent/interoperability_contracts.md +29 -0
  25. package/.agent/tool-call-configs.md +11 -0
  26. package/.agent/tools.md +103 -36
  27. package/.agent/type_naming.md +4 -0
  28. package/dist/index.d.mts +189 -19
  29. package/dist/index.d.mts.map +1 -1
  30. package/dist/index.mjs +3 -1
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +2 -2
@@ -547,6 +547,41 @@ for (const wel of ourWels) {
547
547
  }
548
548
  ```
549
549
 
550
+ ## 011 — write the integration test
551
+
552
+ End the build with a test you keep: re-read the workflow and prove the
553
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
554
+ never-matching selection runs the FULL pipeline (selection → filter →
555
+ decision) and intercepts only the final action call, so no message
556
+ leaves, yet the acknowledgement proves the workflow is runnable. This
557
+ block runs live under `make validate-cookbook`.
558
+
559
+ ```typescript
560
+ // Re-GET — the workflow must still be live, or nothing will run.
561
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
562
+ if (wfRow.status !== 'live') {
563
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
564
+ }
565
+ // Behavioural probe — a dry run against a selection no row can match:
566
+ // the pipeline executes end-to-end, the final action call is
567
+ // intercepted, and the acknowledgement carries the run-log id. The
568
+ // clause speaks this workflow's selection dialect: a GENERIC-TABLE
569
+ // dataset is addressed by its own columns (no `ra.` dataset alias —
570
+ // that alias exists only for system-dataset selections).
571
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
572
+ sql_where_clause: "submission_id = 'test-never-matching-submission'",
573
+ mode: 'dry_run',
574
+ manual_override: false,
575
+ })
576
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
577
+ throw new Error('dry-run probe returned no workflow_run_log_id')
578
+ }
579
+ ```
580
+
581
+ If the probe fails in production, escalate with the run response as
582
+ evidence — don't flip the workflow's status or rewrite its configs to
583
+ chase the error.
584
+
550
585
  # Branches
551
586
 
552
587
  - **The filter is permissive** — `filter_config.body: 'true'` passes
@@ -103,6 +103,42 @@ if (typeof detail !== 'string' || detail.length === 0) {
103
103
  }
104
104
  ```
105
105
 
106
+ ## 004 — write the integration test
107
+
108
+ End the walk with a test you keep: anything that references a system
109
+ template by path (`template_config: { type: 'system', path: … }`) depends
110
+ on that path staying in this datalake's scope — so the durable test
111
+ re-asserts the anchor is listed, the domain filter holds, and the detail
112
+ surface renders. This block runs live under `make validate-cookbook`.
113
+
114
+ ```typescript
115
+ const { data: tplList } = await api.templates.systemTemplates(tenantSlug, datalakeSlug)
116
+ const tplPaths = (tplList.data ?? []).map((t) => t.path)
117
+ const anchor = 'data_activation/interoperability/subscription/stripe/customers_subscription_customer'
118
+ if (!tplPaths.some((p) => p.includes(anchor))) {
119
+ throw new Error(`anchor template ${anchor} vanished from the domain-scoped list`)
120
+ }
121
+ const foreign = ['healthcare', 'payments', 'foundation', 'core_banking', 'service_commerce', 'trading']
122
+ const leakedPaths = tplPaths.filter((p) => foreign.some((d) => p.split('/').includes(d)))
123
+ if (leakedPaths.length > 0) {
124
+ throw new Error(`cross-domain templates leaked: ${leakedPaths.join(', ')}`)
125
+ }
126
+ // Behavioural probe — the detail surface must render for the anchor.
127
+ const { data: anchorDetail } = await api.templates.metadataDetails(
128
+ tenantSlug,
129
+ datalakeSlug,
130
+ 'customers_subscription_customer',
131
+ 'data_activation_interoperability',
132
+ )
133
+ if (typeof anchorDetail !== 'string' || anchorDetail.length === 0) {
134
+ throw new Error('anchor template metadataDetails came back empty')
135
+ }
136
+ ```
137
+
138
+ If the anchor disappears in production, escalate with the listing
139
+ evidence — a `type: system` reference that stops resolving is a platform
140
+ regression, not something to paper over by inlining the template body.
141
+
106
142
  # Gotchas
107
143
 
108
144
  - **The list is domain-scoped, not global.** `systemTemplates` only returns
@@ -103,6 +103,45 @@ if (!/\bn\b/.test(csv) || !csv.includes('1')) {
103
103
  }
104
104
  ```
105
105
 
106
+ ## 004 — write the integration test
107
+
108
+ End the walk with a test you keep: both surfaces are read-only, so the
109
+ durable test is the pair of deterministic boundary probes — the
110
+ round-trip works, and the read-only wall holds. Neither depends on an
111
+ LLM provider or seeded data, which is what makes the test durable. This
112
+ block runs live under `make validate-cookbook`.
113
+
114
+ ```typescript
115
+ // Round-trip probe — deterministic, data-independent.
116
+ const probe = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
117
+ sql: 'SELECT 1 AS ok',
118
+ mode: 'unregulated',
119
+ })
120
+ if (typeof probe.data === 'string' || probe.data.data[0]?.[0] !== 1) {
121
+ throw new Error('executeSql round-trip probe failed')
122
+ }
123
+ // Boundary probe — write SQL must be rejected with a 422; the
124
+ // read-only wall is the security property this capability rests on.
125
+ let walled = false
126
+ try {
127
+ await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
128
+ sql: "INSERT INTO legal_entities (legal_name) VALUES ('test-should-never-land')",
129
+ mode: 'unregulated',
130
+ })
131
+ } catch (err) {
132
+ const status = (err as { _httpStatus?: number })._httpStatus
133
+ if (status !== 422) throw err
134
+ walled = true
135
+ }
136
+ if (!walled) {
137
+ throw new Error('executeSql accepted a write statement — the read-only wall is down')
138
+ }
139
+ ```
140
+
141
+ If the write probe ever lands in production, stop and escalate
142
+ immediately with the accepted statement as evidence — a read-only
143
+ boundary failure is a security regression, never a config problem.
144
+
106
145
  # Branches
107
146
 
108
147
  - **Generation failure.** If every configured LLM provider fails, `textToSql`
@@ -480,6 +480,38 @@ for (const wel of ourWels) {
480
480
  }
481
481
  ```
482
482
 
483
+ ## 013 — write the integration test
484
+
485
+ End the build with a test you keep: re-read the workflow and prove the
486
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
487
+ never-matching selection runs the FULL pipeline (selection → filter →
488
+ decision) and intercepts only the final action call, so no message
489
+ leaves, yet the acknowledgement proves the workflow is runnable. This
490
+ block runs live under `make validate-cookbook`.
491
+
492
+ ```typescript
493
+ // Re-GET — the workflow must still be live, or nothing will run.
494
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
495
+ if (wfRow.status !== 'live') {
496
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
497
+ }
498
+ // Behavioural probe — a dry run against a selection no row can match:
499
+ // the pipeline executes end-to-end, the final action call is
500
+ // intercepted, and the acknowledgement carries the run-log id.
501
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
502
+ sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
503
+ mode: 'dry_run',
504
+ manual_override: false,
505
+ })
506
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
507
+ throw new Error('dry-run probe returned no workflow_run_log_id')
508
+ }
509
+ ```
510
+
511
+ If the probe fails in production, escalate with the run response as
512
+ evidence — don't flip the workflow's status or rewrite its configs to
513
+ chase the error.
514
+
483
515
  # Branches
484
516
 
485
517
  - **Agent emits an out-of-enum band** — the response schema's
@@ -520,6 +520,38 @@ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
520
520
  }
521
521
  ```
522
522
 
523
+ ## 014 — write the integration test
524
+
525
+ End the build with a test you keep: re-read the workflow and prove the
526
+ pipeline still executes — without a side effect. `mode: 'dry_run'` with a
527
+ never-matching selection runs the FULL pipeline (selection → filter →
528
+ decision) and intercepts only the final action call, so no message
529
+ leaves, yet the acknowledgement proves the workflow is runnable. This
530
+ block runs live under `make validate-cookbook`.
531
+
532
+ ```typescript
533
+ // Re-GET — the workflow must still be live, or nothing will run.
534
+ const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
535
+ if (wfRow.status !== 'live') {
536
+ throw new Error(`workflow regressed from live: ${wfRow.status}`)
537
+ }
538
+ // Behavioural probe — a dry run against a selection no row can match:
539
+ // the pipeline executes end-to-end, the final action call is
540
+ // intercepted, and the acknowledgement carries the run-log id.
541
+ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
542
+ sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
543
+ mode: 'dry_run',
544
+ manual_override: false,
545
+ })
546
+ if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
547
+ throw new Error('dry-run probe returned no workflow_run_log_id')
548
+ }
549
+ ```
550
+
551
+ If the probe fails in production, escalate with the run response as
552
+ evidence — don't flip the workflow's status or rewrite its configs to
553
+ chase the error.
554
+
523
555
  # Branches
524
556
 
525
557
  - **The no-phone customer is filtered, not failed** — §011
@@ -780,3 +780,27 @@ const { data: csv } = await api.datalakes.executeSql(
780
780
  `meta.columns[i]`); never assume `row.someColumn`. The `?format=csv`
781
781
  branch returns a CSV **string** with no `meta` envelope — for the
782
782
  structural metadata, use the JSON call.
783
+
784
+ 14. **On a FRESH create, never query the lake before `migrate` has
785
+ run.** The schema does not exist between `create()` and a completed
786
+ migration, so any check that queries the lake in that window fails
787
+ with `relation "…" does not exist` — and if that failing check
788
+ *halts your provisioning flow* (a CLI contract hook's
789
+ `afterCreateOrUpdate` runs exactly there), the halt lands BEFORE
790
+ migrate ever fires. The lake is then created-but-unmigrated, and a
791
+ reconcile-style flow reads it `unchanged` forever after —
792
+ unrecoverable without a manual migrate. Sequence query-the-lake
793
+ checks strictly after migrate + `ready` (in a CLI contract, that
794
+ means `afterMigrate`/`assertReady`, never `afterCreateOrUpdate`);
795
+ the create-then-migrate window is for platform-DB work only (§5's
796
+ storage-location split).
797
+
798
+ 15. **A pooled reader endpoint can serve a stale schema on a
799
+ just-migrated lake.** If a reader profile points at a connection
800
+ pooler (e.g. Neon's `-pooler` host), a per-datalake DB session
801
+ started against the unmigrated schema can keep resolving the old,
802
+ empty `search_path` even after the migration lands — writes then
803
+ fail with `undefined_table` while `executeSql` reads the same table
804
+ fine. Prefer the direct (non-pooler) endpoint for the writer
805
+ profiles; if ingest 202s but nothing lands on a fresh lake, this is
806
+ the first thing to check. (Platform tracking: platform#784.)
@@ -236,6 +236,35 @@ match the consumer's expectations. If `filter_result === 'skip'`
236
236
  (`stage === 'filtered'`), the row was filtered out before rendering
237
237
  — useful for verifying the filter semantics on a known-skip example.
238
238
 
239
+ #### `.run` takes your payload VERBATIM — the pipeline adds two fields
240
+
241
+ The sandbox renders exactly what you send it. A real ingest does
242
+ not: before the templates see a row, the pipeline injects two
243
+ fields the sandbox has no way to know.
244
+
245
+ ```
246
+ source_uri the URI of the DATA SOURCE behind the bound data
247
+ activation client — not anything in the row. A datalake's
248
+ default manual-upload data source is created with
249
+ `<datalake_id>.alvera.ai`, so that is what a row ingested
250
+ through it carries.
251
+ timezone the datalake's timezone
252
+ ```
253
+
254
+ Neither is bound during `.run` — there is no client in scope — so
255
+ `{{ p.source_uri }}` renders whatever your sample row happens to
256
+ carry (commonly nothing, or a placeholder like `test://crm/clients`).
257
+ Both templates are faithful; the INPUT differs.
258
+
259
+ The consequence bites when the identification `uri` is derived from
260
+ `source_uri`: MDM stores the rendered `uri` verbatim, so the sandbox
261
+ shows your placeholder and the persisted
262
+ `legal_entity_identifications` row shows `<datalake_id>.alvera.ai`.
263
+ **Do not assert on a `uri` that comes from `source_uri` in `.run`
264
+ output** — assert on `id_type` / `id_number`, which are row-derived
265
+ and survive persistence unchanged. To verify the client-derived
266
+ half, ingest through the DAC and query the persisted row.
267
+
239
268
  ### Generic-table contracts: auto-created identity contracts
240
269
 
241
270
  When you create a generic table (see `generic_tables.md`), the
@@ -17,6 +17,7 @@ Fetches data via REST API with pagination support.
17
17
  - **body** (TemplateConfig, nullable) — Request body, rendered as Liquid template
18
18
  - **params** (TemplateConfig, nullable) — Query parameters, rendered as Liquid template
19
19
  - **pagination_context_template** (TemplateConfig) — Pagination control template, must render to JSON with `has_next` boolean field
20
+ - **events_template** (TemplateConfig, nullable) — Extracts the raw event array from a response page; required when this config drives an action status updater REST poll (the poll fails with `missing_events_template` otherwise), unused by data-activation fetches
20
21
 
21
22
  <!-- alvera:section kind="tool-call-config" atom="sql_query" module="Platform.Tools.ToolCallConfig.SQLQuery" -->
22
23
 
@@ -79,6 +80,16 @@ Sends an SMS message via the configured SMS provider.
79
80
  - **body** (TemplateConfig) — Message body, rendered as Liquid template
80
81
  - **sms_type** (enum: transactional, promotional) — Message classification; defaults to `transactional`
81
82
 
83
+ <!-- alvera:section kind="tool-call-config" atom="mms_request" module="Platform.Tools.ToolCallConfig.MMSRequest" -->
84
+
85
+ ## Tool Call Configuration (mms_request)
86
+
87
+ Sends an MMS (media message) via AWS End User Messaging.
88
+
89
+ - **to** (TemplateConfig) — Recipient phone number in E.164 format, rendered as Liquid template
90
+ - **body** (TemplateConfig) — Message body (caption), rendered as Liquid template
91
+ - **media_url** (string) — Public http(s) URL of the media to attach; verified reachable (GET 2xx + image/*) at authoring, fetched and re-staged into the tool's S3 media bucket at send
92
+
82
93
  <!-- alvera:section kind="tool-call-config" atom="email_request" module="Platform.Tools.ToolCallConfig.EmailRequest" -->
83
94
 
84
95
  ## Tool Call Configuration (email_request)
package/.agent/tools.md CHANGED
@@ -36,6 +36,7 @@ Intent enum (typed):
36
36
 
37
37
  ToolIntent.DATA_EXCHANGE // fetch / push payloads
38
38
  ToolIntent.SMS // outbound text messages
39
+ ToolIntent.MMS // outbound MMS (media) messages
39
40
  ToolIntent.EMAIL // outbound email
40
41
  ToolIntent.VOICE // outbound voice / voicemail
41
42
  ToolIntent.EXPORT // report / extract generation
@@ -53,11 +54,11 @@ Intent enum (typed):
53
54
  // with AI agents (see ai_agents.md).
54
55
  ```
55
56
 
56
- `ToolIntent` has exactly these seven members — the wire values are
57
- `sms`, `email`, `export`, `voice`, `data_exchange`, `status_poller`,
58
- `llm_enrichment` (`Ecto.Enum` in `platform/lib/platform/tools/tool.ex`
59
- line 236; `enum ToolIntent` in
60
- `packages/sdk/src/generated/types.gen.ts` line 863). There is no
57
+ `ToolIntent` has exactly these eight members — the wire values are
58
+ `sms`, `mms`, `email`, `export`, `voice`, `data_exchange`,
59
+ `status_poller`, `llm_enrichment` (`Ecto.Enum` in
60
+ `platform/lib/platform/tools/tool.ex` line 241; `enum ToolIntent` in
61
+ `packages/sdk/src/generated/types.gen.ts` line 881). There is no
61
62
  `chat_completion` or `context_extraction` intent.
62
63
 
63
64
  Polymorphic body dispatch — the `body.tool_body_type` field is
@@ -73,6 +74,10 @@ the discriminator. Each branch is its own TypeScript type:
73
74
  tool_body_type = 'rest_api' → generic REST endpoint
74
75
  tool_body_type = 'sns' → AWS SNS topic (used for
75
76
  SMS / push notifications)
77
+ tool_body_type = 'end_user_messaging'
78
+ → AWS End User Messaging (used
79
+ for MMS / media messages via
80
+ the SendMediaMessage API)
76
81
  tool_body_type = 'sharepoint' → Microsoft Graph SharePoint
77
82
  tool_body_type = 'aws_lambda' → invoke a Lambda function
78
83
  tool_body_type = 'sqs' → AWS SQS queue
@@ -95,9 +100,9 @@ discriminator is a string-literal on each branch of the generated
95
100
  Consult the branch literals in
96
101
  `packages/sdk/src/generated/types.gen.ts` for the complete set,
97
102
  since new body types land independently of doc revisions. The
98
- platform declares the same eleven variants in
103
+ platform declares the same twelve variants in
99
104
  `platform/lib/platform/tools/tool.ex` (`polymorphic_embeds_one(:body,
100
- …)`, lines 250266).
105
+ …)`, lines 255269).
101
106
 
102
107
  ## 2. Rules the type cannot encode
103
108
 
@@ -111,6 +116,7 @@ create time:
111
116
  data_exchange → s3, sftp, sql_database, rest_api,
112
117
  manual_upload, aws_lambda, sharepoint, sqs
113
118
  sms → sns, rest_api (provider-specific)
119
+ mms → end_user_messaging
114
120
  email → email (dedicated body), rest_api,
115
121
  aws_lambda (custom dispatchers)
116
122
  voice → rest_api (Twilio Voice, etc.)
@@ -148,6 +154,21 @@ Each branch has its own required-list. Common patterns:
148
154
  + optional endpoint_url)
149
155
  OR (iam_role)
150
156
 
157
+ end_user_messaging body region, auth_method (same three-way
158
+ access_key / iam_role / assume_role
159
+ shape as sns — gotcha 3), phone_number
160
+ (E.164 origination number), media_bucket,
161
+ configuration_set_name, plus EITHER
162
+ (access_key_id + secret_access_key)
163
+ OR (iam_role)
164
+ OR (assume_role_arn +
165
+ assume_role_external_id);
166
+ optional endpoint_url (overrides the End
167
+ User Messaging API endpoint) and
168
+ media_endpoint_url (overrides the S3
169
+ media-staging endpoint; blank ⇒ S3
170
+ derived from the tool's region)
171
+
151
172
  sql_database body db_type, db_host, db_port, db_name,
152
173
  auth_method, db_username,
153
174
  db_password (or iam_role)
@@ -383,7 +404,7 @@ Common rejections:
383
404
  ───────────────────────────── ─────────────────────────────
384
405
  "is invalid" on intent value not in ToolIntent enum
385
406
  "is invalid" on tool_body_type value not one of the
386
- eleven body-branch
407
+ twelve body-branch
387
408
  discriminator literals
388
409
  "is invalid" on body intent ↔ body_type pairing
389
410
  rejected at platform level
@@ -430,22 +451,32 @@ await api.tools.testInvocation(
430
451
  inlines the value verbatim; `type: 'system'` references a
431
452
  platform-shipped template (see `templates.md`).
432
453
 
433
- **Test-invocation accepts exactly FOUR `tool_call_type` variants** —
454
+ **Test-invocation accepts exactly FIVE `tool_call_type` variants** —
434
455
  the interactive ones. Its request casts into a `ManualToolInvocation`
435
456
  embed (`on_type_not_found: :raise`) whose polymorphic set is
436
- `sms_request`, `email_request`, `restapi_request`,
457
+ `sms_request`, `mms_request`, `email_request`, `restapi_request`,
437
458
  `aws_lambda_request`. A tool whose body is SFTP / S3 / SQL /
438
459
  SharePoint has **no** test-invocation variant and cannot be exercised
439
460
  through this endpoint (same shape as the `status_poller` case below).
440
461
  Those config shapes still exist — but on the broader **workflow-action
441
462
  `ToolCallConfig`** surface (see `tool-call-configs.md`), not here. The
442
- per-branch required-list for the four test-invocable variants:
463
+ per-branch required-list for the five test-invocable variants:
443
464
 
444
465
  ```
445
466
  tool_call_type: 'sms_request'
446
467
  required: to, body, sms_type
447
468
  enums: sms_type ∈ { 'transactional' (default), 'promotional' }
448
469
 
470
+ tool_call_type: 'mms_request'
471
+ required: to, body, media_url
472
+ notes: body is the message caption; media_url is a PLAIN
473
+ STRING — a public http(s) URL, no Liquid. The server
474
+ verifies it at authoring (GET must answer 2xx with an
475
+ image/* content-type; malformed/non-http 422s without
476
+ fetching), then re-stages it into the tool's
477
+ media_bucket before send — targets a tool whose body
478
+ is end_user_messaging
479
+
449
480
  tool_call_type: 'email_request'
450
481
  required: to, subject, body
451
482
  embeds: TemplateConfig variants accepted on each ({ type:
@@ -472,10 +503,10 @@ the workflow-action `ToolCallConfig` catalog in `tool-call-configs.md`;
472
503
  they are **not** part of test-invocation.
473
504
 
474
505
  Liquid template bodies (`query`, `payload`, body/params on
475
- `restapi_request`, `sms_request` body, `email_request` body) are
476
- parsed at create time — invalid Liquid syntax surfaces as a field
477
- rejection on the corresponding TemplateConfig path, not on the
478
- outer request.
506
+ `restapi_request`, `sms_request` body, `mms_request` body,
507
+ `email_request` body) are parsed at create time — invalid Liquid
508
+ syntax surfaces as a field rejection on the corresponding
509
+ TemplateConfig path, not on the outer request.
479
510
 
480
511
  The response shape:
481
512
 
@@ -495,27 +526,63 @@ Only platform-internal failures (DB unreachable, etc.) produce
495
526
  5xx. Treat 5xx as infra noise; treat `200 + status:"error"` as a
496
527
  real (and handleable) configuration problem.
497
528
 
498
- ### `status_poller` tools cannot be test-invoked the proof seam is the ASU
499
-
500
- The `tool_call_type` set above is closed, and it carries **no
501
- variant** for a `status_poller`-intent tool (`cloud_watch_log_group`
502
- or a poller-side `rest_api`) a poller tool cannot be exercised
503
- standalone. Its run surface is the **action status updater that
504
- fetches through it**: after the ASU's cron fires, read the ASU's
505
- read-only `last_run_at` / `last_run_status` / `last_run_error` /
506
- `last_run_events_found` fields (they are excluded from the drift
507
- checksum, so a poll never reads as config drift) and the `Last run`
508
- section of the ASU's AI metadata. A failed poll lands its
509
- structured reason in `last_run_error`.
510
-
511
- There is **no manual trigger and no per-run log** for an ASU —
512
- unlike a data activation client (run-manually + `/logs`) or a
513
- workflow (execute + workflow-logs) — so `last_run_*` is the ONLY
514
- run surface; see `action_status_updaters.md` §7 (KNOWN
515
- LIMITATION). A born-red contract for a poller tool should assert
516
- the tool's own create/config truths and prove the polling
517
- behaviour on the owning ASU's `last_run_*`, not chase a
518
- test-invocation variant that doesn't exist.
529
+ ### Proving a `status_poller` tool it depends on the BODY, not the intent
530
+
531
+ Invocability is keyed on the tool's **body type**, not its intent.
532
+ `restapi_request` is the call type for ANY intent with a `rest_api`
533
+ body, so a `status_poller` with a `rest_api` body the shape a
534
+ provider events-API poller takes **can** be test-invoked, and
535
+ that is the cheapest way to prove its credentials:
536
+
537
+ ```typescript
538
+ await api.tools.testInvocation(tenantSlug, datalakeSlug, pollerId, {
539
+ tool_call: {
540
+ tool_call_type: 'restapi_request',
541
+ method: 'get',
542
+ path: { type: 'custom', body: '/v3/YOUR-DOMAIN/events' },
543
+ params: { type: 'custom', body: '{"limit": 1}' }, // keep it read-only
544
+ },
545
+ })
546
+ ```
547
+
548
+ A `cloud_watch_log_group` body is the one that has no call type —
549
+ nothing in the closed `tool_call_type` set fits it. **That** poller's
550
+ run surface is the action status updater that fetches through it:
551
+ its `last_run_at` / `last_run_status` / `last_run_error` /
552
+ `last_run_events_found` fields (excluded from the drift checksum, so
553
+ a poll never reads as config drift) plus the `Last run` section of
554
+ the ASU's AI metadata. A failed poll lands its structured reason in
555
+ `last_run_error`.
556
+
557
+ You do **not** have to wait for the cron. `actionStatusUpdaters.refresh`
558
+ fires one poll cycle on demand (CLI: `alvera refresh-action-status-updater
559
+ --id <uuid>`), and reading `last_run_*` correctly is what makes it
560
+ usable as a proof — three things to get right:
561
+
562
+ - **`refresh` answers `202` with the updater row AS-IS.** The poll runs
563
+ asynchronously, so the response carries the PREVIOUS run's stamps.
564
+ Capture `last_run_at` as a baseline BEFORE calling it, then poll the
565
+ row until `last_run_at` advances past that baseline.
566
+ - **`last_run_status` has three values.** `'ok'` means the run completed
567
+ and read its whole time window; `'partial'` means the provider fetch was
568
+ truncated, so the newest events may be missing and `last_run_error`
569
+ carries the detail; `'error'` means the run failed. Only
570
+ `last_run_status === 'ok'` proves a complete reconciliation — a
571
+ `!== 'error'` check silently accepts a truncated `partial`.
572
+ `last_run_events_found` counts the current run's events, and because
573
+ `refresh` is async an early read can show an in-progress figure.
574
+ - **`status` is the halt flag.** It is `'active'` normally and the
575
+ server sets `'cycle_detected'` when a run re-reads events it has
576
+ already handled — every later job then fails without calling the
577
+ provider, and only setting it back to `'active'` resumes polling. It
578
+ is also the fastest deterministic failure to assert on: a
579
+ non-advancing pagination config trips it within seconds, no provider
580
+ credential rejection required.
581
+
582
+ See `action_status_updaters.md` §7 for the full refresh + `last_run_*`
583
+ contract. A born-red contract for a CloudWatch poller should assert the
584
+ tool's own config truths and prove the polling behaviour through the
585
+ owning ASU on those terms.
519
586
 
520
587
  ### Update
521
588
 
@@ -105,6 +105,10 @@ await api.tools.get(tenantSlug, datalakeSlug, toolId)
105
105
  // 2b. downstream resources reference the tool by id
106
106
  await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
107
107
  updater_tool_id: toolId,
108
+ action_log_config: {
109
+ type: 'custom',
110
+ body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
111
+ },
108
112
  // …
109
113
  })
110
114
  ```