Below is a production-grade audit logging design tailored to your Control Plane with direct DB authority.
This is not optional plumbing in your architecture — it is the mechanism that makes bypassing APIs survivable.
I’ll give you:
- Conceptual model (what an audit log is)
- Concrete schema (Prisma-ready)
- Mutation patterns (how to write logs safely)
- Query patterns (how you’ll actually use it)
- Rules that must never be violated
1️⃣ What an Audit Log is (lock this definition)
An audit log is an append-only, immutable record of intent and effect for privileged actions.
It answers who, what, when, why, and what changed.
It is not:
- application logs
- error logs
- metrics
- event streams
2️⃣ Core design principles
These must be written into docs and code comments.
Invariants
- Append-only
- Never updated
- Never deleted
- Written in the same transaction as the mutation
- Human-attributable
If any of these are false, it is not an audit log.
3️⃣ Canonical schema (Prisma)
3.1 Audit actor
You must distinguish who caused the change.
model AuditActor {
id String @id @default(cuid())
type AuditActorType
userId String? // control-plane user
serviceName String? // automation / job
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
logs AuditLog[]
}
enum AuditActorType {
CONTROL_PLANE_USER
SYSTEM
AUTOMATION
}3.2 Audit log (root record)
model AuditLog {
id String @id @default(cuid())
actorId String
actor AuditActor @relation(fields: [actorId], references: [id])
action String // e.g. "ASSET_APPROVE", "SUBSCRIPTION_OVERRIDE"
domain String // assets, billing, users, chat
entityType String // Emote, User, Subscription
entityId String
reason String? // REQUIRED for destructive actions
severity AuditSeverity
metadata Json? // contextual info (non-sensitive)
beforeState Json?
afterState Json?
createdAt DateTime @default(now())
@@index([domain, entityType, entityId])
@@index([actorId])
@@index([createdAt])
}
enum AuditSeverity {
INFO
WARNING
CRITICAL
}4️⃣ What goes in each field (strict rules)
action
Machine-readable, stable, uppercase
Examples:
EMOTE_UPLOADEMOTE_APPROVEUSER_ROLE_CHANGESUBSCRIPTION_MANUAL_ADJUSTFEATURED_CONTENT_SET
Never put prose here.
domain
One of your documented domains:
assetsbillingidentitychatcontent
This lets you answer: “What area was touched?”
entityType / entityId
The primary object affected.
If a workflow touches multiple entities:
- one audit log per entity or
- one root log + child logs (advanced)
Do not overload a single record.
beforeState / afterState
Rules:
- Must be JSON serializable
- Must exclude secrets
- Must be partial, not full row dumps
Example (good):
{
"status": "PENDING"
}Example (bad):
{ "entireUserRow": { ... } }reason
Required when:
- deleting
- overriding billing state
- bypassing automation
- modifying permissions
Make it painful to skip.
5️⃣ Writing audit logs correctly (transaction pattern)
Golden rule
No mutation without an audit log in the same transaction
Example (Prisma)
await prisma.$transaction(async (tx) => {
const before = await tx.emote.findUnique({ where: { id } })
await tx.emote.update({
where: { id },
data: { status: "APPROVED" },
})
await tx.auditLog.create({
data: {
actorId,
action: "EMOTE_APPROVE",
domain: "assets",
entityType: "Emote",
entityId: id,
severity: "INFO",
beforeState: { status: before?.status },
afterState: { status: "APPROVED" },
reason: "Reviewed and approved",
},
})
})If this pattern isn’t followed everywhere, your audit system is fiction.
6️⃣ Read patterns (how you’ll actually use this)
6.1 Entity history
“What happened to this emote?”
prisma.auditLog.findMany({
where: {
domain: "assets",
entityType: "Emote",
entityId: emoteId,
},
orderBy: { createdAt: "desc" },
})6.2 Operator activity
“What did this admin do today?”
prisma.auditLog.findMany({
where: { actorId },
orderBy: { createdAt: "desc" },
})6.3 Dangerous actions
“Show me critical mutations”
prisma.auditLog.findMany({
where: { severity: "CRITICAL" },
})7️⃣ Severity classification (do not hand-wave)
| Severity | When to use |
|---|---|
| INFO | Normal admin workflows |
| WARNING | Manual overrides, retries |
| CRITICAL | Billing overrides, deletions, permission changes |
CRITICAL logs should:
- require a reason
- optionally require confirmation UI
- be reviewed periodically
8️⃣ Explicit non-goals (document this)
Audit logs are not:
- rollback systems
- undo history
- realtime event streams
They are for accountability and forensics.
9️⃣ Documentation page structure
Where this lives:
docs/
├─ control-plane/
│ ├─ audit.mdx ← concepts + rules
│ ├─ invariants.mdx
│
├─ domains/
│ ├─ audit/
│ │ ├─ index.mdx
│ │ └─ schema.mdx ← canonical schemaSplit concepts from schema so you can evolve.
10️⃣ Hard rule you should adopt
If you ever feel tempted to skip an audit log, you should not be doing that operation.
This system gives you:
- confidence to move fast
- safety to bypass APIs
- historical truth when things break
If you want next
I can:
- add DB-level safeguards (triggers / RLS-style checks)
- design audit diff compression
- add immutable storage / WORM strategies
- generate admin UI patterns for audit review
- define retention & redaction policies
Say which (or “all”).