This FRD defines a canonical audit-event schema and write helpers that standardise how audit logs are created across all platform applications. The library provides a Zod-validated event schema, actor/resource/action normalization, write helpers for common mutation patterns, and ensures the existing audit viewer widget can consume the same schema. It builds on top of the logging library (FRD-013) for transport and extends the AuditEvent type defined there into a richer, write-side contract.
Introduction
The platform has an audit viewer widget that displays security-relevant events. However, the write side — the code that creates audit entries — has no shared implementation. Each application invents its own audit log format, actor identification, resource naming, and action taxonomy. This makes cross-app audit queries unreliable and compliance reporting fragile. This library standardises the write side so that every application produces audit events in the same shape, with the same actor/resource/action normalization, and the same validation guarantees.
Actor normalization: { id, type, ip?, userAgent? } where type is user, system, api_key, or service.
Resource normalization: { id, type, tenantId? } with a consistent taxonomy (e.g., user, order, document, role).
Action normalization: a fixed set of action verbs (create, read, update, delete, grant, revoke, login, logout, export, import).
Write helpers: auditCreate, auditUpdate, auditDelete, auditGrant, auditRevoke, auditLogin, auditLogout that produce validated audit events and write them via the logging library.
createAuditWriter(logger) factory that binds an audit writer to a specific logger instance.
Integration examples for mutation flows (e.g., updating a user role, deleting an order).
Audit viewer compatibility: the schema must be a superset of what the existing audit viewer widget expects.
Tests validating schema enforcement, required fields, and action normalization.
Out of scope
Audit event storage or persistence layer (events are written to the logging pipeline).
Audit viewer widget modifications (it already exists and will consume this schema).
Tamper-proofing or cryptographic signing of audit entries (a future enhancement).
Querying or searching audit logs (handled by the aggregation layer).
Direct write to the logging pipeline as the sole audit store (see ADR note below).
Users and Pain Points
User
Pain Point
App developer
Invents a new audit log shape for each mutation, with inconsistent field names and missing context.
App developer
Forgets to include the actor’s IP address or user agent, which are required for compliance.
Security reviewer
Cannot write reliable cross-app audit queries because event shapes vary.
Compliance officer
Cannot generate a consistent audit trail for SOC 2 or GDPR data-access reporting.
Platform maintainer
The audit viewer widget receives inconsistently-shaped events and must handle missing fields defensively.
Definitions
Term
Definition
Audit event
A structured log entry documenting a security-relevant action: who (actor) did what (action) to which thing (resource) and when.
Actor
The entity that performed the action. Can be a user, a system process, an API key, or an internal service.
Resource
The entity that was affected by the action. Identified by type and ID.
Action
A normalized verb describing the operation: create, read, update, delete, grant, revoke, login, logout, export, import.
Write helper
A convenience function that constructs a validated audit event for a specific action and writes it to the logger.
Current State
An audit viewer widget exists in the component layer for displaying audit events.
The logging library (FRD-013) defines a basic AuditEvent type with event, actor, resource, action, and details.
No shared write-side helpers exist. Applications log audit events as ad-hoc JSON objects with inconsistent shapes.
No canonical action taxonomy. Some apps use verbs like modified, others use updated, others use changed.
No files exist under src/lib/audit/.
6.5 ADR-027 Alignment Note
ADR-027 §5 states: “Audit events: Separate immutable store. Do not use debug logs as audit logs.”
This library’s write helpers will:
Write audit events to a dedicated audit_events PostgreSQL table (the immutable store).
Also emit a structured Pino log entry via the logging library (FRD-013) for correlation — but the log is supplementary, not the primary audit record.
Each helper validates the event with the canonical schema, sets the timestamp to new Date().toISOString() (overridable via a now option), and writes it to the logger via logger.audit().
Requirements
ID
Priority
Requirement
AUDIT-01
P0
Canonical auditEventSchema with all required fields validated by Zod.
AUDIT-02
P0
Actor normalization with id, type, optional ip and userAgent.
AUDIT-03
P0
Resource normalization with id, type, optional tenantId.
createAuditWriter(logger) factory producing a writer with convenience methods.
AUDIT-06
P0
Convenience write helpers for each action verb.
AUDIT-07
P0
Schema validation on every write; invalid events throw with descriptive Zod errors.
AUDIT-08
P1
outcome field (success, failure, denied) with default success.
AUDIT-09
P0
Tests verifying required fields, action normalization, and validation rejection of invalid events.
AUDIT-10
P1
Integration example showing audit writes in a mutation flow.
Functional Requirements
Schema validation: Every call to a write helper validates the event against auditEventSchema before writing. If validation fails, the write helper throws a ZodError. The event is never written to the logger in an invalid state.
Timestamp: If timestamp is not provided in the event, the writer sets it to new Date().toISOString(). If provided, it must be a valid ISO 8601 string.
Action pre-fill: audit.create(event) sets event.action = "create" before validation. If the caller also provides action, it must match — a mismatch throws.
Logger integration: The writer calls logger.audit(validatedEvent) from the logging library (FRD-013). The logger handles transport (stdout, remote drain).
Actor from AuthenticatedUser: Provide a utility actorFromUser(user, request?) that extracts { id: user.id, type: "user", ip?, userAgent? } from an AuthenticatedUser and optional request headers.
System actor: Provide a constant SYSTEM_ACTOR = { id: "system", type: "system" } for background jobs and scheduled tasks.
Audit viewer compatibility: The exported AuditEvent type and schema must be importable by the audit viewer widget for display rendering. The viewer should not need to transform or normalize events.
Non-Functional Requirements
Category
Requirement
Performance
Audit writes add less than 1ms overhead above the underlying logger call. Zod validation is the primary cost.
Reliability
Audit write failures (validation errors) must not crash the calling mutation. The writer catches, logs an error to the logger, and re-throws so the caller can decide whether to proceed.
Testability
createAuditWriter accepts a logger, so tests can provide a mock or a captured-output logger.
Compliance
The schema satisfies SOC 2 CC7.2 (system change logging) and GDPR Article 30 (records of processing activities) field requirements: who, what, when, to which resource.
Zero runtime dependencies
Only depends on zod (existing) and the logging library (FRD-013).
API/Interface Requirements
Schemas
const auditActorSchema: z.ZodObject<...>;
const auditResourceSchema: z.ZodObject<...>;
const auditActionSchema: z.ZodEnum<[...]>;
const auditEventSchema: z.ZodObject<...>;
type AuditActor = z.infer<typeof auditActorSchema>;
type AuditResource = z.infer<typeof auditResourceSchema>;
type AuditAction = z.infer<typeof auditActionSchema>;
type AuditEvent = z.infer<typeof auditEventSchema>;
This is a headless library with no UI. The audit viewer widget (which consumes this schema) handles its own accessibility. The event and action field values should be human-readable strings suitable for display in the viewer without transformation.
Content and Documentation Requirements
TSDoc on every exported function, type, and schema.
A docs/best-practices/audit-logging.mdx guide covering: schema overview, writer setup, convenience helpers, actor construction, integration with mutation flows, and testing audit writes.
Storybook docs page under Docs/Best Practices/Audit Logging.
At least two integration examples: (1) auditing a user role change in an API route, (2) auditing a record deletion with the denied outcome.
Dependencies
Dependency
Type
Notes
src/lib/logging/ (FRD-013)
Internal
Audit events are written via the logging library’s logger.audit() method.
src/lib/auth/
Internal
actorFromUser consumes AuthenticatedUser.
zod
Existing
Schema validation.
Audit viewer widget
Internal (consumer)
The widget imports AuditEvent type/schema for rendering.
Risks and Tradeoffs
Risk
Likelihood
Impact
Mitigation
Fixed action taxonomy may not cover all future use cases.
Medium
Low
The taxonomy covers standard CRUD, access control, and auth actions. The write() method accepts any action from the enum. New actions can be added to the enum in a non-breaking minor release.
Zod validation on every audit write adds latency in hot mutation paths.
Low
Low
Validation of a small, flat object is sub-millisecond. Profile and cache schemas if this becomes measurable.
Audit writes that throw on validation errors could disrupt mutation flows.
Medium
Medium
Document that callers should catch audit write errors and decide whether to proceed or abort. The writer logs the validation error before re-throwing.
Schema evolution may break the audit viewer if fields are renamed.
Low
High
The viewer imports the schema from this library. Both sides evolve together. Breaking changes require a major version bump.
Open Questions
Should the library support batch audit writes for operations that affect multiple resources (e.g., bulk delete)? Leaning toward individual writes per resource with a shared requestId for correlation.
Should outcome: "denied" audit events be logged at warn level instead of info? This would make denial events stand out in log aggregation.
Should the schema include a previousValue / newValue field for change-tracking, or is that too complex for v2.0? Leaning toward deferring structured diffs to v2.1 and using the free-form details field for now.
Should the AuditAction enum be extensible per-app, or is the fixed set sufficient?
createAuditWriter(logger) returns a writer with convenience methods for each action.
Convenience methods pre-fill the action field and validate the event before writing.
Invalid events (missing required fields, unknown action) are rejected with descriptive Zod errors.
actorFromUser(user, request) correctly extracts actor fields from AuthenticatedUser and request headers.
SYSTEM_ACTOR constant is exported for background job audit events.
Audit events are written to the logger via logger.audit().
The exported AuditEvent type is compatible with the audit viewer widget’s expected input.
Tests cover: valid event creation, missing required fields, invalid action, actor construction, and timestamp auto-fill.
LLM Handoff Instructions
When implementing this FRD:
Create src/lib/audit/ as a new module. Files: index.ts, schema.ts (Zod schemas and types), writer.ts (AuditWriter factory and convenience methods), actor.ts (actorFromUser and SYSTEM_ACTOR), types.ts (re-exports from schema for external consumers).
The auditEventSchema in schema.ts should import and extend the basic AuditEvent type from the logging library (FRD-013) src/lib/logging/audit.ts. If FRD-013 is not yet implemented, define the full schema here and mark the logging integration as a follow-up.
The AuditWriter convenience methods are thin wrappers: create(event) { this.write({ ...event, action: "create" }) }.
actorFromUser extracts id from user.id, sets type: "user", and optionally reads x-forwarded-for (or request.headers.get("x-forwarded-for")) for ip and user-agent for userAgent.
Tests go in src/lib/audit/*.test.ts. Use a mock logger that captures calls to logger.audit() and asserts the event shape.
Export the schemas and types from index.ts so the audit viewer widget can import them directly.
Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.
Decision Log
Date
Decision
Rationale
2026-05-26
Fixed action taxonomy with 10 verbs.
Covers CRUD, access control, and auth actions. Keeps queries predictable and avoids synonym proliferation (e.g., modified vs. updated).
2026-05-26
outcome field with success/failure/denied.
Enables filtering for security-relevant denials without parsing free-text details.
2026-05-26
Audit events flow through the logging library, not a separate transport.
Single pipeline. Aggregation layer filters on audit: true. Avoids a second log infrastructure.