← Back to home

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.


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:

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-history row in test/fixtures/corpus/manifest.ts names exactly the backends that serve the endpoint, and widening that row is what "backend X now serves history" means. It currently reads ALL, 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 being ALL is 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:

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-byId operation to its All<Plural> Model field, Flutter skips the read in collectFlutterReads while still referencing a provider, and Phoenix/HEEx maps it onto list_<aggregates> — the list, not the trail. That damage is in the read the surrounding QueryView registers, 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 at dotnet fable / flutter analyze / mix compile time. A frontend joins the set by collecting the read and rendering Timeline — 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.