Skip to content

Audit Log — Cross-Cutting Who-Did-What Trail

AuditService is a shared, cross-cutting service, not a domain feature. A domain module injects it and appends one immutable event per mutation inside its own Prisma transaction, so the audit row commits atomically with the change it records. Attendance is the first consumer (chapter 19); grades, rosters, and future write-heavy modules adopt the same pattern. The module also owns one descriptor entity (audit_log) and a grant-gated, tenant-wide read API (seeded for Admin, Director, and Department Principal — §6).

Specs: 2026-06-25-audit-log-design.md (base), 2026-08-04-audit-log-admin-read-iteration-2-design.md (scope-governed visibility + consumability). RBAC primitives: chapter 04.


1. Mental model

An append-only trail. One AuditLog row per event; rows are never updated or deleted. There is no domain logic, no lifecycle, no cascade — writing an event is a side-effect a caller opts into, and reading is all-or-nothing for roles granted audit_log.configuration READ.

Two facts make the design simple:

  • Atomic with the mutation. record() takes the caller's transaction handle (Prisma.TransactionClient) and writes through it. If the domain mutation rolls back, so does its audit row — they share one transaction. There is no fan-out, no post-commit queue, no fail-soft branch.
  • Self-describing & decoupled. entityType and action are free strings (the audit module knows nothing about attendance). The actor and the changed values are denormalized snapshots captured at write time, so a row stays readable even after the user or the referenced entity is gone.

2. Data model

model AuditLog (prisma/schema.prisma), table audit_logs, migration 20260625130406_add_audit_log (applied):

Field Type Notes
id uuid PK
tenantId uuid FK → tenants, onDelete: Restrict tenant-scoped; a tenant with audit rows can't be hard-deleted
actorUserId uuid? FK → users, onDelete: SetNull nullable; the user who acted. Goes NULL if the user is later deleted — the trail survives
actorName VarChar(200) denormalized name snapshot at write time
actorRole VarChar(200) denormalized role snapshot (see §3)
entityType VarChar(100) free string, e.g. attendance_record
entityId uuid NOT NULL, no FK soft reference — required and UUID-typed, but has no foreign key, so the referenced entity can be hard-deleted without breaking the trail
action VarChar(150) free string, e.g. attendance_record.updated
data Json (NOT NULL) full value snapshot for this event, shape per entityType
requestContext Json? { requestId } lifted from CLS, or null
createdAt DateTime @default(now()) event time; the only ordering key

Two composite indexes, both tenant-first:

  • (tenantId, entityType, entityId, createdAt) — backs historyFor() and the entity-filtered admin list.
  • (tenantId, actorUserId, createdAt) — backs actor-filtered queries.

entityId is the one detail people get wrong. It is not nullable and not a free string — it is a required UUID. What makes it "soft" is the absence of an FK: nothing constrains it to a live row, so deleting the referenced entity leaves the audit rows intact (and still findable by id). Contrast with actorUserId (nullable, SetNull) and tenantId (FK, Restrict).


3. Writing events

Two calls, both on AuditService (src/audit-log/audit.service.ts):

// once per request, at the top of the transaction
const actor = await this.audit.resolveActor(tx, ctx);
// once per mutation
await this.audit.record(tx, actor, { entityType, entityId, action, data });

resolveActor(tx, ctx: RecordAccessContext) → AuditActor builds the actor snapshot once and is reused across every record() in the same request. It looks up the acting user's name (firstName + lastName, falling back to the raw userId if the lookup is empty) and derives the role string: 'platform_admin' when ctx.isPlatformAdmin, otherwise ctx.roles.join(','). The returned AuditActor = { userId, tenantId, name, role } carries everything record() denormalizes.

record(tx, actor, params: AuditRecordParams) → void appends one row via tx.auditLog.create. It pulls requestId from CLS (ClsService) and stores requestContext: { requestId } when present (else undefined → column null). Because it writes through the caller's tx, the event is part of the caller's atomic unit — never a separate write.

AuditRecordParams (interfaces/audit.interface.ts): { entityType, entityId, action, data: unknown }. data is the full value snapshot; the consumer decides its shape.

Naming convention (by consumers, not enforced)

entityType is a stable string the consumer owns. action is namespaced <entityType>.<verb> — attendance writes attendance_record.created / attendance_record.updated (src/attendance/attendance.service.ts). Nothing in the audit module validates these strings; the convention lives in the consumers — except for structural events, whose vocabulary the audit module owns outright (see below).

Disciplinary notes use the value vocabulary disciplinary_note.created, .updated, .withdrawn, and .acknowledged. All four are recorded inside the owning mutation transaction. Sweeper delivery is intentionally not audited because it has no human actor; notifiedAt, firstNotifiedAt, and revision are the machine evidence.

What goes in data

A full snapshot of the meaningful values for this event, not a diff. Attendance, for instance, stores the cell's value fields (status, time, note) so a history entry is self-contained and readable without joining back to live rows that may have changed or been deleted.


3b. Structural events

Everything above is value audit: "this field changed to that". The temporal program (valid time in domain tables, transaction time here) needs a second kind — structural audit: "who moved this student to that homeroom, when did we act, and from which date does it apply". Value audit has one time axis (createdAt); a structural event has two.

Spec: 2026-07-26-attendance-temporal-d-structural-audit-contract-design.md.

Two primitives, one service

Value audit Structural audit
Write record / recordMany recordStructuralChange / recordStructuralChanges
data consumer-shaped, free-form the fixed envelope below, built by one internal producer
Vocabulary consumer-owned strings src/audit-log/structural-audit.constants.ts, review-enforced
action check none runtime <entityType>. prefix assert
Time axes createdAt createdAt + effectiveOn

Both live on the same AuditService — a module that emits both kinds injects one service. The write discipline is identical: one create on the caller's tx, no separate transaction, no fail-soft branch, createdAt as the transaction-time stamp.

The envelope

// audit_logs.data for every structural event
{
  "v": 1,
  "before": { /* full row snapshot */ } | null,   // null on open/create
  "after":  { /* full row snapshot */ } | null,   // null on genuine hard removal (rare)
  "effectiveOn": "YYYY-MM-DD" | null              // valid-time application date
}

All four keys are always present — a fixed shape is what makes the envelope machine-readable without per-key existence checks. v exists so a future semantic change bumps to 2 while old rows stay readable (additive keys do not bump v).

data.v === 1 is the discriminator. Value-audit rows (attendance_record and the grade_entry operational projection) have no v key and are not structural envelopes. Pre-ledger grade events are not migrated; no structural event may reuse either entity type.

Snapshot rows with toJsonSnapshot (exported from the barrel): it is the sanctioned row → JSON conversion, so Date columns serialize identically everywhere instead of six consumers hand-rolling it. A Prisma row containing Date isn't assignable to Prisma.JsonValue, so the compiler routes you here.

entityId is the stable anchor, not the row you mutated

This inverts §9's recipe rule for structural events. Interval rows churn by design — one student's homeroom history spans many HomeroomAssignment rows — so anchoring on the mutated row id would shatter one fact's history across many ids and make historyFor useless. Anchor on the history key of the fact:

entityType Anchor
student_placement, student_enrolment, homeroom_membership, subject_group_membership, curriculum_selection studentId
subject_group_teacher subjectGroupId
combined_class_membership combinedClassId
timetable the lineage's stable published Timetable id

Row ids are preserved inside the snapshots, so nothing is lost — and (tenantId, entityType, entityId, createdAt), the index that already exists, becomes the exact history key.

One event per logical command

A move emits a single .moved event — before = the outgoing row pre-command, after = the incoming row, effectiveOn = the shared boundary — never a .closed + .opened pair. Same rule for timetable.republished. The outgoing row's post-close state is derivable (before + validUntil = effectiveOn).

Note DB-level ON DELETE CASCADE paths emit nothing; that history loss is accepted per-cascade in the consuming spec, not papered over here.

Snapshots are full rows — with two ledgered exceptions

before/after are the affected row's full column set, id included. Two entityTypes deliberately snapshot a projection instead:

  • student_enrolment{ id, enrollmentDate, exitDate, status }. The Student row spans eight RBAC scopes including health; copying it wholesale into a trail readable through the admin list would bypass field-level filtering for zero forensic value. The affected fact is enrolment.
  • timetable → version metadata (ids, revisionNumber, effectiveFrom/effectiveUntil). The affected fact is the governance interval; the schedule-content evidence is the retained archive rows, which no Timetable-row snapshot could carry.

requestContext

Caller passed Stored
omitted, CLS has requestId { requestId } — same as record()
omitted, no CLS requestId not provided → column NULL
explicit null column NULL (Prisma.DbNull) — deliberate suppression
object shallow-merged over { requestId }, caller keys winning
non-object non-null verbatim (legal, discouraged)

Adding a name

Editing structural-audit.constants.ts is the procedure — an inline structural entityType/action string anywhere else is a review reject, and structural-audit.constants.spec.ts pins the exact sets. Two names are reserved without a shipping writer: attendance_register.* (its owning spec is deferred) and the verb .amended (exclusive to the deferred amendment spec).

Envelope rows render through the same presentation resolver as value events; their before/after ids supply the from/to references described in §5.


4. Reading history (consumer-facing)

historyFor(tenantId, entityType, entityId) → AuditLogEntryResponseDto[] returns every event for one entity instance, newest-first. Domain modules call it to surface a typed value-change history — e.g. attendance's per-cell history endpoint returns { current, history }, where history is historyFor(tenantId, 'attendance_record', id) (src/attendance/attendance.service.ts). This read goes through the non-transactional PrismaService (it's a query, not part of a write).

Mapping to the response DTO is centralized in toAuditLogEntryResponse (audit-log.queries.ts); requestContext is normalized to null when absent. historyFor and the admin list both run the same grouped presentation resolver, so embedded history entries carry the same summary, references, primaryLink, labels, and forensic fields described in §5. A link still stays null when its destination cannot be reconstructed or no longer exists.

Grades are the exception to the domain-history recipe. Since the 2026-08-12 ledger cutover, GET /grades/:id/history reads immutable GradeEntryRevision rows directly. Generic grade_entry.* audit events remain an atomic cross-cutting who-did-what projection for the admin audit surface, but they are not complete grade state and must never be used to reconstruct it. grade_entry.deleted is retained in the label catalogue only for historical rows; new commands emit grade_entry.withdrawn.


5. Read API

Two GETs (src/audit-log/audit-log.controller.ts) — read-only, no write endpoints. Both carry the same guard pair:

@RequireScopes(EntityKey.AUDIT_LOG, 'read')
@AggregateResponse()

No role gate — the scope grant alone governs (see §6). Seeded readers are Admin, Director, and Department Principal; all receive the same tenant-wide trail. HR, Front Office, Curriculum Coordinator, and profile roles remain ungranted.

GET /audit-log — paginated (PaginationQueryDto), newest-first, tenant-scoped. ListAuditLogQueryDto filters:

Param Semantics
entityType / entityId / actorUserId exact match (base spec)
action exact match; actions are namespaced <entityType>.<verb>, so the FE passes the matching entityType alongside — the query then enters through the (tenantId, entityType, …) index
search case-insensitive substring on actorName (people-list search idiom)
from / to inclusive createdAt range

buildAuditLogWhere (audit-log.queries.ts) assembles the where.

Entries are render-ready and semantically navigable (iteration 4, 2026-08-14). On top of the raw columns, each entry carries:

Field Semantics
summary: LocalizedMessage \| null The complete event sentence in {en_US, it_IT}. The FE renders summary[lang] verbatim. Every catalogued current action has an action-specific projection; only an unknown historical/future action with no catalog fallback yields null.
references: AuditReferenceDto[] Every domain object named by the summary or semantic destination. Each item is {role, kind, id, label}: role is event-local (student, from, to, record, subject, and so on), kind is the closed resource vocabulary, and label is a snapshot or tenant-scoped live label when recoverable. A deleted historical object stays as an id with label: null when the row still captured the id.
primaryLink: AuditLinkDto \| null Exact semantic frontend destination: {destination, section, params, query}. It is not an href; the frontend maps destination to its router and forwards the supplied context. Attendance links include date/cohort/student/cell or event context; grade, discipline, communication, referent, roster, curriculum, and timetable events select their relevant screen/section. It is null when a live destination cannot be proven.
target: {kind, id, label} \| null Deprecated iteration-3 compatibility field. New clients use primaryLink and references. It remains additive during the migration window.
entityTypeLabel / actionLabel: LocalizedMessage \| null Short localized vocabulary from ENTITY_LABELS and module-owned AUDIT_ACTION_LABELS. These remain useful for filter options and chips; unknown keys are null and render as their raw keys.
effectiveOn: string \| null Hoisted from the structural envelope — the date the change applies from (YYYY-MM-DD), distinct from createdAt (when it was recorded).
changes: AuditChangeDto[] \| null Structural events (data.v === 1): generic before/after field diff. Values remain raw forensic evidence; they are no longer the row-summary contract.
details: AuditDetailDto[] \| null Value events: top-level data keys as labeled values. Also forensic/expanded content rather than headline copy.

resolveAuditEntryPresentations (audit-presentation.queries.ts) collects ids for the whole page, performs bounded tenant-scoped IN lookups by resource kind, and projects each action. There is no per-row lookup. Snapshot labels win where the audited event captured them; live tables fill gaps and decide whether a link target still exists. Grade summaries prefer the immutable GradeEntryRevision named by data.revisionId, never generic audit JSON as a grade-state ledger. Missing/deleted references and unknown actions fail soft and never turn the audit read into an error.

Exactly one of changes/details remains non-null, and data stays verbatim for the expandable/debug view. buildAuditEntryReadable still owns those query-free forensic fields. The presentation-registry drift spec separately pins current AUDIT_ACTION_LABELS keys to summary/reference/link coverage.

GET /audit-log/filters — dropdown vocabulary for the list: the tenant's distinct (entityType, actions[]) pairs via groupBy, alphabetical by key at both levels, each key served with its catalog label (null when uncatalogued). Data-derived on purpose — value-audit vocabulary is consumer-owned free strings with no central registry, so the rows are the only complete source; a new audit consumer surfaces in the dropdowns with zero code here (label null until catalogued).


6. RBAC wiring

audit_log is a descriptor-only, grant-governed entity — see chapter 04.

  • Entity key EntityKey.AUDIT_LOG = 'audit_log' (src/common/constants/entity-keys.ts).
  • Catalogue entry at sortOrder 18 with a single scope configuration (descriptor-only) and no action key (prisma/seed/rbac-catalogue.ts).
  • The grant is the whole gate. The former @RequireRoles('admin') belt was deleted (iteration-2 spec, 2026-08-04): the FE scopes views by GET /permissions, and a role gate stacked on a granted scope makes that map lie — pre-iteration, all six exclusion-shaped preset matrices held the scope against a route that 403'd them. Now the routes are scope-gated like every other read surface; who sees the audit log is exactly who holds audit_log.configuration READ.
  • Seeded for Admin, Director, and Department Principal. Role matrix v3 grants READ to the principal and department_head presets in addition to Admin; HR Manager, Front Office, Curriculum Coordinator, teacher, referent, and student presets remain excluded. Access is all-or-nothing: a granted role sees the full tenant trail (every entityType, every actor, and the data blobs; no department narrowing or field masking applies to opaque JSON). Existing global and tenant preset rows are reconciled by migration 20260820120000_realign_management_preset_v3_grants_and_labels; ordinary management-preset reseeding remains non-propagating.
  • Migration 20260804120000_revoke_stale_audit_log_grants healed existing environments: editable presets never prune on reseed ("RBAC seed never deletes"), so the six stale preset grants had to be deleted by hand — shipping the gate removal without it would have silently widened access. Tenant-authored custom roles were deliberately left untouched.

No write action exists because there is no write endpoint — events are only ever appended in-process via AuditService, never over HTTP.


7. Module wiring

AuditLogModule (src/audit-log/audit-log.module.ts) imports PrismaModule, declares AuditLogController, and provides + exports AuditService so other modules can inject it. It is registered in src/app.module.ts.

Consumers import from the barrel: import { AuditService } from '../audit-log' (src/audit-log/index.ts re-exports AuditService, AuditLogModule, the AuditActor / AuditRecordParams types, and — for structural consumers — toJsonSnapshot, the STRUCTURAL_* constants and the StructuralChangeInput / StructuralAuditActor / StructuralAuditEnvelope types). A consuming module imports AuditLogModule and injects AuditService in its service constructor (attendance does exactly this). buildStructuralAuditData is deliberately not exported — the envelope has exactly one producer.


8. Deferred: base-service auto-hook

Today, audit emission is explicit — each consumer calls resolveActor once and record per mutation by hand. A design was floated to hang an automatic write-side hook off the shared base service (so any CRUD write would emit an event without per-module wiring). That auto-hook is not shipped. Until it lands, follow the recipe below to wire a module by hand; do not assume writes are audited automatically.


9. Recipe — emit audit events from a new module

  1. Import AuditLogModule in your module and inject AuditService (from '../audit-log').
  2. Do your mutation inside a this.prisma.$transaction(async (tx) => { ... }).
  3. At the top of the tx, call const actor = await this.audit.resolveActor(tx, ctx) once (you need a RecordAccessContext — the ctx your controller already passes for tenant/user/roles).
  4. After each write, call await this.audit.record(tx, actor, { entityType, entityId, action, data }):
  5. entityType — a stable string for your domain (e.g. grade_record).
  6. action'<entityType>.<verb>' (e.g. grade_record.created).
  7. entityId — the UUID of the row you mutated (the soft reference).
  8. data — a full snapshot of the meaningful values for this event, including any denormalized labels you'd want readable after related rows change or are deleted.
  9. Register presentation and vocabulary in the same PR: add the action to AUDIT_ACTION_LABELS, add the entity spelling to ENTITY_LABELS when new, and add the action to the projector in audit-presentation.queries.ts with its localized summary, typed references, and semantic link. The drift spec intentionally fails when the action catalog and presentation registry diverge.
  10. (Optional) To expose a value-change history for one instance, call this.audit.historyFor(tenantId, entityType, id) from a read method and return it alongside the current state (see attendance's { current, history } cell endpoint).
  11. If the action needs facts not recoverable after mutation (especially on a hard delete), snapshot the ids and display names in data at the producer. Extend AUDIT_REFERENCE_KINDS or AUDIT_LINK_DESTINATIONS only when the existing vocabularies cannot describe the resource/screen. Do not emit a concrete href from the backend.

That's the whole integration — no new scope, no new action, no migration. The GET /audit-log surface picks up your events automatically (filterable by your entityType, discoverable via GET /audit-log/filters).

10. Class-register value vocabulary

The assignments module emits assignment.created with the complete original assignment snapshot including initial attachments, assignment.updated with complete before/after business snapshots including current attachments, and assignment.deleted with the complete pre-delete aggregate snapshot. The delete event survives the hard-deleted row. Its shared course-day text emits course_day_lesson_content.created, .updated, and .cleared, with before/ after text plus frozen course-day and actor context. Every material write and its event share one transaction; same-value and clear-of-absent no-ops, reads, deadline passage, and audience changes emit nothing. Admin-authored values use the real Admin user/name and a null Teacher id. The localized action and entity labels are registered in the audit catalogs and the actions participate in the presentation registry. Assignment events currently use the generic presentation and therefore fail soft with no resource link when the row has been hard-deleted.

Post-create attachment mutations use assignment.attachments_added (one event per atomic file/link batch), assignment.attachment_updated (public before/after LINK metadata), and assignment.attachment_deleted (public removed-item metadata). They retain the Assignment as entityId; no child event is emitted during parent hard delete. Audit payloads may contain the public link URL and label or file name/MIME/byte size, but never an internal File id, storage key, signed URL, uploader id, or file content. A normalized link no-op is quiet.