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.
What a row is
Section titled “What a row is”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.
Payload by verb
Section titled “Payload by verb”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.
changes is a tree
Section titled “changes is a tree”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.
Reading the timeline
Section titled “Reading the timeline”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.
Frozen audit data wins
Section titled “Frozen audit data wins”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.