Skip to content

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 category filter is the natural shape.
  • Cross-stream queries ("show me everything Bot-3 did") need both streams together. Two tables would force a UNION in 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 (reasonuser_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:

[Time] [Actor] [Type] [Target] [Payload (expand)]

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:

[Time] [Bot] [Tool] [Status] [Duration ms] [Args (expand)] [Audit link]

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_id and tool_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_id field.
  • Surfaced in the X-Request-Id response 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.