Audit log#
The audit log is the receipts. Every write — domain mutation, auth event, bot tool call, webhook delivery — lands in a single append-only table. The UI's activity feed is a filtered projection of this same table. There is no second write path, no secondary "activity" store.
This page covers what the audit log records, how to read it, and why it's structured the way it is.
One table, two streams#
audit_event is a single Postgres table with a category discriminator:
category |
Visible in | Examples |
|---|---|---|
activity |
UI activity feeds (per-ticket, per-project, dashboard) and the full audit log | Ticket transitions, comments, wiki edits, project changes |
audit_only |
Full audit log only | Logins, token rotations, bot tool calls, sweeper runs, webhook deliveries |
The two are not separate tables. They share schema, indexes, and the emission point. The split is a presentation concern: "which events does the operator want to see in the per-ticket history?" Auth events, sweeper runs, and webhook delivery records aren't useful inline; they live in the audit-only stream.
Why one table#
Quoting the resolved decision in spec/01_data_model.md#activity_event-and-audit_event--one-table-polymorphic-with-a-category-filter:
- Both streams emit from the exact same point in the service layer. Two tables means dual-writes and two sources of truth.
- 100% of activity events are also audit events; the inverse is not true. One table with a
categoryfilter is the natural shape. - Cross-stream queries ("show me everything Bot-3 did") need both streams together. Two tables would force a
UNIONin the hot path.
The activity feed and the audit log share an endpoint (GET /api/v1/audit). The UI just filters with ?category=activity for the feed and leaves it open for the full audit view.
Schema#
The table carries:
| Column | Type | Notes |
|---|---|---|
id |
UUIDv7 | PK; sortable. |
workspace_id |
UUID | Always the actor's workspace. |
category |
text | activity or audit_only. |
event_type |
text | Dotted machine code, e.g. ticket.created. See event taxonomy. |
actor_user_id |
UUID nullable | NULL only for system events. |
actor_kind |
text | human, bot, or system. Denormalized; survives the actor's deactivation. |
target_kind |
text nullable | e.g. ticket, wiki_page, comment. |
target_id |
UUID nullable | The id of the affected row. |
project_id |
UUID nullable | Denormalized for fast per-project queries. |
ticket_id |
UUID nullable | Denormalized for fast per-ticket queries. |
payload |
jsonb | Event-specific structured data. |
request_id |
UUID nullable | Correlates events from the same HTTP/MCP request. |
tool_invocation_log_id |
UUID nullable | FK to tool_invocation_log for bot.tool_invoked rows. |
created_at |
timestamptz | Append-only; never updated. |
Full definition in spec/01_data_model.md#activity_event-and-audit_event--one-table-polymorphic-with-a-category-filter.
Append-only#
No deletes. No updates. The app code has no update_audit_event or delete_audit_event function, and the DB role used by the app revokes UPDATE and DELETE on this table at migration time as a belt-and-braces measure. The table grows; it does not shrink.
In v1 there is no retention sweeper against audit_event. The table grows unboundedly. For a single-operator homelab producing hundreds-to-low-thousands of events per day, this is manageable for years. The future partitioning + retention shape is documented in spec/07_observability.md#retention; not implemented in v1.
Activity vs audit — when each fires#
flowchart LR
Svc[Service mutation] --> Emit[events.emitter.emit]
Emit --> Decide{category?}
Decide -- domain change --> Activity[activity row]
Decide -- security/admin --> AuditOnly[audit_only row]
Activity --> Feed[UI activity feed]
Activity --> Full[full audit log]
AuditOnly --> Full
A few examples to anchor the model:
| Action | What lands in audit_event |
|---|---|
| Human logs in | One auth.login.success row (audit_only). |
| Human transitions a ticket | One ticket.state_changed row (activity). Shows on the ticket's activity feed and the audit log. |
| Bot creates a ticket over MCP | One ticket.created (activity), one bot.tool_invoked (audit_only) pointing at a tool_invocation_log row, plus a tool_invocation_log row for the detail. All share the same request_id. |
| Bot creates a ticket but the project isn't allowlisted | One auth.bot.write_attempt_in_read_mode or auth.bot.failure row (audit_only). No domain row because no domain change happened. |
| Webhook delivery succeeds | One webhook.delivery_succeeded row (audit_only). |
| Sticky note converted to ticket | One sticky_note.converted (activity), one ticket.created (activity). Same request id. |
| Sweeper runs | One system.sweeper_ran row (audit_only) per sweeper per run. Actor kind = system. |
Event taxonomy#
The canonical event_type strings emitted in v1. The format is <domain>.<verb_past>. The category column is locked per data model — events don't move between activity and audit_only.
Reproduced here for quick reference; the full taxonomy with payload schemas is in spec/07_observability.md#event-taxonomy.
Auth#
event_type |
Category | Trigger |
|---|---|---|
auth.login.success |
audit_only |
Human login OK |
auth.login.failure |
audit_only |
Any login failure (reason ∈ user_not_found, inactive, wrong_password) |
auth.login.rate_limited |
audit_only |
Login bucket exhausted |
auth.logout |
audit_only |
Explicit logout |
auth.session.expired |
audit_only |
Idle or absolute timeout on resolve |
auth.bot.success |
audit_only |
Bot auth OK |
auth.bot.failure |
audit_only |
Bot token rejected |
auth.bot.write_attempt_in_read_mode |
audit_only |
Read-only bot tried a write |
Bot lifecycle#
event_type |
Category | Trigger |
|---|---|---|
bot.created |
audit_only |
Human creates a bot |
bot.updated |
audit_only |
Mode/allowlist/etc. PATCH |
bot.enabled / bot.disabled |
audit_only |
is_active flip |
bot.token.created |
audit_only |
First token at create |
bot.token.rotated |
audit_only |
Rotation |
bot.token.revoked |
audit_only |
Explicit revoke |
bot.tool_invoked |
audit_only |
One per MCP tool call |
bot.resource_read |
audit_only |
MCP resources/read |
Tickets#
All activity:
ticket.created, ticket.updated, ticket.state_changed, ticket.assigned, ticket.deleted, ticket.watcher_added, ticket.watcher_removed, ticket.relation_added, ticket.relation_removed.
Generic field changes — priority, due date, parent, labels, unassignment — are carried by ticket.updated with a changes payload of the form {"field_name": {"from": <old>, "to": <new>}}. There are no separate ticket.priority_changed / .due_date_changed / .parent_changed / .label_added / .label_removed event types.
Comments#
All activity. Note that comment.deleted includes the full body and edit history in the payload, so soft-deleted comment content survives in the audit row.
comment.created, comment.updated, comment.deleted.
Wiki#
event_type |
Category |
|---|---|
wiki.created |
activity |
wiki.updated |
activity |
wiki.deleted |
activity |
wiki.render_failed |
audit_only |
Sticky notes#
event_type |
Project note | Personal note |
|---|---|---|
sticky_note.created |
activity |
audit_only |
sticky_note.updated |
activity |
audit_only |
sticky_note.deleted |
activity |
audit_only |
sticky_note.converted |
activity |
activity |
Personal notes use audit_only because no one but the owner would see them in a feed.
Projects and configuration#
event_type |
Category |
|---|---|
project.created, project.updated, project.deleted |
audit_only |
workflow_state.created, workflow_state.updated, workflow_state.reordered, workflow_state.deleted |
audit_only |
label.created, label.updated, label.deleted |
audit_only |
Configuration and project lifecycle changes are audit-only because the operator sees them already in the project settings UI; they'd be noise in the activity feed.
Webhooks#
All audit_only. webhook.created, webhook.updated, webhook.secret_rotated, webhook.deleted, webhook.delivery_succeeded, webhook.delivery_failed, webhook.delivery_retried.
System#
event_type |
Trigger |
|---|---|
audit.orphaned |
Defense-net fallback when a regular audit write failed |
system.sweeper_ran |
Sessions / idempotency / tool-log sweeper |
Reading the audit log#
From the UI#
/audit is the full audit view (humans only — bots receive 403). Filters:
| Control | URL param | Notes |
|---|---|---|
| Date range | ?since= / ?until= |
Defaults: last 24 h. |
| Actor | ?actor_user_id= |
Multi-select over humans and bots. |
| Event type | ?event_type= |
Grouped by domain. |
| Category | ?category=activity\|audit_only |
Default: all. |
| Project | ?project_id= |
Single-select. |
| Ticket | ?ticket_id= |
Requires the ticket UUID. Display ids like PROJ-123 are rejected by the schema with 422. Copy the UUID from the ticket detail response or GET /tickets/{display_id}. |
| Request id | ?request_id= |
Free-text — paste a UUID to see everything from one request. |
Results render as a table:
Click a row to expand the JSON payload inline. The request_id cell is a link that re-filters the table on that request id — one-click correlation across every event the request emitted.
Cursor-based infinite scroll with limit=50 per page.
/audit/tools — bot tool calls#
A dedicated sub-route for the per-call tool-invocation log. The audit table's bot.tool_invoked rows are pointers; the detail (tool name, redacted args, result status, duration) lives in tool_invocation_log. /audit/tools queries the detail table directly so its columns make sense:
Filters: bot_id, tool_name, from/to, status (ok or error), request_id.
The Audit link column jumps to /audit?request_id=<uuid> so you can see the broader request — the tool call, the domain event(s) it caused, and any auth or webhook rows that share the request id.
From the API#
TICKET_UUID=$(curl -s "http://localhost/api/v1/tickets/PROJ-42" \
-H "Cookie: tb_session=$YOUR_SESSION" | jq -r '.id')
curl -s "http://localhost/api/v1/audit?category=activity&ticket_id=$TICKET_UUID&limit=20" \
-H "Cookie: tb_session=$YOUR_SESSION" | jq '.data'
Returns the paginated list with data and page.next_cursor. Per spec/02_api_design.md#audit:
| Endpoint | Purpose |
|---|---|
GET /api/v1/audit |
Paginated event list with filters. |
GET /api/v1/audit/{id} |
Single event detail. |
GET /api/v1/audit/tools |
Per-call tool-invocation log. |
Bots cannot read the audit log. The MCP catalog has no audit tools; GET /audit from X-API-Key returns 403 auth.forbidden. This is deliberate — an adversarial bot reading the audit log could scrub for evidence of its own actions in subsequent prompts.
Request correlation#
Every inbound request (REST or MCP) gets a request_id (UUIDv7) generated at the middleware boundary in observability/request_context.py. The id is:
- Stored on
audit_event.request_idandtool_invocation_log.request_id. - Bound into the structured logger context for every log line emitted during the request.
- Carried into the webhook payload as the
request_idfield. - Surfaced in the
X-Request-Idresponse header for client debugging.
When the UI shows you a "Something went wrong" toast, the request id is in the toast. Paste it into /audit?request_id=<uuid> to see the full trail server-side. Grep the stdout logs for the same id to see every log line for that request.
This is the single most useful debugging primitive in Ticket Board — one id ties audit, logs, webhook deliveries, and the response together.
Worked example#
A bot creates a ticket over MCP and the webhook fires:
sequenceDiagram
autonumber
participant Bot
participant API as FastAPI
participant Svc as ticket_service
participant DB
participant WH as webhook receiver
Bot->>API: POST /mcp/bot/<token>/mcp (create_ticket)
Note over API: request_id = R1
API->>Svc: dispatch
Svc->>DB: INSERT ticket
Svc->>DB: COMMIT
Note over DB: after-commit hook
DB->>DB: INSERT tool_invocation_log (request_id=R1)
DB->>DB: INSERT audit_event(bot.tool_invoked, R1, FK to log)
DB->>DB: INSERT audit_event(ticket.created, R1)
DB->>WH: POST webhook (X-Request-Id: R1)
WH-->>DB: 200
DB->>DB: INSERT audit_event(webhook.delivery_succeeded, R1)
DB->>DB: INSERT webhook_delivery (request_id=R1)
A query for audit_event WHERE request_id = R1 returns 3 rows: the domain ticket.created, the tool-invoked pointer, and the delivery success. Drill into the tool-invoked row's FK to get the redacted args. Click the delivery success to see the webhook payload. Grep stdout for R1 to see every log line. The same single id ties it together.
Why the audit log matters#
The audit log is the operator's answer to "what actually happened?" — across both human and bot actors, across every surface. Tokens leak, bots misfire, deliveries fail; the audit table is where the receipts live. Filter by token_prefix in auth.bot.success payloads to see what a leaked token did, by request_id to see the full trail of one request, or by actor_user_id to scope to one identity. Because activity feeds are filtered projections of the same table, the feed and the log can't disagree about what happened.
When audit might be missing#
A handful of edge cases where an audit row could fail to land:
| Scenario | What you see |
|---|---|
| Domain commit succeeded, after-commit hook failed | The emitter writes one audit.orphaned row carrying the original event type and a redacted payload. Surface: /audit?event_type=audit.orphaned. |
tool_invocation_log write failed |
The pointer audit_event is skipped (no orphan pointer); an audit.orphaned row captures the missing detail. |
| Both writes fail (DB unreachable) | A last-resort emitter.drain.orphan_write_failed or tool_invocation_logger.orphan_write_failed line goes to stdout. Surface: docker compose logs backend \| grep -E 'orphan_write_failed'. |
These are defense-net signals, not normal operation. If you see audit.orphaned rows piling up, something is wrong with the DB or the emitter — investigate. The full failure-mode matrix is in spec/07_observability.md#failure-modes-table.
Common questions#
Can I edit an audit row? No. The table is append-only. The app has no update path; the DB role revokes UPDATE and DELETE. If a row has the wrong payload, file a bug — the answer is to emit a follow-up row, not to mutate history.
Can I delete an audit row? No. Same as above. v2 may introduce partition-based retention; v1 keeps everything forever.
How do I see all events from one bot? /audit?actor_user_id=<bot_id>. Add &category=audit_only to skip the user-facing comments and ticket changes if you only want admin/security visibility.
How do I see what a specific request did? /audit?request_id=<uuid>. The UI links every request_id cell to this filter; clicking surfaces the full request trail.
Why are some sticky-note events audit_only and some activity? Personal sticky-note events are audit-only because no one but the owner would see them in a feed. Project sticky-note events are activity because they're workspace-visible.
Where does the soft-deleted comment body live? In the comment.deleted audit event's payload. The DB column gets soft-deleted (deleted_at set), but the payload carries the full body and edit history. Hard purge is not exposed; the audit row carries the content forever.
Are bot tool args logged in full? No. The tool_invocation_log.arguments_json field is redacted: fields named password, token, secret, api_key, private_key (case-insensitive) are replaced with "<redacted>". The total payload is capped at 4 KiB; over-long strings are truncated with a "<truncated>" marker. Tool results are not logged in full — only result_status and an optional error_message (also redacted).
API surface recap#
| Endpoint | Verb | Purpose |
|---|---|---|
/api/v1/audit |
GET | Paginated list with filters. Humans only. |
/api/v1/audit/{id} |
GET | Single event detail. |
/api/v1/audit/tools |
GET | Per-call tool-invocation log. Humans only. |
Full details: spec/02_api_design.md#audit and spec/07_observability.md.
Next#
- The same emission point feeds Webhooks — every event you see in the audit log is a potential webhook payload.
- If something looks wrong in audit, head to Troubleshooting.