Skip to content

Audit — the record's history

Every change to a business object leaves a permanent trace on its audit timeline — one row per business object event, plus row identity, who did it, when, and which transaction it belonged to.

An AuditEntry carries the same five verbs and the same businessObject / businessObjectId / cause an event does, and adds four fields of its own: id (the row’s, distinct from businessObjectId), at, actor, and correlationId — the id of the unit of work the row was written in, shared by every row of that unit. The table lives in the tenant’s own schema (tenant_<tenantId>) and has no tenant_id column: the schema name is the tenant scope.

It is its own type, not the event type. The two overlap and then diverge, and the divergence is the point: an event is an in-flight signal, so it hands a listener whole state on every verb; a row is history, so it keeps the diff and stores a snapshot only where there is no diff to keep. One type serving both would let this table’s storage budget decide what a listener may know — and the first thing it would cost is a full snapshot of every version of every record, forever, which shows a timeline nothing the diff does not already show.

The verb determines what a row carries — there is no null padding:

operation carries
created, deleted record — the born / final snapshot
updated changes — the field diff, not the record
archived, restored nothing — the verb is the whole story

Two mutations write no row at all: a no-op update (an empty diff is not history) and a draft, silent from birth to death. A record’s timeline therefore opens with the created row from the moment it first became operational — never a bare deleted for a record that never really existed.

The diff mirrors the record tree rather than flattening it. A changed scalar is a { from, to } pair; a changed child collection is a bucket record keyed by child id:

The public type is Changes<S>, with ValueChange<T> at scalar and struct leaves and CollectionChanges<S> for child collections. Added entries carry { id, values }, with the id excluded from values; modified entries carry { id, changes }, and removed entries carry { id }. Empty collection buckets are omitted, and an unchanged object produces null rather than an empty diff.

{
"documentDate": { "from": "2026-08-01", "to": "2026-08-04" },
"lines": {
"modified": [
{ "id": "0197…", "changes": { "quantity": { "from": "1", "to": "3" } } }
],
"removed": [{ "id": "0198…" }]
}
}

added carries child values ({ id, values }), modified a nested diff of the same shape, removed only an id. A struct is a value, so it changes like a scalar — one { from, to } holding the whole member object on each side, null where it was absent; appearing, clearing and a member edit all wear that one shape. Values are the JSON wire form (a decimal is a string, a date is yyyy-MM-dd); reading them back as Decimal and Temporal.PlainDate is what hydration does, below.

The actor is whatever is on the ambient ActorStore frame when the event fires:

write path actor
an HTTP request { kind: "user", id, name, email } — the auth guard sets it on the frame the HTTP pipeline opened
an HTTP request bearing an api key { kind: "service-account", id, name } — id is the key’s, and the roles it holds are assigned under that same id
a seeder { kind: "system", name: "seeder:<plugin>" }
a workflow run { kind: "system", name: "workflow:<workflow id>" } — no guard runs for the engine’s mounted route
an agent run with nobody watching { kind: "agent", id, name } — the agent is the principal, and holds its roles under its own id like a service account does. A chat turn stays the user; the conversation cause is what says an agent made the call
a background job the user who enqueued it

That last row is the one that surprises. A job is not a system actor: enqueueJob captures the current actor into the job envelope and the processor restores it before the handler runs, so a PDF render queued from a request writes rows attributed to the person who asked for it.

Recording audit without an actor is a bug, not a state to tolerate: the staging code calls actorStore.require(), which throws, and the listener runs with suppressErrors: false, so a missing actor rolls the whole write back rather than dropping a row silently. Audit is atomic with the data it describes — rows are staged per unit of work and inserted inside the same commit transaction.

cause names what triggered the mutation one level up — an event, an action, or a job, or null for a write with no frame above it. The mechanism belongs to Events; two consequences bite here. Every action invocation runs inside an action frame, so an action-triggered row is never null. And a cascade’s row points at its parent, not its originator — in A→B→C, C’s row names B, and correlationId is the only thing that groups the whole cascade.

GET /api/audit/business-object/{businessObject}/{id}?cursor=…&limit=…

{businessObject} is the URL-encoded name (e.g. master%2FProduct). limit defaults to 50 and is clamped to 1–200; rows come back newest-first with a nextCursor, null on the last page. From TypeScript, client.audit.timeline fetches and hydrates.

at is not stamped when the event fires. It is a now() column default, and Postgres resolves now() to the transaction timestamp — so every row flushed in one unit of work carries the same instant. The seq bigserial is the real tie-breaker: ordering is at desc, seq desc, and the cursor encodes the pair.

The client cannot page. AuditClient.timeline accepts only { limit } and discards nextCursor, so the Activity sheet shows exactly one page — the newest 50 rows. Paging is the endpoint’s today, not the client’s.

A row is frozen wire JSON written against the schema as it stood that day. The client hydrates every entry against the record’s current schema — the same boundary hydrateRecord is for an ordinary read — and degrades rather than rewriting history:

the schema since the row was written what happens
the business object was renamed or removed the entries pass through raw; one unknown business object does not fail the whole timeline
a field was renamed or removed that field passes through untouched, every other field still hydrates
a field’s kind changed hydration throws, deliberately — that is a breaking schema change, and rendering an old decimal as a date would be worse than a loud failure

In the app, the Activity sheet (the History item on a record’s action menu) shows each entry’s verb, actor and relative time, and — on expand — the snapshot or the diff. A cause adds a line under the entry: via <business object> <id> for an event cause, the action name for an action cause; a job cause has no line today. With no schema for the business object the sheet falls back to a raw JSON dump, and an unknown field renders as an UnknownRow.

correlationId is written on every row but has no timeline UI: within a single record’s history each row is its own transaction, so there is nothing to group. It is the grouping key a future cross-record activity feed will use — kept now because history cannot be backfilled.