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:
| Signal | When it fires |
|---|---|
AfterPublish | Draft or Scheduled → Published |
AfterSchedule | Draft → Scheduled |
AfterArchive | Any → Archived |
AfterDelete | Permanent 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:
| Parameter | Format | Effect |
|---|---|---|
from | RFC3339 | Records at or after this timestamp |
to | RFC3339 | Records at or before this timestamp |
type | string | Filter by content type, e.g. Post |
actor | string | Filter 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.