01The shape
One table — activity_log — captures every state change. One trigger function — tg_write_activity_log(tenant_col) — runs on every tenant-scoped table. The trigger takes the tenant column name as an argument so the same function is reused across approvals, knowledge_entries, projects, team_instances, team_members, teams_channels, invitations and the others — one source of truth for "what happened, when, by whom".
Each row carries:
tenant_id— the company the action affectedactor_id— the user (orservice_role) who performed itop— INSERT / UPDATE / DELETEtable_name+row_id— what was changedbefore+after— the row state on either side of the change (jsonb)occurred_at— server-side timestamp
02What it looks like
A single Friday afternoon on the Acme demo project, viewed in operator mode (timestamps simplified, IDs trimmed):
The same shape covers everything. Approvals being created and decided, team members being added, knowledge entries being materialised, integrations being provisioned. One place to look when the question is "what did the team do this morning."
03Why a trigger, not application code
Application code can forget to log. A trigger can't. The platform's RLS posture deliberately makes most tables not-writable from the authenticated path; writes go through SECURITY DEFINER RPCs (decide_approval, onboard_company, set_project_pricing, ...) — and the trigger fires inside those RPCs the same as it would on a direct write.
The path is single: write happens → trigger fires → audit row exists. If the audit row doesn't exist, the write didn't happen. There's no alternative path that bypasses the log.
04Cross-tenant: gdpr_operations
The activity log is tenant-scoped — when a tenant is deleted (GDPR Article 17), the activity log for that tenant goes with it. That's the right behaviour for tenant data, but the wrong behaviour for the act of deletion itself: the audit of "we deleted a tenant" needs to outlive the tenant.
So a separate cross-tenant table — gdpr_operations — captures every export and delete with a company_name_snapshot, the operator who triggered it, the timestamp, the legal basis stated. Permanent by design. Visible only to platform admins. The audit row outlives the deleted tenant — that's the legal point.
05What you can do with it
- Operator history — "what did the team do this week?" — filter by tenant, by actor, by table, by operation kind.
- Compliance evidence — when an auditor asks how a vendor decision was reached, the log links the proposal, the reviewer's approval, the materialised decision, the timestamp.
- Debugging — when a row's state doesn't match expectations, the before/after history is in the log without needing to reconstruct from event sourcing.
- Customer-side transparency — once SSO is wired and a customer's lead consultant is in tenant_operator role, they get the same audit view scoped to their company.
06Compliance fit
- GDPR Article 30 (records of processing): the log is the record, scoped to processing operations.
- SOX / IFRS internal controls: every state-changing action is captured with an actor, a timestamp, and a before/after; segregation of duties is enforceable on the read.
- ISO 27001 Annex A.12.4 (event logging): structured, parameterised, tamper-evident at the database level.
- Industry-specific retention: rows are retained per the tenant's retention policy; deletion of a tenant cascades the log via
company_idON DELETE CASCADE — except forgdpr_operations, which is permanent.
Every action recorded. By the database, not by the application. The audit trail isn't a feature you opt into — it's the only way state can change.