# Audit log

> The audit_log record shape, the action and resource-type vocabularies, and how to read entries over the API instead of the console.

Every admin mutation in TeleQuick writes a row to `audit_log`. The
console's **Audit log** screen is a thin reader over two tRPC queries:
`adminAudit.list` for the entries and `adminAudit.options` for the
filter vocabularies. Both are scoped to a single org, so a caller only
ever sees the audit trail of the org it passes as `orgId`.

```ts
const { rows, total } = await trpc.adminAudit.list.query({
  orgId,
  resourceType: "…",   // optional
  action: "…",         // optional
  actorUserId: "…",    // optional
  since: "2026-01-01T00:00:00.000Z", // optional ISO-8601
  search: "…",         // optional
  page: 1,
  perPage: 100,
});
```

## What gets written and when

An entry is appended when an admin mutation completes. The write is a
side effect of the mutation, not something you trigger — there is no
"create audit entry" procedure in this surface. The action string on the
row identifies the mutation that produced it, and `resource_type` plus
`resource_id` identify what it touched.

Entries fall into two shapes:

- **Resource mutations** — a `resource_id` is set, and `before` / `after`
  carry the JSON state of that resource on either side of the change.
- **Events without a single target** — `resource_id` is `null`. Bulk
  operations and session events look like this.

## The audit entry record

`adminAudit.list` returns `rows`, an array of records with the following
fields, plus `total`, the count of entries matching the filters (not the
count returned on this page).

| Field           | Type              | Meaning                                                                 |
| --------------- | ----------------- | ----------------------------------------------------------------------- |
| `id`            | `string`          | Entry id. Stable, used as the row key and the expand target.             |
| `created_at`    | `string`          | ISO-8601 timestamp of the mutation.                                      |
| `actor_user_id` | `string \| null`  | The user who performed the mutation. `null` for system-originated writes.|
| `actor_label`   | `string \| null`  | Display name for the actor, resolved at write time.                      |
| `action`        | `string`          | The mutation verb. See the vocabulary below.                             |
| `resource_type` | `string`          | The kind of object touched.                                              |
| `resource_id`   | `string \| null`  | The specific object touched, when the action targets one.                |
| `before`        | `unknown`         | JSON state prior to the mutation. `null` on creates.                     |
| `after`         | `unknown`         | JSON state after the mutation. `null` on deletes.                        |
| `ip`            | `string \| null`  | Source IP of the request that caused the write.                          |
| `request_id`    | `string \| null`  | Correlation id of the originating request.                               |

`before` and `after` are opaque JSON. Their shape follows the resource
they describe, so do not assume a common schema across `resource_type`
values.

## Action and resource type vocabulary

Neither vocabulary is a fixed enum. Both are derived from the entries
that actually exist in the org, and the console fetches them from
`adminAudit.options`:

```ts
const { resourceTypes, actions, actors } =
  await trpc.adminAudit.options.query({ orgId });
```

| Field           | Type                              | Feeds                    |
| --------------- | --------------------------------- | ------------------------ |
| `resourceTypes` | `string[]`                        | The **Resource** filter. |
| `actions`       | `string[]`                        | The **Action** filter.   |
| `actors`        | `{ id: string; label: string }[]` | The **Actor** filter.    |

Build filter UIs from this call rather than hardcoding a list — an org
that has never deactivated anything will not have a deactivation action
in its vocabulary.

Action strings follow verb conventions that the console relies on to
colour each timeline dot. Severity is **not** stored on the row; it is
inferred client-side from the action string, which is the source of
truth:

| Action pattern                                        | Inferred severity |
| ----------------------------------------------------- | ----------------- |
| starts with `insert`                                  | ok                |
| starts with `bulk.`                                   | ok                |
| starts with `update`, or equals `agent.created`       | info              |
| contains `paused` or `flagged`                        | warn              |
| contains `delete`, `deactivate` or `signed_out`       | bad               |
| anything else                                         | info              |

If you write your own reader, reproduce this mapping or ignore it — the
row carries no severity column either way.

## Actors, IP and request id

`actor_user_id` is the acting user. When a mutation originates from the
platform rather than a person, it is `null`; the console renders those
entries as `system`. `actor_label` is a denormalised display name
captured at write time, so it survives later renames or user deletion —
prefer it for display and fall back to `actor_user_id`, then `system`:

```ts
const who = row.actor_label ?? row.actor_user_id ?? "system";
```

`ip` is the source address of the request that caused the write, and is
shown inline next to the actor. `request_id` is on the record but the
console timeline does not render it; use it to join an audit entry back
to application logs and traces for the same request.

## Reading before and after diffs

Expanding an entry renders a JSON diff of `before` against `after`. The
console only renders the diff panel when at least one of the two is
non-null, so entries that carry neither expand to nothing.

Read the two fields together to classify the change:

| `before` | `after`  | Change     |
| -------- | -------- | ---------- |
| `null`   | object   | Creation   |
| object   | object   | Update     |
| object   | `null`   | Deletion   |

Both values are stored as JSON, so over the API you can diff them with
whatever structural diff library you already use.

## Filtering, search and paging

`adminAudit.list` accepts these inputs. Every filter is optional except
`orgId`, and omitting one means "any".

| Input          | Effect                                                                          |
| -------------- | ------------------------------------------------------------------------------- |
| `orgId`        | Required. Restricts the query to one org.                                        |
| `resourceType` | Exact match on `resource_type`.                                                  |
| `action`       | Exact match on `action`.                                                         |
| `actorUserId`  | Exact match on `actor_user_id`.                                                  |
| `since`        | ISO-8601 lower bound on `created_at`.                                            |
| `search`       | Free text over `resource_type`, `action` and `resource_id`.                      |
| `page`         | 1-based page index.                                                              |
| `perPage`      | Page size. The console requests 100.                                             |

The console's **Range** chip is a convenience over `since`: *Today*
sends local midnight, *Last 7 days* and *Last 30 days* send now minus
that many days, and *All time* omits `since` entirely.

Paging is page-number based. `total` is the number of entries matching
the filters across all pages, so compare it against the rows you already
hold to decide whether another page exists:

```ts
const hasMore = total > loadedRows.length;
```

Changing any filter resets the page index back to 1. Keep the previous
page's rows on screen while the next query is in flight if you want to
avoid a flash of empty state — the console does this with
`keepPreviousData`.

The console caches the `adminAudit.options` response for 60 seconds, so a
brand-new `resource_type` may take that long to appear in the filter
dropdowns after its first entry is written. Query `options` directly if
you need it fresh.

## Aggregating a filtered window

The console's right-hand rail — top actors and action mix — is computed
in the browser from the rows currently loaded, not from the whole
filtered range. That is why it shows **Loaded** and **Total in range**
as separate numbers. If you need aggregates over an entire range, page
through `list` yourself and aggregate the full result; do not read a
single page's distribution as representative of the range.

## Immutability and retention

Audit entries are append-only. The surface behind this screen exposes
`adminAudit.list` and `adminAudit.options`, both read-only queries —
there is no procedure to edit, redact or delete an entry, and the console
offers no such control. Treat a written row as final, including its
`before` / `after` payloads.

Because entries are immutable, the practical bound on how far back you
can read is whatever the org's audit data retention allows. Use
`since: undefined` (the *All time* range) to read the oldest entries
still available, and export anything you need to keep beyond that
outside the platform.

## Related

- [Authentication](/concepts/authentication) — API keys and the scopes that gate control-plane mutations
- [Telemetry](/platform/telemetry) — traces carry the same request correlation ids you see on audit entries
