# Action logs (datalake dataset)

One row = one action outcome for one subject. Where `messages`
answers "what went out", `action_logs` answers "what did each
action do, per subject, per run" — including the actions that did
NOT send (skipped, filtered, failed).

Like `messages`, this is a **dataset** — read it via
`api.datasets.createUserSearch` + `api.datasets.search` with
dataset type `'action_log'` — **singular**; the plural
`action_logs` is the SQL *table* name (`executeSql`) and 422s as
a dataset type. Base SQL alias: `ral`. Rows exist only for
**live** mode — dry runs never write here.

## 1. When a row appears (the scheduled gap)

Not all at once. Two moments:

```
   at run time          — failed / filtered / skipped outcomes
                          land immediately
   at execution time    — a scheduled action gets its row only
                          when the trigger clock fires and it
                          actually executes
```

Between those two moments a scheduled send is INVISIBLE here: its
execution log exists (the platform accepted it), but no
action_log row and no message row yet. A UI that only reads the
datalake shows nothing and looks broken — read the execution logs
(`workflows.md` §6) and say "scheduled — nothing sent yet".

## 2. Fields

```
id, workflow_id, action_id, workflow_execution_log_id,
batch_id            — the run this outcome belongs to; THE
                      campaign key (messages does not have it)
decision_key        — which action fired
context_key         — companion string key; like decision_key, it is
                      SQL-lane only (neither is Flop-filterable, §5)
legal_entity_id, subject_name, session_id
action_type ('sms'|'email'|'voice'|'data_exchange'), channel,
classification
status              — see §3
external_id         — provider id; joins to messages.external_id
sent_at, tool_call_payload, idempotency_key, metadata,
attachments, body, sender_tool_id
```

## 3. Statuses

```
   pending → queued → sent → delivered
                        └──→ failed

   skipped    — the action's runtime_filter said no; nothing sent
   filtered   — the parent run was filtered out; nothing sent
   cancelled  — manually cancelled
```

Initial status mirrors the action execution log's outcome; a
status updater with an `action_log_config` can move
`sent → delivered/failed` here too (optional — many setups only
reconcile messages).

## 4. How everything joins

```
   run (batch_id) ──── WorkflowRunLog          "how is the batch doing"
        │
        ├── WorkflowExecutionLog (per subject) "what did the pipeline decide"
        │        │
        │        └── ActionExecutionLog        "what did each action attempt"
        │                 │  (live + actually executed)
        │                 ▼
        └────────── action_logs row            "the outcome, queryable"
                          │ external_id
                          ▼
                    messages row               "the communication + delivery"
```

Left side lives in the platform DB (read via
`api.workflows.batchLogs` / `api.workflows.workflowLogs`); the
bottom two are datalake datasets. `batch_id` ties the datalake
rows back to the run; `external_id` ties an action_log to its
message.

## 5. Reading + audience export

```typescript
// dataset type is SINGULAR 'action_log' (the table is plural)
const { data: search } = await api.datasets.createUserSearch(
  tenantSlug, datalakeSlug, 'action_log',
  { search_query: `al.batch_id = '${batchId}'` },
)
const { data: page } = await api.datasets.search(
  tenantSlug, datalakeSlug, 'action_log',
  { userSearchId: search.id, dataAccessMode: 'tokenized' },
)
```

Filterable columns (Flop, the full set): `id`, `inserted_at`,
`action_type`, `channel`, `status`, `batch_id`, `classification`,
`workflow_id`, `sent_at`, `external_id`, `legal_entity_id`. Note
there is **no `global_search`** here (unlike the message dataset) —
`decision_key`/`context_key` are not in this set either; scope them
via the `search_query` SQL fragment above.

"Who did this campaign go to" = the query above (subject columns
+ status per row — exportable as-is). For bulk export the platform
also merges each finished batch into NDJSON artifacts in cold
storage:

```
   <datalake>/workflows/<wf_slug>/merged/<batch_id>.ndjson                 (per-subject)
   <datalake>/workflows/<wf_slug>/merged/<batch_id>/<decision_key>.ndjson  (per-action payloads)
```

The batch log row (`api.workflows.batchLogs.get`) carries the
download references once the batch completes.

## 6. Gotchas

1. **The scheduled gap (§1).** No action_log row ≠ nothing
   happened. Check the execution logs before declaring a run
   empty.

2. **`batch_id` is a string, not a UUID** — manual runs look like
   `manual:<uuid>`; DAC-triggered runs carry the ingest batch id.
   Compare as strings.

3. **One subject can have many rows for the same decision.**
   Re-fires via `workflows.execute` with `manual_override: true`
   (the retry path) create a NEW action_log each time. Latest row
   wins for "current state"; the full set is the audit trail.

4. **`skipped`/`filtered` rows are successes of the control
   system, not failures.** Only `failed` is the retry surface
   (`workflows.md` §8).

5. **`tool_call_payload` is the rendered call** — what was
   actually sent to the tool, after Liquid. When a message looks
   wrong, read this before blaming the template engine.
