# Audit log

> Who changed what, and when: the append-only admin audit log, its entry fields, the actions and resource types it records, and how to read it over the API.

The TeleQuick console writes an audit row every time an operator or a
service changes a tenant-scoped resource. The rows live in the `audit_log`
table, scoped to one org, and the console reads them back through tRPC. This
page explains what each field means, what the action vocabulary covers, and how
to page through the log outside the console.

Two procedures expose the same table:

| Procedure           | Used by                          | Pagination          | Timestamp field |
| ------------------- | -------------------------------- | ------------------- | --------------- |
| `admin.auditList`   | Portal → **Audit Log**           | `limit` / `offset`  | `ts`            |
| `adminAudit.list`   | Voice AI → **Audits** (Insights) | `page` / `perPage`  | `created_at`    |
| `adminAudit.options`| Voice AI → filter dropdowns      | —                   | —               |

## What gets recorded

An entry is written for a change to a tenant-scoped resource: the insert,
update or delete of a configuration row, a hydration of Postgres state into the
runtime cache, and credential writes and removals. Each entry names the actor,
the action, the resource it touched, and — where the writer supplied them — the
resource state before and after the change.

The log is per-org. `admin.auditList` requires an `orgId` and filters on
`org_id`; access is additionally constrained server-side by row-level security,
so a caller only ever reads rows for orgs it is a member of.

Rows are returned newest-first (`admin.auditList` orders by `ts` descending).

## Anatomy of an entry

`admin.auditList` selects these columns and returns them unmodified:

| Field            | Type              | Meaning                                                                 |
| ---------------- | ----------------- | ----------------------------------------------------------------------- |
| `id`             | `string`          | Entry id. Stable — use it to link to or de-duplicate a row.             |
| `ts`             | ISO-8601 string   | When the change was recorded. `adminAudit.list` exposes this as `created_at`. |
| `actor_user_id`  | `string \| null`  | The console user that made the change, when the change came from a user. |
| `actor_label`    | `string \| null`  | Human-readable name for the actor, for display.                          |
| `action`         | `string`          | What was done. See [Actions](#actions).                                  |
| `resource_type`  | `string`          | Which kind of resource was touched. See [Resource types](#resource-types).|
| `resource_id`    | `string \| null`  | The id of the specific resource, when the change targets one row.        |
| `before`         | JSON              | Prior state, as recorded by the writer. May be `null`.                   |
| `after`          | JSON              | New state, as recorded by the writer. May be `null`.                     |
| `ip`             | `string \| null`  | Source IP of the request that caused the change.                         |
| `user_agent`     | `string \| null`  | User agent of that request.                                              |
| `request_id`     | `string \| null`  | Id of the control-plane request that produced the change.                |

The response envelope is:

```json
{
  "rows": [
    {
      "id": "…",
      "ts": "2026-01-14T09:12:44.118Z",
      "actor_user_id": "8f2c…",
      "actor_label": "ops@acme.example",
      "action": "update",
      "resource_type": "dispatch_rule",
      "resource_id": "dr_abc",
      "before": { "priority": 10 },
      "after":  { "priority": 20 },
      "ip": "203.0.113.9",
      "user_agent": "Mozilla/5.0 …",
      "request_id": "req_01hx…"
    }
  ],
  "total": 4312
}
```

`total` is an exact count of the rows matching the current filters, not the
number returned in this page.

## Actors: users and the system fallback

Three fields describe who acted, and they are populated independently:

- `actor_user_id` — set when a console user performed the change.
- `actor_label` — a display string for the actor. Prefer it in your UI; it is
  what the console shows.
- Neither set — the change did not originate from a signed-in user. Both
  console screens fall back to rendering the actor as `system` in that case.

The portal resolves the actor column as `actor_label`, then a shortened
`actor_user_id`, then `system`. Reproduce that order if you render the log
yourself, because a row can carry a label without a user id, or a user id
without a label.

`adminAudit.options` returns an `actors` array of `{ id, label }` for the org,
built from the actors that actually appear in the log. Feed its `id` values back
as `actorUserId` to filter. Rows with no `actor_user_id` cannot be selected that
way — the system actor has no id to filter on.

## Resource types

The portal's resource filter offers this fixed set:

| `resource_type`            | What it covers                                                        |
| -------------------------- | --------------------------------------------------------------------- |
| `agent_config`             | Agent configuration rows.                                             |
| `trunk`                    | SIP trunk records.                                                    |
| `did`                      | Numbers (DIDs) attached to the org.                                   |
| `agent_provider_override`  | Per-agent overrides of provider selection.                            |
| `dispatch_rule`            | Dispatch / routing rules.                                             |
| `vendor_creds`             | Stored vendor credentials.                                            |

The filter is an exact-match equality on `resource_type`, so a value must be
spelled exactly as it was written. The Voice AI screen instead populates its
dropdown from `adminAudit.options.resourceTypes`, which reflects the resource
types that are actually present in that org's log — use that if you want a list
that tracks the data rather than a hardcoded vocabulary.

## Actions

| `action`            | Meaning                                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `insert`            | A new row was created.                                                                                    |
| `update`            | An existing row was modified.                                                                             |
| `delete`            | A row was removed.                                                                                        |
| `hydrate`           | Postgres state was projected into the runtime's Redis cache so the C++ runtime can read it without a DB hop. |
| `set-credential`    | A credential value was written.                                                                            |
| `delete-credential` | A credential value was removed.                                                                            |

`hydrate` entries are not user edits in the usual sense — they record that the
source-of-truth rows were re-published to the runtime. Trunk transfer rules work
this way: Postgres holds the priority-ordered list, and every write re-fetches
the full list for the trunk and rewrites the Redis key that the runtime's
transfer tool reads. So a single rule edit can produce both a write action and a
`hydrate` action.

Credential writes get their own two actions rather than being folded into
`insert` / `update` / `delete`, which lets you filter for credential activity on
its own. The portal colour-codes any action containing `credential` as a
warning.

Action colours in the portal table:

| Action                       | Treatment     |
| ---------------------------- | ------------- |
| `insert`                     | success       |
| `update`                     | primary       |
| `delete`                     | destructive   |
| `hydrate`                    | secondary     |
| anything containing `credential` | warning   |
| anything else                | neutral       |

The action filter is also an exact equality match. The portal's dropdown lists
the six values above; `adminAudit.options.actions` returns the distinct actions
present in the org's log, so an action written by a newer service shows up there
before it is added to any hardcoded list.

## Before and after payloads

`before` and `after` are opaque JSON columns. The procedures select them
directly and return them as stored — no shaping, diffing, filtering or masking
happens in the read path. What you get is exactly what the writer of the row
recorded.

Consequences worth planning for:

- **Either side can be absent.** Both console screens only render a payload when
  it is non-`null`, and the Voice AI timeline only makes an entry expandable when
  at least one of the two is present. An `insert` typically has no meaningful
  `before`; a `delete` typically has no `after`; some actions record neither.
- **The shape is per-writer.** There is no common envelope across resource
  types. Do not assume a field exists in `after` because it existed for another
  resource type.
- **Treat the payloads as sensitive.** `vendor_creds` is an audited resource
  type, and the read path performs no redaction of its own. If you expose the
  log in your own surface, apply whatever masking your policy requires at that
  layer rather than relying on the API to have removed anything.

Both console screens render the payloads as pretty-printed JSON side by side
rather than computing a structural diff.

## Request context: IP, user agent and request ID

`ip`, `user_agent` and `request_id` describe the request that caused the change,
and are each independently nullable — a change made by a non-interactive actor
may carry none of them.

`request_id` is the most useful of the three for investigation: it is the id of
the control-plane request that produced the row, so it is the value to line up
against your own request logs when you need the full context around a change.
The portal shows all three in the detail panel; the Voice AI timeline shows
`ip` inline next to the actor.

## Retention and immutability

The log is append-only. Rows are written once and are never edited or deleted
through these surfaces — there is no update or delete procedure for `audit_log`,
and the console states this on the page itself. A correction therefore appears as
a new entry, not as a modification of the original.

Neither procedure applies an expiry or trims old rows, and neither exposes a
retention setting. The only bound in the read path is the time filter you pass:
`adminAudit.list` accepts a `since` timestamp, and the Voice AI screen's **All**
window computes `since` from a 3650-day lookback rather than omitting the filter.
If you need a guaranteed retention horizon for a compliance requirement, confirm
it against your contract — it is not expressed in the API.

## Reading the log over the API

The log is readable outside the console through the same tRPC procedures the
console calls.

```ts
// Portal-style read: newest 100 entries for an org.
const { rows, total } = await trpc.admin.auditList.query({
  orgId: "org_abc",
  limit: 100,
  offset: 0,
});
```

Narrow by resource type and action:

```ts
// Every credential write on stored vendor credentials.
const creds = await trpc.admin.auditList.query({
  orgId: "org_abc",
  resourceType: "vendor_creds",
  action: "set-credential",
  limit: 500,
});
```

`admin.auditList` input:

| Input          | Type                            | Notes                                            |
| -------------- | ------------------------------- | ------------------------------------------------ |
| `orgId`        | `string`, required              | The org whose log you are reading.               |
| `resourceType` | `string`, optional              | Exact match. Omit for all resource types.        |
| `action`       | `string`, optional              | Exact match. Omit for all actions.               |
| `limit`        | integer, min `1`, max `500`, default `100` | Rows per page.                    |
| `offset`       | integer, min `0`, default `0`   | Rows to skip.                                    |

The Voice AI variant takes a different shape, including free-text search and an
actor filter:

```ts
const audits = await trpc.adminAudit.list.query({
  orgId: "org_abc",
  search: "dispatch",                       // matches action / resource
  since: "2026-01-01T00:00:00.000Z",        // ISO-8601
  resourceType: "dispatch_rule",
  action: "update",
  actorUserId: "8f2c…",
  page: 1,                                  // 1-based
  perPage: 100,
});
```

Both return `{ rows, total }`. If the underlying query fails,
`admin.auditList` throws a tRPC `INTERNAL_SERVER_ERROR` carrying the database
error message.

Because `orgId` is required and the query is additionally scoped by row-level
security, there is no cross-org read: to audit several orgs, call once per org.

## Filtering and pagination

- **Empty means unfiltered.** The console maps its "All resources" / "All
  actions" selections to omitting the field entirely. Send `undefined`, not an
  empty string, for a filter you do not want applied.
- **Filters combine with AND.** `resourceType` and `action` narrow the same
  query; `total` reflects the filtered count, so it changes as you change
  filters.
- **Reset the page when a filter changes.** Both screens set the page back to
  the first page whenever a filter changes, because an offset that was valid for
  the wider result set usually points past the end of the narrower one.
- **Offset vs page.** `admin.auditList` is offset-based (`offset = page *
  limit`); `adminAudit.list` is 1-based page numbering. Do not mix the two
  conventions.
- **Page count.** With `admin.auditList`, derive the number of pages from
  `total` and your `limit`; the exact count makes that reliable without a
  separate count call.
- **Maximum page size is 500.** Requesting more is rejected by input validation.
  For a full export, iterate `offset` rather than raising `limit`.

Time-bounded reads are only available on `adminAudit.list`, via `since`. When
you compute that timestamp on the client, memoise it — deriving it from a bare
`Date.now()` on every render changes the query key each time and the query
refetches continuously.

## Related

- [Authentication](/concepts/authentication) — API keys and relay tokens; the credentials whose use these entries record
- [Telemetry](/platform/telemetry) — metrics, traces and CDRs, which cover call traffic rather than admin actions
