eduard/cobra-boards

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

A remix template · 1 remixes · Remix eduard/cobra-boards

docs/DESIGN.md

Design decisions

Short notes on the choices that shape Cobra Boards, and why.

1. One set of rules, two doors

Every action is a method on application.use_cases.Boards. doors/web.py and doors/mcp.py only translate: HTTP or a tool call in, a use case call, the answer out. Neither door checks a role itself. That is what makes "a viewer can't edit" true for the page and for an agent: the check lives in Board.require_edit, once. The MCP tests (tests/test_doors.py) go through the real /mcp endpoint with the gateway's identity header, exactly as ChatGPT does.

2. Identity comes only from the gateway

The Dodil gateway signs the person in through the app's appid pool and passes them as X-Dodil-User; it strips any copy a client sends. The app never reads an email from a request body or a tool argument to decide who is acting. There is no login code in the app.

3. A board you can't see doesn't exist

Not being on a board answers 404, not 403, by id and by name, through both doors. Search, My week, Overdue and meaning search only look at the caller's boards; the vector query is filtered by the caller's board ids before ranking, so a similar card on someone else's board can't leak even as a "no result" shape.

4. Natural keys, and a claim for card numbers

DataK3 has no sequences, and an omitted id would be minted silently. Every id is chosen by the app. A card's id is <board_id>-<number>; the number is the next free one, and it is claimed by a plain INSERT: if two people add a card at the same instant, one insert wins and the other gets a unique violation and takes the next number. (An upsert would let both "win" and overwrite.)

5. No lost updates

Cards carry a version. Every write is UPDATE … WHERE id = … AND version = <what you read>. Zero rows means someone changed it in between: 409, and nothing is written. The page shows "Someone changed this card a moment ago" and reloads; the MCP tool says so and tells the agent to re-read. The history line is written in the same transaction, so a refused write leaves no phantom event.

6. History as data, not prose

Each event stores changes: {field: [before, after]} plus a readable summary. The page renders a timeline, the MCP card_history tool reads it out, and the daily briefing counts from it (e.g. cards moved into a done list in the last day). An edit that changes nothing writes nothing.

7. Lists are the board's own

Boards start with To do / Doing / Done, and owners and members can add, rename and reorder lists. A list can be marked done: cards there are finished, so they are never overdue and never count as blockers. Overdue and blocking are therefore rules of the board, not of a list called "Done".

8. Resolve, never guess

Agents (and people) say "move card 12 to done on ops". The resolvers accept titles, numbers, list names in any case, dates in words, and "me". When a reference matches more than one thing, the use case raises Invalid with the candidates and the agent asks its person. The instructions the MCP server sends say exactly that.

9. The AI suggests; people decide

  • Checklist: the suggestion is returned, never saved. The page shows it with checkboxes; the MCP tool says "not saved" and points to add_checklist_items.
  • Daily briefing: the numbers are computed from the board, and the model only words them, with a prompt that forbids inventing cards. If the model fails, the briefing falls back to the plain facts.
  • Meaning search: card text is embedded once per change (a hash of the text decides) and stored in a VECTOR(768) column; ranking is cosine KNN inside DataK3.

10. A first visit that shows the product

A new person's first GET /api/boards builds a private sample board through the normal use cases (so it obeys every rule). Its id is derived from their email, so two tabs opening at once still make one board. Judges and new teams see overdue cards, a blocker chain and a checklist without anyone sharing demo passwords.

11. Small, fast, boring on purpose

  • No front-end framework or build step; no third-party scripts, fonts or analytics.
  • One DataK3 connection reused per worker thread, under a semaphore below the bucket's concurrency limit, refreshed with the service-account token.
  • /healthz makes no database call (for the platform's probes); POST / is the self-check that opens the bucket with the app's own credential and counts rows per table.

12. Comments notify, but only the right people

A comment's @mentions are resolved against the board's people with the same "never guess" rule as everything else: a full address, or the start of one that only one person has. Notifications are rows for the person they're for (mention, assigned, comment on your card), never for the one who acted, and they are filtered by the boards the reader is still on when read, so leaving a board takes its notifications with it.

13. Files: DataK3 object storage, behind the board's rules

The bytes go to the bucket's object storage under attachments/<board>/<card>/<id>, with the app's own service account; the card keeps a record and a history line. There are no public links: /api/attachments/<id> checks that the caller is on the board (404 otherwise) and streams the file. Only plain images are shown inline; anything that could run in the page is sent as a download, with nosniff and a sandbox CSP.

14. Live without sockets

The open board polls /api/boards/<id>/stamp every 4 s: one indexed read of the newest history line. A different stamp written by someone else reloads the board in place and says who did what. It needs no shared channel between replicas, costs nothing when nothing changes, and pauses when the tab is hidden.

15. Archive instead of delete

History is the point of the board, so cards are never deleted. Archiving writes one row (and one event); every view filters archived cards out (board, My week, Overdue, search, blockers, meaning search), an archived card refuses edits until it is restored, and its number is never reused.

What I left out, deliberately

  • Deleting boards and cards: history is kept; cards archive instead (boards not yet).
  • Push updates: the board polls one cheap row instead; conflicts are still caught (409).
  • Org-wide roles: the pool has one role (member); board roles live in the app, per board, because the same person is an owner on one board and a viewer on another.