Command audit & entity history
Loom's audit facility has two halves: a write side that records every successful public command as an append-only row, and a read side that exposes those rows as an entity's change history.
- Write side —
aggregate X audited { … }/operation f() audited. Ships on all five backends. - Read side —
GET /<aggregates>/{id}/history. Served by a subset of the backends (§3 — the list lives in the corpus manifest, not in prose), and consumed by the api clients and the scaffolded Detail page on the four JS-family frontends ("Rendering it"). Every target that falls short is named where it does, rather than left to be discovered.
1. Declaring audit
aggregate Order audited {
reference: string
quantity: int
create(reference: string, quantity: int) { … }
operation cancel(reason: int) { … }
private operation recalc() { … } // NOT audited — `private` is the opt-out
destroy { }
}
audited on the aggregate header marks every public command action —
create, operation, destroy. A private operation is never audited; that
is the opt-out for an internal or high-churn command (there is no negation
keyword). The per-command form marks exactly one action:
operation dispatch() audited { dispatched := true }
Both forms resolve to the same per-command flag during lowering, so every backend gate reads one thing.
2. What a row records
One row per successful command, written inside the same transaction as
the aggregate save — the row and the state change commit or roll back
together. The table (audit_records, one per module, derived from
auditTableShape in src/system/migrations-builder.ts) carries:
| Column | Meaning |
|---|---|
audit_id |
PK |
operation_id |
Stable id of the command that ran |
action |
The command's name |
target_type / target_id |
The aggregate and row it changed |
actor |
The principal in its declared user { … } shape, by declared name — the /api/auth/me body (RS-45); null without auth |
before / after |
Full wire-DTO snapshots (repoTx.toWire), nullable |
at |
When |
status |
Always "ok" — see below |
correlation_id / scope_id / parent_id |
Execution-context linkage |
Indexed on (target_type, target_id) — the per-entity history read — and on
correlation_id, for tracing one command across aggregates.
How the snapshots are stored — one rule, all five backends
before / after are jsonb everywhere. There is no per-backend DDL: the
column comes from the single shared auditTableShape above ({ kind: "json" }),
rendered once by sql-pg.ts.
Every backend binds them as a JSON object, never as a serialized string. A porter indexes the snapshot directly; nobody parses.
| Backend | CLR / runtime binding |
|---|---|
| node (drizzle, MikroORM) | jsonb column → plain object |
| .NET (EF, Dapper) | System.Text.Json.Nodes.JsonNode? |
| Python (SQLAlchemy) | Mapped[object | None] (JSONB) |
| Java (Hibernate) | @JdbcTypeCode(SqlTypes.JSON) Object (a Map) |
| Elixir (Ecto) | :map |
Two consequences worth stating outright:
- Both sides are genuinely nullable — a
createhas nobefore, adestroyhas noafter— and that absence is a real SQLNULL, never the string"null". - Snapshots are not comparable byte-for-byte across backends. Reading back
from
jsonbyields Postgres-normalized values: keys sorted, whitespace stripped, duplicate keys collapsed. The cross-backend contract is the derivedchangesoutput (§3), pinned bytest/behavioral/wire-golden/audit-history.json— never the stored bytes.
Only successful commands are recorded
status is hardcoded "ok" and the insert rides the command's transaction. A
command that throws, trips a precondition, or is denied by a requires gate
writes no row — its transaction rolls back, taking any audit insert with
it.
So this facility answers "what changed", not "who tried". If you need
denied-attempt forensics, that is a different record with a different trigger
point (the authorization decision, not the command boundary) and a different
volume profile; it is not what audited gives you. Read the trail as a
complete history of changes, and never as a complete history of access.
3. Reading history
An audited aggregate automatically serves its own change history:
GET /<aggregates>/{id}/history → AuditEntry[]
Nothing is declared for it. Enrichment derives a find history(id) onto the
aggregate's repository — the auto-findAll analog, in the same pure pass — and
the backend serves it from audit_records.
Backend support — all five, and don't take that from prose. The
audit-historyrow intest/fixtures/corpus/manifest.tsnames exactly the backends that serve the endpoint, and widening that row is what "backend X now serves history" means. It currently readsALL, so the read side has caught up with the write side; a hand-maintained list here would be one more thing to forget, and it already had been once.That claim is proven, not asserted: each backend's behavioral leg diffs its booted responses against
test/behavioral/wire-golden/audit-history.json, minted from node — the oracle. A≡golden ∧ B≡golden ⇒ A≡B, so the row beingALLis a cross-backend equality rather than five self-assertions. The shape, the diff boundary and the authorization rules below are platform-neutral (src/ir/util/audit-history.ts), which is why the ports were emitter work.
The entry shape
[
{
"auditId": "…",
"at": "2026-08-03T05:19:50.554Z",
"action": "cancel",
"operationId": "cancelOrder",
"actor": { "id": "u-17", "role": "clerk" }, // the declared user shape at command time (RS-45)
"correlationId": "…",
"changes": [
{ "field": "status", "before": 0, "after": 7 }
]
}
]
Entries are ordered oldest-first — a timeline reads forwards, and at plus the
(target_type, target_id) index make it the natural scan order.
changes is derived, not stored
The diff is computed at read time by comparing the entry's two snapshots. Nothing about it is persisted: a stored diff is a cache with no invalidation story, and the snapshots already contain everything it says.
A field appears in changes only if it actually moved. In the example above,
cancel also re-saved reference and quantity unchanged, and neither is
listed — a field that did not move is not a change. A create has no before,
so every field reads null → value; a destroy has no after.
Managed and token fields are excluded. after is captured post-save, so a
lifecycle stamp (auditable's updatedAt / updatedBy) and the versioned
counter differ on every single entry. Left in, they would be most of the
timeline and would bury the change the reader came for. The surviving set is
exactly what a caller can influence — which is what "what changed" means. Also
excluded, and for a stronger reason: internal and secret fields, which the
snapshot holds but no API read may disclose.
Authorization
The snapshots are written server-side inside the command's transaction, where there is no caller to mask against. They therefore hold raw, unmasked values for every field. History is a read surface over already-collected unmasked data, so each guard the entity read has is re-established on it explicitly:
| Guard | How history gets it |
|---|---|
| Read gate | The aggregate's read gate (policy { read X requires … }) covers history too — the synthesized find copies it from the list read all. Fails → 403, before any query runs. Under enforcement: denyByDefault, an audited aggregate with no read gate is a compile error (loom.audit-history-ungated) — write the policy line and history is gated with every other read. |
Capability filters (tenantOwned, and any filter capability) |
audit_records is machinery: it carries target_type/target_id and no tenant column, so there is nothing on it for a query-filter to scope. Scoping rides the entity instead — the handler resolves the row through findById, which already carries every capability predicate. A row the caller cannot read yields 404, the same answer the entity read gives, so history discloses nothing about another tenant's rows, not even their existence. The find's ignoring stance is copied from the list read too, so widening one widens both. |
mask unless |
The same predicate composes into every entry. |
A masked field's change is dropped, not redacted
When the caller fails a field's mask unless predicate, that field's
FieldChange is omitted from changes entirely — not emitted with a
redacted value.
A redacted-but-present entry would still disclose that the field changed,
when, and by whom. "The admin changed salary on the 3rd" is itself the
leak; the number is only part of it. Masking is fail-closed: an unauthenticated
caller has a null principal, so every masked field's entries drop.
Why the raw snapshots are not exposed
AuditEntry carries the derived changes list and not the before/after
blobs. Publishing them whole would need a recursive redaction pass over
arbitrary JSON with no schema to guarantee it reached every masked key. The
field-keyed diff is a typed projection where the masking rule is exact and
checkable. Point-in-time state reconstruction ("time travel") is a separate
feature and would need its own authorization story.
Rendering it
The Timeline page primitive renders an AuditEntry[] as an ordered list of
what changed, when, and by whom:
page OrderHistory {
route: "/orders/{id}/history"
state { entries: string[] }
body: Stack {
Timeline { of: entries }
}
}
It emits native <ol>/<li>/<time>/<dl> — no design-pack component, no
client state. A timeline is an ordered list, and saying so in HTML beats
styling a <div> to look like one; it also means the primitive needs no
per-pack template across the 13 design packs. Ships on React, Vue, Svelte,
Angular and Phoenix/HEEx. Feliz and Flutter fall through to a visible comment
until they implement the renderTimeline WalkerTarget seam.
Two things it deliberately does not do:
- No redacted placeholders. A masked field's change is dropped server-side, so the client never learns it existed — there is nothing to render.
- An entry with no changes still renders its header. A command that touched
only diff-excluded fields still happened, and "someone ran
recalcat 14:02" is information even when nothing user-visible moved.
Fetching it — history(id) on the api client
Each frontend's per-aggregate api module gains a history read for an audited
aggregate, over GET /<aggregates>/{id}/history:
// web/src/api/order.ts — React/Vue (svelte-query takes a thunk; Angular an
// `injectQuery` factory over an `OrderService.history` method)
export function useHistoryOrder(id: string | undefined) {
return useQuery({
queryKey: ["orders", id, "history"],
enabled: !!id,
queryFn: async () => {
const r = await api.get(`/orders/${id}/history`);
return AuditEntryListResponse.parse(r);
},
});
}
The AuditEntry / AuditFieldChange DTOs beside it are derived from
auditEntryWireShape() — the same platform-neutral field list the backends
serve — so the client cannot drift from the wire by hand-transcription. The one
narrowing: changes is json[] on the wire (the IR has no nested-record type
to say "array of AuditFieldChange" with), and the client types the element
precisely so __c.field resolves in the timeline.
It is a read of its own rather than an arm of the generic find loop, because
historyFind deliberately sits beside finds (§3): a find reads the
aggregate's own table at /<name>, and this one reads audit_records at a
path-nested /{id}/history. An aggregate with no audit trail emits neither the
DTOs nor the hook.
Calling it from a hand-written page
Both spellings work, and mean the same read:
QueryView { of: Order.history(id), data: entries => Timeline { of: entries } }
QueryView { of: Sales.Order.history(id), data: entries => Timeline { of: entries } }
The second — routed through the ui's api handle — is the one the scaffold itself
emits. It is worth stating because for a while it was the one the validator
refused: listValidApiOperations (the phase-④ allowlist behind
checkApiBodyRefs) knew the CRUD verbs, the public operations and the declared
finds, and history is none of those — it is synthesized at phase ⑥. Membership
is now derived from the audited AST flag through the same predicate the
scaffold macro uses to decide whether to emit the section at all
(aggregateServesHistory, src/util/audit-ast.ts), so "the macro emits it" and
"the validator accepts it" are one answer. On a non-audited aggregate — or one
that serves no history read for any of the §3 reasons — both spellings are still
refused, since the client hook would never have been emitted.
The scaffolded History section
with scaffold(...) grows an audited aggregate's Detail page a History
section — a framed Timeline over history(id), wrapped in a QueryView so
the trail gets the same loading / error / empty arms every other scaffolded read
has (an audit trail that silently renders nothing while in flight reads as
"this entity was never touched"). It is a page-level sibling of the record
view, not a child of it: the trail is addressed by the route id, so it neither
needs nor should wait for the entity read. A non-audited aggregate's scaffold is
byte-identical.
Frontend support is narrower than
Timeline's. The primitive renders on five targets, but only React, Vue, Svelte and Angular collect the derived read. Feliz binds every non-byIdoperation to itsAll<Plural>Model field, Flutter skips the read incollectFlutterReadswhile still referencing a provider, and Phoenix/HEEx maps it ontolist_<aggregates>— the list, not the trail. That damage is in the read the surroundingQueryViewregisters, one level above the primitive, so on those three the whole view is skipped with a visible comment (src/generator/_walker/history-read.ts) rather than emitting a dangling handle that only fails atdotnet fable/flutter analyze/mix compiletime. A frontend joins the set by collecting the read and renderingTimeline— the same day, or not at all.
What history does not answer
Only successful commands are recorded (§2). A denied, failed, or precondition-tripped command leaves no row. Read a timeline as a complete history of changes, never as a complete history of access or of attempts.