posts, no fluff.
Releases, design choices, and the occasional rant. Short list — only when there's something worth your time.
- · engineering
A deterministic id is also an address in the trash
Typing into today's journal on the desktop did nothing, forever, with no error. The day had been deleted once, weeks earlier. Three correct decisions compose into that: delete is a move to the trash and the node keeps its id, a page root's id is derived from its slug so the slug still resolves to it, and Op::Create is idempotent, so reopening the day emitted a Create that found the node and left it exactly where it was. The page rendered, and ensure_live rejected every keystroke under it as NotInTree. Opening the same day once in the TUI fixed it by accident, because the TUI persists by rendering the .md and reconciling it, which emits Move(page, root()) with a fresh HLC. This is that accident done on purpose, the two revive paths that must not call each other, and why the only honest error the backend could return was one no client could act on.
read → - · engineering
OUTL-4 was a bucket, not a crash
OUTL-4 held two unrelated crashes: a Windows WebView2 failure at startup, and a TUI panic reading `index out of bounds: the len is 0 but the index is 13`. They shared one issue id because every frame in an outl panic arrives as `<unknown>`, so the default grouping had nothing to tell them apart, and the frames were unnamed because release CI uploaded the CLI and TUI debug symbols to a Sentry project called `outl-cli` while the binary reports as `tui@<version>` into `outl`. The fix is a 130-line module that fingerprints a panic by its already-redacted exception value, plus one word in a workflow. The cost is the mirror image: a panic message that embeds a changing index is now a fresh issue every time it fires.
read → - · engineering
Refreshing the outline is what deleted the paste
Paste a link copied out of an email into the block you are editing and nothing appeared. Leave the block and the link was gone from the .md too, with nothing on screen to say so. The paste had landed: paste_markdown_at wrote it through the op log and the reply carried the refreshed page. Both GUI clients hold the block being edited as a draft seeded once, so the outline refreshed around a textarea still holding the pre-paste string, and the next Esc committed that back over the paste. A caret paste is the one mutation whose result an editor has to be told rather than shown, so PasteOutcome now carries host_text and a codepoint host_caret: the merge can insert a separating space, and the caret is not the end of the text.
read → - · engineering
On X11 a copy is a promise to answer later
The TUI's status line read `yanked 3 block(s) → clipboard` and `xclip -o -selection clipboard` answered `Error: target STRING not available`. Nothing had failed: on X11 the selection owner serves the clipboard, arboard runs that owner in a thread tied to the Clipboard value, and copy_to_os_clipboard built a fresh one per yank and dropped it the instant set_text returned Ok. One process-wide handle fixes it, and then a second trap closes: a static is never dropped, and arboard hands the selection to a clipboard manager only in Drop, so the handover had to be called by hand at quit. That trades 'the text is gone immediately on a bare session' for 'the text is gone if the process is killed', and leaves PRIMARY, Wayland and a chord with no SSH fallback as named costs.
read → - · engineering
Reading the peer's log was itself a filesystem event
On Linux the outl desktop app opened and then reloaded forever. Nothing was writing to the workspace. The filesystem watcher decided whether to wake the frontend by looking only at the path an event named, never at what the event said happened, and `reload_workspace` replays a peer's ops by opening `ops-<actor>.jsonl` read-only. On Linux that open is an event. So the consumer of `peer-ops-changed` was a producer in the stream it consumed, and the loop could not end. The fix is ten lines of event-kind filter. What makes it interesting is why the path-only filter was correct on macOS and Windows for four months, why the new check is a denylist rather than an allowlist, and that `outl serve` still has both halves of the same shape.
read → - · engineering
4,291 tests, and none of them can see a pixel
A 1px red line ran along the top and bottom of every block in the outline, at rest, on a phone nobody was touching. The colour belongs to the swipe-to-delete panel, and the mechanism is nowhere near it: a row lands on a fractional y, a background is rasterised per element, and the panel and the opaque row covering it rounded to different device pixels. The tidier-looking repair, painting the colour on the clipping parent, measures worse and widens the same leak to the full row. That bug, a hover affordance covering 49.1pt of the line it described, and a calendar grid computing to 627pt inside a 402pt viewport were all invisible to 3,188 Rust tests, 1,103 frontend tests, a clean tsc and a clean clippy. This is what verifying them actually took.
read → - · engineering
Both ways outl dies on open happen before any of my code runs
OUTL-4 is a fatal panic on Windows: `Failed to setup app: runtime error: Could not find the webview runtime, make sure it is installed`. Tauri raises it from setup when `webview_version()` fails, so the guard has to sit in `run()` before the builder exists, and it has to copy Tauri's own predicate exactly: the gate is `is_ok()`, which means an `Ok("")` is a machine Tauri will happily start and the stricter, more obviously correct check is the regression. Android has the same bug one layer lower, in the static initializer of wry's generated `Rust` object, where the error arrives as an `ExceptionInInitializerError` out of `super.onCreate`. Both guards run upstream of the crash reporter, which means the failure class that motivated the reporter is now the one class it will never file.
read → - · engineering
Eight copies of a decision nobody had made
outl doctor printed 908 warnings on a 2,865-page workspace and 840 of them were the same sentence with a different path: a stale .md projection, 85% differing in nothing but the order of their key:: value lines. Pulling on that turned up a policy with no owner. outl renders properties alphabetically, that was documented nowhere, and the rule lived in eight copy-pasted sort_by calls across two crates whose comments gave three different reasons for it. Alphabetical is not a style preference: Op::SetProp carries no position slot, so declaration order is not in the log at all, and the nearest substitute is each op's HLC, the order the properties were last edited. The fix is one function, a lexical ratchet against a ninth copy, and a paragraph in docs/markdown-format.md. The second half aims the same lesson at the diagnostic, where the fix is an asymmetry rather than a threshold.
read → - · engineering
The release notes named an installer nobody had built
outl's release pipeline used to decide what to publish from needs.<job>.result, which made every build job a single point of failure for every other job's assets: one bundler failed and a release holding 25 finished artifacts published none of them. PR #414 replaced that with scripts/release_assets.py, a table of every asset a release carries and which of them are required, read against the draft's own asset list. The first thing the new gate did was publish several releases that said, in their own notes, that the Windows ARM64 installer was missing. It was missing because the desktop job asked dtolnay/rust-toolchain for the cross target instead of running rustup target add (#421), while the CLI job for the same triple on the same image was green. The crates.io publish was failing at the same time for an unrelated reason, a caret requirement on 0.13.0-beta.270 also matching the stable 0.13.0 that sorts above it (#422), and nothing in the release notes said so, because crates.io is not an asset on a draft.
read → - · engineering
reading the workspace minted a writer
One machine's workspace had twenty actors in it, sixteen of them holding fewer than 500 ops. An actor in outl is a writer: it owns an ops-<actor>.jsonl nothing else appends to. Most of those identities were created by commands that write nothing. Every workspace open went through resolve_write_actor, which answers a held device lock by minting a fresh ActorId and locking that, which is correct for a process about to append and wrong for outl workspace info on a thirty-second poll beside outl serve. Compaction drops inert ops and keeps identities, so the cost outlives the bytes. The three obvious repairs each fail for a different reason, and the most instructive is that merging a small actor back into the device actor rewrites the HLC tiebreak. The fix had to be at the open, which is why the workspaces already holding twenty actors keep all twenty.
read → - · engineering
The first crash report outl ever received was the crash reporter
OUTL-1, the first production event from outl's new crash reporting, says `failed printing to stderr: Broken pipe (os error 32)` and every frame in its stack is `<unknown>`. It is not a report of a crash in outl. Rust ignores SIGPIPE, so a write to a closed stderr returns EPIPE; tracing-subscriber reports that failed write with eprintln!, which panics; Sentry's panic integration then calls the previous hook, which is the default eprintln! hook, which panics again and aborts. The stored event is the abort. The frames were unnamed for a separate reason: the Linux job stripped at link time and offered sentry-cli a .dwp it does not accept, and the symbols that did upload went to a different Sentry project than the one the issue lives in. PR #406 is the writer, the hook, the kind check (ErrorKind::BrokenPipe, never errno 32, because Windows 32 is ERROR_SHARING_VIOLATION), and a Linux release job reordered so the symbols are read before the strip.
read → - · engineering
The slugifier only knew Latin, and it was choosing node ids
[[你好]] and [[世界]] opened the same page, and [[Rust 笔记]] opened [[Rust]]. Not a filename clash: a page's NodeId is sha256 of its slug, so every character the slugifier dropped was two names given one identity, on every device at once, merging their blocks and their tags into one node. The fix keeps any letter or digit that has no ASCII fold, normalises to NFC in both directions because the slug decides the id, and lowercases through uppercase because a case-insensitive filesystem compares names that way. Both properties are pinned over all 1,114,112 codepoints, which is how a capital sharp s turned out to break the fixed point. The repair reopens the name through the title match and cannot unmerge the content, because nothing in the op log records which of the collided names a block was written for.
read → - · product
a fence that draws itself
A mermaid fence in an outl note is now a drawn diagram, in the desktop app and on the phone, with no plugin to install and nothing to switch on. A node carrying the internal-link class opens the note it names, the same page a [[ref]] opens. A fence that does not parse keeps its source and gets one line saying where the parser stopped. The terminal app still shows the fence as text, and a diagram draws at the width the note gives it: there is no zoom.
read → - · engineering
A style-src nonce does not read your CSS
Mermaid fences now draw in outl's desktop and mobile apps. The hard part was not the renderer, it was that a nonce on style-src does not check what a declaration says, it checks which DOM API wrote it. The identical text paints through el.style.cssText and is ignored through setAttribute or innerHTML, because an attribute has nowhere to carry a nonce. So the sanitizer outl hands Pele does not filter style attributes, it moves every one of them onto the CSSOM, and the filtering of script, foreignObject and javascript: hrefs rides along on a parse that was already paid for. The same rule cost the feature its zoom: Pele writes the control bar with setAttribute and then reads it back, and a repair pass can fix what a library writes but not what it reads. Zoom shipped off.
read → - · security
Every outl binary since May shipped the environment of the job that built it
rustpython-vm 0.3 to 0.5 has a build script that writes every environment variable of the build into a generated file, and the interpreter compiles that file in so sysconfig.get_config_vars() can return it. outl's code-block runtime pulled it in on 25 May 2026, so every release from v0.5.1-beta.30 to v0.13.0-beta.256, 28 published prereleases, carried the CI job's environment: GitHub Actions metadata, the Sentry DSN and Blacksmith runner tokens. Bitbison found it and told us privately. What leaked, what didn't, the fix in PR #400, and why the release gate now reads every artifact for runner variable names.
read → - · product
a line from anywhere, with the app closed
outl now takes a line without being open. Type `outl capture "ring the dentist"` in any terminal, share text or a link from any app on Android, tap a home screen widget, or pull down a quick settings tile. The line waits as one file in the workspace and becomes a block in the journal for the day you captured it, the next time a client opens. Nothing syncs until then, and a capture is always one line.
read → - · engineering
The share extension had to implement ULID
Capturing a line without opening the app shipped on Android, the desktop and the TUI in one PR, against the same inbox iOS already had. The Android share target writes through outl_actions::capture over JNI and its Kotlin holds a folder path and three strings, because an Android share target is an activity in the app's own process with the Rust library already loaded. An iOS share extension is a separate process under a memory ceiling that library would not fit, so it carries a second ULID encoder, a second copy of the file format and a second copy of the one rule that turns a share into a line. Parity between those two is not a shared library. It is a ten-case JSON fixture that two test suites in two languages both read, which is the weakest form of code reuse and the only one the process boundary leaves available.
read → - · workflow
Help us bring the better interface to outl
On October 4th someone in our Discord said they wanted the usability of Reflect Open Desktop with the engine of outl, and called it a dream. The honest reading of that is that our interface was the thing standing between them and the app. Four days later another member suggested using Reflect as the reference instead of designing from scratch, and six days after that the first person came back with three specific reasons they could not use the desktop app: a line that changed height the moment it took focus, a modal editor that makes sense in a terminal and not on a desktop, and two clicks to put the cursor where they wanted. This is what we changed, what we took from the team-reflect/reflect-open frontend (MIT, and generous), what we deliberately kept, and the one complaint still sitting open because fixing it properly means giving the desktop a real text cursor.
read → - · engineering
A peer named itself and the log believed it
Every sync line named a peer by ten hex characters, so finding out which laptop pushed those ops meant opening peers.json. Pairing already stores a human name for each device, and printing it is a four-line change. It is also a promotion: that name arrives unvalidated from a peer's pairing payload and from the gossip membership merge, bounded only by the 64 KiB wire cap, and peer list could hold it safely because a user asks for one row while outl serve emits it forever, unattended, into a stream grep parses. A newline in an alias forged a WARN record attributed to another device. An escape sequence cleared a tailing operator's screen. A 64 KiB alias printed 65,549 characters on every pass. Why the filter lives at ingress rather than at the log macro, why it is a blacklist, why the hex is parsed rather than sliced, and why the exemption list had to become a test that counts.
read → - · engineering
The op log learned nothing, so the grammar learned everything
A pipe table pasted into outl arrived as a wall of pipes plus one unrecognized_block_marker warning per row. Modelling it as a block whose text carries the rows, with no Op::Table and no new wire shape, kept the op log, the sidecar and every client's DTO unchanged. The price landed everywhere else: a construct recognised by shape rather than by a marker swallowed three lines that belonged to other constructs, one of which froze a page through invariant 8's false-positive direction; consolidating a pre-329 sidecar orphans 100% of a table-heavy page and ran straight into the bulk-delete guard, where subtracting the exemption from both sides of the ratio refused an ordinary 20-of-85 delete as 80%; and padding every row to the widest one is quadratic, so a 17 KB block became 1.6M cell vectors and a 9 MB string the TUI rebuilt on every repaint. All three parser bugs were found by a generator, not by anyone enumerating shapes.
read → - · 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 →