Audit Log
Banking workloads need a forensic trail: who touched what, when, with what old and new state. CrateStack records audit rows inside the same transaction as the mutation they describe, so you can never observe a committed row whose audit entry didn’t also commit.Schema attribute
Opt in per model:@@audittakes no arguments
@@paged, @@emit, and @@id — which do reject a duplicate
declaration at parse time — @@audit has no such check today. The
parser simply recognizes the attribute, and the macro descriptor detects
it with an .any(...) scan, so declaring @@audit twice on the same
model is silently a no-op rather than a validation error.
What gets captured
For everycreate, update, and delete the runtime writes a row to
cratestack_audit containing:
- a fresh
event_id(UUID v4) schema_nameandmodelstrings from the.cstackoperation—create,update, ordeleteprimary_keyas JSONactorderived from theCratestackContext— id, claims, optional source IP (see Trusted Proxy / Client IP for how that IP is resolved, and the bootstrap step it’s silentlyNonewithout)tenantfromPrincipalContext.tenant.idwhen presentbeforesnapshot (null on create) andaftersnapshot (null on delete)request_idfor trace stitchingoccurred_attimestamp
PII redaction
Field attributes participate in the snapshot serializer:@pii— value replaced with"<redacted: pii>"inbefore/after@sensitive— value replaced with"<redacted: sensitive>"@server_only— field omitted entirely from the snapshot
@pii for emails,
phone numbers, and tokenized PANs; @sensitive covers internal risk
scores, dispute notes, and operator commentary.
Transactional guarantee
The audit insert participates in the mutation’s transaction. The flow is:- begin transaction
- apply the mutation
- capture
after(andbeforefor update/delete) - insert into
cratestack_audit - commit
Fan-out to downstream sinks
The in-database table is canonical. Downstream consumers (Kafka topics, SIEM, S3 archives, HTTP webhooks) implementAuditSink:
MulticastAuditSink:
CratestackError::Internal rather than
silently swallowing — MulticastAuditSink still calls every sink in the
list even after an earlier one fails, then aggregates all the errors
into that one CratestackError::Internal, so one bad downstream doesn’t stop
the others from receiving the event. Banks treat downstream errors as
alertable, not fire-and-forget. The default sink is NoopAuditSink; the
table is the source of truth even without one.
Installing a sink
ImplementingAuditSink isn’t enough by itself — it has to be attached to
the runtime with with_audit_sink, the same builder-method shape
IdempotencyStore/RateLimitStore use elsewhere:
NoopAuditSink, and
AuditSink::record is never invoked at all — the cratestack_audit table
still gets every row (that insert is unconditional on @@audit models),
only the downstream fan-out is skipped.
.run_in_tx(...) and db.transaction(...) writes: fan-out is opt-in, not automatic
Every generated ORM write path dispatches the installed AuditSink itself,
after its own transaction commits — .create(...).run(ctx),
.update(...).run(ctx), the batch_* primitives, all of them. The one
exception is the composable .run_in_tx(&mut tx, ctx) variant that lets a
caller chain several model writes inside one hand-managed transaction (see
Transaction isolation): it still writes the
cratestack_audit row inside tx, but it does not call the sink on its
own, because it hands the transaction back to the caller uncommitted and has
no reliable way to know whether — or when — that transaction actually
commits. The same is true of db.transaction(...), the newer combinator
that composes several run_in_tx calls without naming a sqlx type: its
closure body is arbitrary caller code, so the combinator has no way to
discover which audit events that body produced, and cannot dispatch them
either.
This was a real, unaddressed gap for a while (cratestack#534) — a run_in_tx
caller had no way to opt in at all, since dispatch_audit_sink wasn’t even
public and run_in_tx didn’t hand back the built AuditEvent. It is now
a real, working, but still manual opt-in. Every run_in_tx variant
returns a RunInTxOutcome<T> carrying the AuditEvent(s) it built and
already persisted (.value for what .run(...) would have returned,
.audit_events for the events); collect those across every write in your
transaction and call the generated Cratestack::dispatch_audit_sink once,
after your own tx.commit() succeeds (or after db.transaction(...)
returns Ok, threading the collected events out through your own closure’s
return value — the combinator can’t collect them for you):
cratestack_audit still gets every row (that insert is
unconditional on @@audit models), the installed sink just never hears
about it for that transaction. If your compliance posture depends on the
downstream sink firing for every audited write, treat this call as
mandatory wherever you compose writes through .run_in_tx(...) or
db.transaction(...) — there is no way for the framework to enforce that
you remembered it. The identical opt-in exists for @@emit subscribers via
the pre-existing Cratestack::events().drain() — it re-scans the outbox for
undelivered rows rather than needing a specific event handed back, so it
was already usable this way; call it the same way, after your own commit.
Schema
(schema_name, model, occurred_at DESC),
(tenant, occurred_at DESC), and undelivered rows.
The DDL is exposed as cratestack::AUDIT_TABLE_DDL. Banks running their
own migration tooling embed it; the SqlxRuntime calls it idempotently
during bootstrap.
Retention
The framework does not delete fromcratestack_audit. Banks running
regulatory retention (5 / 7 / 10 years depending on jurisdiction) move old
rows to cold storage and prune the live table via their own tooling. The
schema is index-friendly for time-window deletes.
What this is not
- not a tamper-evident chain — no per-row cryptographic signature
- not WORM storage — anyone with
DELETEon the table can rewrite history - not a substitute for application-level event sourcing
MulticastAuditSink is the integration seam.
Read Next
- Field attributes for
@pii,@sensitive,@server_only - Transaction isolation for the transactional model the audit insert participates in
- Trusted Proxy / Client IP for how
actor.ipgets populated behind a reverse proxy