Design notes
Short notes on the decisions that shape Lanes, and why.
One set of use cases, two doors
application/use_cases.py is the only place that changes state. doors/web.py and
doors/mcp.py translate HTTP or MCP into a call and errors back (Forbidden → 403 / tool
error, NotFound → 404, Invalid → 422, Conflict → 409). A rule added to the domain holds
for the page and for Claude on the same commit, and the tests check both doors.
Visibility is enforced inside the use case
Every lookup goes through _board() / _card(), which resolve a board or card for a person.
A board you are not on raises NotFound, never Forbidden, so its existence does not leak.
Cross-board questions (my_cards, overdue, search_cards, find_similar) only ever read
boards from boards_for(person). That is what makes "the AI only sees boards the signed-in
person can see" true: the MCP tools have no other way in.
Identity comes from the gateway
No login code. The Ignite gateway signs people in through the appid pool and injects
X-Dodil-User (it strips any copy a client sends). The app trusts that header and nothing in
the request body. /mcp without a person answers 401 with the RFC 9728 resource_metadata
link, which is what starts the sign-in in Claude.ai and ChatGPT.
Roles live on the board, not in the pool
The pool has one app role (member, meaning "may use the app"). Owner / member / viewer are
per board, in boards.owner_email and memberships, because the same person is often owner
of one board and viewer of another.
Humane references for AI and people
Cards get a per-board number (#12) so people and assistants can say "move card 12 to Done".
The number is claimed with a plain INSERT on the key <board>-<n>; under a race one writer
wins and the loser retries with the next number (an upsert would let both win). Boards can be
named by title ("the Ops board"); an ambiguous number or title is refused with a question
instead of a guess.
No lost updates, no silent overwrites
Cards carry a version; every write is compare-and-set (UPDATE ... WHERE version = ?). The
card editor sends the version it loaded, so a stale save is a 409 "changed since you opened
it" and nothing is written. Moves, assignments and archiving are different: they don't depend
on what you read, so when the compare-and-set loses a race they re-read the card and try
again (up to three times) instead of failing someone's drag.
History is a first-class table
activity stores who, when, which field, the old and the new value for every change, written
by the use case in the same call as the change. Archive replaces delete so history keeps its
card. The use case owns the clock (Boards(now=...)) and passes the time to the repository,
so tests are deterministic and the demo seed can write a believable working week instead of
70 events in the same minute. Times leave the API as ISO 8601 with a UTC offset; the page
shows them relative ("3h ago") with the local time on hover.
Made for assistants, not just for people
MCP tools take what people say: a card by number, id or title ("the launch card" →
the single best title match; several matches are refused with the candidates listed), a
board by title ("the Ops board"), a list in any case, a due date as 2026-11-07, friday
or in 3 days, labels or checklist items as a list or one string. Every list result is
{"count": n, "cards": [...]}, so "nothing" is an explicit zero. Tools carry MCP
annotations: reads are readOnlyHint (clients can run them without asking), archive and
removals are destructiveHint. Errors are one plain sentence the assistant can repeat.
AI is task-shaped and bounded
Assistant has three methods (checklist, summary, embed), so the use cases don't know
about prompts or HTTP. Suggestions never change data on their own: suggest_checklist only
proposes, make_checklist saves because the person asked. Embeddings are cached per card with
a hash of its text, so a search only embeds cards that changed. kimi-k2.6 is a reasoning
model and can take longer than a request may wait, so the daily summary is written in the
background, stored per board per day, and the call answers writing until it lands. Any
failure talking to Dodil Models becomes Unavailable (503 / a tool error) before anything
is written, so an outage never leaves half a checklist behind.
DataK3 specifics we depend on
- Natural keys everywhere (no SERIAL): DataK3 mints an id when a write omits one.
IN (...)lists are expanded in SQL (no= ANY(array)binding on the DuckDB data plane).CREATE TABLE IF NOT EXISTSis refused when the existing table has other columns, so the start-up migration only creates missing tables andALTER ... ADD COLUMNs the rest.- A client-side semaphore (8) keeps us under the 16 concurrent reads per bucket, and transient errors (40001 / 08006 / 58030 / XX000) are retried.