Observability#
Ticket Board records what happened, in one place, with one schema. Domain mutations, auth failures, bot tool calls, token rotations, webhook deliveries — all land in the same audit_event table. The user-visible activity feed is a filtered projection of the same rows. Webhooks fan out from the same emission point. One request ID ties everything together.
This page is the operator-facing summary. The exhaustive spec lives in spec/07_observability.md. The one-table-with-category decision is ADR-0006; the separate tool-invocation table is ADR-0008.
Tenets#
- Every write is attributable. The audit log records who did what. There is no anonymous-mutation path; system-originated writes (sweepers, migrations) record
actor_kind='system'explicitly. - Audit is the security log. Auth failures, token rotations, bot enable/disable, domain events — all in the same table. One stream, one query surface.
- Activity is a projection, not a copy. The UI's activity feed is
SELECT ... WHERE category = 'activity'. There is no second write path. - Operational visibility is single-host. Structured logs to stdout, Prometheus at
/metrics, two health probes. No external trace store or log shipper in v1. - In-process webhook delivery is v1-only. The emitter contract is durable-queue-shaped so a v2 migration to Redis or a DB-backed outbox does not require touching services.
Audit log#
Storage#
audit_event is append-only forever. The DB role used by the app revokes UPDATE and DELETE on this table at migration time as belt-and-braces; the code path also has no update_audit_event or delete_audit_event function. The full schema is in Data model.
Key invariants:
- Append-only. No update path exists in
repositories/. category ∈ {activity, audit_only}.activityrows feed the activity feed;audit_onlyrows are admin/security only.request_idcorrelates events emitted from the same HTTP/MCP request.actor_user_idis NULL only for system-originated rows;actor_kindis always populated.
Emission rules#
Domain writes — after-commit hooks#
The default path for everything except auth failures.
sequenceDiagram
autonumber
participant Svc as service
participant UoW as unit_of_work
participant Emitter as events.emitter
participant DB
Svc->>UoW: BEGIN
Svc->>DB: INSERT ticket
Svc->>Emitter: emit(ticket.created)
Emitter->>UoW: enqueue(event) — not written yet
Svc->>UoW: COMMIT
UoW->>DB: COMMIT (domain tx)
UoW->>Emitter: after_commit() — drain queue
Emitter->>DB: INSERT audit_event(ticket.created) (separate short tx)
Emitter->>Emitter: enqueue webhook deliveries
- On
COMMIT: the after-commit hook drains the queue and writes audit rows in a separate short transaction. Webhook deliveries are enqueued in-process. - On
ROLLBACK: the hook does not run — the domain "didn't happen," and neither does its audit row. This is the correct behavior for, e.g., aticket.createdevent whose transaction rolled back.
Auth-failure events — bypass the unit of work#
auth.login.failure, auth.login.rate_limited, auth.bot.failure, auth.bot.write_attempt_in_read_mode: written immediately on a dedicated short-lived connection via audit_sink.write_now(event). The failure itself is the security signal — it must persist even if the surrounding request transaction rolls back.
audit.orphaned fallback#
If a service raises an exception after emit(event) but before commit, the rollback discards the after-commit hooks and the trail is lost. As a defense, when an exception is about to propagate out of the unit of work and the emitter's queue is non-empty, the emitter writes one audit.orphaned row:
event_type = 'audit.orphaned'category = 'audit_only'payload = { original_event_type, original_payload_redacted, original_target_kind, original_target_id }
The orphan write uses the same dedicated connection as write_now, so it survives the rollback. Redaction on the orphan payload is conservative — strings longer than 256 chars are truncated, and secret-keyed fields are replaced with <redacted>.
If the orphan write itself fails (DB unreachable), the emitter logs a structured audit.orphan_write_failed line to stdout with the original event type and request ID. The log is the last-resort trail.
Event taxonomy#
The full catalog of event_type strings emitted in v1. Format is <domain>.<verb_past>. category is locked per row at write time, not inferred at read.
Auth domain#
| event_type | Category | Payload (key fields) |
|---|---|---|
auth.login.success |
audit_only | {ip, user_agent} |
auth.login.failure |
audit_only | {ip, user_agent, reason, username_attempted} |
auth.login.rate_limited |
audit_only | {ip, username_attempted, bucket} |
auth.logout |
audit_only | {session_id} |
auth.session.expired |
audit_only | {session_id, reason: 'idle'\|'absolute'} |
auth.bot.success |
audit_only | {token_id, token_prefix, surface: 'rest'\|'mcp'} |
auth.bot.failure |
audit_only | {token_prefix, surface, reason} |
auth.bot.write_attempt_in_read_mode |
audit_only | {surface, target_kind, target_id} |
auth.password.changed |
audit_only | {username} (with actor_kind='human') |
auth.password.reset_via_cli |
audit_only | {username} (with actor_kind='system') |
Bot lifecycle#
| event_type | Category | Payload (key fields) |
|---|---|---|
bot.created |
audit_only | {bot_user_id, username, mode, project_ids} |
bot.updated |
audit_only | {bot_user_id, changes} |
bot.enabled |
audit_only | {bot_user_id} |
bot.disabled |
audit_only | {bot_user_id} |
bot.token.created |
audit_only | {bot_user_id, token_id, token_prefix} |
bot.token.rotated |
audit_only | {bot_user_id, old_token_id, new_token_id, new_token_prefix} |
bot.token.revoked |
audit_only | {bot_user_id, token_id} |
bot.tool_invoked |
audit_only | {tool_name} (with FK to tool_invocation_log.id) |
bot.resource_read |
audit_only | {uri} (MCP resources/read) |
Ticket domain#
Field-level changes (priority, due date, parent, label add/remove, assignee→unassigned) are not separate event types. They are folded into the umbrella ticket.updated event whose changes payload describes the diff per field. ticket.assigned is the lone exception — assignee changes also emit a dedicated row so per-assignee notifications stay easy to query.
| event_type | Category | Payload (key fields) |
|---|---|---|
ticket.created |
activity | {display_id, type, title, state_id, priority, assignee_user_id, parent_ticket_id} |
ticket.updated |
activity | {display_id, changes: {field: {from, to}}} — covers priority, due_date, parent, labels, etc. |
ticket.state_changed |
activity | {display_id, from_state_id, from_state_name, to_state_id, to_state_name} |
ticket.assigned |
activity | {display_id, from_assignee_user_id, to_assignee_user_id} (either side may be NULL) |
ticket.watcher_added / ticket.watcher_removed |
activity | {display_id, user_id, subscribed_via} |
ticket.relation_added / ticket.relation_removed |
activity | {display_id, target_display_id, relation_type} |
ticket.deleted |
activity | {display_id, title} |
Comment domain#
| event_type | Category | Payload (key fields) |
|---|---|---|
comment.created |
activity | {ticket_display_id, comment_id, body} |
comment.updated |
activity | {ticket_display_id, comment_id, body, previous_body} |
comment.deleted |
activity | {ticket_display_id, comment_id, body, edit_history} — full body preserved per ADR-0005 |
Wiki domain#
| event_type | Category | Payload (key fields) |
|---|---|---|
wiki.created |
activity | {slug, title, project_id} |
wiki.updated |
activity | {slug, changes: {field: {from, to}}} |
wiki.deleted |
activity | {slug, title} |
wiki.render_failed |
audit_only | {page_id, exc_class} |
Sticky notes#
| event_type | Category | Payload (key fields) |
|---|---|---|
sticky_note.created / .updated / .deleted |
activity (project) / audit_only (personal) | {sticky_note_id, project_id?, body_preview} |
sticky_note.converted |
activity | {sticky_note_id, ticket_display_id} |
Personal sticky notes do not appear in the project/workspace activity feed — other people would never see them anyway.
Project and config#
| event_type | Category | Payload (key fields) |
|---|---|---|
project.created / .updated / .deleted |
audit_only | {project_id, key, name, changes?} |
workflow_state.created / .updated / .deleted |
audit_only | {project_id, state_id, ...} |
workflow_state.reordered |
audit_only | {project_id, ordered_ids} |
label.created / .updated / .deleted |
audit_only | {project_id, label_id, ...} |
Webhook domain#
| event_type | Category | Payload (key fields) |
|---|---|---|
webhook.created / .updated / .deleted |
audit_only | {webhook_id, url, event_types, changes?} |
webhook.secret_rotated |
audit_only | {webhook_id} (plaintext only in HTTP response) |
webhook.delivery_succeeded |
audit_only | {webhook_id, delivery_id, attempt, http_status} |
webhook.delivery_failed |
audit_only | {webhook_id, delivery_id, attempt, http_status, error} |
webhook.delivery_retried |
audit_only | {webhook_id, delivery_id, attempt} (manual retry triggered) |
System#
| event_type | Category | Payload (key fields) |
|---|---|---|
audit.orphaned |
audit_only | {original_event_type, original_payload_redacted, original_target_kind, original_target_id} |
system.sweeper_ran |
audit_only | {sweeper, rows_purged} — one row per swept table per pass (sessions, idempotency, tool-log) |
Retention#
v1: forever. No retention sweeper runs against audit_event. Given the single-operator scale (one human + N bots producing hundreds to low-thousands of events/day), the table size is manageable for years.
The future hook point is partitioning the table by created_at month, then dropping or archiving partitions older than a configurable retention. Documented for v2; not implemented. The data-model invariant ("never deleted by app") still holds.
Activity feed projection#
The activity feed and the audit log share a single endpoint (GET /audit). The frontend filters with category=activity for the activity feed and shows everything for the audit view.
The query:
SELECT *
FROM audit_event
WHERE workspace_id = :workspace_id
AND category = 'activity'
AND (:ticket_id IS NULL OR ticket_id = :ticket_id)
AND (:project_id IS NULL OR project_id = :project_id)
ORDER BY created_at DESC
LIMIT :limit;
Indexes that cover this:
INDEX (ticket_id, created_at DESC) WHERE ticket_id IS NOT NULL— per-ticket feed.INDEX (project_id, category, created_at DESC) WHERE project_id IS NOT NULL— per-project feed.INDEX (workspace_id, created_at DESC)— workspace-wide recent.
The frontend keeps a render registry keyed by event_type so that, for example, ticket.state_changed renders as <actor> moved <link:display_id> from <from_state> to <to_state>. Unknown event types render with a generic fallback (<actor> <event_type> <target>) so the UI stays forward-compatible.
Tool invocation log#
Bot MCP tool calls write to two tables per call:
- A
tool_invocation_logrow carrying the redacted args, result status, error message (if any), and duration. See Data model for the schema. - A lightweight
audit_eventrow withevent_type='bot.tool_invoked',category='audit_only', andtool_invocation_log_idpointing at the log row above. This is the discoverable fact-of-invocation in the audit stream.
Both writes happen after the domain commit. Failure modes:
| Scenario | Result |
|---|---|
| Both writes succeed | Normal case. Audit stream shows bot.tool_invoked; detail is one FK hop away. |
tool_invocation_log write fails |
Pointer audit row is skipped (no orphan pointer). Emitter writes one audit.orphaned row with the redacted payload inline. |
tool_invocation_log succeeds, audit_event fails |
Log row stays (independently meaningful). Emitter writes one audit.orphaned row with tool_invocation_log_id in payload. |
Retention#
tool_invocation_log rows are purged after TOOL_INVOCATION_LOG_RETENTION_DAYS (default 90). The pointer audit_event row survives forever; when the log row is purged, the FK is set NULL — the fact-of-invocation persists; the click-through goes dead.
Operator UI#
The /audit/tools route shows tool invocations in a dedicated view with their own columns (tool_name, duration_ms, result_status). Each row links back to the corresponding bot.tool_invoked audit row via the FK, placing the call in the broader audit timeline.
Webhook delivery#
Subscription model#
Webhooks are workspace-level subscriptions with a URL, an HMAC-SHA256 secret, and an event-type allowlist. See Data model.
Delivery worker#
v1: in-process BackgroundTasks. No Redis, no Celery, no external queue.
sequenceDiagram
autonumber
participant Svc as service
participant Emitter as events.emitter
participant Dispatcher as webhook_dispatcher
participant Worker as in-process worker
participant Target as External URL
participant DB
Svc->>Emitter: emit(ticket.created)
Emitter->>DB: INSERT audit_event(ticket.created) (after-commit)
Emitter->>Dispatcher: enqueue_deliveries(event, audit_event_id)
Dispatcher->>DB: SELECT webhook WHERE is_active AND event_types matches
Dispatcher->>Worker: BackgroundTasks.add_task(deliver_one)
loop attempts 1..3
Worker->>Worker: build payload, HMAC-sign
Worker->>Target: POST payload
alt 2xx
Target-->>Worker: 2xx
Worker->>DB: INSERT webhook_delivery(attempt, delivered_at)
Worker->>Emitter: emit(webhook.delivery_succeeded)
else 4xx non-429
Target-->>Worker: 4xx
Worker->>DB: INSERT webhook_delivery(attempt, response_status)
Worker->>Emitter: emit(webhook.delivery_failed, final=true)
else 5xx, 429, network/timeout
Target-->>Worker: error
Worker->>DB: INSERT webhook_delivery(attempt, next_retry_at)
Worker->>Emitter: emit(webhook.delivery_failed, will_retry=true)
Worker->>Worker: sleep(backoff) then loop
end
end
Retry policy#
- Attempts: 3 total (initial + 2 retries).
- Backoff: 1 s, 5 s, 30 s between attempts.
- Retry-eligible: HTTP 5xx, HTTP 429, network errors, timeouts.
- No-retry: HTTP 4xx other than 429. The target is telling us something we shouldn't ask again.
Each attempt writes a webhook_delivery row. The latest row's next_retry_at is set on transient failures and NULL on terminal success or terminal failure.
Signature scheme#
HMAC-SHA256 over a canonical timestamp + "." + raw_body string, with a separate timestamp header for replay protection. This is the Stripe-style pattern.
Outbound headers:
| Header | Value | Purpose |
|---|---|---|
Content-Type |
application/json; charset=utf-8 |
Standard JSON |
X-Webhook-Id |
<webhook.id> |
Subscription identifier |
X-Webhook-Event |
<event_type> |
E.g. ticket.created |
X-Webhook-Delivery |
<webhook_delivery.id> |
Per-attempt ID (idempotency key for receiver) |
X-Webhook-Timestamp |
<unix_seconds> |
For replay protection |
X-Webhook-Signature |
sha256=<hex> of HMAC-SHA256 over f"{timestamp}.{raw_body}" |
Authenticity |
User-Agent |
ticket-board/<version> |
Identify ourselves |
Receiver verification (documented for integrators, not enforced by us):
- Read
X-Webhook-Timestamp. Reject ifabs(now() - timestamp) > 5 minutes. - Compute
expected = "sha256=" + hex(hmac_sha256(secret, f"{timestamp}.{raw_body}")). - Constant-time-compare against
X-Webhook-Signature. Reject on mismatch.
Payload schema#
{
"id": "<webhook_delivery.id>",
"type": "<event_type>",
"occurred_at": "<audit_event.created_at ISO-8601 UTC>",
"workspace_id": "<uuid>",
"actor": { "user_id": "<uuid>", "kind": "human|bot|system", "username": "..." },
"target": { "kind": "ticket|wiki_page|...", "id": "<uuid>", "display_id": "PROJ-123" },
"payload": { },
"request_id": "<uuid>"
}
The payload field mirrors the audit_event payload exactly. The audit row and the webhook payload are derivable from each other.
Failure resilience#
Warning
If the FastAPI process dies between enqueue and delivery, pending in-process jobs are lost. This is the acceptable v1 trade-off.
- The
webhook_deliveryrow is only written per attempt. If no attempt ran, no row exists. The receiver doesn't see a "lost" delivery; from their perspective, the event simply didn't fire. - On startup, the worker does not replay missed events. We do not implement an outbox in v1.
- The
audit_eventrow exists (it was written in the after-commit hook before enqueue, in a separate tx). The internal record of the event is intact; only the outbound notification is lost.
Future durable-queue migration shape (documented, not implemented):
- Add a
webhook_outboxtable:(id, event_id, webhook_id, payload_jsonb, ready_at, attempts, status, ...). - Replace the in-process
BackgroundTaskswith a poll loop that pulls fromwebhook_outboxWHEREready_at <= now()ANDstatus = 'pending'. - Worker process can be the FastAPI process (poll-loop alongside requests), a separate Python worker (better isolation), or a Redis-backed queue (best isolation).
- The emitter contract does not change: services call
emit(event); the dispatcher writes towebhook_outboxinstead of in-process enqueue.
The shape is mapped now so v1 doesn't bake in a non-migratable design.
Admin UI#
/settings/webhooks lists subscriptions; each row links to /settings/webhooks/{id}/deliveries:
Per-row actions:
- Retry: calls
POST /webhooks/{id}/deliveries/{delivery_id}/retry. Synchronous best-effort. - View payload: expands the original request body.
A delivery that has already succeeded cannot be retried (returns 409 resource.conflict).
Request correlation#
A UUIDv7 request_id is generated in middleware for every inbound request (REST + MCP) and propagated everywhere:
- Stored on
audit_event.request_id,tool_invocation_log.request_id,webhook_delivery.request_id. - Bound into the structured logger's context for every log line emitted during the request.
- Carried into the webhook payload's
request_idfield. - Surfaced in the
X-Request-Idresponse header for client debugging and bug reports.
A user reporting a bug includes the request ID from a toast in the UI; the operator greps logs and audit for that ID and reconstructs the request's full timeline in one query.
Cross-surface correlation example#
A bot creates a ticket via MCP. The full observability footprint:
sequenceDiagram
autonumber
participant Bot
participant API as FastAPI
participant Svc as ticket_service
participant Emitter
participant Dispatcher as webhook_dispatcher
participant DB
participant Target 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->>Emitter: emit(ticket.created, request_id=R1)
Svc->>Emitter: emit(bot.tool_invoked, request_id=R1)
Svc->>DB: COMMIT (domain tx)
Note over Emitter: After-commit hook
Emitter->>DB: INSERT tool_invocation_log
Emitter->>DB: INSERT audit_event(bot.tool_invoked, FK to log)
Emitter->>DB: INSERT audit_event(ticket.created)
Emitter->>Dispatcher: enqueue_deliveries
Dispatcher->>Target: POST payload (X-Request-Id: R1)
Target-->>Dispatcher: 200
Dispatcher->>DB: INSERT webhook_delivery
Dispatcher->>Emitter: emit(webhook.delivery_succeeded)
Operator inspecting "what happened" can:
- Query
audit_event WHERE request_id = 'R1'→ 3 rows (the domain event, the tool-invoked pointer, the delivery success). - Click the tool-invoked row → drill to
tool_invocation_logvia the FK. - Click the delivery success → see the
webhook_deliveryrow with the full payload. - Grep stdout for
request_id=R1→ see all log lines from middleware, auth, service, dispatcher.
One ID ties it all together.
Structured logging#
Library#
structlog with JSON output in production and a pretty console renderer in dev. Selection via Settings.env at process startup.
Why structlog over alternatives:
- Native JSON output via processor chains.
bind_contextvarsties the request_id (and other request-scoped tags) into every log line emitted during the request without threading them through every function.- Plays nicely with
logging.getLogger(__name__)— third-party logs (SQLAlchemy, Uvicorn, Alembic) still get JSON-rendered.
Standard fields#
Every line carries:
| Field | Source | Always present? |
|---|---|---|
timestamp |
TimeStamper(fmt="iso", utc=True) |
yes |
level |
log call | yes |
event |
log message | yes |
request_id |
bind_contextvars from RequestContextMiddleware |
yes for in-request logs |
Other fields (actor_user_id, actor_kind, workspace_id, http_method, http_path, http_status, duration_ms) appear only on log lines emitted from sites that bind them explicitly — they are not threaded through every line by middleware. The instrumentator emits its own HTTP request/response telemetry to Prometheus rather than to structlog.
Redaction#
Two redaction processors run as part of the structlog chain:
- Secret-keyed fields: any JSON field whose key matches
password,token,secret,api_key,private_key(case-insensitive) is replaced with<redacted>. Same allowlist astool_invocation_log. - MCP path tokens: paths matching
/mcp/bot/<token>/...are normalized to/mcp/bot/<redacted>/...in any field where they might appear.
The matching Caddy-side redaction is described in Auth and identity.
Where logs go in v1#
Stdout only. Docker Compose captures stdout; the operator views with docker compose logs -f. No remote shipping, no log aggregator (Loki, ELK, CloudWatch). The JSON-renderer output is forward-compatible with any structured-log shipper that might appear later.
Metrics#
/metrics exposes Prometheus-format metrics via prometheus-fastapi-instrumentator. Lowest-friction option: a single import and one-liner setup produces request count + latency histograms out of the box.
v1 metrics#
Two HTTP metrics come from prometheus-fastapi-instrumentator out of the box; two webhook metrics are defined in events/webhook_dispatcher.py. The remaining four metrics are planned, not implemented — they're listed so dashboard authors know what to expect when they ship.
| Metric | Type | Labels | Status | Purpose |
|---|---|---|---|---|
http_requests_total |
counter | method, route, status |
shipped | Request count by route |
http_request_duration_seconds |
histogram | method, route |
shipped | Latency per route |
webhook_deliveries_total |
counter | webhook_id, event_type, result |
shipped | Outbound delivery health |
webhook_delivery_duration_seconds |
histogram | webhook_id, event_type |
shipped | Delivery latency |
mcp_tool_invocations_total |
counter | tool_name, result |
planned | Bot tool usage |
audit_events_total |
counter | event_type, category |
planned | Emission rate; canary for "did the firehose stop?" |
db_pool_size / db_pool_in_use |
gauge | (none) | planned | Connection pool health |
route is the templated path (e.g. /api/v1/tickets/{id_or_display_id}), not the substituted URL. The instrumentator handles this via FastAPI's route table, which prevents label-cardinality explosion.
Endpoint protection#
/metrics is protected by either:
- Localhost-only by default (the route checks
request.client.hostagainst a configured allowlist). - Basic auth as an alternative (
METRICS_USERNAME,METRICS_PASSWORD), enabled when set.
Dashboards#
Out of scope for v1. The starter dashboard set is documented in operator notes (future deliverable):
- API health: requests/sec, p50/p95/p99 latency by route, 5xx rate.
- Webhook health: delivery success rate per subscription, mean delivery latency, retry rate.
- Bot activity: tool invocations/min by bot, error rate per tool.
Health checks#
Two endpoints, both unauthenticated and intentionally minimal.
/healthz — liveness#
- Returns
200 {"status": "ok"}if the process is responsive. - Does NOT touch the database. A DB outage should not flap liveness — the process is still alive, just degraded.
/readyz — readiness#
- Returns
200 {"status": "ready", "db": "ok", "settings_loaded": true}if: - DB is reachable (
SELECT 1in under 500 ms). - The session-maker is configured.
- Returns
503with a structured body if either check fails.
Startup invariants (e.g. cookie_secure=true in prod) are asserted once in lifespan.py before the listener opens. They are not re-checked on every probe — if /readyz responds at all, the process passed them at boot.
Caddy uses /healthz for "is the container up." Docker Compose's healthcheck: block uses /readyz for "is the container ready to serve." Both endpoints are documented in the OpenAPI schema under a health tag.
Tracing#
Out of scope for v1.
The natural injection site is the request-context middleware, alongside the request_id generation. A v2 PR would parse W3C traceparent on inbound, propagate to downstream HTTP calls (webhook deliveries), and attach to audit_event via a new trace_id column. OpenTelemetry's opentelemetry-instrumentation-fastapi handles the middleware side.
Retention sweeper#
A single background job handles three retention concerns on a shared schedule (SWEEPER_INTERVAL_MINUTES, default 60):
| Table | Purge condition |
|---|---|
session |
expires_at < now(). Idle-expired rows are caught here once they cross absolute. |
idempotency_record |
expires_at < now() (IDEMPOTENCY_KEY_TTL_HOURS, default 24). |
tool_invocation_log |
created_at < now() - TOOL_INVOCATION_LOG_RETENTION_DAYS (default 90). FK on the pointer audit_event row is set NULL in the same pass. |
Each pass emits one system.sweeper_ran event per table with {sweeper, rows_purged}.
Failure modes:
- A failed sweeper pass means rows accumulate until the next successful run. The worst outcome is bloated tables, not data corruption.
- Sweeper errors log a structured line and increment a metric. The metric is a good alerting hook (no rows purged in 24 hours = investigate).
Failure modes table#
The "what if X breaks" matrix. Each row: a single failure, the user-visible effect, and the operational response.
| Cause | User-visible effect | Operational response |
|---|---|---|
| Postgres unreachable on inbound request | 503 server.unavailable + toast; FE may show stale TanStack Query cache |
Operator restarts DB; /readyz is failing during outage. |
| Postgres unreachable on after-commit audit write | Domain write succeeded; audit row written via dedicated connection on next attempt | If dedicated connection also fails, emitter logs audit.orphan_write_failed. |
| Audit write fails after domain commit | Domain change is live but audit row missing; emitter writes audit.orphaned |
Orphan rows surface in /audit filtered by event_type='audit.orphaned'. |
| Webhook target unreachable | webhook.delivery_failed (will_retry=true) audit events; up to 3 attempts |
Operator sees failures in /settings/webhooks/{id}/deliveries; can hit Retry. |
| Webhook target returns 4xx (non-429) | webhook.delivery_failed (will_retry=false) immediately |
Operator inspects target; fixes config; manual retry. |
| FastAPI process crashes mid-webhook-delivery | Pending in-process queue items are lost. Already-written webhook_delivery rows persist. No replay on startup. |
Operator monitors webhook_deliveries_total; v2 outbox prevents this. |
| Sweeper job fails | Tables grow until next successful run; no functional impact | Sweeper logs an error; metric ticks; operator investigates. |
tool_invocation_log write fails |
Pointer audit row skipped; emitter writes audit.orphaned with inline redacted payload |
Orphan rows in /audit surface the gap. |
request_id middleware not run (config bug) |
All audit rows for that request have NULL request_id |
CI test asserts middleware runs; on prod, absent request_id is detectable as a metric anomaly. |
| Login attempt floods (DoS) | Per-IP and per-username buckets hit; legitimate user temporarily locked out | auth.login.rate_limited events; operator clears bucket via restart in v1. |
| Webhook secret leaked | Receiver sees signature-valid replay attempts | Operator rotates via PATCH /webhooks/{id}?rotate_secret=true; emits webhook.secret_rotated. |
| Bot token leaked | Attacker can impersonate bot | Operator revokes via POST /bots/{id}/tokens/revoke. Audit log captures any actions taken (greppable by token_prefix). |
Reverse proxy fails to redact /mcp/bot/<token>/... |
Token appears in upstream access logs | Operator-runbook responsibility; spec mandates redaction at proxy. |
| Wiki render exception | Page shown as raw markdown wrapped in <pre>; wiki.render_failed audit event |
Operator investigates; fix is auto-picked-up on next read. |
| Clock skew between server and client | ETags still work; webhook receivers may reject stale X-Webhook-Timestamp |
Document the 5-min replay window; operators sync NTP. |
For the full failure-mode matrix and severity tagging, see spec/07_observability.md.
Operator dashboards (frontend)#
Recap of what the human operator sees in the UI:
| FE route | Backend surface | Shows |
|---|---|---|
/audit |
GET /audit |
Full audit log with filters |
/audit/tools |
GET /audit/tools |
Per-call tool-invocation log; bot/tool filters |
/ (dashboard widget) |
GET /audit?category=activity&limit=20 |
Recent activity feed |
/projects/[key] (widget) |
GET /audit?project_id=...&category=activity |
Per-project activity |
/tickets/[displayId] (feed) |
GET /audit?ticket_id=...&category=activity |
Per-ticket activity |
/settings/webhooks |
GET /webhooks |
Subscription list |
/settings/webhooks/{id}/deliveries |
GET /webhooks/{id}/deliveries |
Delivery log per subscription |
/metrics and /healthz are for operator tooling, not the UI. See the Operator guide for the audit-log walkthrough.
What this page does not cover#
- The wire shape of
GET /auditandGET /audit/tools(filters, cursor format) — see the REST API reference. - Specific Prometheus / Grafana dashboard JSON — devops-expert deliverable, in the operator runbook.
- Log shipping pipeline — out of v1 scope.
- FE component implementation for the audit / delivery viewers —
spec/06_frontend.md.