# Robotics audit log

> The fact_robotics_audit record: actor, action kind, target, detail, severity, and how to read the log over the API.

Every operator action and configuration change on the robotics fleet
lands in a single append-only table, `fact_robotics_audit`. The console
renders it at **Audit log · Robotics**. This page describes the record
itself so that you can read it outside the console, correlate it with
telemetry, and understand how the console derives what it shows.

## What gets recorded

The log covers operator and system actions against robots in an org:
teleop sessions, e-stops, control transfers, alerts, and deploys. Rows
are written as the action happens, by the service that performs it —
the log is not reconstructed after the fact.

Every row is scoped to an org. `fleetRobotics.auditLog` takes `orgId`
and returns only rows for that org, so a caller cannot read another
tenant's actions through it.

## The audit row: actor, kind, target, detail

A row as returned by `fleetRobotics.auditLog` carries these fields:

| Field         | Meaning                                                                 |
| ------------- | ----------------------------------------------------------------------- |
| `ts`          | Timestamp of the action, as an ISO-8601 string.                         |
| `actor_label` | Who performed the action. Absent for actions with no human initiator.   |
| `kind`        | The action kind (see below). Machine-readable, stable.                  |
| `robot_id`    | The robot the action targeted. Absent for fleet-wide actions.           |
| `detail`      | Free-form context. Either a string or a JSON object.                    |

Two of these are nullable, and the console substitutes placeholders
rather than blanking the cell:

- `actor_label` missing → the console displays `system` and swaps the
  avatar for a CPU glyph. Treat `system` as "no human actor recorded on
  this row", not as the name of a principal.
- `robot_id` missing → the Target column shows `—`.
- `kind` missing → the Action column shows `—`.

`detail` has no fixed schema. When it is an object, the console
JSON-stringifies it for the Detail column; when it is a string it is
rendered verbatim. Write your own consumers to handle both shapes.

## Action kinds and severity

`kind` is the vocabulary you filter and alert on. The audit table itself
stores no severity or verdict column. Severity is **derived from the
kind string**, and the console does that derivation client-side:

| Derived verdict | Matched when the lowercased `kind` contains |
| --------------- | -------------------------------------------- |
| `bad`           | `estop`, `bad`, or `error`                    |
| `warn`          | `alert`, `warn`, or `flag`                    |
| `ok`            | anything else, including a missing `kind`     |

Matching is substring-based on the lowercased kind, so `estop_engaged`
and `fleet_estop_clear` both derive `bad`. Kinds whose name begins with
`estop` are additionally rendered in the danger colour in the Action
column.

Because this mapping is a naming convention rather than a stored
column, a new kind inherits its severity from how it is named. If you
emit or parse kinds yourself, keep the convention: put `estop`,
`error`, `alert`, `warn` or `flag` in the name when the action deserves
attention.

The `verdict` input on the procedure applies the same classification
server-side, so filtering by `bad` and colouring a row `bad` always
agree.

## Retention and append-only guarantees

The table is append-only. There is no procedure to edit or delete a
row, and the console exposes none — the log is the compliance artefact
for ISO 27001 and SOC 2 controls over operator actions on physical
hardware. Corrections are made by appending a new action, not by
rewriting history.

The log's window is bounded by what the query range covers, not by the
console's default range. The screen opens on the last six hours purely
as a default; supply a wider `fromMs` / `toMs` to read further back.

## Reading the log over the API

The console screen is a thin client over one procedure. You can call it
directly:

```ts
const { rows, total } = await trpc.fleetRobotics.auditLog.query({
  orgId,
  page: 1,
  perPage: 50,
  fromMs: Date.parse("2026-01-01T00:00:00Z"),
  toMs: Date.parse("2026-01-02T00:00:00Z"),
  verdict: "bad",
  search: "estop",
});
```

| Input     | Type                            | Notes                                                        |
| --------- | ------------------------------- | ------------------------------------------------------------ |
| `orgId`   | string                          | Required. Scopes the query to one org.                        |
| `page`    | number                          | 1-based.                                                      |
| `perPage` | number                          | Page size. The console uses 50.                               |
| `fromMs`  | number \| undefined             | Inclusive lower bound, epoch ms. Omit for no lower bound.     |
| `toMs`    | number \| undefined             | Upper bound, epoch ms. Omit for no upper bound.               |
| `verdict` | `ok` \| `warn` \| `bad` \| undefined | Omit for all verdicts.                                   |
| `search`  | string \| undefined             | Free-text match across actor, action, target, and detail.     |

The response is `{ rows, total }`. Filtering, searching and pagination
all happen server-side, so `total` counts the rows matching the full
predicate — range, verdict and search — not just the rows on the
current page. Derive page count as `ceil(total / perPage)`.

Two details worth copying from the console when you build your own
poller:

- **Round your range bounds.** The console floors `fromMs` and ceils
  `toMs` to the minute so that a relative range like `now-6h → now`
  produces a stable query key instead of a new one every render.
- **Debounce search.** The console waits 300 ms after the last keystroke
  before issuing a query, and resets to page 1 whenever the range,
  verdict or search changes.

The console refetches on a 60 s interval, which is a reasonable cadence
for a live view of an append-only table.

## Correlating an e-stop with robot telemetry

An audit row gives you three join keys: `ts`, `robot_id`, and `kind`.
To investigate an e-stop:

1. Filter the log to `verdict: "bad"` over the window in question, or
   search for `estop` directly.
2. Take `robot_id` and `ts` from the matching row.
3. Query your telemetry store for that robot around that timestamp. The
   audit row tells you *who* commanded the stop and *when*; the metrics
   and traces tell you what the robot was doing either side of it. See
   [Telemetry](/platform/telemetry) for the stream shapes and how to
   correlate them.
4. Read `actor_label` to distinguish an operator-initiated stop from one
   with no recorded human actor.

Because severity is derived from the kind name, a `bad` verdict alone
does not tell you whether the event was an e-stop or an error. Read
`kind` for that distinction, and `detail` for the context the emitting
service attached.

## Related

- [Telemetry](/platform/telemetry) — metrics, traces and capture streams to correlate against
- [Authentication](/concepts/authentication) — how a caller is authorised for control-plane procedures
