# Action status updaters

An **action status updater** is a scheduled job that polls an
external system (e.g. a cloud-provider log group, a delivery
provider's API) for status updates on actions the platform
previously fired, and writes those updates back onto the
matching message rows.

The typical use case: a workflow's SMS action fires through a
provider (a sender tool), and a status updater periodically
polls the provider's log group to mark each message as
`delivered` / `failed` / `bounced` on its tracked record.

SDK namespace: `api.actionStatusUpdaters`.

Action status updaters are **Datalake-DB-resident** — the
parent datalake must be `status: 'ready'` and every
referenced tool must exist before you `create()`.

## 1. Wire shape

```typescript
import type {
  ActionStatusUpdaterRequestWritable,
  ActionStatusUpdaterResponse,
} from '@alvera-ai/platform-sdk'

const { data: created } = await api.actionStatusUpdaters.create(
  tenantSlug,
  datalakeSlug,
  {
    name: 'SMS Delivery Updater',
    cron_expression: '*/30 * * * *',                  // every 30 minutes
    updater_type: 'cloud_watch',                      // polling source family
    updater_tool_id: cloudWatchToolId,                // the tool that issues the poll
    sender_tool_ids: [smsToolId],                     // tools whose sent messages this updater tracks
    datalake_id: datalakeId,

    // Polymorphic — discriminator: updater_body_type
    updater_body: {
      updater_body_type: 'cloud_watch_request',
      log_group_name: 'sns/us-east-1/...',
      start_time: '{{ now_msec | minutes_ago: 45 }}', // Liquid window
      end_time:   '{{ now_msec }}',
    },

    // Liquid, rendered ONCE PER EVENT — the event IS the assigns, so there is no
    // `events` list to loop. Emits a FLAT object: the message to update, plus the
    // fields to set at the top level.
    message_config: {
      type: 'custom',
      body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
    },

    // REQUIRED on every updater, both types, on create AND update. Same
    // per-event rendering contract as `message_config`, but targets the
    // action_log row instead of the message row.
    action_log_config: {
      type: 'custom',
      body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
    },
  },
)
```

## 2. Rules the type cannot encode

### `updater_body` is polymorphic on `updater_body_type`

The body's discriminator selects the polling shape. Each
variant declares the fields the platform needs to construct
the poll request:

```
updater_body_type: 'cloud_watch_request'
  log_group_name   string — the log group to scan
  start_time       string — Liquid template producing a timestamp
  end_time         string — Liquid template producing a timestamp
```

(See the SDK type for the full set of body types — others
follow the same `<verb>_request` naming and carry the fields
their target API requires.)

`updater_body_type: 'restapi_request'` additionally **requires an
`events_template` field**. Without it the REST poll fails with
`missing_events_template`. `events_template` is specific to the REST
poll's event-list extraction — it is not read by data-activation
fetches.

`start_time` / `end_time` are Liquid, and they **must render to
unix milliseconds** — that is what the poll request takes. Use the
**`now_msec`** variable (unix ms) and pipe it through the
relative-time filters. **Not the bare `now`** — that is a DateTime,
and feeding it a millisecond filter now raises at render:

```
start_time: '{{ now_msec | minutes_ago: 45 }}'    // 45 minutes back
end_time:   '{{ now_msec }}'                       // right now
```

Also available: `hours_ago: N`.

**Do not use the bare `now` variable or the ISO-8601 `now` filter
here.** The `now` variable is a DateTime and `{{ "" | now }}`
renders a datetime *string* — neither is a valid unix-ms window,
and the platform rejects both. An ISO 8601 literal is not valid on
these fields either.

### `message_config.body` renders ONCE PER EVENT

The template is rendered **once for each fetched event, with that
event as the entire assigns**. There is no `events` collection in
scope — do not loop.

```liquid
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
```

> **Shape only — do not copy this mapping.** A hardcoded `"delivered"`
> records every event as delivered, failures included. Real bodies
> branch on the provider's status; see the SNS and MMS sections below.

It must render a **flat** JSON object:

```
external_id  string — the message to update (matches the message row's
                       external_id, i.e. the provider's id)
<field>      any    — each remaining TOP-LEVEL key is a field to set:
                       status, sent_at, delivered_at, read_at, opened_at
```

There is **no `set_params` wrapper**. Every key that is not
`external_id` *is* the update. Nesting the fields under
`set_params` renders an object the platform's field allowlist
drops, so the update silently applies to nothing.

The platform joins each rendered `external_id` to a message row
and applies the remaining fields. Events that don't match any
message are silently discarded — a provider poll routinely sees
events for messages this updater doesn't own.

Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
available here, as everywhere.

### SMS-over-SNS delivery tracking — two `custom` bodies

The SNS SMS delivery receipt is **binary and single-shot**: one terminal
outcome, `status` of `SUCCESS` or `FAILURE`. Its event shape is
**disjoint from EUM's** — `status`, `notification.messageId`,
`notification.timestamp`, `delivery.phoneCarrier`,
`delivery.providerResponse` all sit at the root. Do not carry an EUM
template over to SNS or vice versa.

Two convenient consequences, both unlike MMS:

- `delivered_at` is `notification.timestamp` — a UTC
  `"YYYY-MM-DD HH:MM:SS.sss"` string Ecto casts directly, so **no date
  filter is needed** (EUM needs `from_unix_ms` on its Unix-ms field).
- the DLR **does** surface the US carrier as `delivery.phoneCarrier`.

**`message_config`:**

```liquid
{"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"{% if status == 'SUCCESS' and notification.timestamp %}, "delivered_at": "{{ notification.timestamp }}"{% endif %}{% unless status == 'SUCCESS' %}, "failure_reason": "{{ status | json_escape }}"{% endunless %}{% if delivery.phoneCarrier and delivery.phoneCarrier != '' %}, "sms_carrier": "{{ delivery.phoneCarrier | json_escape }}"{% endif %}}
```

**`action_log_config`:**

```liquid
{"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"}
```

`status_description` is `delivery.providerResponse` verbatim on both
sides — the carrier's own text, on success and failure alike. Because
SNS's status is binary, `failure_reason` can only ever be `"FAILURE"`;
the granular carrier text stays in `status_description`.

**Note how the two SNS bodies differ from each other**: same status
mapping, fewer emitted fields on the action log (no `delivered_at`,
`sms_carrier`, or `failure_reason` — those are message concerns). That
is a *different* reason from MMS's, where the two also disagree on the
mapping itself. Both channels need two bodies; they need them for
different reasons.

### MMS delivery tracking needs TWO different templates — author both as `custom`

MMS delivery tracking is a `cloud_watch` updater. Author **both**
`message_config` and `action_log_config` as `custom` bodies — the two
render *different* JSON from the same event, so one body cannot serve
both. This is the single most-missed thing about updaters.

**Why they differ.** A message's status vocabulary includes
`customer_rejected`; an action log's does not. A recipient or carrier
rejection (`SPAM` / `BLOCKED` / `CARRIER_BLOCKED`) is a message-level
outcome — the action *did* deliver — so the action-log render must
**NO-OP** on those events while the message render records
`customer_rejected`. The message side also carries `delivered_at`,
`failure_reason`, and `sms_carrier`; only `status` +
`status_description` are ever applied to an action log.

AWS End User Messaging (`sms-voice`) event fields: top-level
`messageId`, `eventType` (`MEDIA_*`), `messageStatus`,
`messageStatusDescription`, `eventTimestamp` (Unix ms), `isFinal`.

**`message_config`** — map every status, emit the extra fields:

```liquid
{% case messageStatus %}
{% when 'DELIVERED' %}{% assign mapped_status = 'delivered' %}
{% when 'SUCCESSFUL' %}{% assign mapped_status = 'sent' %}
{% when 'PENDING' %}{% assign mapped_status = 'sent' %}
{% when 'QUEUED' %}{% assign mapped_status = 'queued' %}
{% when 'SPAM' %}{% assign mapped_status = 'customer_rejected' %}
{% when 'BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
{% when 'CARRIER_BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
{% else %}{% assign mapped_status = 'failed' %}
{% endcase %}
{"external_id": "{{ messageId }}", "status": "{{ mapped_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% if messageStatus == 'DELIVERED' and eventTimestamp %}, "delivered_at": "{{ eventTimestamp | from_unix_ms }}"{% endif %}{% if mapped_status == 'failed' or mapped_status == 'customer_rejected' %}, "failure_reason": "{{ messageStatus | json_escape }}"{% endif %}{% if carrierName and carrierName != '' %}, "sms_carrier": "{{ carrierName | json_escape }}"{% endif %}}
```

**`action_log_config`** — the rejection statuses fall through to a
bare `external_id`, which applies nothing:

```liquid
{% assign al_status = '' %}{% case messageStatus %}{% when 'DELIVERED' %}{% assign al_status = 'delivered' %}{% when 'SUCCESSFUL' %}{% assign al_status = 'sent' %}{% when 'PENDING' %}{% assign al_status = 'sent' %}{% when 'QUEUED' %}{% assign al_status = 'queued' %}{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}{% else %}{% assign al_status = 'failed' %}{% endcase %}{"external_id": "{{ messageId }}"{% if al_status != '' %}, "status": "{{ al_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% endif %}}
```

Note the `{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}` arm is
deliberately **empty** — it leaves `al_status` blank, so the render
emits `external_id` alone and the action log is untouched.

**Capture every event, not just the terminal one.** 2–3 events arrive
per message — interim `MEDIA_QUEUED` / `MEDIA_PENDING` /
`MEDIA_SUCCESSFUL` (`isFinal: false`), then one terminal event
(`isFinal: true`), up to 72h late and out of order. Apply is
idempotent and a monotonic guard only ever advances `status`, so a
late or interim lower-rank event can never demote a message that
already reached a higher state. Poll a window that overlaps
deliberately; nothing is dropped.

`queued_at` and `sent_at` are **your outbound pipeline's** times, set
when the platform enqueues and dispatches — a delivery poll never
overwrites them. Only `delivered_at` comes from the carrier event
(`DELIVERED`'s own `eventTimestamp` via `from_unix_ms`), so
re-polling a wide window does not drift it.

### Carrier information is available for SMS, not MMS

The recipient's mobile carrier is present on SMS delivery events but
not MMS. This is AWS behavior — verified against real delivery
events — not something the platform chooses to drop:

- **SMS via Amazon SNS** — the delivery-status log
  (`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
  under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
  numeric `mcc`/`mnc`). The SNS status template maps it to
  `sms_carrier`.
- **MMS via AWS End User Messaging (`sms-voice`)** — the event *can*
  carry `carrierName`, but it is DLR-populated and comes back
  **absent for US destinations** (the US DLR does not surface it),
  unlike the SNS path's `phoneCarrier`, whose US DLR does. So map it
  when present — `{% if carrierName and carrierName != '' %}` — and
  expect `sms_carrier` to stay null for US long-code traffic.
  Verified against real AWS delivery events: the terminal
  `MEDIA_DELIVERED` event for a US destination exposes
  `totalCarrierFee` (a cost) and no `carrierName`.

AWS's Nov-2024 EventBridge-integration announcement states MMS
delivery events carry carrier information in EventBridge, but the
real events do not — a documented doc-vs-behavior discrepancy. For
per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
`PhoneType`) at enrollment and join on destination/`messageId`; it is
not in the delivery stream.

### `action_log_config` is required — same rendering contract as `message_config`

`action_log_config` is a **required** field on every action status
updater — `cloud_watch` and `restapi` alike — on **both `create()` and
`update()`**. There is no updater for which it is optional and no way
to omit it on a PUT.

It follows the same per-event rendering contract as `message_config`
(§2 above), but targets the **action_log** row instead of the message
row: it must render a JSON object containing `external_id` plus at
least one updatable action-log field — one of `status`, `sent_at`, or
`metadata`. A minimal valid value:

```liquid
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
```

Two distinct rejections come off this field, and they read differently:

- **Missing entirely** — omitting `action_log_config` on create or
  update is a 422 with `Missing field: action_log_config` at
  `source.pointer: /action_log_config`.
- **Present but renders nothing** — a value that passes shallow
  validation but renders `null` or an empty result at run time (e.g.
  a `{"type": "null"}` config) is refused with a 422 whose detail
  begins `must render a JSON object containing external_id`.

**Response nullability is lazy, not retroactive.** Updaters created
before this became required still read back `action_log_config: null`
— the platform does not backfill a template onto old rows. That
`null` persists until the row's **next edit** forces a real template
through, and "next edit" is broader than a plain field change: it
also includes resuming a halted updater by setting `status` back to
`'active'` (§7) and issuing a `refresh()` call with a one-shot
`updater_body` override (§7) — both now require `action_log_config`
to be present too, which is what completes the migration for an old
row.

### `cron_expression` is a standard 5-field cron string

```
*/30 * * * *      — every 30 minutes
0 * * * *         — every hour on the minute
0 9 * * 1-5       — 9am Mon-Fri
```

Sub-minute cadences are NOT supported. The minimum interval
the scheduler honors depends on platform-level configuration;
default minimum is 5 minutes.

### `updater_type` and `updater_body.updater_body_type` — set them to agree, but only one is load-bearing

The body carries TWO discriminator fields that name the same
choice from different angles:

```
   updater_type                  outer field on the request body
                                  (top-level)
   updater_body.updater_body_type   inner field on the polymorphic
                                  body embed
```

The intended pairing is a fixed mapping:

```
   updater_type: "cloud_watch"   ↔   updater_body_type: "cloud_watch_request"
   updater_type: "restapi"       ↔   updater_body_type: "restapi_request"
```

**But the platform does NOT cross-validate the pair — a mismatch is
NOT rejected.** The two fields are cast independently; there is no
create-time check that `updater_type` matches
`updater_body_type`, so a mismatched-but-well-formed pair (e.g.
`updater_type: "cloud_watch"` + a `restapi_request` body) **persists
silently**. At runtime the platform dispatches on the polymorphic
**body** struct, so `updater_body_type` is the one that matters and
`updater_type` is effectively informational. Set them to agree
anyway — a future release may start enforcing it, and a lying
`updater_type` is a debugging trap — but don't expect a 422 to catch
a mismatch for you today.

### `updater_tool_id` and `sender_tool_ids` serve different roles

- `updater_tool_id` (single) — the tool the updater uses to
  ISSUE the poll. By convention this is a tool whose `intent` is
  `status_poller` (e.g. a `cloud_watch_log_group` body type) — but
  the platform does **not** verify the intent at create time (it
  checks only that the tool exists and belongs to the tenant). A
  wrong-intent tool is accepted and only fails later, when the poll
  actually runs. Wire a `status_poller` tool anyway; just don't rely
  on a create-time 422 to enforce it.
- `sender_tool_ids` (array) — the tools whose sent messages
  this updater is responsible for tracking. The match logic
  joins each polled event back to a message row that was
  sent through one of these tools.

A single status updater can track messages from multiple
sender tools (e.g. an SMS tool plus a voice tool routed
through the same provider).

## 3. Field ownership

**Server-derived (Response-only).** Universal set from
`type_naming.md`.

**Caller-supplied (round-trip).**

```
name                  required string
cron_expression       required string — standard cron
updater_type          required enum   — e.g. 'cloud_watch'
updater_tool_id       required UUID   — the polling tool
sender_tool_ids       required UUID[] — tools whose messages this updater tracks
datalake_id           required UUID
updater_body          required embed  — { updater_body_type, ... }
message_config        required embed  — { type, body }
action_log_config     required embed  — { type, body } (required on create AND update)
```

**Write-only (Request-only).** None.

## 4. Error envelopes

Standard JSON:API envelopes per `errors.md`. Common rejections:

| `source.pointer`                    | Cause                                              |
|-------------------------------------|----------------------------------------------------|
| `/cron_expression`                  | invalid cron syntax or below minimum interval      |
| `/updater_tool_id`                  | tool id doesn't exist or wrong datalake (intent is NOT checked — see §2) |
| `/sender_tool_ids/0`                | tool id doesn't exist or wrong datalake            |
| `/updater_body/updater_body_type`   | value not in the polymorphic embed's type enum     |
| `/message_config/body`              | empty when `type: 'custom'`                        |
| `/action_log_config`                | missing entirely — `Missing field: action_log_config` (create AND update) |
| (detail, not pointer)               | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
| `/base`                             | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |

`refresh()` has two refusals of its own, both **409** with an empty
`source.pointer`: the updater is halted, or a poll for it is already
queued or running. Neither queues anything. See §7.

## 5. Lifecycle

### Create

Synchronous. A successful create registers a scheduled poll
under the supplied `cron_expression`; the first run fires at
the next cron slot at-or-after create time.

The cron registration is part of the create transaction — if
the body validates but scheduler registration fails, the
platform rolls the row back and returns 422 with
`cron_management_failed` on `/base` (see §4). Conversely, a
body that fails structural validation never reaches the
scheduler; cron-job registration only happens on the success
branch.

### Read shapes

```
.list(tenantSlug, datalakeSlug, query?)      Paged: { data, meta }
                                              (full Flop query — type_naming.md)
.get(tenantSlug, datalakeSlug, id)           One row — UUID only
.metadata(tenantSlug, datalakeSlug)          Markdown — catalog
.metadataDetails(tenantSlug, datalakeSlug,   Markdown — one updater
                  id)                         (UUID only)
```

### Update

`update()` replaces the whole resource — resupply the full
body; there's no partial update. The polymorphic `updater_body`
must keep the same `updater_body_type` on update — the
discriminator can't change on `update()`; to change body type,
`delete()` and `create()` fresh.

### Delete

`delete()` removes the row and cancels future scheduled polls.
In-flight polls complete; their writebacks land normally.

The delete is **graceful** with respect to scheduler state: if
the row exists but its cron-job entry is somehow missing or
the scheduler raises during cancellation, the row is still
deleted and the DELETE returns 204. Consumers do not need to
check or retry for partial-delete states.

## 6. Gotchas

1. **The polling tool should be `status_poller` — but nothing
   enforces it at create.** By convention `updater_tool_id` points
   at a `status_poller`-intent tool, and the runtime poll assumes
   that shape. The create endpoint, however, does not check the
   tool's intent (only existence + tenant), so an `intent: 'sms'`
   tool is accepted and fails only when the poll runs. Wire the
   right tool; don't count on a create-time rejection to catch a
   wrong one.

2. **`message_config.body` must render one flat JSON object per
   event.** The two authoring mistakes that cannot work:
   looping a `{% for event in events %}` collection (there is
   none — the event *is* the assigns, so the loop renders
   nothing), and nesting the fields under a `set_params` key
   (the platform's allowlist drops them, so the update applies
   to nothing). Both are now **rejected at create**. A render
   that fails at run time is reported in `last_run_error`.

3. **`external_id` join is exact-match.** Events whose
   `external_id` doesn't match a message row are discarded
   silently. If your provider returns a prefixed id (e.g.
   `provider-prefix/12345`) but your message rows carry
   the bare id, transform inside the Liquid template (string
   filters) so the rendered `external_id` matches.

4. **The fields you set must be writable columns on the
   message row** — `status`, `sent_at`, `delivered_at`,
   `read_at`, `opened_at`. Unknown keys are silently dropped
   during the update. They go at the TOP LEVEL of the rendered
   object, alongside `external_id`.

5. **Cron windows + polling-window overlap.** Choose
   `start_time` / `end_time` to overlap consecutive cron
   intervals (e.g. cron every 30 min, window of 45 min) so
   transient delivery delays don't fall between two polls.
   The defaults in the wire-shape example illustrate this
   pattern: 30-minute cron, 45-minute window.

6. **Key `has_next` off the page being NON-EMPTY, not off the
   cursor's presence.** Real providers (Mailgun is one) return a
   `paging.next` URL on EVERY page — including the final, empty
   one — so a pagination template that emits `"has_next": true`
   whenever the cursor exists loops until the runaway cap. The
   canonical template keys off content instead:

   ```liquid
   "has_next": {% if items.size > 0 %}true{% else %}false{% endif %}
   ```

   The poll follows pages while `has_next` is true and stops on
   the first empty page.

## 7. Manual trigger via `refresh` — 202 or 409, and what `last_run_*` means

An action status updater CAN be fired on demand:

```ts
const { data } = await api.actionStatusUpdaters.refresh(
  tenantSlug, datalakeSlug, updaterId,
);
```

**`refresh` answers `202` with the updater row AS-IS — the poll is
ENQUEUED, not run.** The `last_run_*` fields on that response are the
PREVIOUS run's stamps; reading them as the outcome of the refresh you
just issued is the single most common mistake here. An optional body
carries a one-shot `updater_body` override (e.g. a widened
`start_time`/`end_time` window for a backfill) that does not mutate
the persisted updater. Apply jobs are asynchronous too — observe
delivery status on the message rows, not in this response.
(CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
`refresh-updater`.)

The correct read is baseline-then-advance:

```ts
const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
const baseline = before.data.last_run_at ?? null      // BEFORE refreshing

await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)

let row = before.data
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
  await new Promise((r) => setTimeout(r, 2_000))
  const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
  if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
}
// row.last_run_status / last_run_error now describe THIS run
```

### When `refresh` answers 409, nothing was queued

`refresh` refuses with **409 Conflict** in two cases. The body is the
standard `errors[]` envelope (`errors.md`), thrown with
`_httpStatus === 409`. Either way **no poll is queued**, and a one-shot
`updater_body` override sent with that call is not queued either, so
resend it on the retry.

| Case | `detail` | What clears it |
|------|----------|----------------|
| **Halted**: `status` is not `'active'` | `Updater is halted: status is <status>. Set status to active to resume polling.`, followed by ` Last run error: <text>` when one is recorded | Fix the cause, then `update()` with `status: 'active'` (below). Retrying `refresh` changes nothing. |
| **A poll is already queued or running**: a cron tick's, an earlier refresh's, or a later page of a run under way | `A poll for this updater is already queued, so this refresh was not. Retry once it finishes.` | Wait for that poll to finish (`last_run_at` advances), then call `refresh` again. |

Both carry `title: "Conflict"` and an empty `source.pointer`, so
`title` cannot tell them apart. Read the row instead: the halt is
checked first, so `status !== 'active'` means halted and anything else
means a poll is in flight.

```typescript
async function refreshOrExplain(updaterId: string): Promise<void> {
  try {
    await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
  } catch (err) {
    if ((err as { _httpStatus?: number })._httpStatus !== 409) throw err
    const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
    if (data.status !== 'active') {
      // Halted. Fix the cause, then update() with status: 'active'.
      throw new Error(`updater halted (${data.status}): ${data.last_run_error ?? 'no run error recorded'}`)
    }
    // A poll is already queued or running. Wait for last_run_at to
    // advance past data.last_run_at, then call refresh again.
  }
}
```

### One run at a time per updater

A run reads every page of its window before it ends. A cron tick that
finds an earlier run of the same updater still going does not start a
second run beside it: it **skips**, and records when in
`last_skipped_at`. A skip is not a run, so it leaves `last_run_at`,
`last_run_status`, `last_run_error` and `last_run_events_found` alone.
The same rule is why `refresh` answers 409 while a poll is queued or
running.

`last_skipped_at` is `null` until a tick has skipped. A recent one
means a run outlasted a cron interval. If skips keep recurring, runs
take longer than the interval: lengthen `cron_expression` or narrow
the poll window.

What the ASU still does NOT have is a **per-run log** (nothing like
a data activation client's `logs` or a workflow's `batchLogs`). The
run-outcome surface is four fields on the row itself, plus the skip
stamp and the halt flag:

```
last_run_at            when the poll worker last made progress
last_run_status        'ok' | 'error'
last_run_error         why the last run stopped; null when it read its whole window
last_run_events_found  events fetched so far in the current run
last_skipped_at        when a cron tick last skipped because a run was still going; null if none has
status                 'active' | 'cycle_detected'
```

**A poll run has two outcomes:**

- `'ok'` — the run read its entire window. The reconciliation is
  complete.
- `'error'` — the run stopped; `last_run_error` says why.

**There is no partial run.** The poller drains the window as a chain of
jobs, one page per job, carrying the provider's next-page token into the
next job, and there is no cap on how many pages it reads — so a run
either reads every page of its window or stops with an error. A CloudWatch
poll that is handed the same next-page token twice stops with an error
naming that page rather than reading it again.

`last_run_events_found` is the event count for the current run; because
`refresh` is asynchronous (below), a read taken before the run finishes can
legitimately show a smaller, in-progress figure than the final count.

**`status` is the halt flag.** The server sets `'cycle_detected'` when
a run re-reads events it has already handled — the runtime backstop for
a poll whose pagination does not advance (a provider cursor that never
drains, or a one-shot `updater_body` override). Every later job then
fails without calling the provider, and `last_run_error` carries the
diagnostic. **Nothing clears it but setting `status` back to
`'active'`.** While it is halted, `refresh` answers 409 and queues
nothing (above). It is also the cheapest deterministic failure to assert
on: a non-advancing config trips it within seconds, no provider
credential rejection required. Related: a `restapi` updater that
declares pagination but whose `path`/`params`/`body` never read
`msg.pagination_context` or `msg.page` is now refused at create/update
— see §2's template contract.

**If a message's status is not reconciling, do this — in order:**

1. Call `refresh()`. A 409 says why nothing was queued: halted, or
   a poll already in flight (above). Otherwise wait for `last_run_at`
   to advance past the baseline you captured, then read `last_run_status` /
   `last_run_error` / `status`. They name the failing stage: window
   render, source fetch, template render, JSON parse — or a halted
   poll cycle.
2. Check the config against §2 above. A config that violates the
   template contract is rejected at create, so a *deployed* updater's
   templates are structurally sound — the fault is more likely the
   event shape or the `external_id` join.
3. **If those don't explain it, ESCALATE TO A HUMAN.** Report the
   updater id, `last_run_error`, and the event shape you expected.

A wrong config guess is worse than no change: replacing the unix-ms
poll window with the ISO-8601 `now` filter, for example, stops the
updater polling at all — verify each `refresh` outcome before
touching the config again.

## 8. Where delivery truth comes from (consumer view)

If you only READ the platform (an operator UI, a dashboard), this
is the part that matters:

```
   dispatch writes:            messages row, status 'sent',
                               external_id = provider's id
   THIS updater writes:        'delivered'  or  'failed' +
                               failure_reason (+ delivered_at)
   nothing else writes those.
```

- **No updater configured → no delivery truth.** Rows stay `sent`
  forever; a delivery-rate number computed from them is fiction.
  Updaters are created manually (this page §1) — the platform does
  not auto-create them per sender tool.
- **The join is `{external_id, set_params}`,** exact-match, silent
  on misses (gotcha 3). "Eventually honest": a just-sent row is
  neither delivered nor failed yet — show the in-between state.
- **Retry is not here.** The updater only reports. Re-sending a
  failed message is `workflows.execute` for that subject +
  decision (`mode: 'live'`, `manual_override: true`) — the same
  call as any manual dispatch; there is no retry endpoint. See
  `workflows.md` §8.
- **Engagement is not here either.** `clicked` arrives via
  `authenticatedLinks.exchange`, and `opened` / `form_submitted`
  with their times via `authenticatedLinks.updateMessageTracking`
  — both called by the host behind the action's `link_prefix`
  (`authenticated_links.md`).
- Field-by-field semantics of the rows being updated:
  `messages.md`, `action_logs.md`.
