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.