Architecture decisions (ADRs)#
Architecture Decision Records capture the why behind contested choices. They are short, durable, and dated: when someone (a contributor, an LLM, a future operator) asks "why did this go that way?" the ADR is where to look first.
Each summary on this page is a one-page distillation. The full ADRs live in adr/ in the repository and carry the full context, alternatives considered, and consequences.
| ADR | Decision | Tags |
|---|---|---|
| 0001 | Postgres over SQLite | database, foundational |
| 0002 | Per-bot MCP URL with token-in-path | auth, mcp, security |
| 0003 | Single user model for humans and bots | data-model, identity |
| 0004 | REST over GraphQL | api, developer-experience |
| 0005 | Mixed soft-delete policy | data-model, retention |
| 0006 | Single audit_event table with category discriminator |
data-model, observability |
| 0007 | Workspace as a first-class entity from day one | data-model, schema |
| 0008 | Separate tool_invocation_log table |
data-model, mcp, retention |
| 0009 | First-run setup via a self-disabling public endpoint | auth, bootstrap, onboarding |
| 0010 | Invalid session cookie resolves to anonymous | auth, sessions, security |
All ADRs have status Accepted. ADRs 0001–0008 were decided on 2026-06-11; 0009 and 0010 on 2026-07-11 as part of public-readiness.
ADR-0001: Postgres over SQLite#
Context#
Ticket Board is a self-hosted homelab system with concurrent writers (one human and N bots). Day-one requirements include tsvector + GIN full-text search across tickets and wiki pages, jsonb payloads for audit events and edit history, SELECT ... FOR UPDATE for the per-project ticket counter, partial unique indexes for soft-delete uniqueness, and timestamptz end-to-end. SQLite would be operationally simpler but the migration cost of switching later is asymmetric.
Decision#
Adopt PostgreSQL 16 from day one, running as a sibling container in Docker Compose. SQLAlchemy 2.0 async (asyncpg driver), Alembic for migrations, schema authored against Postgres-native features. No SQLite-compatible fallback is maintained.
Consequences#
- First-class
jsonb,tsvector,timestamptz, partial unique indexes, row-level locks — every spec-level decision works as written. - One database personality across dev, test, and prod. Tests use
testcontainers-pythonto spin up a real Postgres per pytest run. pg_dump/pg_restoreis the backup story (see Backup and restore).- One additional container in the stack; contributors need basic Postgres familiarity.
ADR-0002: Per-bot MCP URL with token-in-path#
Context#
Bots authenticate over REST via X-API-Key. The MCP transport needs an answer to the same question. Some MCP clients cannot easily inject custom headers on the JSON-RPC transport. An OAuth flow was on the table but adds complexity unjustified by a single-operator homelab.
Decision#
Each bot's MCP endpoint is /mcp/bot/{token}/.... The token is the credential. An ASGI middleware extracts it from the path, verifies the argon2id hash against the active bot_token row, and attaches CurrentActor to the MCP request context. The same token is the bot's REST X-API-Key — one credential, two transports.
Consequences#
- Maximum client compatibility — anything that can hit a URL can authenticate.
- The URL is a bearer secret. Logging middleware redacts
/mcp/bot/<token>/...to/mcp/bot/<redacted>/.... Thetkb_prefix makes leaks greppable. - Token rotation is a 1-step operation; no grace period in v1. Reconfiguring the bot is the trade-off for the simpler model.
- Future move to a header-based or OAuth flow is purely additive.
See MCP server for the runtime detail.
ADR-0003: Single user model for humans and bots#
Context#
Bots are first-class identities. Every ticket, comment, watcher entry, and audit event must reference an actor. The two kinds share a lot (username, display name, workspace membership, audit history, "subject of FKs"); they differ in some fields (humans have password hashes; bots have a mode and an allowlist). The choice is one table or two.
Decision#
One user table with a type discriminator column (text CHECK (type IN ('human', 'bot'))). Human-only fields (email, password_hash) and bot-only fields (bot_mode, bot_project_allowlist, can_edit_global_wiki) are nullable, with database-level CHECK constraints enforcing the cross-field invariants. Pydantic v2 provides type safety via a discriminated union; SQLAlchemy stays as a single ORM class.
Consequences#
- One foreign-key target across the schema. Every
assignee_user_id,actor_user_id,author_user_idis one column type referencing one table. - The repository returns a discriminated union; service code pattern-matches on
user.typeand gets the narrow type for free. - Schema-level CHECKs catch invariant bugs in tests and in migration data loads.
- The "nullable column for the wrong actor type" smell exists in the schema, mitigated by CHECK constraints and Pydantic narrowing.
ADR-0004: REST over GraphQL#
Context#
The API serves a Next.js frontend (bounded, known views) and bots (using REST for plain HTTP and MCP for the LLM-curated tool surface). The team is small. GraphQL adds a query language layer (schema + resolvers + dataloader + caching) that pays for itself in arbitrary-shape data requirements that this product does not have.
Decision#
REST + OpenAPI 3 under /api/v1. FastAPI generates the OpenAPI document. The frontend regenerates src/types/api.ts from it via pnpm gen:types. Bots get the OpenAPI document for free; the MCP tool catalog provides the "labeled capability" surface where it matters for LLMs.
Consequences#
- End-to-end TypeScript type safety with no extra schema language.
- Two transports (REST + MCP) share one service layer; no duplicated business logic.
- Coupled to OpenAPI quality — Pydantic discriminated unions require explicit
discriminatorconfiguration for clean codegen output. - "Query anything" is not on the menu; the curated endpoints are the contract.
See OpenAPI spec.
ADR-0005: Mixed soft-delete policy#
Context#
A blanket "soft-delete everything" policy makes every read a filtered query and stores rows nobody needs. A blanket "hard-delete everything" policy loses recovery options for high-referential entities (tickets, wiki pages). Different entities pull on these properties differently.
Decision#
Four buckets, applied per entity:
- Soft delete (
deleted_at timestamptz NULL = live):Ticket,WikiPage,Project,Comment. The deletionaudit_eventfor a comment captures the full body + edit history so content survives even after a later hard purge. - Hard delete (row gone):
StickyNote,Label,WorkflowState,TicketRelation,Watcher(ticket_watcher),Webhook,BotToken(with the caveat that bot tokens are immutable for app code — rotation modifies in place). - Sweeper-driven hard delete (retention job):
Session,IdempotencyRecord,ToolInvocationLog. - Never deleted by app code:
AuditEvent,ActivityEvent(a view overaudit_event),WikiRevision,WebhookDelivery.
Consequences#
- Soft-deleted entities support
?include_deleted=true(humans only) and410 resource.goneon default reads. - Hard-deleted entities make queries simpler and storage smaller, at the cost of no undo.
- The CLI is the only restore path; there is no public restore endpoint in v1.
ADR-0006: Single audit_event table with category discriminator#
Context#
Two conceptually distinct event streams share the same emission points and the same row shape: activity events (the per-ticket history feed) and audit-only events (auth failures, token rotations, tool invocations). Both carry actor_user_id, event_type, target_kind, target_id, payload, request_id. The shape question is whether to give them one table or two.
Decision#
One table, audit_event, with a category column (activity | audit_only). The discriminator is stored, not inferred — the emitter sets it at write time. The activity feed is WHERE category = 'activity' AND ticket_id = :id ORDER BY created_at DESC. The audit log is humans-only and shows everything. ActivityEvent as a domain concept is a Pydantic read-model projection over audit_event rows.
Consequences#
- One write path, one source of truth, one index strategy.
- "What did Bot-3 do today?" is a single query spanning both streams.
- The category bug class ("we forgot to set it on this code path") is real and is caught by tests on the emitter contract.
ADR-0007: Workspace as a first-class entity from day one#
Context#
v1 has exactly one workspace; the UI does not surface it. Multi-workspace is not a v1 goal but is a plausible v2+ direction. The cost is asymmetric: a column per table now vs an ALTER TABLE + backfill + service rewrite later.
Decision#
Every domain entity carries workspace_id uuid NOT NULL as a FK to workspace.id from the initial migration (with the exception of pure many-to-many join tables, whose composite PK inherits the scope). The workspace table exists with exactly one row, seeded at install. The singleton invariant is enforced at the application layer, not the database — a config flag (MULTI_WORKSPACE_ENABLED, default false) governs whether workspace-creation endpoints are wired up. The current workspace is resolved once per request from CurrentActor.workspace_id and bound into the unit-of-work.
Consequences#
- Multi-workspace becomes a config flip + UI work, not a data migration.
- Every query carries an implicit
WHERE workspace_id = :wsclause from the repository base. - One extra column per table is the price; it pays for itself by removing the option of an expensive retrofit later.
ADR-0008: Separate tool_invocation_log table#
Context#
Every MCP tool invocation needs forensic detail (tool_name, redacted arguments_json, result_status, error_message, duration_ms). The payload is heavier than a domain audit event, the per-call volume is higher (a busy LLM agent can fire hundreds per task), and retention wants to differ (tool detail useful for ~90 days; the audit trail is forever).
Decision#
Each tool invocation produces two rows in two tables:
tool_invocation_log— the per-call detail with redacted args, status, duration, retention viaTOOL_INVOCATION_LOG_RETENTION_DAYS(default 90).audit_event— a lightweight pointer row (event_type='bot.tool_invoked',category='audit_only',tool_invocation_log_idFK to the detail). Retention: forever. When the detail is purged the FK is set NULL — the fact-of-invocation persists; the click-through link goes dead.
In addition, the tool's normal domain event (e.g. ticket.created) is emitted as its own activity row.
Consequences#
- The audit log UI shows the lightweight pointer in the timeline; the
/audit/toolspage serves the detail-heavy view. - Retention policies for the two streams are independent.
- Three rows per tool call (activity + pointer + detail) is more writes per call, paid back in clean separation of concerns.
See MCP tool-invocation logging for the runtime view.
ADR-0009: First-run setup via a self-disabling public endpoint#
Context#
A fresh stack bootstraps the singleton workspace but leaves the user roster empty; the only bootstrap path was the CLI (make create-admin), which requires shell access. For self-hosters, the first admin should be creatable from the browser — but the endpoint that creates it cannot require authentication (there is no account yet), and an unauthenticated "create an admin" endpoint is dangerous if it stays open.
Decision#
Two public endpoints that self-disable the instant the first human exists: GET /setup/status reports needs_setup (true iff zero humans exist; bots don't count), and POST /setup/admin creates the admin account and logs the operator straight in. The service re-asserts the zero-humans guard immediately before the insert (409 Conflict otherwise), and the (workspace_id, username) unique constraint backstops the double-submit race. CLI and REST share one service method (setup_service.create_first_admin), differing only in the audit event_type and connection source.
Consequences#
- A fresh install is usable entirely from the browser — redirect to
/setup, create the admin, land logged-in. - One code path for both bootstrap channels; account-creation rule changes apply to both automatically.
- One more unauthenticated POST on the attack surface, mitigated by the guard-first service and the permanent
409after first success. needs_setupis workspace-scoped; if multi-workspace ever ships (ADR-0007), the guard's scope must be revisited.
ADR-0010: Invalid session cookie resolves to anonymous#
Context#
The auth middleware treated a present-but-invalid tb_session cookie (not found, revoked, timed out) as a blanket 401 for the whole request — before the route ran, even for public endpoints. A stale browser cookie after a database wipe made the setup wizard (ADR-0009) unreachable, and an expired cookie 401'd the login request itself, so users couldn't log back in without manually clearing cookies.
Decision#
An invalid/expired/revoked session cookie resolves to anonymous (current_actor = None) instead of hard-401ing, and the dead cookie is cleared on the response (unless the handler issued a fresh one — a login/setup success wins). Protected routes still 401 via the current_actor dependency — the single source of truth for "is this endpoint protected". The bot token (X-API-Key) path is unchanged: a bad key still hard-401s.
Consequences#
- First-run setup and re-login work with a stale cookie present; dead cookies self-heal on first contact.
- Access control lives in one place (the dependency), not duplicated in middleware path allowlists.
- The failure code on protected routes shifts from
auth.invalid_credentialstoauth.unauthenticated(both 401); session-expiry audit events are preserved.
Cross-references#
- Architecture overview — the system the ADRs constrain.
- Data model — the entities the ADRs decide.
- Auth and identity — where ADR-0002 and ADR-0003 land in the runtime.
- Observability — where ADR-0006 and ADR-0008 land in the runtime.
- Glossary — terms the ADRs use.