Who did that, and how are we doing
Two questions, asked by the same person on different days. The audit log answers the first with a row per action. The dashboard answers the second with a page of panels, each one assembled by the plugin that owns the numbers behind it.
For administrators, and whoever has to report upwards
The log cannot drift from what happened
A change does not write its own audit row. It appends an event to a transactional outbox, in the same transaction as the change itself — so an audited change cannot commit without its event, and an event cannot survive a change that rolled back. There is no window in which the two disagree.
A subscriber then turns each event into an activity_events row. Delivery is at-least-once, which would normally mean double-counting; the row’s primary key is the source event id and the insert is ON CONFLICT DO NOTHING, so a redelivered event lands exactly once. The audit is derived downstream, which also means the write path and the audit path can change independently.
Rows are keyed to their subject by id and hold no foreign key to it. That is deliberate: the audit outlives the thing it is about. Deleting a user does not erase what they did.
Six audit rows, each with a timestamp, an actor, an action and the subject it happened to.
What the log records
Each plugin owns its own event kinds, so the sink stays generic.
| Area | Kinds recorded |
|---|---|
| Identity | Sign-ins, API token lifecycle, workspace create and update. |
| People | Invites, acceptances, member added and removed, role changes. |
| Content | Entry created, updated, published, unpublished and deleted — a restore arrives as the update it raises, naming the fields it put back. |
| Media | Asset and folder lifecycle across the library. |
Every row carries the actor’s id and email, the kind, the subject, the time, and an open jsonb of detail. Reading the log needs activity:read, which only an administrator holds.
Reading it back
The log is a list you interrogate, not a stream you scroll.
- Filter by anything on the row
- Subject type and id, actor, actor email as a text search, kind as a list, and a from/to range. It uses the same filter grammar the content library does.
- Sorted by time or by kind
- Newest first by default, because the question is usually "what just happened". Sorting by kind is how you read one class of action across a week.
- One row per action, not per field
- An edit is one entry in the log. What changed within it is a question for version history, which holds the before and after.
- The assistant is in here too
- A change made through the AI assistant is recorded as the person who asked for it, with the run behind it, because the assistant has no identity of its own to attribute anything to.
A dashboard nobody owns
The insights page ships the frame and nothing else: the route, the range picker, the bands, the card shell and a per-widget error boundary. It contributes no panels of its own and imports no other feature package.
Every card arrives from the plugin that owns the data behind it. Content contributes eight — three tiles for Entries, Published and Drafts, plus Gone quiet, Draft and published by type, Publishing velocity, Waiting to go live, and a Team punchcard. Media contributes four: a storage tile, What’s using the storage, Uploads, and Images missing alt text. Localisation contributes Translation coverage.
Each card loads on its own and fails on its own, inside its own error boundary, so a slow figure delays its own panel rather than the page. The three overview tiles deliberately share one query key so they arrive in a single request rather than three, without giving up their independent loading states.
The numbers the panels are built to answer
- 13 Panels, contributed by three plugins
- 4 Bands, themselves contributions
- 1 Request behind the three overview tiles
- 0 Panels the dashboard package owns
Questions administrators ask
Can I prove who published something last March?
Yes, if it happened after you deployed the version that audits publishes — filter by kind and date range and the row names the actor. What the log gives you is the action and the actor; what changed inside the entry is in version history.
Why is "Waiting to go live" counted against live records?
Because "94 of 1,046 live records have pending edits" is a backlog someone can clear, and the same 94 against every record you have ever created says nothing. Never-published drafts are a footnote under the card rather than part of the headline.
Can I add my own panel?
Yes. A panel is a contribution to a named slot, and the bands are contributions too — there is no privileged set. Adding a band and replacing a built-in one are the same act.
Does alt-text coverage follow the date range?
No, deliberately. Accessibility debt is a standing total, and windowing it would make the number shrink whenever somebody narrowed the range.
Next
Every feature is included, free
The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.