posts, no fluff.
Releases, design choices, and the occasional rant. Short list — only when there's something worth your time.
- · engineering
A caret cannot be a glyph you insert
The Insert-mode caret in outl's TUI was a ▏ spliced between two characters of the block's text. A terminal is a cell grid and a glyph costs a cell, so everything right of the cursor sat one column over and walked back and forth by one as the cursor moved, and a block near the pane edge wrapped a character earlier while it was being edited than it did the moment Esc was pressed. The fix is to make the caret a property of a character that is already there, which is cheap, and the consequences are not: a foreground colour paints nothing on a space, so the underline became load-bearing; and the moment the caret was the cell, view::wrap's rule that a space is a separator it may discard silently ate the cursor at columns 9 and 19 of a 43-cell block in a 16-cell pane. The blanket fix could not ship, because the spaces inside bold, code and page refs really are separators.
read → - · engineering
Nothing to lose and nothing to go on are the same empty string
A journal day written on a laptop showed on the phone as one empty bullet, for good, while every other day synced. The op log and the tree were both correct; only the .md the view reads was stale. The gate that refused to refresh it asks whether a sidecar can say what the log held, and a sidecar written before 0.11 cannot, so it is refused. A page nobody has written in yet has that identical shape: one block, text: "", nothing at risk. Absence and ignorance were the same bytes on disk. The fix is a two-clause predicate, but the gate was only half of it: the same conflation sat one layer below in three counters where a bare `-` normalises to the empty string, and lines_removed > 0 routes a page to the sweep's withheld arm before it can reach the write arm. Measured on a 2,874-page workspace: one page frozen this way and zero genuine pre-0.11 sidecars.
read → - · engineering
Preserving history is not giving it back
outl's invariant 6 says delete is Move(node, TRASH_ROOT), not physical removal, because it preserves history. On a real workspace that meant outl doctor could count 683 blocks across 393 deletions and print their text, while nothing else in the binary could name one of them. Building outl trash list and outl trash restore is what showed what the invariant had actually bought: the bytes are still on disk, which is much weaker than getting them back. Where a block came from cannot be read off Move.old_parent, because 65,141 of the 65,703 stored Moves say root regardless of the truth, so the origin is folded from Create.parent and Move.new_parent. That fold is only correct if it replays each placement against the ancestors the target had at that instant, because invariant 4 keeps cycle-refused Moves in the log, and the first version of this checked against today's tree and would have restored a block under its own former child.
read → - · engineering
Adding a negative narrowed every positive
outl's query fence could only say what a block is. Adding not-tag, not-prop, not-status, not-kind, not-since and not-text meant going back through every filter that already shipped and asking which of them were lenient in a way a complement could no longer afford. tag: matched by substring, so tag: ops also hit #opsec, which is one extra row you can see and ignore until you negate it and the row is simply gone. This is why the fix is one Filter::Not wrapper answered by a single ! rather than six hand-written negative variants, why prop: had to ship in the same change, why not-since: deliberately reads oddly, why every permissive-but-empty filter value became a parse error, and what the narrowing broke for queries that used to work.
read → - · engineering
The warning was a countdown, not a wall
A page whose markdown opened with a YAML frontmatter fence came back with a first bullet called `- ---`. The guard that exists to stop exactly that write had already refused it once, correctly, and `outl doctor` had been warning about the page the whole time. Then one block append put those four lines into the op log as bullets, the guard's answer flipped from no to yes, and the next projection wrote the outline over the user's metadata. Afterwards doctor went quiet, because the file had become valid outl dialect. This is why a guard that reads derived state can be satisfied by the very operation it exists to prevent, why the fence now travels the op log as a page property instead of being merely preserved by the parser, why 'both write gates run all three checks' could not stay a sentence in a module doc, and why config.toml had the same shape one bad character wide.
read → - · engineering
Nested pages shipped, and my workspace showed one
#os/linux already parsed as a single tag and already resolved to a page, but nothing treated the slash as a hierarchy. Deriving it from the page title instead of adding a field was the cheapest correct design: no new op, no on-disk field, no migration, because a slug is one path component and slugify folds the slash to a dash, so the / survives only in title::. It passed every test and shipped nothing. On my own 2,575-page workspace, 14 pages had a title::, so the nested-pages section rendered empty for every namespace while the other half of the same feature, which reads the mention rather than the title, credited 3,221 blocks to a single parent. This is where the lost name was still written down, why the repair is a join rather than a guess, the three cases it refuses to decide, and the file name whose slug collapses onto the namespace page itself.
read → - · engineering
A fence is not always yours
A ```lua code block in outl had a shell, arbitrary file read and write, and getenv. It took 7ms to prove. The interpreter was built with mlua's default library set, where safe means memory-safe rather than sandboxed: it excludes debug and ffi and includes os, io and package. That is a defect rather than a feature because a fence body is not always written by the person running it, arriving over sync from a paired device, through an import of someone else's graph, or from an LLM agent over MCP. This is the allowlist that closed it and why a denylist could not, the same hole found in the Lisp runtime during review, and then the harder half: a deadline the runtime contract demanded and no interpreter honoured, why stopping a Lua block takes three layers rather than one, why the timeout signal is an atomic the script cannot reach instead of a string it could forge, and what a bounded leak of four worker threads buys over an editor that freezes.
read → - · engineering
Two devices, one Wi-Fi, no internet
A laptop and a phone two metres apart had no local path between them. A peer's address was learnable exactly one way, through a discovery service that runs on the internet, so a new DHCP lease killed the stored address, every dial stalled about ten seconds on a corpse, and the fallback was the relay that is slow or blocked on precisely the networks where this gets reported. mDNS fixes it, and the interesting part is everything that had to be true for it to work on three platforms at once: an attach that happens after the bind because a failed mDNS start would otherwise take the whole endpoint down, a multicast lock on Android because the Wi-Fi driver discards the packets below the socket API, a soft-deprecated Apple API because the modern one hides exactly the thing iroh needs, a 63-byte DNS label that would have broken discovery in one direction only and in silence, and a test pinning all of it that spent its first version being a tautology.
read → - · engineering
The page I deleted came back
I deleted a page on my laptop. It came back on my phone, under root, and survived a restart. The op log was intact, every operation was there, and the tree built from it was wrong. The cause was a pair of functions that were supposed to be inverses and weren't: do_op(Create) skips a node that already exists, undo_op(Create) removed it unconditionally, so undoing a Create that created nothing deleted a node somebody else's Create had made. That shape isn't exotic: page roots are addressed by a deterministic hash precisely so two devices landing on the same page converge, which makes a duplicate Create the most common duplicate in production. The property suite never caught it because it refused to generate the shape, with a comment explaining that duplicate Creates were malformed input. Both halves of that comment were wrong. This is the measurement that located the defect, why it is latent on one device and reachable exactly when a workspace becomes multi-device, the fix that added no field to the wire, and the second bug in the same release: a log scan that has now been removed three times because an index and a scan return identical bytes.
read → - · engineering
What shipped since 0.12, and what it cost
A deep engineering update on outl: the elected endpoint lease that lets a headless daemon hold P2P sync without starving the GUI, a pairing handshake where anyone who knew your address could take the slot, page history read straight out of an append-only op log that turned out to be lying about 65,141 of its own Move operations, a 24.7 second boot caused by 5,656 fsyncs, client parity turned into an exhaustive match, and the one that cost most: 233 pages holding 1,426 lines of my own notes that existed in no operation, deleted by a repair command that printed 708 fixed while it ran. Every decision here comes with the alternatives I rejected and why.
read → - · workflow
The journal is the only page I open
A friend asked how I structure my notes in outl. The honest answer is that I don't. Everything from today goes into today's journal and the only work I do is drop links as I type. The pages assemble themselves out of backlinks, a task for my future self is just a date written into the block where the task was born, and when that day arrives the journal opens with the whole thing waiting, context included. This is the actual workflow: what a real 1on1 note looks like on disk, why I never created a folder in six years of daily notes, how a query fence replaced the task manager I never installed, and why the agents I work with write into the same journal I do.
read → - · engineering
A keystroke should never wait for the disk
Adding backlinks made outl slow to open and edit on a big workspace: 2800 pages, 211k ops, and every Esc stuttered. The fix wasn't one trick, it was a rule. The op log is the truth, so everything else (the .md, the sidecar, the backlink index, the plugin hooks) is a projection that can happen in the background. How I found the real cost, the numbers before and after, and why the user should never feel any of the machinery.
read → - · engineering
The snapshot I couldn't reorder
I imported four years of notes out of another app in one shot, sixty-odd thousand blocks and a couple hundred thousand operations, and the volume dragged a pile of latent move-op and p2p-sync bugs into daylight in a single week. Boot got slow, so I did the obvious thing: cache the materialized tree and skip replaying the op log, and when a device pairs, ship it the tree the other device already built instead of a 200k-op log. Adopt the peer's snapshot, apply whatever it hasn't seen on top, done. It forked the tree. Two devices, the same set of operations, two different outlines, both internally consistent, both wrong about the other. The reason is the exact property that makes the move-op tree CRDT highly available: it converges by reordering the op log, undoing and redoing operations so a late one lands in its correct causal spot. A snapshot is materialized state with the log thrown away, so it's the one thing you can't reorder against, and a cycle-forming Move can resolve the opposite way on top of it. This is the one-line HLC guard that catches it, why the snapshot cutoff has to be a per-actor vector clock and not a global clock, the identical bug showing up again on the sync wire, and the storage door I'd left open that made every reload replay the whole log forever.
read → - · engineering
The status dot that broke sync
I wanted a green dot that said peer-to-peer was alive. The obvious way to know whether a peer answers is to dial it, so the status check stood up a small endpoint, dialed each peer, measured, tore it down. Thirty lines, read-only, can't hurt anything. It broke sync. Not the dot's report, sync itself: the relay keeps exactly one route per node id, my probe wore the device's identity, and registering stole the route from the endpoint doing the actual work. Inbound sync got CONNECTION_REFUSED from an endpoint that didn't speak the sync protocol. This is what iroh's source said, the nuance that turned 'never share the identity' into a better rule, and why the fix was to stop asking a question the transport had already answered.
read → - · engineering
Your notes never touch our relay
outl syncs your notes device to device with no server in the middle. Almost. There's exactly one server-shaped thing in the path, a relay, and it's the part that makes people nervous: 'wait, my notes go through a machine you run?' The honest answer is that the relay forwards bytes it can't read, and the reason it can't isn't a promise in a policy doc, it's the transport. This walks a single sync from boot to merge: the ed25519 identity that never leaves the device, the one QUIC endpoint, the vector-clock handshake that streams only the ops the other side is missing, and the exact moment the relay touches the connection. At every step the content is encrypted end to end with keys the relay never has. The step-by-step is the proof: there is no point in the pipeline where the relay could read a note even if it wanted to.
read → - · engineering
One node, two writers: how my split-brain fix doubled every title
Last month I gave every page root a deterministic id derived from its slug, so two devices that create the same day's journal land on the same node and merge instead of splitting. It worked. It also quietly doubled the title of every journal opened on two devices: '2026-06-25' became '2026-06-252026-06-25'. The Create op converged to one node exactly as designed, but each device also wrote the slug into that node's text CRDT, and two concurrent inserts at position zero don't overwrite, they concatenate. This is the story of two convergence mechanisms that are each correct and compose into a wrong answer, the two-replica test that proved it in eight lines, and why the fix was to stop using a sequence CRDT for a value that only ever has one writer's worth of meaning.
read → - · engineering
Two 'today' pages: debugging a bug I couldn't inspect
The journal on my phone flickered between two versions of the same day. I fixed the relay. Still flickering. I fixed a reload race in the frontend. Still flickering. I went to attach a debugger and the webview wouldn't let me. So I rendered the debug output onto the phone screen itself, and the numbers said the bug was in a layer I hadn't looked at once. This is the debugging story, the two wrong turns, the improvised instrument, and the split-brain in the op log it finally exposed.
read → - · engineering
The op log is the source of truth, so stop keeping everything resident
Importing 80,000 blocks killed outl on the iPhone: iOS jetsam shot the app on open because every block kept a live Yrs CRDT document resident for the whole session. The fix was to treat block text as what it already is, a projection of the op log, and rebuild it on demand. Half a gig of RAM down to a few megabytes, why the rebuild is provably exact, and the replay peak nobody thinks about.
read → - · privacy
Private by default: outl now syncs peer-to-peer, end-to-end
outl dropped iCloud for iroh. Your notes now sync device-to-device over end-to-end encrypted QUIC — no server, no account, no company in the middle. And you give up nothing: same TUI, desktop, and iOS, same clean markdown, same offline-first UX. Privacy that isn't a downgrade.
read → - · beta
outl beta: three clients, one tree
TUI, native desktop, and an iOS app on TestFlight — all reading the same workspace folder. Apple-first, same Rust core, zero cloud. Inside: how outl makes file sync converge using a tree-CRDT and an HLC-ordered op log.
read → - · release
Announcing outl 0.1.0
outl 0.1.0 ships today: a local-first markdown outliner, vim-style TUI, executable code blocks in five languages, and the tree-CRDT algorithm that makes offline sync provably correct.
read →