# Workflows

A **workflow** is the event-driven decision pipeline that fires on
top of ingested dataset rows. Every workflow has the same shape:

```
   dataset row event
         │
         ▼
   filter        — does this row qualify? (Liquid expression → boolean)
         │
         ▼
   enrichment    — optional AI step that derives extra context (see §3)
         │
         ▼
   decision      — which action key fires? (Liquid array of strings, OR
                   an AI agent's output)
         │
         ▼
   action(s)     — scheduled tool calls keyed by decision_key
                   (SMS, email, voice, export, ...)
```

SDK namespace: `api.workflows`. Sub-namespaces for run-time logs:

```
api.workflows.batchLogs       — one log row per workflow run (batch)
api.workflows.workflowLogs    — one log row per dataset row processed
                                (workflow execution logs)
```

Workflows are **Datalake-DB-resident** — the parent datalake must
be `status: 'ready'` and every referenced resource (tools, AI
agents) must exist before you `create()`.

## 1. Wire shape

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

const { data: created } = await api.workflows.create(
  tenantSlug,
  datalakeSlug,
  {
    name: 'Appointment Review SMS',
    description: 'Send a review-request SMS after a fulfilled appointment',

    // The workflow listens on ONE generic table, named by id. The
    // platform's own dataset types (legal_entity, message,
    // action_log, ...) are the other legal values — see §4.
    dataset_type: 'generic_table',
    generic_table_id: appointmentsTableId,

    // Load-bearing whenever a message carries a link: resolution is
    // what gives the link its `legal_entity_id`, the person a tap on
    // it belongs to. See §2 and authenticated_links.md.
    skip_mdm_resolution: false,

    status: 'live',                         // 'live' | 'draft' | 'manual' (see §5)
    tags: ['appointments', 'sms'],          // REQUIRED on every write — [] if untagged (see §2)
    filter_config: {
      type: 'custom',
      body: '{% if event_dataset.status == "fulfilled" %}true{% endif %}',
      // NO output_schema here — filter_config takes { type, body } only.
      // See §2: output_schema is server-pinned for filter_config, not a
      // request field.
    },
    decision_config: {
      type: 'custom',
      body: `["send_appointment_review_sms"]`,
      output_schema: { type: 'array', items: { type: 'string' } },
    },
    context_datasets: [
      {
        dataset_type: 'message',
        where_clause:
          `m.legal_entity_id = '{{ legal_entity_id }}' ` +
          `AND m.sent_at > NOW() - INTERVAL '6 months'`,
        limit: 1,
        position: 0,
      },
    ],
    actions: [
      {
        action_type: ActionType.SMS,
        tool_id: smsToolId,
        decision_key: 'send_appointment_review_sms',
        position: 0,
        trigger_template: 'now',                // 'now' = next available slot
        action_window_start: 9,                 // send only 09:00–20:59 in the
        action_window_end: 21,                  //   datalake tz (SMS quiet-hours failsafe; see §2)

        // Row identity for a generic_table workflow is the event
        // row's own unique column. There is no {{ subject_id }} —
        // see §2, and note an unknown binding renders EMPTY.
        idempotency_template:
          '{{ event_dataset.appointment_id }}-{{ decision_key }}',

        // Rendered once per send and stored on the message; the host
        // behind link_prefix reads it back from the exchange's
        // `message.metadata`. See §7.
        metadata_template:
          '{"appointment_id":"{{ event_dataset.appointment_id }}"}',
        tool_call: {
          tool_call_type: 'sms_request',
          to:   { type: 'custom', body: '{{ event_dataset.patient_mobile | e164 }}' },
          // Write the real destination. At send, every URL in the body
          // goes out as <link_prefix>/<token>.
          body: { type: 'custom', body: 'Hi {{ event_dataset.patient_name | split: " " | first }}, please share your feedback at https://reviews.example.com/visit' },
          sms_type: 'transactional',
          link_prefix: 'https://go.example.com/l',   // REQUIRED on sms / mms / email calls
        },
      },
    ],
  },
)
```

`actions`, `context_datasets`, and `workflow_ai_agents` are all
inline arrays — one `create()` builds the entire workflow body, agents
included (see §3).

## 2. Rules the type cannot encode

### `filter_config` / `decision_config` are Liquid expressions — but their
### request shapes are NOT the same

- `filter_config.body` renders to a boolean — the literal string
  `"true"` (non-empty) means PASS; an empty render means SKIP.
  (Same inverted-semantics rule as `interoperability_contracts.md`
  §2 `filter_template`.)
- `decision_config.body` renders to a JSON array of decision keys.
  Each rendered key fires the matching `action` (by
  `action.decision_key`).

**`filter_config` takes `{ type, body }` only — `output_schema` is NOT
a request field.** The platform pins `filter_config`'s output schema
internally to `{ type: 'string', enum: ['true', 'false'] }` and never
exposes it as writable — the wire type is `SimpleTemplateConfigRequest`,
whose own description says "output_schema is server-derived, never
request-supplied". Submitting an `output_schema` alongside `filter_config`
is silently ignored (it's not a field on the request schema); the response
always echoes the pinned schema regardless of what you sent. Don't author
one, and don't expect a submitted value to round-trip.

**`decision_config` DOES take a caller-authored `output_schema`, and it
is a JSON-Schema OBJECT, not a string** — `{ type: 'array', items: { type:
'string' } }`, never `'{"type":"array","items":{"type":"string"}}'`. The
wire type is `ComplexTemplateConfigRequest`, which requires `output_schema`
as an object; submitting the string form 422s with `Invalid object. Got:
string` on `/decision_config/output_schema`. The platform validates the
rendered decision array against this schema before dispatching actions.

The same Simple-vs-Complex split applies to other resources built on the
same template-config primitive (tool/DAC filter and extractor configs are
Simple/pinned; AI-agent and interop-contract output schemas the caller
authors are Complex) — don't assume every `{ type, body }`-shaped config
also carries a request-writable `output_schema`; check whether the field
is pinned or caller-authored for that specific config before authoring one.

### `tags` is required on every write, and has no default

`tags` is a `string[]` on both the request and the response, and it is
in `required:`. **Every `create()` / `update()` without it is a 422.**
Send `tags: []` for an untagged workflow — the empty array is how you
say "no tags", not something you can omit.

The missing default is deliberate. `update()` is full-replacement (there
is no PATCH, and no add-tag / remove-tag endpoint), so an omitted key
cannot mean "leave tags alone". With a default, omitting it would have
silently emptied a workflow's tags and returned 200. A 422 beats losing
state the caller never mentioned.

The labels are free text — no taxonomy, no shared vocabulary, and the
execution pipeline does not read them. But they **do** participate in the
workflow checksum, so retagging shifts the drift fingerprint. Re-tag and
`checksum()` returns a different value even though nothing executable
changed.

### `update()` replaces inline arrays whole

`actions` and `context_datasets` are replaced whole by `update()`.
`update()` replaces the whole array — resupply the full set; any
item you omit is removed. Always re-supply the full array on every
update, even when you're only changing `description`.

Factor the workflow body construction into a helper that
accepts the changing fields and returns the full body — same
pattern used for datalake updates (see `datalakes.md` §5).

### `idempotency_template` prevents duplicate action firings

An action's `idempotency_template` renders to a string per
dataset row; the platform deduplicates so the same idempotency
key never fires twice. Typical recipe:

```
'{{ <row identity> }}-{{ action_id }}-{{ decision_key }}'
```

The row identity depends on the workflow's `dataset_type`. For a
`generic_table` workflow it is the event row's unique column —
`{{ event_dataset.submission_id }}`, `{{ event_dataset.customer_number }}`;
for a `legal_entity` workflow the row IS the subject, so it is
`{{ legal_entity.id }}`.

**There is no `{{ subject_id }}`.** GH-859 removed it along with the
per-domain subjects (`patient_id`, `customer_id`, `party_id`), and it is a
trap rather than merely a rename: an unknown binding renders EMPTY instead of
failing, so a template still using one produces a key that is missing the very
component it exists to key on — and nothing anywhere says so. If two rows that
should dedupe are firing twice, look here first.

### Two scheduling levers, and they answer different questions

There are **two**, and picking the wrong one is the most common mistake
here:

| lever | lives on | answers |
|---|---|---|
| `scheduled_at` | the **run** (`.run` body, §5) | *when is this send going out?* |
| `trigger_template` | each **action** (below) | *when does this action fire, relative to the run?* |

**`scheduled_at` is the one you want for a campaign.** A send time is
per-send, not per-workflow: "this campaign goes Tuesday 9am" is a
property of *this* send, and the next one goes at a different time. Pass
`scheduled_at` on the run and leave the workflow definition alone.

**`trigger_template` is a property of the workflow itself** — "the
reminder goes 24h before the appointment", "the follow-up goes 3 days
after signup". It is relative to the run and belongs in the definition
because it is true of every run.

Use `trigger_template` for a campaign send time and you are rewriting the
workflow before every send — a full `update()` per campaign, since
`update()` replaces the resource whole. That was the only option before
runs became a resource, and it is no longer the right answer.

Neither lever is a guarantee of the instant a message leaves: the
action window / quiet hours (below) can push an action later, and a
`runtime_filter` can drop it entirely.

### `trigger_template` controls scheduling

A Liquid template rendering to a datetime or one of these
literals:

```
'now'      — schedule at the next available slot at-or-after current time
''         — same as 'now' (empty string also routes to the now-slot)
ISO 8601   — schedule at the named instant, read as LOCAL wall-clock
             in the datalake's timezone (NOT UTC)
```

The ISO 8601 form is interpreted as a **local naive datetime in the
datalake's timezone** — `2026-07-20T09:00:00` means 9am in the
datalake's zone, not 9am UTC. (The datalake timezone is the same
8-zone US whitelist the datalake guide documents.) **DST is handled
for you**: an ambiguous fall-back local time and a non-existent
spring-forward gap time both resolve to a sane instant — consumers do
no DST math.

A `trigger_template` that renders to an **unparseable** datetime does
not raise at run time: the action's log row is marked `:failed`, no job
is enqueued for it, and **the HTTP call still returns 200**. This is
the same fire-only envelope described for `.run` and `.execute` in §5 —
never read success off the 200; read it off the action's own log.

The action is queued through the platform's scheduling layer
(action windows, throttling), so even `'now'` may resolve to a
slot some minutes in the future under load.

### Send-window / quiet hours: `action_window_start` / `action_window_end`

An action carries an optional **send window** — two integer hours
(0–23, both nullable) that bound when the platform may dispatch it,
evaluated in the **datalake's timezone**. This is the compliance
failsafe for SMS (quiet-hours rules such as TCPA): even a workflow that
fires at 3am holds its SMS until the window opens.

```
action_window_start   integer 0–23, nullable — hour the window opens
action_window_end     integer 0–23, nullable — hour the window closes
```

- **Half-open interval**: an action may dispatch when
  `window_start <= local_hour < window_end`. The end hour itself is
  *outside* the window (end `21` means "up to 20:59, not 21:00").
- **One-sided windows are allowed**: set only `start` and `end`
  defaults to 24 ("from 09:00 onward"); set only `end` and `start`
  defaults to 0 ("until `end`, from midnight"). Note the literal
  `action_window_end: 24` is **rejected** (valid range is 0–23) — for
  "until midnight", omit `end` entirely.
- **No wrap-around.** The window is a same-day daytime interval:
  `start > end` (e.g. `21`→`9`) matches *no* hour, so the action snoozes
  forever and never sends. To express **overnight quiet hours**, encode
  the complement *send* window: to stay silent 21:00–09:00, set
  `action_window_start: 9, action_window_end: 21`.

The window is enforced **twice**, so it holds regardless of how an
action was scheduled:

1. **Clamp at enqueue** — when an action is scheduled (via its
   `trigger_template` instant), a time outside the window is advanced to
   the next `window_start` boundary (same day if before the window,
   next day if past it; minutes/seconds zeroed) before the job is
   queued.
2. **Snooze at fire** — if a job nonetheless reaches execution outside
   the window (e.g. a `trigger_template: 'now'` action, which is not
   clamped at enqueue), the executor snoozes it until the next window
   open and retries. Either way the send lands inside the window.

### Action-window + queue-throttle render `'now'` non-literal

Avoid templates like `{{ "" | now | date: "%Y-%m-%d..." }}` —
they render a literal current timestamp, which then collides
with the action-window clamp + queue throttle and pushes the
slot forward unpredictably. Use the literal `'now'` sentinel
when you want "fire as soon as possible".

### `runtime_filter` is the pause lever for pending sends

An action's optional `runtime_filter` is a Liquid template evaluated
**at execution time** — when a queued job fires, not when it was
enqueued. If it renders falsy, that send is skipped
(`status: 'skipped'`, `runtime_filter_result: false`) instead of
dispatched. A nil/empty `runtime_filter` always passes.

The load-bearing asymmetry: **the execution *mode* is frozen onto the
job when it is enqueued, but the *action row* is not** — the executor
re-loads the action from the database at fire time. So a `runtime_filter`
you edit *after* jobs are already queued is honoured by every still-pending
send. **This is the supported way to pause an in-flight scheduled campaign:
edit the action's `runtime_filter` to render falsy, and each pending job,
when it fires, evaluates the new filter and skips.** There is no separate
cancel endpoint — this is the mechanism.

Two caveats:

- **A skip is terminal.** A job that has already fired (its scheduled
  instant passed) is skipped permanently — un-pausing only rescues jobs
  whose trigger time has not yet arrived. Resume *before* the fire
  clock, not after.
- **Editing `runtime_filter` is a whole-body `update()`** like any other
  workflow change — re-supply the full `actions` array (and every other
  inline array), or you'll delete the siblings you omitted (see the
  replace-on-PUT rule above and gotcha 2).

## 3. Agent-driven workflows: AI enrichment + AI-emitted decisions

When `decision_config.type` references an AI agent (instead of
`'custom'` with a literal Liquid body), the workflow gains an
**enrichment** step between filter and decision:

```
   filter
     │
     ▼
   enrichment    — each ai_agent runs, producing typed output
                   that the decision template can reference
     │
     ▼
   decision      — AI agent emits the decision_key array
                   (or a custom decision template references
                    agent outputs via Liquid)
     │
     ▼
   action(s)
```

Agents are **nested directly on the workflow body** — the create and
update body accept a `workflow_ai_agents` array. There is
no separate attach/detach endpoint. The agents **fold into the
workflow's own checksum** — there is no per-join checksum. `position` is
required, and workflow joins **omit `output_schema`** (the server pins
it from the agent's `input_schema`). The agent create body (prompt
config, `input_schema` / `llm_response_schema`, runtime failure codes)
lives in `ai_agents.md` §3. A workflow `get()` surfaces the nested agents as
`workflow_ai_agents: [...]`.

`update()` replaces the whole array: the body must
re-supply every agent you want to keep — agents absent from the array
are removed, and re-supplied ones are repositioned.

```typescript
await api.workflows.create(tenantSlug, datalakeSlug, {
  name: 'Contact-Us Triage',
  description: 'Triages inbound contact-us messages.',
  dataset_type: 'generic_table' as const,
  status: 'live' as const,
  tags: [],
  workflow_ai_agents: [
    {
      ai_agent_id: aiAgentId,                      // UUID from api.aiAgents.create
      position: 0,                                 // ordering when multiple agents fire; REQUIRED
      context_mapping_config: {
        type: 'custom',
        // Liquid renders to a JSON object whose keys match the referenced
        // agent's input_schema. Values reference the inbound row's fields
        // via `event_dataset.<field>`. Workflow joins OMIT output_schema
        // — the server pins it from the agent's input_schema.
        body: JSON.stringify({
          msg:           '{{ event_dataset.message }}',
          submission_id: '{{ event_dataset.submission_id }}',
        }),
      },
    },
  ],
})
```

The agent's parsed output is exposed in the workflow's runtime
context at `additional_context.<agent_slug>.<field>` (property
paths match the agent's `llm_response_schema`). Decision and
action templates reference it via Liquid:

```liquid
{{ additional_context.contact-us-triage-categorizer.category }}
```

A workflow with no attached agents observably runs the enrichment
stage but emits `{ status: 'skipped' }` in the per-step artifact
(see §6).

### `event_dataset` is the workflow accessor for the inbound row

Workflow templates (filter, context mappings, action bodies)
reference the inbound dataset row via the `event_dataset`
variable. Field paths match the columns of the workflow's
`dataset_type`:

```
   dataset_type: 'legal_entity'  → event_dataset.id, event_dataset.name, ...
   dataset_type: 'generic_table' → event_dataset.<column_name> for each
                                   column on the bound table
```

For generic-table-bound workflows, `event_dataset` carries the
table's columns directly (no separate `row` indirection).

**Where the row came from is on its log row, not on the row.** A row
carries no `batch_id`, `client_id`, `user_id` or `source_uri` of its
own: it points at the `data_activation_logs` row of the ingest that
wrote it, and templates read that log row loaded on the event —
`event_dataset.alvera_data_activation_log.source_uri`,
`event_dataset.alvera_data_activation_log.batch_id`. It is null when
the row came from no ingest (an app wrote it straight into its table),
so guard a template that reads it. A generic table may declare its
own column called `source_uri` or `batch_id`; then
`event_dataset.source_uri` is that column, plain data.

### `skip_mdm_resolution` is a deliberate choice, not a generic-table default

`skip_mdm_resolution` (default `false`) opts the workflow out of the
platform's entity-resolution step on the inbound row. **It is NOT
auto-forced by `dataset_type` — the server casts exactly what you send,
for every dataset type.** Choose it by what the workflow needs:

- **Leave it `false`** whenever the workflow must resolve the row to an
  MDM subject — which is required for anything keyed on a subject: a
  link that knows who tapped it (without resolution its
  `legal_entity_id` is `null` — see `authenticated_links.md`), a
  `messages` row, or cross-dataset context. A `generic_table` workflow can absolutely run
  with resolution ON: stamp the entity FK (e.g. `legal_entity_id`) onto
  the row at ingestion and the workflow resolves its subject from that
  FK.
- **Set it `true`** only when the row IS the subject and there is no
  canonical entity to resolve to (a pure operational table whose
  workflow sends nothing subject-scoped).

Entity-anchored datasets (`patient`, `customer`, `legal_entity`, …)
effectively always want `false`. Generic-table workflows split both
ways — pick per use case; do not assume `true`.

## 4. Field ownership

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

```
actions[*].id              UUID per inline action row
context_datasets[*].id     UUID per inline context-dataset row
```

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

```
name                         required string
description                  optional string
dataset_type                  required string — the dataset this listens on
status                       required enum — 'live' | 'draft' | 'manual' (§5 Create)
tags                         required array — string[]; NO default, send [] if
                                              untagged; feeds the checksum (§2)
generic_table_id              optional UUID — required iff dataset_type='generic_table'
skip_mdm_resolution           optional bool — default false; a deliberate
                                              choice, NOT a generic-table default (§3)
filter_config                required embed — { type, body } — output_schema
                                              is server-pinned, not writable
decision_config              required embed — { type, body, output_schema } —
                                              output_schema is a caller-authored
                                              JSON-Schema OBJECT (not a string)
context_datasets             required array — inline; replace-on-PUT
actions                      required array — inline; replace-on-PUT
workflow_ai_agents          optional array — inline; replace-on-PUT (see §3)
```

Caller-supplied fields **inside each `context_datasets[*]`** row:

```
dataset_type                 required string — CLOSED ENUM, not free text. An
                                              arbitrary value 422s at apply and
                                              the error enumerates the allowed
                                              types
generic_table_id             required UUID when dataset_type == 'generic_table';
                                              omit otherwise. NOT the same field
                                              as the workflow's own
                                              `generic_table_id` one level up
where_clause                 optional string — Liquid, max 10_000 chars (see
                                              below for what it can reach, and
                                              which tier it runs against)
limit                        optional int   — must be positive
position                     optional int   — default 0
```

### Reading what a context query returned

`additional_context.<dataset_type>` — the `dataset_type` string **literally**,
not the table name and not a slug. A `document` context is read at
`additional_context.document`.

Do not confuse this with the AI-agent form documented above
(`additional_context.<agent_slug>.<field>`): an agent is keyed by its slug, a
context dataset by its type.

### ⚠ Two context datasets of the same `dataset_type` silently overwrite

The loader accumulates them into a map keyed by `dataset_type`, so declaring
two of the same type leaves **only the last one**. There is no error and no
warning — the workflow runs happily against the wrong context. If you need two
slices of one type, express it as one query with a wider `where_clause`.

### The `where_clause` reaches the row differently than the filter does

This asymmetry is the easiest thing here to get wrong:

```
where clause   {{ <dataset_type>.field }}      e.g. {{ generic_table.external_client_id }}
filter         {{ event_dataset.field }}       the SAME row, different accessor
```

That makes three conventions for "the current row" across this corpus — the
third being `msg.row.x` in an interoperability filter versus `msg.x` in its
transform. Nothing about a name tells you which one you are in; the surface you
are authoring does.

### Context queries run against the REGULATED tier

The loader reads context in regulated mode, so a `where_clause` must name the
**regulated** table alias (e.g. `regulated_alvera_custom_unsubscribe_list`),
not the unregulated name. This changes every where clause you write, and a
clause written against the unregulated name finds nothing rather than failing
loudly.

### `context_datasets` cannot be cleared by omission

It is a **required, replace-on-PUT array**. Dropping the block from a manifest
does not clear it: the server retains what was last deployed, the rendered and
deployed checksums never converge, and every subsequent `plan` reports drift on
a config that was just applied. Send an explicit `[]` to mean "no context".

Same class as the `assume_role_external_id` convergence note — an omission that
reads as "leave it alone" on the wire and as "remove it" in the author's head.

Caller-supplied fields **inside each `actions[*]`** row:

```
action_type                  required enum — 'sms' | 'mms' | 'email' | 'voice'
                                              | 'data_exchange' | 'data_activation'
tool_id                      UUID — tool.intent must match action_type. Required
                                              for every action type EXCEPT
                                              data_activation, which refuses one
                                              (its client carries its own tool)
decision_key                 required string — unique within the workflow
trigger_template             required string — schedule; 'now'/'' = immediate,
                                              else local ISO 8601 in datalake tz (§2)
idempotency_template         required string — dedup key per row (§2)
tool_call                    required embed — variant-specific body (tool_call_type)
position                     optional int  — display order, default 0
runtime_filter               optional string — fire-time skip / pause lever (§2)
action_window_start          optional int  — send-window open hour 0–23 (§2)
action_window_end            optional int  — send-window close hour 0–23 (§2)
metadata_template            optional string — Liquid→JSON rendered once per send, stored
                                              on the message (§7); every action type
```

Inside `tool_call`, the `sms_request`, `mms_request` and `email_request`
variants also take `link_prefix` (**required**) and `link_validity_days`
(optional, default 365) — see §7.

### The `data_activation` action runs a client for the row

An action of type `data_activation` sends nothing. It runs a data
activation client — the one `client_slug` names, in the workflow's own
datalake — for the row that started the workflow. Its tool call is
`run_data_activation_client`, and it names no tool: the client carries
its own, so an action with a `tool_id` is refused.

```typescript
import { ActionType } from '@alvera-ai/platform-sdk'

const fetchMemberAction = {
  action_type: ActionType.DATA_ACTIVATION,   // no tool_id: the client carries its own tool
  decision_key: 'fetch_member',
  trigger_template: 'now',
  idempotency_template: '{{ event_dataset.message_id }}-fetch',
  tool_call: {
    tool_call_type: 'run_data_activation_client',
    client_slug: 'fetch-member',
    tool_call_override_template:
      `{"tool_call_type": "sql_query", "query": {"type": "custom", "body": "SELECT * FROM inbound_messages WHERE message_id = '{{ event_dataset.message_id }}'"}}`,
  },
}
```

The questions a builder asks about it:

- **What does the override do?** `tool_call_override_template` is
  Liquid rendered against the row (`event_dataset`) to the client's
  WHOLE tool call as JSON — the same shape `.runManually` takes as its
  one-shot override (`data_activation_clients.md` §6.3). An override of
  the same `tool_call_type` as the client's stored call is merged over
  it; one of another type replaces it.
- **Does it change the client?** No. The override applies to that one
  run; the client's stored tool call — a scheduled query, say — is
  untouched.
- **Two rows at once?** Each row's action is its own run of the client,
  with its own rendered override, and runs beside any scheduled run
  rather than merging into it.
- **How do I see what it did?** The client's run is an ordinary data
  activation run: read it back through the client's runs and log rows
  (`data_activation_clients.md` §6.4–§6.5).

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

## 5. Lifecycle

### Create

Synchronous. The `status` enum has **three** values, and it decides
both auto-firing and the run mode:

```
'live'    armed — fires automatically on dataset events, and runs live
          when invoked via .run/.execute.
'draft'   save-without-arming — never auto-fires; and a manual .run /
          .execute over a draft runs in dry_run (the platform derives
          the mode from status, so a draft never sends for real). Use
          it to stage a workflow you're still editing.
'manual'  armed for hand-invocation only — does NOT auto-fire on
          dataset events, but runs LIVE when you explicitly call
          .run/.execute. This is the "campaign I trigger by hand, but
          it really sends" status.
```

Drafts can be re-saved via `update()`. Flip to `'live'` (auto + live)
or `'manual'` (hand-invoked + live) to arm.

### 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, id)      Agent-facing metadata — UUID ONLY
```

The id/slug split is a platform-wide convention: **CRUD routes
(get/update/delete/metadata) take the UUID**; **operations routes
take the slug** (`.run`, `.execute`, `.batchLogs`, `.workflowLogs`
are all `workflow_slug`-keyed). Passing a slug to `.get` 422s with
`Invalid format. Expected :uuid` — resolve slug → id via `.list`.

### Run

**`.run` does not run the workflow. It records a run and returns.**
The work happens when that run *fires* — immediately if you did not ask
for a time, or at `scheduled_at` if you did.

```typescript
const { data: run } = await api.workflows.run(
  tenantSlug, datalakeSlug, workflowSlug,
  {
    sql_where_clause: `le.id = '${legalEntityId}'`,  // fragment against the dataset's alias
    mode: 'live',                                    // 'live' | 'dry_run'
    manual_override: false,                          // see below
    scheduled_at: '2026-08-12T09:00:00+01:00',       // OPTIONAL — see below
  },
)
// run.workflow_run_id   — UUID of the run. This is the handle for
//                          everything else: get, cancel, and the ids below.
// run.status            — 'scheduled' | 'processing' | 'completed'
//                          | 'cancelled' | 'failed'
// run.scheduled_at      — when it will fire (always present; "now" is a
//                          run scheduled for now, not a second code path)
// run.matched_count     — nullable PREVIEW, see the warning below
```

**`batch_id`, `workflow_run_log_id` and `enqueued_count` are NOT on this
response.** They described work that had not happened yet: the segment is
resolved when the run fires, not when it is scheduled, so a run scheduled
for Friday reaches Friday's rows. `batch_id` and `workflow_run_log_id`
become readable from `workflowRuns.get` once the run has fired (below);
`enqueued_count` has no replacement — count the rows the fan-out actually
produced via `workflowLogs`, because a count column can claim three while
nothing was inserted, and rows cannot.

**`matched_count` is a preview, not the audience.** It is what the clause
matched at schedule time, for sanity-checking the clause you just wrote.
The segment is resolved *again* at send time with suppressed records
excluded, so the number that actually goes out can be smaller. Never
report `matched_count` to a user as "this many people will receive it".

#### Scheduling a run

`scheduled_at` is an ISO-8601 timestamp **with an offset**
(`2026-08-12T09:00:00+01:00`). Omit it, or pass `null`, and the run fires
as soon as a worker picks it up.

It is **not** a guarantee of send time. Each action still passes through
the workflow's action window and its own `trigger_template` (§2), so an
action may execute later than the run fires — `scheduled_at` says when the
segment is resolved and the fan-out begins, nothing more.

#### Reading a run back — and the trap in it

```typescript
const { data: state } = await api.workflowRuns.get(tenantSlug, datalakeSlug, runId)
```

Poll until **`workflow_run_log_id` is a string** — *not* until `status`
leaves `'scheduled'`. Those are different moments: the run reaches
`processing` first and writes `workflow_run_log_id` and `batch_id` a beat
later. A predicate on status alone releases you to read a `null`, and
because `typeof null === 'object'` in JavaScript the symptom is a baffling
*"expected string, got object"* rather than an obvious nil.

Raise on `status === 'failed'` carrying `failure_reason` instead of
polling to your deadline — a caller blocked on a run that will never fire
should learn why on the first read, not thirty seconds later behind a
generic timeout.

**The HTTP 200 from `.run` means the run was *accepted*, not that you know
its outcome.** For the actual truth, poll the run to get its
`workflow_run_log_id`, then read `batchLogs` + `workflowLogs` and the
action's own status. Never branch logic on the immediate response body.

#### Listing and cancelling

```typescript
await api.workflowRuns.list(tenantSlug, datalakeSlug, { page: 1, page_size: 50 })
await api.workflowRuns.cancel(tenantSlug, datalakeSlug, runId)
```

`list` is **datalake-scoped, not workflow-scoped** — it answers "what is
going out from this datalake", across every workflow, which is the question
a campaign screen actually asks. It takes the full Flop query surface
(`page`, `page_size`, `order_by`, `order_directions`, `filters`).

`cancel` is **refused once a run reaches `processing`**: the fan-out has
begun and a job already executing cannot be stopped. Cancelling races the
worker — both sides contend on one conditional update, so exactly one wins
and the loser is told. Treat a refusal as a normal outcome to surface, not
an error to retry.

`sql_where_clause` accepts a fragment against the dataset's own
tables. For a **platform dataset** the server assigns the alias
(`le` legal entities, `m` messages, `al` action logs, `d` documents,
`bo` beneficial owners — same scheme as `data_activation_clients.md`
§6.6), and `id` is ambiguous across the base query's joins, so always
prefix it: `le.id`.

For a **generic-table** workflow there is no alias — the run selects
on the table's own columns directly, as the cookbooks do
(`customer_number IN (…)`). Prefixing one is an error, not a
precaution.

`manual_override: true` bypasses **dedupe/idempotency ONLY** —
the same row may fire again even though its idempotency tuple
already fired. The mechanism: with the override set, the platform
**appends a unique suffix to the rendered idempotency key** for that
fire, so it can't collide with the earlier row's key. The filter
expression and the decision template **still run**; a row the filter
rejects stays rejected. (The platform pipeline discards the flag before
filter and decision evaluation — override is not a "force through"
switch.)

A zero-match `sql_where_clause` still returns 200 and still records a run
that fires and writes a batch log row — poll `workflowRuns.get` for the
`workflow_run_log_id` uniformly, no special empty case. At schedule time a
zero-match clause reports `matched_count: 0`, but see the warning above:
that is a preview of the clause, not proof that nothing was sent.

### Execute (per-row, per-action)

`.execute` skips the workflow filter + decision evaluation
(you name the action via `decision_key`), but **still runs
context build + AI enrichment**, and the action's own
`runtime_filter` still applies at execution time:

```typescript
const { data: result } = await api.workflows.execute(
  tenantSlug, datalakeSlug, workflowSlug,
  {
    dataset_id: appointmentRowId,                   // the row's id — see below
    decision_key: 'send_appointment_review_sms',
    mode: 'live',                                   // 'live' | 'dry_run' (same enum as .run)
    manual_override: true,                          // load-bearing on re-fires
    // trigger_override: true,                      // optional — fire NOW even if the
    //                                                 action's trigger_template schedules later
  },
)
// result.status                       — ALWAYS 'pending' (dispatch is async;
//                                        outcome lands on the logs, not here)
// result.scheduled_count              — 1, or 0 when the trigger_template is
//                                        invalid (a :failed action log is
//                                        recorded; the HTTP call still 200s)
// result.workflow_execution_log_id    — UUID of the execution-log row
// result.scheduled_job_id             — background job id (null when count 0)
```

`dataset_id` is simply the row's id, and **there is only one.** An
older version of this guide said to pass a tokenized-tier id and
warned that the raw one would not resolve. That was true of a design
with paired schemas and two rows per subject; GH-859 removed it.
Today the derived lakes are replicated copies that preserve the primary
key, so every lake reports the same id. And the lookup does not consult
a mode at all: `validate_dataset_exists/4` passes `:raw` as a **literal**
in both of its clauses — there is no branch, no mode argument threaded
in, and no third clause. Every dataset type resolves against the raw
lake.

So an id from a tokenized or redacted search is already the id
`.execute` wants. The re-search the old advice called for was not
merely unnecessary; it was answering a question the code never asks.
`primary-care` §029 reads one row through all three lakes on a single
slug, which is the direct evidence that the key survives replication.

`manual_override: true` on `.execute` sets the unique-key
constraint to `false` on the per-row job, allowing re-fires
on rows that have already been processed (otherwise the new
log row sits in `:pending` forever, deduplicated by the
sampled-event uniqueness check).

### Update

`update()` replaces the whole resource — resupply the full
body; there's no partial update. The whole-array replace applies
to all inline arrays (see §2).

### Delete

`delete()` removes the workflow. In-flight runs decouple their
lifecycle from the workflow row; deleting one mid-run rejects
with a 422.

## 6. Run-time logs

### Batch logs — `api.workflows.batchLogs`

One row per workflow run (per `.run` call). Lifecycle:

```
   :pending → :completed | :failed | :partial
```

- `:completed` — every row processed terminally
- `:failed` — every row's processing errored
- `:partial` — mixed (some rows succeeded, some filtered, some failed)

Methods:

```
.list(tenantSlug, datalakeSlug, workflowSlug, query?)      Paged
                                              (full Flop query — type_naming.md)
.get(tenantSlug, datalakeSlug, workflowSlug, runLogId)     One row
.refresh(tenantSlug, datalakeSlug, workflowSlug, runLogId) Force a status
                                                  recompute (per-row jobs are
                                                  async; status only flips
                                                  after explicit refresh)
.start(tenantSlug, datalakeSlug, workflowSlug, runLogId)   Begin polling
.stop(tenantSlug, datalakeSlug, workflowSlug, runLogId)    Stop polling
```

The `.refresh` verb is **load-bearing** for synchronous
progress checks — `.get` returns the cached status, but the
underlying per-row jobs may have completed since the last
refresh. Call `.refresh` in your poll loop.

### Workflow execution logs — `api.workflows.workflowLogs`

One row per dataset row evaluated. Status enum:

```
:pending  → :executing → :completed | :filtered | :failed
```

Each log carries:

- `id`, `workflow_id`, `batch_id` — join keys
- `status` — terminal state
- `actions_total` — number of actions wired on the workflow
- `actions_pending` — actions scheduled but not yet completed by
  their background jobs
- `actions_completed` — actions whose background job has finished
- `actions_skipped` — siblings of the matched action that didn't
  fire (the decision template returned one key; other actions
  with non-matching `decision_key` get :skipped)
- `error_message` — coarse failure summary (e.g.
  `"AI enrichment failed: <agent_name>"`); rich detail lives
  in `error.json` (§ Per-step artifacts below)
- `action_execution_logs` — nested rows, one per action that fired,
  carrying the `message_body` virtual field

Methods:

```
.list(tenantSlug, datalakeSlug, workflowSlug, query?)          Paged
.get(tenantSlug, datalakeSlug, workflowSlug, logId)            One row
.download(tenantSlug, datalakeSlug, workflowSlug, logId)       The run's artifact
```

`.list` takes the full Flop surface — without it you only ever see
page 1, and older dispatches drown under newer rehearsal logs:

```typescript
await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, slug, {
  page: 2,
  page_size: 50,                       // 1-100
  order_by: ['inserted_at'],
  order_directions: ['desc'],
  filters: [{ field: 'mode', op: '==', value: 'live' }],
})
```

`filters` uses the Flop empty-bracket serialization on the wire —
the SDK's serializer handles it; never hand-roll the query string.

`.get` takes NO `dataAccessMode`. It used to; the platform removed the
`data_access_mode` query from this endpoint, and the generated type is now
`query?: never`, so passing one no longer compiles.

An execution log is workflow bookkeeping rather than lake rows, so there is
no tier to select. Choosing a tier belongs to the reads that return data —
`datasets.search` and `datalakes.executeSql` both take a mode, and that is
where `'raw' | 'tokenized' | 'redacted'` still applies.

### Per-step artifacts (event, filter, enrichment, error)

For each workflow execution, the platform writes JSON artifacts to
the datalake's cloud-storage bucket. The keys follow a fixed
convention:

```
workflows/<workflow_id>/executions/<execution_log_id>/<step>.json
```

Where `<step>` is one of: `event` | `filter` | `enrichment` |
`error`.

Fetch them via the datalake's `createDownloadLink`:

```typescript
const { data: link } = await api.datalakes.createDownloadLink(
  tenantSlug, datalakeSlug,
  { bucket: datalakeBucket, key: 'workflows/.../filter.json' },
)
const artifact = await (await fetch(link.url)).json()
```

Artifact bodies:

```
event.json       { event_dataset: { ... full inbound row snapshot ... } }
filter.json      { filter_expression, filter_result: boolean }
enrichment.json  Success-with-agents:
                   {
                     status: 'completed',
                     <agent_slug_1>: { status: 'completed',
                                       output: { ...matches llm_response_schema... } },
                     <agent_slug_2>: { ... },
                   }
                 Skipped (no ai_agents on the workflow):
                   { status: 'skipped' }
error.json       Only present on :failed runs:
                   {
                     error_code: string,
                     stage: string,                   // 'enrichment' | 'decision' | 'action'
                     ai_agent_slug?: string,          // present for enrichment failures
                     error_message: string,           // coarse summary (mirrors WEL.error_message)
                     detail: string,                  // rich provider context (URL, HTTP status, ...)
                                                      // ONLY in error.json, NOT mirrored to the WEL row
                   }
```

The PASS / REJECT branches of the filter both emit
`filter.json` — the body just differs in `filter_result`.

### `error.json` error codes

When `stage: 'enrichment'`, the `error_code` IS the AI agent
runner's failure code — the full set lives in `ai_agents.md` §7
(`tool_execution_failed`, `json_decode_failed`,
`context_mapping_failed`, `input_schema_validation_failed`,
`response_extraction_failed`, …). Note there is **no**
`ai_agent_disabled` or `ai_agent_not_found` code: a *disabled* agent
is silently skipped by the enrichment stage (not an error), and a
*deleted* agent can't be referenced at all (delete is blocked while a
workflow points at it — see `ai_agents.md` §6). The non-enrichment
stages carry these `error_code` values:

```
decision_evaluation_failed      decision   decision template render OR
                                           JSON-array parse/validation failed
template_render_failed          action     action body Liquid render failed
trigger_template_render_failed  action     trigger_template rendered to an
                                           invalid datetime (action logged :failed)
```

The `detail` field carries rich context (URL, HTTP status,
transport error) that lives ONLY in `error.json`. The
workflow execution log row's `error_message` is intentionally
coarse so the database column doesn't grow with arbitrary
provider responses. Always read `error.json` for diagnostics.

## 7. Links in a message

Every URL in an SMS or MMS body, and every `href` in an email body,
goes out as an authenticated link, `<link_prefix>/<token>`. Write the
real destination into the body; the platform rewrites it at send.

Action wiring on the workflow:
- `tool_call.link_prefix` — **required** on `sms_request`,
  `mms_request` and `email_request`: the address of the host you run
  to serve the links. Absolute `http`/`https`, path allowed, no query,
  fragment or trailing slash.
- `tool_call.link_validity_days` — how many days each link resolves
  after the send; default 365.
- `metadata_template` — on the action itself, any action type: a
  Liquid template rendering a JSON object, once per send. It is
  stored on the message, beside the recipient's `to`, and a template
  that does not render fails the send.

When a person taps the link, your host calls
`api.authenticatedLinks.exchange` with the token and gets back where
the link goes, who it was for, and the message — its `metadata`
included. When the page opens or the form is submitted, the host
reports it with `api.authenticatedLinks.updateMessageTracking`. The
whole surface, its refusals (404 · 410 · 503) and what counts as a
person's tap are in `authenticated_links.md`.

## 8. Watching a run — what to read for which question

A run leaves marks in TWO places. Three log tables in the
platform DB (every run, dry runs included), and two datasets in
the datalake (live dispatch only):

```
   PLATFORM DB (always)              DATALAKE (live dispatch only)
   ────────────────────              ─────────────────────────────
   batch log      one per .run
     └ execution log  one per row
         └ action log   one per action ──→ action_logs row  (outcome)
                                             └──→ messages row (the send,
                                                   + delivery status later)
```

| Question | Read |
|---|---|
| how is the batch doing? | `batchLogs.get` + `.refresh` (§6) |
| what did the pipeline decide, per row? | `workflowLogs.list` / `.get` |
| what did each action do? | `action_logs` dataset (`action_logs.md`) |
| did the message actually arrive? | `messages` dataset `status` (`messages.md`) |
| who got it / who clicked? | `action_logs` × `messages` join + engagement fields |

### run vs execute

|  | `.run` | `.execute` |
|---|---|---|
| selects rows by | SQL WHERE | one `dataset_id` |
| filter + decision | run | **skipped** (you name the `decision_key`) |
| enrichment | runs | runs |
| action `runtime_filter` | applies | applies |
| `manual_override` | dedupe/idempotency bypass only | same |
| answers | `workflow_run_id`, `status`, `scheduled_at`, `matched_count` (the ids follow from `workflowRuns.get` once it fires) | `workflow_execution_log_id`, always `status: 'pending'` |
| runs when | the run fires — now, or at `scheduled_at` | immediately |

### The three states a send can be in

```
   rehearsed   — dry_run: full pipeline runs, logs written, no tool
                 call and nothing delivered. A dry-run DOES write a
                 datalake `messages` row, at status :pending with no
                 external_id (see below + messages.md §1) — the row is
                 the rehearsal artifact, not a send.
   scheduled   — accepted, waiting on the trigger clock. Logs exist;
                 the message row is written when the action fires, not
                 at schedule time. Say "scheduled — nothing sent yet".
   dispatched  — action_logs + a :sent messages row exist; delivery
                 truth arrives later via the status updater.
```

**A dry-run is not a free preview of the idempotency slot.** Because the
rehearsed `messages` row lands at `:pending` and `:pending` is *inside*
the idempotency uniqueness constraint (only `:failed` / `:invalidated`
are excluded), a rehearsed row's idempotency key is now taken. A later
**live** run over the same rows — or a second dry-run — sees the key
already present and is deduped (`skipped`) unless you pass
`manual_override: true` (which suffixes the key; see §5). Rule of thumb:
rehearse to inspect the pipeline, but drive the real send with
`manual_override` (or over rows you haven't rehearsed) so the rehearsal
row doesn't swallow it. When counting "what actually went out", filter
`messages` by `status`/`mode` — never by row presence alone.

### The honest verbs

- **Send now** (first fire or re-fire — same call):
  `execute({ dataset_id, decision_key, mode: 'live',
  manual_override: true })`.
- **Send now on a SCHEDULED action** — add `trigger_override:
  true`: the trigger_template's schedule is bypassed and the queued
  job dispatches immediately (the log still records the rendered
  `scheduled_at`; only the job is fast-forwarded). This is the only
  way to drive a future-triggered action (a year-roll birthday SMS)
  to completion on demand.
- **Retry** = the same call, aimed at a subject whose message
  row says `status: 'failed'` (+ `failure_reason`). There is no
  retry endpoint and no approval queue; "approve"/"skip" are not
  platform verbs — not acting IS the skip. The prior *failed* row
  doesn't block the retry at the message layer: `:failed` and
  `:invalidated` rows are excluded from the idempotency uniqueness
  constraint, so the same idempotency key is free to be re-used. (The
  per-row job dedupe is a separate layer — see gotcha 5.)

## 9. Gotchas

1. **`filter_config` follows the same inverted-render rule as
   interoperability contracts: non-empty PASSES, empty SKIPS.**
   Same wording, same trap. See
   `interoperability_contracts.md` §2 for the worked example.

2. **`update()` silently drops omitted arrays.** Workflow
   updates must re-supply `actions` and `context_datasets`
   verbatim — otherwise the existing rows are deleted. Factor the
   body construction so create and update share one builder.
   (Nested `workflow_ai_agents` follow the same whole-array-replace
   rule — re-supply the full set on every `update()`; see §3.)

3. **`sql_where_clause` ids require an alias prefix — on a platform
   dataset.** `id` is ambiguous across the multi-JOIN base query, so
   use `<base_alias>.id` (`le.id`, `m.id`). A generic-table workflow
   has no alias and takes bare column names.

4. **`.execute` and `.run` take the same id.** They once did not, and
   an older version of this guide described the asymmetry at length.
   The paired schemas that caused it are gone: the derived lakes
   preserve the primary key, so there is one id per row and no
   re-search is needed before calling `.execute`.

5. **`manual_override: true` on `.execute` is required for
   re-fires.** Without it, the per-row job dedupes against the
   sampled-event uniqueness check and the new log row sits in
   `:pending` indefinitely. The override flips the job's
   unique-constraint behavior so the re-fire is allowed.

6. **`batchLogs.get` returns cached status; call `.refresh`.**
   Per-row jobs are async; `.get` does not recompute. Poll
   `.refresh(runLogId)` in your wait loop.

7. **`trigger_template: 'now'` (literal) is different from
   `'{{ "" | now | date: ... }}'`.** The literal sentinel
   routes to the next available scheduling slot directly;
   the rendered timestamp gets clamped against the action
   window + queue throttle and pushed forward unpredictably.

8. **Enrichment runs for every workflow, even without agents.**
   For standard (non-agent) workflows, `enrichment.json` lands
   with `{ status: 'skipped' }`. Don't treat the artifact's
   absence as the "no AI" signal — read its body.

9. **Use `| to_json`, NEVER `| json`, in Liquid bodies.** When
   serializing nested values into a JSON payload (most commonly
   in an agent's `context_mapping_config`, but also any
   TemplateConfig body that must produce valid JSON), the
   correct filter is `| to_json`. `| json` is NOT in the
   platform's allowlist of custom filters (see `ai_sandbox.md`
   §2 for the closed thirteen-filter set). Using it produces
   structurally-invalid JSON output silently — the downstream
   JSON parse step then rejects the rendered string with a
   generic decode error far from the Liquid template's location,
   making it expensive to diagnose. Always write
   `{{ event_dataset | to_json }}`, not
   `{{ event_dataset | json }}`.

10. **Don't improvise an "approval queue" over dry-run logs.**
    Rehearsals, scheduled sends, and dispatches are three different
    states with different reading surfaces — §8 has the map, the
    verbs, and the mistakes this prevents. Datalake truth lives in
    `messages.md` / `action_logs.md`.

11. **Delivery truth arrives later, via the status updater.** A
    just-dispatched message is neither delivered nor failed yet —
    `status` is *eventually honest* (see
    `action_status_updaters.md` §7 and `messages.md` §1).

12. **Why a send silently didn't fire — check the tool and the
    workflow status.** Two non-obvious gates run at execution:
    (a) the action's **tool must be active** — if its tool is a draft,
    the action is *cancelled* at execution (the action log records the
    cancel; nothing dispatches); and (b) a **`draft` workflow driven
    manually runs in `dry_run`** — the platform derives the mode from
    the workflow's status, so a draft never sends for real even when
    you invoke `.run`/`.execute` explicitly. Arm the workflow
    (`status: 'live'` — or `'manual'`, which runs live when explicitly
    invoked) and activate the tool before expecting a real send.

13. **A `runtime_filter` edit pauses pending sends; nil never
    pauses.** Editing an action's `runtime_filter` to render falsy
    skips every not-yet-fired job (§2 — the action is re-read at fire
    time). A nil/empty `runtime_filter` always passes, so clearing it
    does the opposite of pausing.

14. **Send windows don't wrap midnight.** `action_window_start >
    action_window_end` matches no hour and snoozes forever — encode
    overnight quiet hours as the complement *send* window (§2).
