96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 1 | # macltok design decisions |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 2 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 3 | the contract the working tree actually implements. if the code disagrees with |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 4 | this, one of them is lying and it should be fixed |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 5 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 6 | ## two ways in, one way out |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 7 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 8 | state changes are commands. `ClientHandle::send` queues one and hands back a |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 9 | `Pending`, `execute` queues and waits, and there's an awaited convenience method |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 10 | for every command. each command answers exactly once with |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 11 | `Result<CommandOutput, Error>`. drop the `Pending` if you don't care, the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 12 | command still runs |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 13 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 14 | pure reads skip the engine and ask the sdk directly through the handle. rooms, |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 15 | members, power levels, account data, devices, push notification resolution, |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 16 | and media bytes all live here |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 17 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 18 | the event stream carries state and only state. timelines, history, typing, |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 19 | rooms, connection, encryption, verification, upload progress, session. no |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 20 | command results ride on it. drain it from its own task, it's bounded and the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 21 | engine waits for you |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 22 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 23 | one error type for everything, `Error { kind, message, challenge }`. match on |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 24 | `kind`. `Interrupted` means the engine stopped, the room closed, or the task |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 25 | doing the work died before it could answer. `challenge` turns up with |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 26 | `InteractiveAuthRequired` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 27 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 28 | ## engine |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 29 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 30 | the run loop routes and does nothing slow. every task it spawns is registered |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 31 | with a context. a command task that panics costs its caller an `Interrupted` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 32 | and gets logged. a watcher or a room actor that panics takes the engine down, |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 33 | emitting `Failed` then `Stopped` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 34 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 35 | sync restarts itself with backoff after an error or an unexpected stop, and |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 36 | `ConnectionChanged` shows `Offline` meanwhile. every return to `Online` re-arms |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 37 | the send queues, since the sdk parks a room's queue on any send failure and |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 38 | never unparks it. a recoverable failure while online gets the room another go |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 39 | after a few seconds |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 40 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 41 | logout and a rejected token both stop the engine. `Shutdown` answers once |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 42 | `Stopped` has gone out |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 43 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 44 | ## rooms |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 45 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 46 | one actor per open room. the engine holds a slot per room, `Active` with a |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 47 | sender or `Closing` with a fifo of opens and closes that arrived while it wound |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 48 | down. those replay in order once the actor exits. a room you leave, or get |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 49 | kicked or banned from, closes itself |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 50 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 51 | opening sends `TimelineReset` on the stream before it answers. opening an |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 52 | already open room sends a fresh reset, so a second frontend gets a snapshot |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 53 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 54 | sends, edits and replies run on the actor in order, so they reach the send |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 55 | queue in the order they were asked for. redactions, reactions and read |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 56 | receipts are network calls, so they run beside the actor and a slow one |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 57 | doesn't queue the room behind it. closing lets them finish rather than lie |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 58 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 59 | threads belong to their room's actor. commands for a thread that's still |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 60 | opening wait behind the open, up to the actor's queue capacity |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 61 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 62 | pagination runs one request at a time per timeline and emits `HistoryChanged` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 63 | around each. the sdk remembers where history starts, we don't, because a gappy |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 64 | sync can unload it again |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 65 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 66 | every task inside an actor is supervised, and a panic in any of them panics the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 67 | actor |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 68 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 69 | ## timelines |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 70 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 71 | `TimelineDiff::try_apply` refuses a diff whose indices don't fit your snapshot |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 72 | and leaves the snapshot alone. stop applying, `resync`, and carry on from the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 73 | next `TimelineReset`. resync kills the old watcher before the new reset goes |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 74 | out, so no stale diff can land after it |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 75 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 76 | `TimelineId` tells a room from a thread by its root event |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 77 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 78 | ## mutations |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 79 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 80 | membership, moderation, power levels, room creation and account data writes go |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 81 | through one keyed fifo scheduler. same room, same alias, same dm partner or |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 82 | same account data type run in order, everything else runs at once. |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 83 | `create_direct` hands back the existing dm when there is one |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 84 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 85 | notifications and encryption each get one serial worker. the encryption worker |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 86 | owns the verification controller outright |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 87 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 88 | power level changes build one state event from the local mapping, since the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 89 | sdk's own patcher is private |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 90 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 91 | ## media |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 92 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 93 | media is a read. `ClientHandle::media` takes a `MediaId` a retained timeline |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 94 | handed you, `mxc_media` takes any plain mxc uri. both share one bounded |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 95 | download path with the byte cap the sdk's own download lacks, and |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 96 | concurrent asks for the same bytes share one flight |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 97 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 98 | the cache holds plaintext, keyed like the sdk keys it, so the sdk and macltok |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 99 | can read each other's entries. that's how your own encrypted uploads come back |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 100 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 101 | media ids are registered as timelines render and forgotten when their timeline |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 102 | closes, least recently seen first once the cap is hit |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 103 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 104 | uploads are commands that answer once the send queue has them. progress and |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 105 | failure arrive as events from one watcher on the sdk's global send queue |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 106 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 107 | ## sessions and secrets |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 108 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 109 | `Session::login_password`, `begin_oauth` and `restore` share one client builder |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 110 | with encrypted sqlite, refresh tokens and threads. secret-bearing inputs don't |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 111 | derive `Clone` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 112 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 113 | the caller owns the saved record. save `Session::record()` after login and |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 114 | replace it atomically with `ClientHandle::session_record()` on every |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 115 | `SessionTokensChanged`. logout and a hard rejection kill the record and the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 116 | store. a soft logout keeps the store for a same-device login, or the e2ee keys |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 117 | go with it. a bare `Stopped` kills nothing. one store directory per login |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 118 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 119 | ## notifications and room data |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 120 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 121 | the notification client is built lazily and shares the engine's sync service. |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 122 | sdk ui types stay behind the boundary |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 123 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 124 | `rooms` normalises on demand with bounded concurrency and sorts by latest event, |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 125 | then name, then id. `RoomsChanged` is an invalidation, coalesced so one sync |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 126 | touching fifty rooms is one event |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 127 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 128 | ## verification |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 129 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 130 | `Confirm`, `Reject` and `Cancel`. reject tells the other side the emoji didn't |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 131 | match. the watcher and the action paths claim the terminal event, so a racing |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 132 | cancel can't turn `Done` into `Cancelled` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 133 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 134 | ## public model |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 135 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 136 | owned ruma ids and typed data wherever there's a stable shape, `Raw` where |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 137 | arbitrary content has to survive. anything expected to grow is |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 138 | `#[non_exhaustive]`. `Engine::from_client` stays public for people who built |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 139 | their own client |