# Change record

The shape of the record BasaltDB keeps for every action an agent asks for, whether it was applied, held or refused, with every field and value.

BasaltDB keeps one record per action an agent asks for. It is written whether the action was applied, held or refused. `run_history` and `basalt log --json` return these records. To read a run in plain text, see [See what it did](/guides/see-what-it-did/).

## The shape

```json
{
  "changeId": "0193a4c2-8a10-7c55-8e21-0b7a6d4c9e10",
  "at": "2026-11-10T06:02:11Z",
  "actor": {
    "kind": "AGENT",
    "name": "mbg-tracker-bot",
    "runId": "0193a4c2-7d1e-7b3a-9f00-3c1d5e8a2b44",
    "taskId": "0193a4c2-7d1e-7b3a-9f00-3c1d5e8a2b45"
  },
  "intent": "Merge duplicate case reports from yesterday",
  "target": { "table": "public.cases", "key": { "id": 4211 } },
  "op": "UPDATE",
  "statement": "UPDATE cases SET status = $1 WHERE id = $2",
  "before": { "status": "open" },
  "after": { "status": "merged" },
  "basedOn": { "version": "xmin:88123" },
  "durationMs": 12,
  "rows": 1,
  "policy": { "decision": "ALLOW", "rule": "default.single-row-update" },
  "status": "APPLIED"
}
```

## Fields

| Field        | What it holds                                                                                         |
| ------------ | ----------------------------------------------------------------------------------------------------- |
| `changeId`   | The record's id. A UUIDv7, so ids sort by time.                                                       |
| `at`         | When it happened. RFC 3339 UTC.                                                                       |
| `actor`      | Who asked. `kind` is `AGENT` or `PERSON`. `name`, `runId` and `taskId` say which agent, run and task. |
| `intent`     | The reason the agent gave, in a sentence.                                                             |
| `target`     | The table and the primary key of the row, when the action touched one row.                            |
| `op`         | What kind of statement. See the table below.                                                          |
| `statement`  | The SQL the agent sent. Values are in `params`.                                                       |
| `before`     | The row's old values, for the columns that changed.                                                   |
| `after`      | The row's new values, for the columns that changed.                                                   |
| `basedOn`    | The row version the agent last read, as `xmin:<n>`. See [What the agent read](#what-the-agent-read).  |
| `durationMs` | How long the statement took.                                                                          |
| `rows`       | Rows touched. For a read, rows returned.                                                              |
| `policy`     | The decision and the rule behind it. `decision` is `ALLOW`, `HOLD` or `REFUSE`.                       |
| `status`     | Where the action ended up. See the table below.                                                       |

As `run_history` and `basalt log --json` return it, a record also carries:

| Field          | What it holds                                                                                                                              |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `params`       | The values bound to `$1`, `$2`, ...                                                                                                        |
| `cascadedRows` | How many of the rows changed through cascades.                                                                                             |
| `rowChanges`   | One entry per row written, cascades included. Each has `key`, `before` and `after`, and `redacted` or `pruned` when the values are hidden. |
| `revertId`     | On a revert's own record: the revert it belongs to.                                                                                        |
| `revertedBy`   | The revert changes that restored rows of this change.                                                                                      |
| `approvalId`   | On an applied write that was held first: the approval that released it.                                                                    |
| `reason`       | On a `REFUSED` record: `{ "code", "message" }` with an [error code](/reference/errors/).                                                   |

## Values

| Field             | Values                                                                                                                  |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `status`          | `APPLIED`, `APPROVAL_PENDING`, `REFUSED`, `APPROVAL_EXPIRED`, `REVERTED`                                                |
| `op`              | `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `OTHER` (a refused statement that is none of these), `REVERT` (a person's undo) |
| `actor.kind`      | `AGENT`, `PERSON`                                                                                                       |
| `policy.decision` | `ALLOW`, `HOLD`, `REFUSE`                                                                                               |

What the statuses mean:

- `APPLIED`: it ran. A write that was held first is replaced by an `APPLIED` record that carries `approvalId`. A write only partly undone stays `APPLIED`.
- `APPROVAL_PENDING`: held, waiting for your approval. It has not run.
- `REFUSED`: the rules or the guard said no. Nothing changed. `reason` says why.
- `APPROVAL_EXPIRED`: a held write was not approved within 24 hours.
- `REVERTED`: an undo restored every row of this change. If any row was skipped, the status stays `APPLIED` and `revertedBy` lists the undo.

## What the agent read

Every `query` is a change record with `op: SELECT` and `status: APPLIED`. Its `rows` is the number of rows returned. It has its statement, parameters and duration, no `rowChanges`, and the run's `intent` when it ran in a run. BasaltDB keeps the version of each row the read saw.

An `UPDATE` or `DELETE` of a row the run had read carries `basedOn: { "version": "xmin:<n>", "changeId": "<the read>" }` on that row's entry in `rowChanges`. A row the run never read carries `blindWrite: true`. That is allowed. Cascaded rows and inserts carry neither. A change with exactly one row that was read also carries `basedOn` at the top level, as in the example. If the row changes after the read, the write is refused with [`STALE_READ`](/reference/errors/#stale-read).

## An undo is a new record

An undo restores old values and never edits or deletes ledger rows. It is recorded as one change with `op: REVERT` and `actor.kind: PERSON`, plus one entry in `rowChanges` per restored row. A skipped row is listed with its reason (`CHANGED_AFTER`, `ROW_GONE`, `KEY_TAKEN`, `ALREADY_REVERTED`, `VALUES_PRUNED`, `SCHEMA_CHANGED`, `CASCADE` or `CONSTRAINT`) and no values. See [Undo](/concepts/undo/).

## How long it is kept

| What                                    | How long |
| --------------------------------------- | -------- |
| `before` and `after` values             | 30 days  |
| The rest of the ledger: who, what, when | 1 year   |

A row whose old values are older than 30 days shows `pruned` and cannot be restored. It is skipped as `VALUES_PRUNED`. The numbers are also in [Limits and defaults](/reference/limits/).

## Tamper evidence

The ledger is append-only and hash-chained, inside your Postgres. `basalt log` checks the chain each time it runs and warns if it is broken. See [Ledger](/concepts/ledger/).
