> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow audit records

> The workflow-audit source: one event name for the whole audit trail, the selectors that silently deliver nothing, and migrating the workflow SIEM webhook to a drain

<Warning>
  **Available from lua-cli 3.39 and the platform release of 23 September 2026.** Until that release lands, `--sources workflow-audit` is refused and no `lua.workflow-audit.info` record is produced. The date is the one in force as this is written and is subject to change until the release is announced — treat the version as the reliable half.
</Warning>

The `workflow-audit` source copies your organization's **workflow audit trail** — who approved a run, who changed a policy, who exported what — into the same [log drain](/drains/overview) as everything else. Selecting it is how an organization ships its audit to a SIEM from this release onwards, and it replaces the per-organization [workflow SIEM webhook](#migrating-from-the-workflow-siem-webhook).

`workflow-audit` is **off by default**, on a new drain and an existing one alike. It is a compliance stream you opt into:

```bash theme={null}
lua drains create \
  --name "Audit to SIEM" \
  --type http \
  --endpoint https://siem.example.com/lua \
  --sources workflow-audit \
  --min-severity info \
  --environments production
```

<Note>
  Opting in has nothing to do with `--include-content`. `workflow-audit` is not a content source: selecting it never prints the content acknowledgement, and a drain that selects only it is not a drain that carries end-user messages. `--include-content` changes only [which five attributes](#the-content-rule) arrive.
</Note>

## One event name, not one per action

```text theme={null}
lua.workflow-audit.info
```

That is the whole vocabulary. An event name is `lua.<source>.<suffix>` with the suffix restricted to lowercase letters, and every audit action is dotted and underscored (`workflow.run.step_parked`), so an action could never be the suffix — and widening the name pattern would be a breaking schema change rather than an additive one.

**The action is an attribute. Switch on `lua.audit.action`, with a default branch**, exactly as you would switch on `lua.source` rather than on a per-server MCP event name. New actions appear within this major version and must not break your ingest.

### Every audit record is `INFO`

`severityNumber` is `9` on all of them. There is deliberately no severity ladder over the actions: a compliance sink wants the whole trail rather than the alarming part of it, and a second vocabulary to keep in step with the first is a source of drift.

That has one consequence big enough to have its own row below: **a drain whose `--min-severity` is `warn` or `error` receives no audit records at all**, even though it selects the source.

## Selectors that will silently deliver nothing

Three of a drain's selectors can leave you with a correctly configured audit drain and an empty destination. There is no error and no delivery-log row — the records are simply never matched.

| Selector         | The trap                                                                                                                                                                          | What an audit drain wants       |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `--min-severity` | Every audit record is `INFO`. `warn` and `error` match none of them                                                                                                               | `info` (the default) or `debug` |
| `--agents`       | Most audit actions have **no agent** — a policy change, a template install, an audit search. A drain pinned to an agent list receives only the run-scoped actions of those agents | `--agents all` (the default)    |
| `--environments` | An audit entry carries no environment, so it is read as `production`. A sandbox-only drain receives nothing                                                                       | include `production`            |

`--sampling` is not a fourth trap, but it is not a volume knob here either: only `debug` records are sampleable and audit records are always `info`, so a sampling rule has no effect on this source.

<Tip>
  Creating an audit drain inside a project directory sets `--agents` to that project's agent rather than to the whole organization. For an audit drain that is almost never what you want — pass `--agents all` explicitly.
</Tip>

## Attributes

| Attribute                       | On                          | Meaning                                                          |
| ------------------------------- | --------------------------- | ---------------------------------------------------------------- |
| `lua.log.type`                  | always                      | `"log"`                                                          |
| `lua.source`                    | always                      | `"workflow-audit"`                                               |
| `lua.audit.action`              | always                      | The audit action, verbatim — for example `workflow.run.approved` |
| `lua.audit.schema`              | always                      | `"lua.workflow.audit/1"`, the audit entry's own schema id        |
| `lua.audit.actor.type`          | always                      | `user`, `staff`, `system`, `apiKey`, or `agent`                  |
| `lua.audit.actor.id`            | when known                  | The actor's id                                                   |
| `lua.audit.target.type`         | when the entry has a target | For example `workflow_run`                                       |
| `lua.audit.target.id`           | when the entry has a target | The target's id                                                  |
| `lua.run.id`                    | run-scoped actions          | The workflow run                                                 |
| `lua.step.id`                   | step-scoped actions         | The workflow step                                                |
| `lua.audit.evidence.surface`    | when recorded               | Where the action came from — `desktop`, `api`, `cli`, …          |
| `lua.audit.evidence.api_key.id` | when recorded               | The API key that acted                                           |
| `lua.content.withheld`          | content off                 | `true` — the record is complete apart from the group below       |
| `lua.audit.actor.label`         | **content only**            | The actor's display label, often an email address                |
| `lua.audit.evidence.session.id` | **content only**            | The session that acted                                           |
| `lua.audit.evidence.ip`         | **content only**            | The request's source address                                     |
| `lua.audit.evidence.user_agent` | **content only**            | The request's user agent                                         |
| `lua.audit.metadata`            | **content only**            | The entry's metadata as **one JSON string**, at most 4 KB        |

**There is no `body`** on an audit record, with or without content.

### `lua.audit.metadata` is a JSON string

Attribute values in this schema are scalars, and that does not change. Audit metadata is free-form per action, so flattening it would put unbounded per-action key names into every destination's attribute index — a cost bug at the destination, not a convenience. Parse it at ingest:

```js theme={null}
const meta = record.attributes['lua.audit.metadata']
  ? JSON.parse(record.attributes['lua.audit.metadata'])
  : undefined;
```

Over the 4 KB cap the whole value is replaced by a marker rather than cut:

```json theme={null}
{"__truncated":true,"bytes":9214}
```

So an oversized entry is never dropped and never silently halved — and `JSON.parse` still succeeds, which is why the marker is an object rather than a string.

## The content rule

The five attributes marked **content only** are free text about a person. They are withheld unless the drain has `--include-content`, and the record **still arrives**, carrying `lua.content.withheld: true`. Ids, the action, the target and the surface are never withheld, so an organization can always see that the action happened.

`lua.workflow-audit.info` carries `lua.content.withheld` whenever content is off — **even for an entry that recorded no evidence at all**. That is deliberate: an entry with nothing to show must not be indistinguishable from one whose evidence you were not sent. Treat the flag as advisory and the presence of each key as authoritative.

One audit entry produces a *different record per drain*: a drain with content on and a drain with content off in the same organization each receive the record they asked for, under the same record id.

Content or not, every attribute passes the [scrubber](/drains/overview#what-never-leaves-lua). An organization scrub rule that matches inside `lua.audit.metadata` masks it there exactly as it would in a log body.

## Delivery, ordering and duplicates

The record id is the audit entry's own id, so a retried write or a replayed sweep cannot double-deliver. Delivery is at-least-once like every drain: **deduplicate on `records[].id`**.

Records are enqueued only **after** the audit entry is durable, so a drain never carries an entry the platform's own audit trail does not have. The reverse is possible and intended: the drain is fire-and-forget, and a drain outage never fails the action that was audited. Your SIEM is a copy, not the system of record.

Audit entries are written by more than one part of the platform, so the trail a drain carries is the whole one — run and ledger actions alongside read-access, policy, export and artefact actions. A newly created or newly edited drain starts (or stops) receiving audit within about **30 seconds**.

## Migrating from the workflow SIEM webhook

The per-organization workflow SIEM webhook (`audit.siemWebhook` on the workflow policy) is replaced by a drain that selects `workflow-audit`. It was a second delivery pipeline with its own signing, its own backoff and its own ordering, and none of the health, quota, delivery log or replay a drain already has.

**Existing webhooks are migrated for you.** You do not have to create the drain.

### What changes for your receiver

|              | Old webhook                                          | New drain                                                                                   |
| ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Body         | One audit event per POST                             | A `logs/1.0` [batch](/drains/event-schema): `{schemaUrl, batchId, resource, records[]}`     |
| Signature    | `X-Lua-Signature: sha256=<hex>` over `"<ms>.<body>"` | `X-Lua-Signature: t=<seconds>,v1=<hex>` over the body                                       |
| Timestamp    | `X-Lua-Timestamp`, milliseconds                      | Inside the signature header, `t=`, whole seconds                                            |
| Content type | `application/json`                                   | `application/json` or `application/x-ndjson`, with `Content-Encoding: gzip`                 |
| User agent   | `lua-workflows-audit/1`                              | `LuaDrain/1.0 (+https://docs.heylua.ai/drains)`                                             |
| Retry        | Fixed backoff, 8 attempts                            | The [drain retry ladder](/drains/delivery-guarantees), a 6-hour horizon, and a delivery log |

Every field of the old body is present on the new record, as an attribute — the [table above](#attributes) maps them. The old `action` is `lua.audit.action`; the entry's `metadata` is `lua.audit.metadata`, a JSON string.

[Verify signatures](/drains/verify-signatures) has the new format, working verifiers and published test vectors.

### The overlap release

A drain created by the migration adds **two extra headers to every delivery**, for one release only:

```text theme={null}
X-Lua-Signature-Legacy: sha256=<hex>      # HMAC-SHA256(secret, "<ms>.<body>")
X-Lua-Timestamp: <ms>
```

`<body>` is the **uncompressed** batch body — the same bytes `X-Lua-Signature` covers. So a receiver written against the old webhook can keep its existing verification code pointed at `X-Lua-Signature-Legacy` and `X-Lua-Timestamp` while it moves to `X-Lua-Signature`, and the two headers can never disagree about which bytes were signed.

The legacy value ships under its own header name because `X-Lua-Signature` is already the drain protocol's header and the two values are not interchangeable: a different signed string and a different timestamp unit. Overwriting it would break every conforming verifier at once.

<Warning>
  **The legacy headers are removed in the release after this one.** Move your verifier to `X-Lua-Signature` before then. They appear only on drains the migration created — there is no create or update option that turns them on, in the CLI or over the API, so a drain you make yourself is signed the one modern way from the start.
</Warning>

### What to check after your organization is migrated

<Steps>
  <Step title="Verify the drain">
    The migration leaves the drain in `pending_verification`, which buffers rather than delivers. Prove ownership of the endpoint and it moves to `healthy`:

    ```bash theme={null}
    lua drains verify drn_9f31a7c04b2e615d8a03cc71
    ```
  </Step>

  <Step title="Watch the test record land">
    A `lua.drain.test` record is enqueued for you and delivered by the normal flush, not by the migration. Confirm it arrived rather than trusting that the migration finished:

    ```bash theme={null}
    lua drains deliveries drn_9f31a7c04b2e615d8a03cc71 --kind test
    ```
  </Step>

  <Step title="Check the quota">
    The drain's allowance is recomputed from your plan on its next write. Confirm it is not sitting at a zero allowance before you call it live:

    ```bash theme={null}
    lua drains status drn_9f31a7c04b2e615d8a03cc71 --json
    ```
  </Step>
</Steps>

### Two things the migration changes on purpose

* **`--include-content` is turned on.** Creating such a drain by hand would require the content acknowledgement first. The migration does not ask for one, because nothing is transitioning: the webhook had been delivering this organization's audit `metadata` to this same endpoint under the policy you already set, and leaving the flag off would silently halve every record you receive. The drain selects only `workflow-audit`, so no end-user message content is in scope — but the flag *is* on, and adding a content source to that drain later will not re-ask.
* **You may receive more actions than before.** A drain selector has no per-action filter: `workflow-audit` is the whole vocabulary. An organization whose webhook was narrowed to a subset of actions therefore receives **more** audit actions than before, never fewer. Plan for the ingest volume.

### If your organization was not migrated

A migration run can refuse an organization rather than guess. The three reasons you can act on:

| Reason                                                             | What to do                                                                    |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| The stored SIEM secret could not be read                           | Re-enter it on the workflow policy, then ask support to re-run                |
| The organization is at its drain cap                               | Free a slot, or move to a tier with a higher cap — 5 drains, 10 on Enterprise |
| A live drain is already called `siem-webhook` and points elsewhere | Rename or delete it, then ask support to re-run                               |

The run is idempotent, so a re-run adopts the drain it already made rather than creating a second one.

### The workflow policy field is deprecated

`PATCH /admin/orgs/:orgId/workflow-policy` with `audit.siemWebhook` **still works** in this release. The write happens; the notice is advisory, not a refusal. A request that names the field answers a `deprecations` entry in the body:

```json theme={null}
{
  "deprecations": [
    {
      "field": "audit.siemWebhook",
      "message": "audit.siemWebhook is deprecated: workflow audit now ships through a log drain with sources: ['workflow-audit']. Existing webhooks are migrated for you; new ones should be created as drains.",
      "link": "https://docs.heylua.ai/log-drains/siem-webhook-migration"
    }
  ]
}
```

and the same thing in headers, in [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) shape, because an SDK reads JSON while a gateway or a linter reads headers:

```text theme={null}
Deprecation: true
Link: <https://docs.heylua.ai/log-drains/siem-webhook-migration>; rel="deprecation"; type="text/html"
```

A PATCH that does **not** name `audit.siemWebhook` carries neither the key nor the headers, so watching for `Deprecation: true` is enough to find the callers that still set it. Point new configuration at a drain instead.

## See also

<Columns cols={2}>
  <Card title="Ship logs to your stack" href="/drains/overview">Every selectable source, and what a drain is.</Card>
  <Card title="Event schema" href="/drains/event-schema">The full attribute catalogue and the content rule.</Card>
  <Card title="Verify signatures" href="/drains/verify-signatures">The `X-Lua-Signature` format and working verifiers.</Card>
  <Card title="Delivery records" href="/drains/sources/delivery">The other phase-2 source with its own page.</Card>
</Columns>
