Architecture
Architecture
High-level structure and the major design decisions behind it.
Overview
outl is split into six crates today:
flowchart TB
bins["<b>Clients</b><br/>outl-cli · outl-tui · outl-mobile (Tauri 2 + Solid, iOS) · outl-desktop (Tauri 2)"]
actions["<b>outl-actions</b><br/>UI-agnostic workspace operations + SyncEngine<br/>(edit, indent, toggle TODO, render md, reload workspace, scan orphans)"]
md["<b>outl-md</b><br/>parse / render / sidecar / 3-level matching / diff / inline tokens / outline_ops"]
core["<b>outl-core</b><br/>Tree CRDT, op log, storage trait, domain models<br/>(Workspace, Page, Journal, Block, Property, Tag)"]
bins --> actions --> md --> core
outl-core knows nothing about files, markdown, or networks.
outl-md knows about markdown and sidecars but nothing about a workspace mutation pipeline.
outl-actions is the only crate where workspace-changing logic lives; every client routes through it so TUI and mobile cannot diverge on what “indent” or “toggle TODO” means.
outl-cli, outl-tui, and outl-mobile are I/O shells (key handling, Tauri commands, frontend wiring).
This split is what makes cross-device sync possible — both clients operate on the same op log, through the same operations, with the same matching algorithm.
Major design decisions
These were locked in before code shipped. Don’t unilaterally pivot — ask first.
1. Markdown is source of truth
The user’s words live in .md files.
The op log is derived from user-facing operations.
If we lose the op log, we can reconstruct the current state from .md + sidecar.
Trade-off: the file system is the canonical interface.
We accept the overhead of writing .md after every op.
The alternative (DB-only) is what Logseq moved to and what broke their community.
2. Op log on disk: JSONL per actor
Each device appends to a single ops/ops-<actor>.jsonl file inside the workspace. iCloud Drive (and any other file-level sync transport) syncs each actor’s jsonl independently, so two devices never collide at the filesystem layer — the CRDT merges the per-actor streams after reading them.
This is the only persistent backend; 0.5.0 removed the older SQLite backend after it kept producing divergent op logs between clients on the same workspace.
Trade-off: JSONL is easy to inspect, easy to ship across any file-level transport, and trivial to reason about.
We give up SQL queries (we don’t need them — replay + in-memory index is fast enough) and PRAGMA-level integrity guarantees (replaced by partial-write tolerance: a half-written tail line is just skipped).
Storage is still a trait — ChronDbStorage (issue #1) can land later without touching outl-core logic.
3. Storage is a trait, not a concrete struct
trait Storage: Send + Sync {
fn append_op(&mut self, op: &LogOp) -> Result<()>;
fn ops_since(&self, ts: HLC) -> Result<Vec<LogOp>>;
// ...
}
outl-core consumes dyn Storage.
JsonlStorage (in storage/jsonl.rs) is the only persistent impl; MemoryStorage (in storage/memory.rs) is the test double.
Why: swapping backends is a single-file change.
Test doubles are trivial.
The sync code can mock storage.
Future ChronDB integration is a PR adding storage/chrondb.rs.
4. Sidecar JSON instead of inline IDs
Logseq writes id:: 01HXY... lines into the .md.
We refused that.
We write the IDs into a sidecar file foo.outl (JSON next to foo.md).
The .md stays clean.
VS Code shows what the user wrote.
GitHub renders it beautifully.
Obsidian doesn’t get confused.
The sidecar lives in the same directory so the sync transport ships it alongside the .md — the dotfile form was abandoned because iCloud Documents (one file-transport option) skips dotted paths when syncing across devices.
Trade-off: external edits require matching to reconstruct IDs.
That’s a real algorithm (outl-md/src/matching.rs) with three confidence levels and an orphan log.
It’s more work than inline IDs, but the user experience is dramatically better.
5. Tree CRDT specifically, not a generic CRDT
We could use a generic op-based CRDT (Automerge). We chose to implement Kleppmann 2022 directly because:
- The paper is short and the algorithm fits in ~300 lines of Rust.
- Domain-specific = better error messages, simpler API.
- We control the on-disk format (op log schema).
- No transitive deps on a heavyweight CRDT framework.
Trade-off: we’re on the hook for correctness. That’s why the test battery is huge and the coverage target on the four critical functions is 100%.
6. Yrs for block text content
Tree CRDT moves blocks. Yrs (Yjs in Rust) handles concurrent edits to the text inside a block. Combining them gives us:
- Block-level structure: tree CRDT.
- Character-level text: Yrs.
Yrs is mature, battle-tested in Yjs-based apps. Reusing it lets us focus on the part nobody else has solved (the tree).
7. ULID for IDs
128 bits, lexicographically sortable, monotonic per millisecond, no central authority. Better than UUIDv4 (random, sorting nightmare) and better than UUIDv7 (good but ULID is established and the spec is finalized).
8. uhlc for timestamps
Hybrid Logical Clock = wall clock + logical counter + actor. Comparing two HLCs gives a total order without coordination, and the wall-clock component keeps timestamps human-meaningful for debugging.
9. Journal is a first-class concept
Daily notes (2026-05-24.md) live in <workspace>/journals/, separate from <workspace>/pages/.
Navigation keys [, ], t are dedicated to journals.
When you open outl-tui, you land on today’s journal.
This isn’t an afterthought — it’s the primary input path for the user’s day-to-day notes. Anything that makes journal access slow or hidden is wrong.
10. MIT license
One license, no dual-license boilerplate to maintain, no patent grant language to argue about. Permissive enough for any downstream — including plugin authors who want to relicense their own crates differently.
11. iroh for P2P
QUIC, hole punching, no central servers, no STUN/TURN dependency in the common case, in Rust, BSD-licensed. The alternatives are heavier (libp2p) or non-Rust.
11.5. Shared library crates
A handful of pure-data, dep-light crates exist solely to keep the clients (outl-tui, outl-desktop, future Tauri shells) honest about agreeing on shape:
outl-actions— UI-agnostic workspace operations (block ops, journal render, sync engine). Anything two clients would otherwise re-implement lives here. Seecrates/outl-actions/CLAUDE.md.outl-theme— palette (named hex colors) for seven presets (outl,dracula,nord, …). TUI converts hex →ratatui::Color; desktop converts hex → CSS custom properties. One source for the look of every renderer.outl-shortcuts—Actionenum +Chord+Bindingcatalog. TUI’s key handler and desktop’slib/shortcuts.tsboth consume the samedefault_bindings()soj/k/i/o/dd/c/qq/⌘P/…mean the same thing in both clients.outl-config— TOML at~/.config/outl/config.toml(XDG-style on every OS, including macOS). Reads global theme preset, vim-mode toggle, last-opened workspace, font size. The desktop’s Settings modal writes here; the TUI reads it on startup. Per-workspace.outl/config.toml(workspace identity) overrides on top.outl-frontend-shared(@outl/shared) — TS/Solid library used byoutl-mobileandoutl-desktop. Pure helpers, DTO types, theMarkdownInlinerenderer,invoke()wrappers — anything the two frontends would duplicate.
The rule: a new helper or constant only lands in a client crate when it’s genuinely client-specific. Otherwise it belongs in one of the shared crates above. The root CLAUDE.md “Reuse-first” section is the policy.
12. Tauri for desktop
Rust core reuse, smaller binary than Electron, native webview. Slightly worse UX consistency than fully-native, but acceptable for an outliner where the bulk of the UX is text and lists.
13. Tauri 2 for mobile (replaces the earlier uniffi plan)
Originally planned around uniffi with SwiftUI / Compose native UIs.
The plan changed when the mobile client landed: Tauri 2 ships a single Rust binary that hosts a WKWebView running a SolidJS + Tailwind frontend, with native bits (NSMetadataQuery, accessory toolbar, ref suggester) written in Objective-C alongside the Tauri shell in gen/apple/Sources/outl-mobile/main.mm.
Trade-offs:
- Win: the entire workspace operation surface is shared with the TUI via
outl-actions. Zero duplicated business logic. Adding a feature on one client means adding it on the other for free. - Win: Solid + Tailwind iterates fast and we control the rendering pipeline end-to-end.
- Loss vs. uniffi: the UI is webview-hosted, not native widgets. Acceptable for an outliner where the bulk of the UX is text and bullets; would be a worse trade for a graphics-heavy app.
Android lands on the same Tauri 2 surface when it’s prioritised; only the main.mm-equivalent layer (iCloud watcher) needs an Android counterpart.
Data flow
User types in TUI (write path)
flowchart TB
keystroke[User keystroke]
input[TUI input handler]
action["Workspace::apply_user_action() → Op"]
applyop[outl-core::apply_op]
storage["Storage::append_op<br/>(jsonl)"]
tree[Update materialized tree]
notify["Workspace notifies<br/>'page X changed'"]
render["outl-md::render(page_ast)<br/>→ new .md text<br/>outl-md::sidecar_write"]
fs["File system:<br/>pages/X.md + .X.outl updated"]
p2p["broadcast Op<br/>to peers via iroh"]
keystroke --> input --> action --> applyop
applyop --> storage
applyop --> tree
storage --> notify
tree --> notify
notify --> render --> fs --> p2p
User edits .md in VS Code (read-from-disk path)
flowchart TB
save[File save in VS Code]
notify["notify (filesystem watcher in outl serve)<br/><i>200ms debounce</i>"]
parse["outl-md::parse(.md) → new AST (no IDs)<br/>outl-md::sidecar_read → old AST (with IDs)"]
match["outl-md::matching::match_blocks(old, new)<br/><i>3-level confidence</i>"]
diff["outl-md::diff::diff_to_ops(...) → Vec<Op>"]
apply["For each Op: outl-core::apply_op<br/>(commits via transaction)"]
write["outl-md::sidecar_write (refresh)"]
p2p["broadcast Ops to peers"]
save --> notify --> parse --> match --> diff --> apply --> write --> p2p
Concurrency model
Two layers:
Within one device. outl-tui and outl-mobile each hold a single Workspace behind a Mutex (or its parking_lot equivalent).
All mutations route through outl_actions::* functions that take &mut Workspace and append to the actor’s ops-<actor>.jsonl via Workspace::apply.
The TUI’s optional file watcher and the mobile’s Tauri command surface are the two writers; they serialise on the workspace lock.
Across devices. Each device only ever writes to its own ops-<actor>.jsonl.
The transport (iroh by default, file/iCloud opt-in) is responsible for shipping each actor’s file to every other device.
outl_actions::SyncEngine is the shared piece that both the TUI poller and the mobile NSMetadataQuery watcher call when a peer file changes:
snapshot_peers()lists everyops-*.jsonlexcept this device’s so a client never reacts to its own writes (the destructive save-reload-race loop is closed at this filter).reload_workspace()reopens the workspace from disk, merging all per-actor jsonls by HLC and replaying through the move-op algorithm.reproject_page(workspace, page_id)re-emits the focused page’s.md+ sidecar from the new tree state.scan_for_orphans()finds.mdfiles whose sidecar is missing or whoselast_synced_hashno longer matches — fresh imports (Roam/Logseq dump, peer-shipped projection without sidecar) or external edits in vim. Both paths feedoutl_md::reconcile::reconcile_md.
The TUI poller checks peer snapshots every ~2s on a worker thread.
Mobile registers NSMetadataQuery on the iCloud ubiquity container.
Both call into the same SyncEngine.
Insert mode in the TUI defers the reload via a pending_reload flag drained on commit — see crates/outl-tui/CLAUDE.md for the policy.
Error handling philosophy
thiserrorfor typed errors in libs.anyhowonly at the binary boundary (CLI prints errors with context).- No
unwrap()in non-test code. - A corrupt sidecar is recoverable:
outl doctorregenerates it from the op log. Don’t crash, log + fall back. - A corrupt op log is catastrophic but we surface it loudly via
outl doctorso the user can intervene before further writes.
Future considerations (documented, not built)
- End-to-end encryption of sync traffic — iroh supports it, we’ll enable.
- Per-workspace identity — each device gets a stable ActorId stored in the workspace’s
.outl/config.toml. (Global preferences — theme, vim mode, font size, last workspace — live separately in~/.config/outl/config.tomlvia theoutl-configcrate.) - Read-only export — Hugo, static HTML, PDF.
- Plugin system — a JavaScript runtime (Boa) ships today (consume op stream, op hooks, slash commands); deeper hooks (new query types, richer render hooks) are still planned.