Auditability
This is the reference for Byline's auditability subsystem: the per-version acting-user trail, the document-level audit log for changes that sit outside the version stream, and the history and activity views built on top of them. It builds on the withTransaction capability that lets each audited change and its audit row commit atomically.
Audit and versioning are two halves of one accountability story, split along Byline's document-level vs version-level line: versioning makes every content change accountable as an immutable, diffable version, while the audit log covers the document-level and status changes that deliberately mint no version (path, availableLocales, the tree edge, and lifecycle transitions). Read that architectural decision first if you want the why behind the split.
Why — the claim being honoured
bylinecms.app leads with auditability as a principle:
"Auditable: versions, editorial trails and citations — ask 'where did this come from?' and get a real answer."
"Every document carries its history: who wrote it, who changed it, and which version is the one you stand behind."
The immutable version stream honours the what and when — versions, a History view, per-version diffs. The auditability subsystem honours the who, and records the two classes of change that sit outside the version stream (non-versioned document-level writes, in-place status transitions) plus deletions and — reserved — admin-module actions.
Vocabulary — "audit", not "attribution"
Two words that sound adjacent but must not bleed in Byline:
- Attribution is public-facing: copyright, author / publisher credit on published content (the "original, attributable, auditable" thesis aimed at readers and the provenance story). It surfaces to the audience — e.g. a media item's
Credit / Attributionfield. - Auditability is internal: which staff actor did what to a document or version, and when. A staff-accountability record inside the admin, never shown to readers.
Everything in this domain is the second. The internal vocabulary is consistently audit (the record), acting user / actor (the who), and auditability (the property) — never "attribution", which is reserved for the public credit concept. The stored column is the neutral created_by.
The version audit trail (acting user + action)
Answers "who wrote it, who changed it" for every content save. A content save is a new document_versions row, so the audit record on a version is its creator — there is no separate updatedBy.
Write side
Every createDocumentVersion call site in packages/core/src/services/document-lifecycle/ passes createdBy: context.requestContext?.actor?.id:
Module | Call sites | Action |
| 1 |
|
| 2 |
|
| 2 |
|
| 1 |
|
| 1 |
|
No schema change was needed — document_versions.created_by uuid NULL already existed, projected through the current_documents / current_published_documents views. Rows written before the wiring stay NULL (render as em-dash / "unknown"); there is nothing to backfill from.
Attribution requires a real persisted user id — a UUID (actorId() in document-lifecycle/internals.ts). Internal-tooling callers either pass no requestContext (seeds, migrations — the documented escape hatch) or a synthetic super-admin context whose id is not a UUID (createSuperAdminContext({ id: 'import-docs-script' })); both yield NULL created_by. A non-UUID actor id is treated as "no attributable user" rather than written to the uuid column, so script and seed writes never fail on it.
Read side
- Naming: plain createdBy, no underscore.
created_byis a raw column (not derived), so the read-surface underscore convention (leading_= derived/computed) does not apply —createdByis the exact sibling ofupdatedAt. UI labels remain free to read "Updated By" in list contexts; that's presentation. created_byis surfaced per version through the history server fn (packages/host-tanstack-start/src/server-fns/collections/history.ts) and throughshapeDocument(packages/client/src/response.ts) ascreatedBy(raw uuid) onClientDocument/ history rows.- Display names are an admin-realm concern, resolved in the admin server fns — never a JOIN inside the document storage adapter (which must stay ignorant of
byline_admin_usersfor the futureUserAuthwriter realm). The fns batch-resolve ids viaAdminUsersRepository.getByIds(ids)and return anactors: Record<id, { label }>map alongside the page; the UI joins by id (resolveActorLabels,server-fns/collections/actors.ts). Ids absent from the map are deleted users — rendered as a "former user" tombstone.
UI — the audit strip
The audit record (acting user + action + time) renders in a framework-owned, muted colspan sub-row under each table row (the "audit strip") rather than as listViewColumns entries — listViewColumns is the collection author's surface over user-defined fields, whereas audit metadata is a system concern that should be structurally present, not opt-in per collection.
- Strip content, compact single line:
created by <label> · <action> · <when>. NULL-created_byrows render an em-dash label. - Markup: a second
<tr>per row — an empty spacer cell under the version column, then a<td colSpan>carrying the strip (@byline/uiTable.CellspreadscolSpan, so no Table-primitive extension was needed). - The strip renders in the History view by default. It is not shown in the list view — it roughly halves row density, and the density toggle (per-collection admin config vs. a view-level control) is unresolved.
The document-level audit log
Records the changes the version stream deliberately does not: non-versioned document-level writes (path, availableLocales), in-place status transitions, deletions, and — reserved — admin-module actions.
Table
byline_audit_log id uuid PK (UUIDv7 — time-ordered, no separate sort column needed) document_id uuid NULL -- nullable: admin-realm events have no document collection_id uuid NULL actor_id uuid NULL -- NULL = system / internal tooling actor_realm varchar(16) -- 'admin' | 'user' | 'system' action varchar(64) -- namespaced, see below field varchar(128) NULL before jsonb NULL after jsonb NULL occurred_at timestamptz NOT NULL DEFAULT now()One generic table, not a document-scoped one. document_id is nullable and action is namespaced — document.path.changed, document.locales.changed, document.status.changed, document.deleted today; admin.user.created, admin.role.updated, … reserved — so the system activity area and any future admin-module auditing land in the same table without a second migration. actor_realm is 'admin' | 'user' | 'system' ('system' for non-UUID synthetic / tooling actors). Deliberately FK-free — an audit row outlives the doc / collection / actor it names (a document.deleted row cannot cascade-delete itself). Indexes on (document_id, id), (actor_id, id), (action, id).
The version stream stays the record for content. Content saves are never double-written into the audit log — the activity area unions the two sources at read time. The audit log records only what the version stream cannot.
Atomicity (the load-bearing property)
The mutation and its audit-log row commit together. The one unacceptable outcome for an auditability feature is a change that succeeds while its audit row silently fails to write — a silent gap in the record. So the audit insert runs in the same database transaction as the mutation, not best-effort afterwards.
This rides on the request-scoped withTransaction boundary owned by the service layer (the audit write is a peer command in the same transaction; the storage adapter never learns the word "audit"). That mechanism — its AsyncLocalStorage propagation and the DB↔DB vs DB↔external distinction — is specified in Transactions.
Write points
Inside the existing service entry points, under the existing auth gates (no new enforcement surface), each wrapping its mutation + audit append in db.withTransaction(...). The shared helper (document-lifecycle/audit.ts) provides requireAuditCapability(db) — which throws ERR_AUDIT_UNSUPPORTED loudly if the adapter lacks withTransaction / commands.audit rather than recording a gap — plus auditActor(ctx) (UUID id → realm 'admin'; synthetic → NULL + 'system') and the AUDIT_ACTIONS constants.
updateDocumentSystemFields(document-lifecycle/system-fields.ts) — path and availableLocales changes, one row per field that actually changed (before/after; a same-value save records nothing).changeStatus(document-lifecycle/status.ts) — every transition, from→to.deleteDocument(document-lifecycle/delete.ts) — the deletion event (the one change that otherwise erases its own history); the soft-delete is in the transaction, the storage-file cleanup stays outside it (DB↔external).
Read surface
getDocumentAuditLog(documentId)— per-document history, on the adapter contract + Postgres adapter. Reached end-to-end throughCollectionHandle.auditLog()(the gated client read) →getCollectionDocumentAuditLoghost server fn (server-fns/collections/audit.ts) → the document-history tab.findAuditLog({ … })— the system-wide activity union (see below).
Authorization — transitive per document, gated system-wide
Two distinct read scopes, deliberately not transitive between each other:
- Per-document audit history inherits the document's own read gate.
getDocumentAuditLogresolves the document through the actor's read pipeline first (inheriting thecollections.<path>.readability andbeforeReadrow-scoping), then fetches audit rows scopedWHERE document_id = X— exactly as versionhistorygates viafindById. An actor who cannot see the document gets an empty log rather than leaked change metadata. - The system-wide activity area is not reachable transitively from any collection ability — it sits behind the separate
admin.activity.readability. Admin-realm events (document_id NULL) appear only there.
The document-history view
Two tabs on a document's /history route, selected by a tab search param ('versions' | 'document', absent → 'versions') under an AdminTabs bar. Both data sources load in parallel in one Promise.all; the active tab is a pure render concern read from the URL, so switching tabs never refetches.
- Content versions — the existing table, diff modal, restore flow, with the audit strip. Default tab.
- Document history — a chronological list of audit-log entries for this document: when, action, actor, from → to. No diff viewer; before/after render inline (arrays comma-join, the deletion event shows an em-dash). Empty state explains that content edits live on the first tab. The client
auditLog()gate mirrorshistory()(findByIdwithstatus: 'any'; an excluded document yields an empty log). Acting-user ids resolve to labels admin-side (resolveActorLabels: system/tooling → "system"; deleted → "former user").
The per-document log is small and bounded, so it fetches a single generous page (page_size: 100) rather than wiring a tab-specific pager. i18n keys ship in the byline-admin bundle (EN/FR): collections.history.tabs.* and collections.documentHistory.*.
The system activity area
A top-level admin area at /admin/activity — the installation-wide who-did-what feed.
- Route:
createAdminActivityRoutefactory in@byline/host-tanstack-start/routes(physical route atapps/webapp/src/routes/_byline/admin/activity/index.tsx); an Activity menu item (ActivityIcon) in the admin-management section of the menu drawer, shown when the actor holdsadmin.activity.read. - The report is a filterable, paged feed over the read-time union of the version stream (content saves, surfaced as
document.created/document.updatedfromevent_typeand attributed viacreated_by) and the audit log (everything else). The two sources are disjoint — a delete mints no version, a status change mutates the version row in place — so the union double-counts nothing. Ordered by the normalisedoccurred_at(the per-source UUIDv7 ids are separate sequences). Backed byIAuditQueries.findAuditLog(@byline/db-postgresAuditQueries, aUNION ALLnormalised onto theAuditLogEntryshape). Filters: collection (by path, resolved to id server-side), action type, date range, and actor (param plumbed; UI control deferred). Each row resolves its actor and collection labels and links to the document it describes. - Authorization: the
admin.activity.readability (@byline/admin/admin-activity, registered throughregisterAdminAbilitieslikeadmin.users.*) gates the host server fngetSystemActivityLogviaassertAdminActor. Unlike the per-document modules it owns no AdminStore command — it reads the document db adapter'sfindAuditLogdirectly — so the assertion lives in the host server fn. The ability is system-wide and not reachable transitively from any collection ability, so an auditor role gets activity visibility without content read/write.
Code map
Concern | Location |
Version audit-trail write ( |
|
|
|
|
|
Client shaping ( |
|
Display-name batch resolution ( |
|
|
|
History view + audit strip |
|
Tabs primitive |
|
Audit table schema |
|
Audit write points |
|
Audit queries ( |
|
Audit read (gated client) |
|
Per-document audit host server fn |
|
Document-history tab UI |
|
System activity server fn |
|
Activity route factory + feed UI |
|
Activity menu item |
|
|
|