# Instatic Audit Log Implementation and Append-Only Design: A Complete Guide

> Learn about Instatic's immutable audit log implementation. Discover its append-only design for a tamper-evident trail of administrative actions. Get the complete guide.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: deep-dive
- Published: 2026-07-28

---

**TLDR:** **Instatic stores every administrative action in an immutable, append-only `audit_events` table where rows are never updated or deleted, ensuring a tamper-evident trail of who did what, when, and how.**

The CoreBunch/Instatic CMS implements a strict append-only audit log that records state-changing operations across authentication, publishing, user management, and plugin activity. This design enforces a write-only, never-update-or-delete rule on the `audit_events` table to guarantee an immutable history. According to the CoreBunch/Instatic source code, every entry is backed by a TypeBox schema, protected behind capability checks, and stored with flat metadata to keep queries fast and UI rendering predictable.

## Core Components of the Instatic Audit Log

According to the CoreBunch/Instatic source code, the audit system is organized into four tightly integrated layers that span from the database to the admin dashboard.

### Type-Safe Action Schema with TypeBox

All possible audit actions are defined in [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts) as a closed `AuditActionSchema` literal union using TypeBox. This closed union provides compile-time safety by ensuring that only recognized action strings can be persisted, preventing typo-ridden or arbitrary events from polluting the log.

### Repository Layer: `createAuditEvent` and `listAuditEvents`

The [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts) file exposes two primary functions: `createAuditEvent` for inserting rows and `listAuditEvents` for fetching them. Every insert receives a fresh `nanoid` value for the `id` column, avoiding auto-increment reuse and strengthening the immutability contract. The repository also defines the typed `AuditEvent` interface that maps directly to the `audit_events` table columns.

### HTTP Handler and Capability Gating

Public access to the log is exposed through a single endpoint implemented in [`server/handlers/cms/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/audit.ts). The handler responds to `GET /admin/api/cms/audit` and requires the caller to hold the `audit.read` capability via `requireCapability`. If authorization passes, it returns the event list as JSON; otherwise, access is denied before the database is queried.

### Dashboard Widgets and UI Formatters

On the frontend, [`src/admin/pages/dashboard/widgets/ActivityWidget.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/widgets/ActivityWidget.tsx) consumes the endpoint to display the last ten operational events. For human-readable output, [`src/admin/pages/users/utils/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/users/utils/audit.ts) exports `formatAuditTitle`, which maps raw action strings to curated titles based on the event payload. This separation keeps the API flat while allowing the UI to present rich, contextual descriptions.

## Append-Only Design Guarantees

The Instatic audit log is built around hard rules that guarantee tamper resistance.

Rows in `audit_events` are never updated or deleted once inserted. Each event receives an immutable `nanoid` identifier at creation time, eliminating sequence gaps or reuse. The `metadata_json` column stores a flat `Record<string, string|number|boolean|null|string[]>`, which avoids expensive nested parsing and simplifies UI rendering.

Schema changes are handled exclusively through additive migrations in [`server/db/migrations-pg.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-pg.ts) and [`server/db/migrations-sqlite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-sqlite.ts), and the `_json` suffix follows the project's database-dialect convention. Read access is further gated by the `audit.read` permission, preventing unauthorized enumeration of administrative history.

## How to Write Audit Events in Instatic

Audit events are emitted only after a state-changing operation succeeds, such as publishing a row or installing a plugin. Production code should `await` the repository call, although best-effort contexts like AI chat streaming may deliberately swallow failures.

The following pattern from [`docs/features/audit-log.md`](https://github.com/CoreBunch/Instatic/blob/main/docs/features/audit-log.md) records a publish event:

```ts
import { createAuditEvent } from '@/server/repositories/audit';
import { clientIp } from '@/server/utils/ip';

await createAuditEvent(db, {
  action:      'data.row.publish',
  actorUserId: user.id,
  targetId:    row.id,
  targetType:  'row',
  metadata: {
    tableId:   row.tableId,
    tableSlug: 'posts',
    slug:      row.slug,
    fromStatus: 'draft',
    toStatus:   'published',
  },
  ipAddress: clientIp(req),
  userAgent: req.headers.get('user-agent'),
});

```

### Extending the Audit Action Schema

To track a new domain event, add a literal to the `AuditActionSchema` union in [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts):

```ts
// In server/repositories/audit.ts
export const AuditActionSchema = Type.Union([
  // …existing literals
  Type.Literal('my.custom.action'), // ← new action
]);

// Then use it:
await createAuditEvent(db, {
  action: 'my.custom.action',
  // …other required fields
});

```

## How to Read and Render Audit Events

Fetching the log is handled by `listAuditEvents(db, limit?)`, which returns the most recent events and defaults to a limit of 100. The CMS HTTP handler in [`server/handlers/cms/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/audit.ts) delegates directly to this function:

```ts
// server/handlers/cms/audit.ts
export async function handleAuditRoutes(req: Request, db: DbClient) {
  if (new URL(req.url).pathname !== `${CMS_API_PREFIX}/audit`) return null;
  await requireCapability(req, db, 'audit.read');
  if (req.method !== 'GET') return methodNotAllowed();
  return jsonResponse({ events: await listAuditEvents(db) });
}

```

### Formatting Events for the UI

The admin UI translates raw rows into readable text using the formatter in [`src/admin/pages/users/utils/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/users/utils/audit.ts):

```ts
// src/admin/pages/users/utils/audit.ts
export function formatAuditTitle(event: AuditEvent) {
  switch (event.action) {
    case 'login.success': return `User ${event.actorLabel} logged in`;
    case 'data.row.publish': return `Published ${event.metadata.slug}`;
    // …default fallback
  }
}

```

## Summary

- Instatic persists every admin action to an append-only `audit_events` table that forbids updates and deletions.
- The [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts) file defines a TypeBox `AuditActionSchema` and the `createAuditEvent` and `listAuditEvents` functions.
- A `nanoid` primary key and flat `metadata_json` column keep the log immutable and query-friendly.
- The `GET /admin/api/cms/audit` endpoint in [`server/handlers/cms/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/audit.ts) requires the `audit.read` capability.
- Dashboard widgets and user-page formatters consume the API to render human-readable activity feeds.

## Frequently Asked Questions

### What makes Instatic's audit log append-only?

Instatic enforces a strict write-only rule on the `audit_events` table: rows are never updated or deleted after insertion. Each record receives a unique `nanoid` at creation, and the schema only allows additive migrations. These constraints produce an immutable, tamper-evident trail.

### How does Instatic enforce type safety for audit events?

Type safety is guaranteed by the `AuditActionSchema` in [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts), which is a closed TypeBox union of string literals. Only actions defined in this schema can be passed to `createAuditEvent`, preventing invalid or misspelled event types from reaching the database.

### Who can access the audit log data?

Access is restricted to users who possess the `audit.read` capability. The [`server/handlers/cms/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/audit.ts) handler explicitly calls `requireCapability(req, db, 'audit.read')` before executing `listAuditEvents`, ensuring unauthorized requests are rejected at the edge.

### How do I add a custom action to the Instatic audit log?

Extend the `AuditActionSchema` union in [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts) with a new `Type.Literal(...)`, then invoke `createAuditEvent` with that action string. The new event will immediately be compatible with the existing read pipeline and UI formatters.