Design notes
Shape
Two doors, one set of rules. doors/web.py (JSON for the page) and doors/mcp.py (tools
for Claude and ChatGPT) do three things only: work out who is calling, call a method on
application.Boards, and turn domain errors into a status code (404 / 403 / 400 / 409) or
an MCP tool error. Permission checks, validation and history live in the use cases and the
domain, so the two doors cannot drift apart. The tests call the same actions through both.
The use cases depend on two ports (application/ports.py): a BoardRepository and an
Assistant. Production uses SqlBoards (DataK3) and ModelsAssistant (Dodil Models). Tests
and STORE=memory use MemoryBoards and a fake assistant, and one contract suite runs
against both repositories.
Identity and roles
The Ignite gateway signs people in through the app's user pool and passes them on in
X-Dodil-User. The app has no login code. A role is stored per board (memberships), and
the board's owner is kept on the board row itself, so a board can never lose its owner.
Rules live on Board: require_edit, require_owner, and check_new_role (nobody can
become a second owner or change the owner's role).
A board you cannot see raises NotFound, never Forbidden. Strangers cannot probe which
boards or card numbers exist. The same applies to cards and to OPS-12-style references.
Card references
People say "card 12", so cards carry a per-board number and the board key (OPS, made
from the title) turns it into OPS-12. DataK3 has no sequences, so the number is claimed by
a plain INSERT of id <board>-<n>. A clash is a unique violation, and the use case retries
with the next number. Archived cards keep their number.
Resolving 12 searches every board the person can see. If more than one matches, the tool
says so and asks for the board, rather than guessing.
Ordering
Cards and lists have a floating sort_order with gaps of 1024. A move computes a value
between its neighbours and writes only the moved card. When the gap gets too small, that
list is respread. The page sends the drop index; the server works out the position.
Concurrent edits
Each card has a row_version. Updates run
UPDATE … WHERE id = %s AND row_version = %s AND archived = FALSE and check the row count.
If the row changed in between, the edit is refused with Conflict, which is 409 on the
web and an error message over MCP. The page then reloads the card. Both drag-and-drop and
edits send the version they were based on.
History
Each card write compares the old and new card field by field (changes_between). It
records one activity row with actor, happened_at (UTC, microseconds) and a JSON list of
{field, from, to}. List ids are written as list names and blocker ids as OPS-3, so the
history reads well after a list is renamed or deleted.
Overdue and blocked
Both are derived when a card is read: overdue means a due date before today and not in a done list; blocked means at least one blocker that is still open. There is no flag column to keep in sync and no scheduled job that could miss a run.
Dodil Models
- Checklist suggestions are a preview. Saving is a separate, explicit action
(
add_checklist), so a model never writes to the board by itself. - The daily summary is built from facts the code computes. The model only rewrites them as a short note, and the facts come back alongside it.
- Similar cards: embeddings are cached in
card_embeddings, keyed by a digest of the card text, so only new or edited cards are embedded. Vectors are stored as JSON text rather than a vector column, which keeps the schema free of a fixed dimension. Boards are small, so cosine similarity in Python is fast enough. If the model fails, keyword overlap answers instead, and the response says which method was used.
DataK3 specifics
- Connections are pooled and reused (each one is closed after 4 minutes). Opening a TLS connection per query was the main source of latency.
- Transient SQLSTATEs (
40001,08006,58030,XX000) are retried. - Upserts always send the full row, because DataK3 replaces every column in
VALUES. - Arrays are bound with
= ANY(%s). Reserved words are avoided as column names. schema.sqlat the root is what the remix applies. The app keeps an identical copy and creates any missing tables at start-up. A test checks that the two copies match.
What I left out
- Real-time updates between browsers. The page reloads after each action, and version checks catch stale edits.
- Attachments and card covers.
- Recurring cards. A scheduled job would be the place for them, and the board works without one.