@alvera-ai/platform-sdk 0.13.0 → 0.15.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 +273 -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 +35 -1
  9. package/.agent/cookbook/bulk-ingest.md +48 -0
  10. package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
  11. package/.agent/cookbook/dunning-sms-for-delinquent.md +33 -1
  12. package/.agent/cookbook/generic-tables.md +40 -0
  13. package/.agent/cookbook/kyc-notification-on-account-activation.md +35 -1
  14. package/.agent/cookbook/marketing-campaign-send.md +35 -0
  15. package/.agent/cookbook/paginated-restapi-poller.md +383 -0
  16. package/.agent/cookbook/rest-fetch.md +27 -0
  17. package/.agent/cookbook/sanctions-screening-with-agent-review.md +35 -1
  18. package/.agent/cookbook/score-leads-with-llm-categorization.md +36 -1
  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 +33 -1
  22. package/.agent/cookbook/welcome-sms-for-customers.md +33 -1
  23. package/.agent/datalakes.md +68 -3
  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
@@ -182,7 +182,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
182
182
  decision_key: DECISION_KEY,
183
183
  position: 0,
184
184
  trigger_template: 'now',
185
- idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
185
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}',
186
186
  connected_app_id: connectedAppId,
187
187
  connected_app_route: '/portal/welcome',
188
188
  connected_app_metadata_template: '{"customer_id":"{{ mdm_output.customer.id }}"}',
@@ -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
@@ -240,7 +240,7 @@ a hostname pattern; values like `"invalid_host!"` (with
240
240
  non-hostname punctuation) are rejected.
241
241
 
242
242
  This is the only field-format constraint surfaced before the
243
- synchronous reachability probe runs (see §6 Gotcha 9) — a
243
+ synchronous reachability probe runs (see §8 Gotcha 9) — a
244
244
  malformed identifier never gets as far as a connection attempt.
245
245
 
246
246
  ### Schema names must be unique within a database
@@ -576,7 +576,7 @@ agent-facing markdown, or the whole-domain catalog can be fetched
576
576
  in one shot via `api.datasets.metadata(tenantSlug, datalakeSlug)`. Pair `.systemDatasets()` with a
577
577
  `page_size: 1` probe per name as a post-migration smoke (proves
578
578
  each industry-built table is queryable; the post-ready probe
579
- code is in §6 Gotcha 3).
579
+ code is in §8 Gotcha 3).
580
580
 
581
581
  ```typescript
582
582
  const { data: catalog } = await api.datalakes.systemDatasets(
@@ -641,7 +641,48 @@ const { data: csv } = await api.datalakes.executeSql(
641
641
  ) // csv is a string, not the JSON envelope
642
642
  ```
643
643
 
644
- ## 6. Gotchas
644
+ ## 6. Logical name → physical table
645
+
646
+ The name you author is not the name you query. Datasets are addressed
647
+ **logically** everywhere in a manifest or SDK call, and **physically** in SQL:
648
+
649
+ | You author | You query in SQL | Schema |
650
+ |---|---|---|
651
+ | `message` | `regulated_messages` | `app_regulated` |
652
+ | `action_log` | `action_logs` | `app_unregulated` |
653
+ | a generic table `clients` | `regulated_alvera_custom_clients` | `app_regulated` |
654
+
655
+ Custom (generic) tables carry an `alvera_custom_` prefix, and the regulated side
656
+ adds a further `regulated_` prefix on top. The schema follows `execute-sql`'s
657
+ `--mode` (`regulated` → `app_regulated`, `unregulated` → `app_unregulated`).
658
+
659
+ **Don't derive these by hand — read them.** Guessing column and table names is
660
+ how six turns get spent on `column "error_message" does not exist` (the real
661
+ column is `failure_reason`):
662
+
663
+ - `alvera get-metadata dataset --only <type>` prints the **physical dataset
664
+ name** plus every column, from the Ecto struct itself.
665
+ - `alvera get-metadata generic-table --only <slug>` prints the exact **primary
666
+ table alias** to use in a `WHERE` clause, with a worked `SELECT`.
667
+
668
+ An `execute-sql` failure on an undefined column, relation, table, or schema now
669
+ names these verbs in its error, so the correction path is one command away.
670
+
671
+ ## 7. The roster owns the dedupe uri — don't inject it per source
672
+
673
+ MDM matches on the full identification triple, so a `source_uri` injected
674
+ per data source participates in matching. Two sources describing the same
675
+ subject then produce two different triples, and the same subject is written
676
+ **twice** instead of being reconciled onto one entity.
677
+
678
+ Derive the dedupe uri **once, at the roster**, and let every data source inherit
679
+ it. Never compute a per-source `source_uri` and expect MDM to see through it.
680
+
681
+ This divergence is **not visible through `get-metadata`**: the roster value and
682
+ the per-source values each look well-formed in isolation, and only the duplicate
683
+ rows downstream reveal the mismatch. Check the derivation, not the metadata.
684
+
685
+ ## 8. Gotchas
645
686
 
646
687
  1. **`create()` does NOT auto-enqueue migration.** The create response
647
688
  returns immediately with `status: 'new'`; Datalake-DB-resident
@@ -780,3 +821,27 @@ const { data: csv } = await api.datalakes.executeSql(
780
821
  `meta.columns[i]`); never assume `row.someColumn`. The `?format=csv`
781
822
  branch returns a CSV **string** with no `meta` envelope — for the
782
823
  structural metadata, use the JSON call.
824
+
825
+ 14. **On a FRESH create, never query the lake before `migrate` has
826
+ run.** The schema does not exist between `create()` and a completed
827
+ migration, so any check that queries the lake in that window fails
828
+ with `relation "…" does not exist` — and if that failing check
829
+ *halts your provisioning flow* (a CLI contract hook's
830
+ `afterCreateOrUpdate` runs exactly there), the halt lands BEFORE
831
+ migrate ever fires. The lake is then created-but-unmigrated, and a
832
+ reconcile-style flow reads it `unchanged` forever after —
833
+ unrecoverable without a manual migrate. Sequence query-the-lake
834
+ checks strictly after migrate + `ready` (in a CLI contract, that
835
+ means `afterMigrate`/`assertReady`, never `afterCreateOrUpdate`);
836
+ the create-then-migrate window is for platform-DB work only (§5's
837
+ storage-location split).
838
+
839
+ 15. **A pooled reader endpoint can serve a stale schema on a
840
+ just-migrated lake.** If a reader profile points at a connection
841
+ pooler (e.g. Neon's `-pooler` host), a per-datalake DB session
842
+ started against the unmigrated schema can keep resolving the old,
843
+ empty `search_path` even after the migration lands — writes then
844
+ fail with `undefined_table` while `executeSql` reads the same table
845
+ fine. Prefer the direct (non-pooler) endpoint for the writer
846
+ profiles; if ingest 202s but nothing lands on a fresh lake, this is
847
+ 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
  ```