Side-effect orchestration: Rust vs Elm
Status: Adopted 2026-04-25.
The recurring question
Section titled “The recurring question”Tauri + Elm gives you two natural homes for “after user does X, fire async Y, then do Z” logic:
- Elm (TEA): dispatch a
Cmd, wait for the reply, update the model. Built-in to the Elm Architecture; the language is designed for it. - Rust (tokio): spawn a background task, write the result to JSONL, let the frontend pick it up on next read.
This question recurs every time we add an AI feature, a deferred write, or any side-effect chain. We keep re-litigating it. This doc encodes the rule so we don’t.
Decision rule
Section titled “Decision rule”If the side-effect’s result is written to the log (mutates domain state) or must survive app close → Rust.
If the side-effect’s result only affects what’s on screen this session (ephemeral UI: focus, scroll, optimistic spinner, modal state) → Elm.
Rationale
Section titled “Rationale”Why “domain state” goes to Rust
Section titled “Why “domain state” goes to Rust”The Elm runtime dies when the runtime dies. Closing the app, navigating away, the OS suspending the WebView on iOS — all of these terminate any in-flight TEA Cmd. There is no concept of “durable in-flight effect” in TEA.
Rust + tokio + the JSONL log do have a concept of durable state. A spawned tokio task can complete after the user has navigated away, written its result to disk, and on next EntriesList the frontend sees the update naturally. If we want to make the durability stronger, we can persist a “pending classification” record to JSONL itself, and a launch-time sweep finishes any unfinished work.
The litmus question: would a CLI or a second frontend want this orchestration? If yes, it’s domain. If no, it’s UI side-effect coordination.
Why TEA is not enough for domain orchestration
Section titled “Why TEA is not enough for domain orchestration”Yes, TEA is a beautiful state machine. Yes, the flow reads more linearly in one update function. But:
- TEA’s state is per-frontend-session. Closing the app loses it.
- Multi-step writes that span an LLM call are vulnerable to user-initiated interruption (background, force-quit).
- Cross-feature coordination (a queue of pending work) requires a queue somewhere; doing it in Elm reinvents what the JSONL log already gives you for free in Rust.
The ergonomic win of TEA is real but local; the durability loss is global.
Counter-cost: Rust orchestration is more code
Section titled “Counter-cost: Rust orchestration is more code”Concrete costs in this codebase:
- The
DeviceandLlmClienttraits are sync. To do async work we needtauri::async_runtime::spawn+spawn_blockingfor the blocking ureq call. Tested workable; doesn’t require refactoring traits. - The api dispatch layer must declare a side-effect contract (a
DispatchSideEffectenum return) so the Tauri host can spawn it. Adds ~30-50 lines per feature. - If we want push-back-to-Elm,
tauri::ipc::Channel<T>is the sanctioned primitive (not an ad-hoc port). Most domain side-effects don’t need this — the next read picks up the change.
Net assessment: moderate one-time cost, compounding payoff per feature.
Examples
Section titled “Examples”| Side effect | Where | Why |
|---|---|---|
| Classify entry on capture, write supersede record | Rust | Mutates domain state (Shape). Must survive app close. |
| Schedule iOS local notifications on sign-in | Rust | Writes to OS notification queue (durable system state). |
| Daily anchor LLM continuation (carry-this) | Elm | Ephemeral session UI. Nothing persisted. If app closes, it just regenerates next time. |
pendingDeleteConfirm modal state | Elm | Pure UI. No persistence. |
pendingPracticeDeepLink flag | Elm | Cross-page UI coordination, no domain state. |
| Notification reschedule on entry mutation | Rust | Writes to OS state. Dispatched from Elm but orchestrated in Rust. |
Mechanism
Section titled “Mechanism”When you decide “Rust orchestrates,” the pattern is:
- The api dispatch arm for the user-initiated command returns a
DispatchSideEffectvariant in addition to its normalResponse. - The Tauri host (
src-tauri/src/lib.rs::core_dispatch) matches on the side effect and callstauri::async_runtime::spawn(async move { spawn_blocking(|| { ... }).await }). - The spawned task uses
&dyn Device(cloned viaArc<TauriDevice>) to make the LLM call and write the supersede record. - The Elm side just sees the new state on the next
EntriesListrefetch — nopendingXstate, no parallelCmd, no reply handler.
When you decide “Elm orchestrates,” the pattern is:
- Dispatch the user-initiated
Commandand the side-effectCommandin parallel from the sameupdatearm. - Track in-flight state in a
pendingX : Maybe ...model field. - Handle the reply in a dedicated
XReplyarm; clearpendingX; trigger any follow-up dispatches from there.
Both patterns are valid. Pick by the rule above, not by which is more familiar.
When to revisit
Section titled “When to revisit”- If we ever want a second frontend (web, CLI) hitting the same Worker → revisit any Elm-orchestrated AI features (none today; carry-this is the only one and it’s session-ephemeral).
- If we add features where Rust needs to call the LLM without a user tap (scheduled re-classification, server-pushed reminders) → confirms the rule; Rust must own those.
- If we hit three or more concurrent Elm
pendingXfields per page → likely a sign that some of them belong in Rust.
Prior art
Section titled “Prior art”- 2026-04-25 conversation that adopted this rule: classify-on-capture moved from Elm orchestration to Rust orchestration after consulting three architecture agents. First-principles agent recommended Rust on durability grounds; codebase-pragmatic agent recommended Elm on cost grounds; Tauri-patterns agent showed that Rust’s cost was lower than the codebase agent estimated (no async-trait refactor needed;
Shape::Unsuredoubles as the “needs classify” marker, no separate queue required).