Who published that? Forge now ships an opt-in audit trail

forge v1.22.0 adds App.Audit — a single call that records every publication state transition to a SQL table and exposes it via GET /_audit.

Every content platform eventually gets the same question: who changed that, and when? Until now, Forge left that to application code. As of v1.22.0 it ships a built-in audit trail — opt-in, zero overhead unless wired.

What it records

App.Audit subscribes to four signals on the signal bus:

SignalWhen it fires
AfterPublishDraft or Scheduled → Published
AfterScheduleDraft → Scheduled
AfterArchiveAny → Archived
AfterDeletePermanent delete

AfterCreate and AfterUpdate are deliberately excluded. Those fire on every save, including draft auto-saves. An audit log full of intermediate draft edits is noise, not signal. The audit trail records transitions that change publication state.

Each record is immutable:

type AuditRecord struct {
    ID            string    `json:"id"`
    Timestamp     time.Time `json:"timestamp"`
    Signal        Signal    `json:"signal"`
    ContentType   string    `json:"content_type"`
    Slug          string    `json:"slug"`
    ActorID       string    `json:"actor_id"`
    ActorRole     string    `json:"actor_role"`
    PreviousState string    `json:"previous_state"`
}

ActorID is the token UUID of whoever triggered the action. PreviousState is the status before the transition — useful for answering "was this published directly from draft, or promoted from scheduled?"

Wiring it

Two lines at startup:

// Create the table once (idempotent)
forge.CreateAuditTable(db)

// Wire the store
app.Audit(forge.NewAuditStore(db))

That's it. No module options, no middleware. App.Audit registers itself as an OnSignal subscriber — the same extension point available to application code. The audit trail is a third-party subscriber that happens to ship with the framework.

If you need a different storage backend, implement the interface:

type AuditStore interface {
    Append(ctx context.Context, r AuditRecord) error
    List(ctx context.Context, f AuditFilter) ([]AuditRecord, error)
}

The HTTP endpoint

Wiring App.Audit also mounts GET /_audit. It requires Editor role or higher — the same gate as content management operations.

GET /_audit
Authorization: Bearer <editor-token>

Response:

[
  {
    "id": "019740ab-1c2d-7e3f-a4b5-c6d7e8f9a0b1",
    "timestamp": "2026-05-16T09:14:02Z",
    "signal": "after_publish",
    "content_type": "Post",
    "slug": "forge-v1-22-0",
    "actor_id": "0196fa3b-...",
    "actor_role": "editor",
    "previous_state": "draft"
  }
]

The endpoint accepts query parameters to narrow results:

ParameterFormatEffect
fromRFC3339Records at or after this timestamp
toRFC3339Records at or before this timestamp
typestringFilter by content type, e.g. Post
actorstringFilter by actor ID

Empty result returns [], never null.

forge-cli

If you prefer the terminal:

forge-cli audit list
forge-cli audit list --type Post
forge-cli audit list --from 2026-05-01T00:00:00Z --to 2026-05-31T23:59:59Z
forge-cli audit list --actor 0196fa3b-...

Output is a tab-aligned table, newest first. Requires FORGE_TOKEN with Editor role.

Zero overhead when not wired

App.Audit is fully opt-in. If you don't call it, nothing is registered and GET /_audit returns 404. No background goroutine, no table required, no performance cost.

The signal bus dispatches to registered subscribers only. An app with no Audit call has no audit subscriber — the bus does nothing extra on publish.