Design decisions
Short notes on the choices that shape Otter Boards, and what is still unproven.
Two doors, one set of rules
The web routes and the MCP tools hold no rules. Each calls a use case in
application/use_cases.py, and the rules live in domain/. A test fails if a new route or tool
isn't added to the role tables in tests/test_roles.py, so an action can't ship without someone
deciding who may use it.
People say names, not ids
"Move card 12 to Done" has no ids in it, so use cases accept what people say. A board is an id or a name, a list is an id or a name, a card is an id, a number or a title. Matching ignores case and punctuation, so "todo" finds "To do". When a name fits two things, the tool asks instead of guessing. Resolving only searches boards the person is on, so a name can't reveal a board they can't see.
Card numbers are per board, so "card 12" can be on two of your boards. Then the tool names the boards and asks.
You can't tell a hidden board from a missing one
An outsider gets the same "no such board" for a real board and an invented one, on both doors. A viewer gets a clear "viewers can't change the board", because they can already see it.
The SQL stays small
DataK3 speaks the Postgres wire protocol but isn't full Postgres, so the repository uses only what
the starter showed working, plus plain clauses: SELECT with WHERE, ORDER BY and LIMIT, and
INSERT. Writes are keyed upserts. There is no UPDATE, DELETE, UNION, join, transaction,
trigger or RETURNING, and a test checks that.
So nothing is deleted. Cards and lists are marked archived, and a removed member's row becomes
removed. This also keeps history complete and stops card numbers being reused.
Column names avoid words a parser might trip on: sort_order not position, card_number not
number, old_value not before, embedding not vector.
Card numbers without sequences
A card's primary key is <board>-<number>. To add a card, take the highest number plus one and
try a plain INSERT. If someone else took that number, the insert fails on the key and we take
the next. It held up with 24 parallel adds on real Postgres. It relies on DataK3 enforcing primary
keys, which is the first thing to check there.
Lists are rows, and "finished" is a flag
Cards point at a list's id, so renaming a list touches one row. Each list has an is_done flag
rather than a magic name, because a team may rename Done to Shipped. That flag decides what counts
as overdue, what still blocks, and what's on your plate.
Dragging a card writes one row: its sort position becomes the midpoint of its new neighbours.
Labels, checklists and blockers live on the card
They are JSON in TEXT columns. A card is always read and written whole, nothing queries inside
them, and there are no link rows to delete. Blockers stay on one board and are stored as card
numbers. Loops are refused.
Overdue is worked out, never stored
A card is overdue if its due date has passed and its list isn't finished. Working that out on each read means it's always right and needs no scheduled job, which Ignite may not offer an app.
AI is an extra, and it never acts alone
- A checklist suggestion changes nothing. A person keeps what they want, then saves it.
- The daily summary's facts come from the board. AI only words them, so it can't invent an overdue card, and if AI is down the facts are the summary.
- Similar cards use stored embeddings, in their own table because a vector is large. They are made on first search and refreshed when the text changes, so saving a card never waits on AI. If Models is down, cards with stored vectors still match by meaning, and otherwise we fall back to shared words. The result says which method it used.
- Viewers can read summaries and similar cards. Only people who can save a checklist can ask for one, since each suggestion costs the org money.
- Card text goes to the model marked as data, and MCP's instructions tell the assistant the same.
Other changes to the starter
- The page used to build HTML from card titles, so a title could run script in a teammate's browser. All text now reaches the page as text, and a content security policy backs that up.
/docsand/openapi.jsonwere public. They are off.- Changes from another site are refused (
Sec-Fetch-Site), because the sign-in travels with the browser. - Each worker thread reuses one database connection, because a page load runs several queries and each new connection costs TLS, a token and a trip to London. Stale connections are replaced.
- MCP results are typed. A bare
listreturn sent an empty answer as no content at all.
What the live run found
Local tests ran on Postgres and passed, then the first run on Dodil found three things they couldn't:
- DataK3 returns
TIMESTAMPwith a time zone, and Postgres without one. History showed "Invalid Date" and the board summary crashed. Times now become plain UTC where the rows are read. - DataK3 sometimes refuses a query with
tcp connect error, twice within milliseconds. One instant retry wasn't enough, so a statement now gets three tries with short pauses. - The first embedding call after a quiet spell took about two minutes, then 0.3 seconds once cached. The page says so while it waits. A wake-up call or a shorter timeout would be the next step.
- DataK3 is slow after a quiet spell. Bursts of requests after 7 or more idle minutes failed with
tcp connect errorfor up to 41 seconds, while a single read after 9 idle minutes waited 10 seconds and worked. A judge's first click lands exactly there, so a statement now retries for about 30 seconds with growing pauses. After that change, 30 adds in three bursts following 8 idle minutes all saved, though the bursts took over 45 seconds together. If retries run out, the page says "That didn't work. Try again." and the failed adds save nothing.
Not proven yet
Proven on the real platform: CI runs test, build, deploy and the remix check on a push; a real
remix loads all of schema.sql into a new bucket; the gateway signs people in and passes their
email; the web page, drag and drop, history and invites work on DataK3; kimi-k2.6 writes
checklists and jina-embeddings-v4 finds similar cards; /mcp serves all 30 tools; a duplicate
primary key is refused, seen in the console's SQL panel; viewer, member and stranger behave
correctly on web and MCP, tested with one signed-in account and role rows set by hand; a faked
X-Dodil-User header gets nothing without a session and is ignored with one; a real Claude Code
signed in by OAuth, read overdue cards, added a card and moved it; 30 same-instant adds hit 107
real number collisions and all saved with unique numbers.
schema.sql only runs when a remix makes the bucket, and there are no migrations. A later change
to it reaches new remixes, not a bucket that already exists.
These still need proving:
- A second person: that an invited person sees the board after really signing up.
- Invited addresses: "Require verified email" is off after a remix, so someone may be able to claim an invited address they don't own. Turn it on before real use.
- A full remix into a fresh organization. The free plan allows one user pool, so we could only run a remix as far as its pool.
- Connecting ChatGPT. Claude Code connected; ChatGPT hasn't been tried.