rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

docs/DESIGN.md

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 EXISTS is refused when the existing table has other columns, so the start-up migration only creates missing tables and ALTER ... 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.