Skip to content
BasaltDBBasaltDB
Star

Change record

View as Markdown

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.

{
  "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"
}
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.
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.
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.

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.

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.

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.

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.

BasaltDB · Apache-2.0