The first audit-log schema usually assumes a person clicked a button. Then the product sends a payment reminder at 02:00, processes a webhook, or expires a subscription, and the audit insert asks for a user who does not exist.
That exact constraint broke both recurring-invoice generation and payment-reminder dispatches in one live build. The work ran without a user session, while audit_logs.user_id was NOT NULL. The system was the actor; the schema only knew how to name people.
Do not solve this with a fake “system user.” It creates a credential-like identity that nobody owns, distorts user analytics, complicates deletion, and still cannot tell a scheduler from a webhook.
Model the actor you actually have
Keep the user reference nullable and add an explicit actor type:
create type audit_actor_type as enum (
'user',
'system',
'cron',
'webhook'
);
alter table public.audit_logs
alter column user_id drop not null,
add column actor_type audit_actor_type not null default 'user',
add constraint audit_actor_consistency check (
(actor_type = 'user' and user_id is not null)
or
(actor_type <> 'user' and user_id is null)
);
For non-user events, record enough stable context to investigate the action: job name, webhook provider, external event ID, request or trace ID, tenant, target entity, and outcome. Put structured details in a bounded metadata object rather than hiding the actor inside a sentence.
Some operations have more than one identity. A staff member may trigger a background export, or a support user may impersonate a customer. In those cases, keep separate fields for initiator, execution actor, and affected subject. One overloaded user_id cannot answer all three questions.
Decide what happens when logging fails
Audit logging often sits at the end of a handler. That creates two bad outcomes if its insert fails:
- inside the same database transaction, the business change rolls back;
- after an external side effect, a retry may repeat work that already happened.
Choose the behavior deliberately. Security-sensitive changes may need the audit row in the same transaction. External effects need idempotency and a durable event record so a logging failure does not create duplicate charges or messages.
Test every actor class
Create one integration test for a user action, one scheduled action, and one webhook. Assert that each produces an audit row with the right tenant, actor type, target, and correlation ID. Also test that a user actor without user_id is rejected.
The lesson is not merely “allow NULL.” Nullability without semantics creates anonymous history. The durable model says why the user is absent and preserves the evidence needed to reconstruct what happened.
