Filed by the autonomous architecture cycle (/improve-codebase-architecture). Top deepening opportunity this pass.
Problem
CONTEXT.md invariant: "AuditLog rows are written from server-side action handlers; never directly by clients" and "append-only / immutable once written". Today there is no seam enforcing that — every writer hand-rolls the insert.
17 insert(auditLog) call sites across 10 files, all the same boilerplate:
await db
.insert(auditLog)
.values({
id: randomUUID(),
actorUserId: opts.userId,
action: "affiliate.enrolled",
targetType: "affiliate",
targetId: id,
metadata: { code },
})
.onConflictDoNothing();
Each caller must import randomUUID, the auditLog schema, and db just to record one event. Shape drift is already visible (some sites set organizationId/ipAddress/userAgent, some don't; conflict handling is copy-pasted). Any change to the write contract — id strategy, default createdAt normalization, conflict policy, redaction of metadata, enforcing the immutability invariant — must be patched in 17 places.
Call sites
packages/api/src/affiliate.ts
packages/api/src/gdpr.ts
packages/api/src/referral.ts
packages/auth/src/lib/welcome.ts
apps/web/src/app/onboarding/_actions.ts
apps/web/src/app/api/webhooks/polar/route.ts
apps/web/src/app/api/webhooks/resend/route.ts
apps/admin/src/app/[locale]/users/_actions.ts
apps/admin/src/app/[locale]/feature-flags/_actions.ts
- (
packages/db/scripts/seed.ts — leave as-is, seed is not an action handler)
Deletion test
Passes. Deleting today's per-site code does not move the complexity elsewhere — it concentrates it. A single recordAuditLog(...) makes the Audit context deep: a one-line interface (action, targetType, targetId, optional actorUserId/organizationId/metadata/request ctx) over the id-gen + insert + conflict + invariant behind it.
Proposed seam
New module in the Audit bounded context — colocated with the schema it owns:
// packages/db/src/audit.ts (or packages/api/src/lib/audit.ts)
export async function recordAuditLog(event: {
action: string
targetType?: string
targetId?: string
actorUserId?: string | null
organizationId?: string | null
metadata?: Record<string, unknown>
request?: { ipAddress?: string; userAgent?: string }
}): Promise<void>
Owns: randomUUID() id, onConflictDoNothing(), future immutability/redaction policy. All 9 handler files call it; the schema + db imports disappear from callers.
Acceptance criteria
Size / risk
Low risk, mechanical. ~10 files, ~17 sites. Vertical slice; one PR.
Problem
CONTEXT.md invariant: "AuditLog rows are written from server-side action handlers; never directly by clients" and "append-only / immutable once written". Today there is no seam enforcing that — every writer hand-rolls the insert.
17
insert(auditLog)call sites across 10 files, all the same boilerplate:Each caller must import
randomUUID, theauditLogschema, anddbjust to record one event. Shape drift is already visible (some sites setorganizationId/ipAddress/userAgent, some don't; conflict handling is copy-pasted). Any change to the write contract — id strategy, defaultcreatedAtnormalization, conflict policy, redaction ofmetadata, enforcing the immutability invariant — must be patched in 17 places.Call sites
packages/api/src/affiliate.tspackages/api/src/gdpr.tspackages/api/src/referral.tspackages/auth/src/lib/welcome.tsapps/web/src/app/onboarding/_actions.tsapps/web/src/app/api/webhooks/polar/route.tsapps/web/src/app/api/webhooks/resend/route.tsapps/admin/src/app/[locale]/users/_actions.tsapps/admin/src/app/[locale]/feature-flags/_actions.tspackages/db/scripts/seed.ts— leave as-is, seed is not an action handler)Deletion test
Passes. Deleting today's per-site code does not move the complexity elsewhere — it concentrates it. A single
recordAuditLog(...)makes the Audit context deep: a one-line interface (action,targetType,targetId, optionalactorUserId/organizationId/metadata/request ctx) over the id-gen + insert + conflict + invariant behind it.Proposed seam
New module in the Audit bounded context — colocated with the schema it owns:
Owns:
randomUUID()id,onConflictDoNothing(), future immutability/redaction policy. All 9 handler files call it; the schema +dbimports disappear from callers.Acceptance criteria
recordAuditLog()lives in one module in the Audit context; exported for api + apps + auth.insert(auditLog)remains outside the seam (seed script exempt).pnpm typecheck && pnpm test && pnpm lintgreen.Size / risk
Low risk, mechanical. ~10 files, ~17 sites. Vertical slice; one PR.