Design notes
Short notes on the decisions that shape TaskFlow.
One set of use cases, two doors
The web routes and the MCP tools are thin: they read who is calling, call one method on
TaskFlow (application/use_cases.py), and translate errors (NotFound → 404, Forbidden → 403,
Invalid → 422, or a tool error for MCP). No rule lives in a door, so Claude can't do anything
the web page couldn't, and the tests in tests/test_doors.py check that both doors refuse the
same things.
Rules live in the domain
Board knows roles (require_view, require_edit, require_owner), who may hold a card, and
its lists. Card.edit validates every field and returns a diff of what really changed. The use
cases only orchestrate: load, ask the domain, save, log. The domain has no imports beyond the
standard library, so its tests run in milliseconds.
A board you can't see doesn't exist
A stranger asking for a board or card gets "not found", never "forbidden", so ids and titles don't leak. Lookups by title or card number only search the caller's own boards. That is what makes "the AI only sees the boards the signed-in person can see" true by construction: every MCP call runs as the gateway's person, through the same lookups.
People talk in numbers and names
Cards get a per-board number (#12); boards can be named by title. A number is claimed with a
plain INSERT into card_numbers (key board:number), so two people adding cards at the same
moment can't both get #7: one insert wins, the other moves on to #8. An upsert would let both
win.
Assistants pass card="12" and board="Ops" exactly as the person said it. When a number is
ambiguous across boards, the answer says which boards have it instead of guessing.
History records the diff, not just a sentence
Each activity row stores a readable action ("moved #12 from To do to Done") and a JSON diff
({"list_name": ["To do", "Done"]}). Edits that change nothing write nothing, so the history
stays meaningful.
Lists, done and overdue
Lists are per board and ordered. The list called Done (or Completed, Finished, Closed) counts as finished wherever it sits; a board without one treats its last list as done. New lists go in before Done, so adding "Review" never turns Review into the finish line (an early version used "the last list", and a tester's Review list broke overdue). That one rule drives overdue (due date passed and not done), "my cards" (open work only), and dependencies (a finished blocker no longer blocks).
Ordering for drag and drop
Cards have a float position. Dropping between two cards takes the midpoint, so a move writes
one row instead of renumbering the list.
Dependencies
"Blocked by" links stay on one board. Adding a link that would close a loop is refused
(would_cycle walks the blocker's chain). what_blocks follows the chain and reports only open
cards.
AI is advice, facts are computed
Dodil Models writes checklist steps, ranks similar cards by embeddings, and words the daily
summary. The summary's facts (counts, overdue, due soon, recent activity) are computed by the
app and handed to the model, so it can't invent numbers. suggest_checklist changes nothing;
checklist_from_ai is the explicit "do it" version.
Data
DataK3 has no sequences, so every key is chosen by the app (short random ids; memberships keyed
board:email). Small collections (lists, labels, dependencies, checklists) are JSON in TEXT
columns: they're always read with their row, and it keeps the schema to four tables. Writes are
upserts, so a retried request is harmless.
No login code, no secrets
The Dodil gateway signs people in through the app's user pool and passes the person in
X-Dodil-User; the app never sees a password. The service account's id and secret come from CI
secrets via deploy.yaml. Nothing secret is in the repo.
Changes only from the page itself
The page signs in with the gateway's cookie, so a change request must come from the page.
Every Ignite app shares *.ignite.dodil.cloud, which makes another organization's app
"same-site" for the browser; a SameSite cookie alone is not enough. A middleware refuses any
/api write whose Sec-Fetch-Site is not same-origin or whose Origin host is not this
app's. Claude and scripts send neither header and use a bearer token, so they are unaffected.
Limits
A board holds at most 12 lists and 1000 cards; a card at most 10 labels and 50 checklist items; titles, descriptions and emails have length caps. Nothing grows without bound.
Safe page
Every value from the API is escaped before it reaches the page, so a card titled
<img onerror=...> shows as text.