theaio/theaio-boards

Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix theaio/theaio-boards

DESIGN.md

Design notes

Short notes on the decisions that shape Laneway. The code is small enough to read; these say why it looks the way it does.

Rules live in the domain

domain/model.py is plain Python: no database, no HTTP, no Dodil. Board knows who may view, edit and manage people (require_view, require_edit, require_owner), that an assignee must be an owner or member, that list names are unique and the last list is "done". Card.update() sets fields and returns the Changes it made, which is what the history records, and blocking_path() finds a loop before a "blocked by" link is saved.

The use cases (application/use_cases.py) load, call the domain, save, record. The two doors, doors/web.py and doors/mcp.py, only translate: a request or tool call becomes one use-case call, and Forbidden / Invalid / NotFound become 403 / 422 / 404 or a readable ToolError. So a rule cannot be skipped by using the other door, and it is tested once, in a second, without a server (tests/test_domain.py, tests/test_boards.py). tests/test_doors.py checks that both doors reach the same use cases and map refusals the same way.

Visibility is enforced in one place

Two methods in Boards decide what a person can see: _visible(person) (the boards they are on) and _board(person, ref) (one board by id or title, only among theirs). _card() resolves a card by id, number or title, but only on boards that came from those two. Every use case starts there: show, edit, my cards, overdue, what is blocking, similar cards (the vector search is filtered to the visible board ids), the daily summary. A board you are not on is NotFound, never Forbidden, so its existence does not leak. The identity comes only from the gateway's X-Dodil-User header (doors/identity.py), never from a request body or a tool argument.

Card numbers without sequences

DataK3 has no sequences and no SERIAL. A card's primary key is board_id + ':' + number. Adding a card reads max(num) on the board and does a plain INSERT of the next number. If someone else claimed it first, the primary key refuses the insert (23505), the repository returns False, and the use case re-reads and tries the next number (CLAIM_ATTEMPTS). An upsert would be wrong here: both writers would "win" and one card would overwrite the other. Numbers are unique and increasing per board; gaps are possible only after a failed claim. Tools and routes accept the number, the id or the title; a number that exists on several of your boards gets a question back ("say which board") rather than a guess.

History is an append-only table

card_history has one row per field that changed: who, when, field, old value, new value. Rows are written with plain INSERTs and never updated or deleted, so the history is a log you can trust, and edits stay cheap keyed upserts on cards. Old and new values are stored as text as a person reads them (2026-11-30, urgent, client, list names rather than ids), so the history still makes sense after a list is renamed. Comments are history entries too (field = comment), which keeps one timeline per card and one table less. Board-level events (members, lists) go to the starter's activity table.

Data shape

Natural keys everywhere (board_id:email for memberships, card_id<blocked_by for links); every table has a primary key; writes are INSERT ... ON CONFLICT (id) DO UPDATE so a retry is safe. Insert-only columns (created_by, created_at) stay out of the upserts. Labels are a small JSON array in a text column. Lists are real rows with a pos; positions are renumbered on a move, which is fine at board scale.

schema.sql is the full schema for a fresh remix. migrations/001_board_upgrade.sql brings a starter bucket to the same shape with ALTER TABLE ... ADD COLUMN and new CREATE TABLE IF NOT EXISTS only. A starter board has no list rows; the repository gives it To do / Doing / Done and numbers its cards the first time it loads it (_adopt_starter_board). The starter's cards.list_name is NOT NULL on old buckets and cannot be dropped additively, so it is written as '' and only read during that adoption. tests/test_schema.py applies the starter schema plus the migration and compares it with schema.sql, and runs the real repository SQL against SQLite.

AI behind a port

Assistant (in application/ports.py) has three methods: checklist, embed, summarize. infrastructure/dodil.py implements them on Dodil Models' OpenAI-compatible API (unwrapping the gateway's {"status", "data"} envelope); tests use a fake. AI never decides anything: the checklist is a suggestion, the summary's facts (overdue, due soon, blocked, the last day's changes) are computed by the use case and the model only words them, and if the model is down the facts are returned as they are.

Similar cards embed lazily: a card's vector is stored in card_embeddings (VECTOR(2048), jina-embeddings-v4) with a digest of its text, and only cards whose text changed are re-embedded, in batches, when someone asks. The search is pgvector's ORDER BY embedding <=> ... filtered to the person's boards.

The web page

One HTML file with inline CSS and vanilla JS, served by FastAPI, no CDN. Drag and drop uses native HTML5 drag events and sends the target list and position to the same move_card use case the MCP tool uses. Viewers get the same page without the edit controls; the server refuses their edits anyway.