Under the hood
The technology behind Enliner
Enliner is a native macOS app written in Swift 6. The model layer is a standalone package that imports no UI framework. Your files stay canonical on disk as Markdown, a SQLite index serves the runtime, and every edit writes both. Here is the detailed version, subsystem by subsystem, with the numbers and the dependencies.
01 · Architecture
Three layers, one direction
The dependency arrow only ever points down: the SwiftUI app depends on the editor, the editor depends on the model, the model depends on nothing but the disk.
The model is a standalone package OutlineModel
Outliner (SwiftUI) → OutlineEditor → OutlineModel → disk. The model layer imports no AppKit and no SwiftUI, so the whole editing engine is testable with no UI running. That split is the reason there are thousands of headless tests.
Files at rest, SQLite at runtime invariant
Our Markdown files are the source of truth on disk. A SQLite index is the source of truth while the app runs. Every edit writes both, and the index is fully rebuildable from the files. The index and other runtime state live outside the vault, in a device-local folder that never syncs.
State lives behind an actor Swift 6
Model state sits behind an actor Vault under strict concurrency. The editor sends a Command and gets back an immutable Diff; reads happen against an immutable Snapshot. Indexing and file I/O stay off the main thread.
Stable identity for every block ULID
Each block carries a 26-character Crockford base32 ULID, minted once and never rewritten. The generator is about forty hand-rolled lines with no dependency. Sibling order is a fractional key, so inserting between two blocks never renumbers the rest.
One Swift core, more platforms coming iPad · iPhone
The model and editor are plain Swift 6 with a single small platform seam for text input (NSTextInputClient on Mac, UITextInput on iOS). Most of the app carries over to iPad and iPhone as a theme swap on the same core, which is why those apps are what we build next.
02 · The editor
A caret engine, built from scratch
The editing surface does not use NSTextView. It is a custom Core Text engine, written to fix one specific defect: keeping the caret exactly where it belongs when the layout underneath it changes. Bike and TaskPaper left NSTextView for the same reason.
The caret is anchored to the model Core Text
The caret is a (BlockID, offset) pair, never a screen point. Relayout, reflow, and window resizing move pixels around it without moving it. Each block keeps a CTFramesetter cache keyed by its text, width, and theme generation.
Layout follows the viewport windowing
There is no character cap on a block. Layout is windowed to the viewport, so a block with hundreds of thousands of characters shapes only what we can see. Mapping a caret outside the window costs one line of shaping at most, never the whole block.
A 100,000-character block, edited live ~21 ms
A single giant block used to freeze for about 1,116 ms per keystroke. Incremental inline derivation, incremental layout repair, and a String.Index splice took that to roughly 21 ms median in a Release build. The splice itself is about 0.56 ms at 100k characters.
Japanese input is a build gate IME
CJK composition is a first-class case. Marked text renders while the model stays unmutated until the composition commits. A scripted NSTextInputClient corpus, IMEHarness, is a named gate that runs before the engine ships. The author works in Japanese daily.
The surface is pure SwiftUI now Canvas
The editor draws into a SwiftUI Canvas from a serializable list of draw commands replayed into a GraphicsContext. Core Text stays the typography layer. The one platform seam is a minimal TextInputProxyView for IME (NSTextInputClient on Mac, UITextInput on iOS).
Caret motion matches the platform geometry
Trailing newlines, blank lines, and up/down motion are checked against NSLayoutManager as the oracle. Both the linear and windowed geometry paths must agree, enforced by a differential caret-motion test matrix.
03 · Model & Markdown
One value type, and honest Markdown
Everything is a Block value type
A block holds an id, a parent, a fractional order, a RowType, its inline text, properties, and a collapsed flag. RowType covers plain rows, headings, bullets, numbered items, tasks, quotes, tables, and fenced code.
Mutation has one door Command / Diff
Nothing mutates a block in place. The editor holds a Snapshot, sends a Command such as splitBlock or indent, and applies the returned Diff. Undo coalesces contiguous edits to one block, and a full IME composition is a single undo group.
The source is the truth source-canonical
Inline styling is the Bike model: a plain string with tagged mark ranges, and no markup characters inside the display string. The stored text is the verbatim Markdown source, so what we wrote round-trips byte for byte.
The Markdown reader is hand-rolled MarkdownIO
A line-oriented reader and writer, built in-house for byte stability rather than a general parser. A property test generates random trees, with every row type, mark, soft break, CJK, emoji, and escape, and asserts that write, read, write is a fixed point.
Real constructs, not a subset == $ [!note]
Highlight with ==text==, callouts as a RowType.callout, reference links normalized to inline on paste, and math with $…$ rendered by SwiftMath (Core Text, so no WebKit in the editor). Pipe tables and fenced code are the two multi-line block types.
Syntax highlighting via tree-sitter SwiftTreeSitter
Code blocks tokenize through tree-sitter behind a SyntaxTokenizer protocol, with grammars pinned exactly (Swift, Python, JavaScript, JSON, Rust, Go, Bash). This one is built and gated on a later code-block phase, not yet switched on.
04 · On-device AI
The model runs on our machine
No cloud inference, no embedding API, no telemetry, no account. The whole pipeline works on a plane. We can watch the network stay silent while it runs.
A local LLM, loaded on demand MLX
Inference runs through Apple's MLX on Apple Silicon. The default model is Qwen3-4B at 4-bit, about 3 GB of RAM, with an 8B option on larger machines. ⌥⌘A loads and unloads it, RAM is freed when off, and it idles out after fifteen minutes.
Two transcription engines SpeechAnalyzer · WhisperKit
On macOS 26 the default is Apple's on-device speech recognition (SpeechAnalyzer), with no model download and faster-than-realtime output. WhisperKit (4-bit Core ML) is the alternative and the fallback, with explicit language detection for Japanese.
Speakers, the honest version Me / Them
Calls capture two tracks, so the split into us and the far end is structural rather than guessed: the mic is us, the far end is them. Full per-speaker diarization is designed as a drop-in seam and is not built yet. v1 ships the two-track ladder.
Semantic search with no vector database Qwen3-Embedding
Blocks embed to 1024 dimensions with a 0.6B on-device model. The vectors are fp16 BLOBs in a sibling SQLite file, searched by brute-force cosine through Accelerate's cblas_sgemv. That is 100% recall and a scan of a few milliseconds at 100k blocks, with no index to drift.
Ask the whole vault synthesis
A vault question expands into a handful of queries, gathers the best passage per page above a relevance floor, and runs a map-reduce over the results. The answer is ephemeral until we choose to turn it into a page.
Every AI edit is labelled ai::
Any block an AI wrote carries an ai:: property with its source and a UTC timestamp, and the editor shows a small gutter mark. We can always tell our own words from the machine's.
05 · Search & index
SQLite, tuned for two languages
GRDB migrations GRDB 7
The runtime index is plain SQLite through GRDB, defined as versioned migrations: pages, blocks, properties, references, assets, and full-text tables. The whole thing rebuilds from the Markdown files when it needs to.
Trigram full-text search FTS5
Full-text search uses the FTS5 trigram tokenizer instead of the usual word tokenizer, so substring matching works across CJK for a bilingual English and Japanese vault.
Backlinks in about a millisecond 1.17 ms
Backlinks are a join on the references table. The budget is 100 ms; the measured time is 1.17 ms across 541 backlinks on the reference machine.
Unicode-correct names normalized
Page-name and wikilink resolution use a full-Unicode case fold in a dedicated column, because SQLite's built-in case-insensitivity only covers ASCII. Later migrations made link resolution folder-agnostic.
06 · Sync, sharing & the relay
Collaboration we can host ourselves
Sync is opt-in, per vault. A Local vault stays plain Markdown and never talks to a server. A Collaborative vault runs a CRDT, projects readable .md the whole time, and moves between machines through a relay that only ever sees ciphertext.
Personal sync moves the vault's plain Markdown through iCloud Drive, with no relay and no CRDT. Shared sync runs a Loro CRDT and a content-blind relay that only ever forwards bytes sealed on your machine; host it yourself, down to a Raspberry Pi on your own network. The iPad and iPhone apps are on the way, built on the same Swift core.
A movable-tree CRDT Loro
Collaborative vaults use Loro, chosen for a first-class movable-tree type, which is exactly what an outline needs. A Collaborative vault keeps a snapshot plus an append-only log beside the files and projects readable Markdown the whole time.
A content-blind relay Rust · redb
The sync server is a single static Rust binary that forwards sealed bytes and never sees plaintext. It buffers each vault's deltas in an embedded redb file keyed by room and sequence, so a device that was offline catches up from the buffer with no other peer online. It authenticates who may sync; it cannot read what.
Admission is a signed challenge Ed25519
To connect, the relay sends a random nonce and we sign it with our Ed25519 identity key. It checks that key against the room's member list. Our private key stays in our Mac's Keychain and never crosses the wire.
Membership is a signed document epoch
The member list is a small document the owner signs: the room, an epoch counter, the owner, and each member's key and role. Only an owner or admin can change it, and only with a higher epoch. The relay verifies the signature over the exact bytes and enforces who may write, without understanding a word of the content.
The content key never reaches the relay X25519
Every delta is encrypted on our machine with the vault's content key before it is published. That key is wrapped to each member's X25519 public key, so adding someone means wrapping the key to them, not sharing a secret in the clear. The relay stores and forwards only what those keys have already sealed.
Inviting someone trust on first use
An invite is a one-time code we pass out of band. Devices pin each other's key on first use, and we can confirm a short fingerprint to be sure. Once a member is verified, they are challenged again only if their key changes.
Chunked, sealed deltas 4 MiB
Large CRDT deltas split into sealed chunks of about 4 MiB, one seal per logical delta, reassembled from ciphertext before a single decrypt. An early bug capped WebSocket messages below the relay's blob size and caused a resubscribe loop; the client limit now sits well above it.
Bounded and self-cleaning 256 MiB
The relay keeps a delta until every member has acknowledged it, then drops it, with a fourteen-day age-out and a 256 MiB per-room cap as backstops. An owner can mark a full-state snapshot so old history is reclaimed while the snapshot survives, and Stop Sharing wipes all server-side state for that vault at once. Its metrics report sizes and counts, never content.
Run it on our own network mDNS · wss
The relay advertises itself over mDNS as _enliner._tcp, so the app finds it on a flat network with nothing typed in. It terminates TLS itself with a self-signed certificate the app trusts by pinning the key's SPKI fingerprint. Install it as a hardened systemd service, keep the buffer on an SSD, and reach it from away over Tailscale rather than a forwarded port.
Verified across two Macs 435 MB
Live two-Mac realtime sync is verified, including a 435 MB, thirteen-thousand-block vault syncing between machines over the local relay. The robustness work beyond that is built and tested.
iCloud, the simpler option D28
A Local vault can sync as plain files through iCloud Drive, with no relay and no CRDT. The groundwork is done: runtime state like the index and caches was moved out of the vault into device-local storage, so only our canonical Markdown syncs and nothing machine-specific fights across devices. The file-sync transport on top of that is being finished.
Markdown survives, in every mode S7
A Collaborative vault keeps projecting plain .md, and moving a vault between Local and Collaborative preserves its ids. Whether we sync through the relay, through iCloud, or not at all, the vault still reads as files with no app in sight.
07 · Encryption at rest
Portable encryption, opt-in
Encryption is off by default and portable when we turn it on. The point is that an encrypted vault still opens with a standard tool and our key, with no app in the picture.
Content is sealed with age age
Markdown, assets, and snapshots are encrypted in the age format (X25519, ChaCha20-Poly1305, HKDF). The implementation is CryptoKit-native and wire-compatible with stock age, proven both directions at the spike.
Databases sealed whole ChaCha20-Poly1305
The runtime databases are sealed as whole files with ChaCha20-Poly1305, decrypted to the app container on unlock and re-sealed on lock. Full-database encryption and a stronger passphrase KDF are documented as later hardening.
One key, many ways to unlock Touch ID
A per-vault identity key is wrapped in a keyring to one or more unlock sources: a passphrase, the Secure Enclave through Touch ID, and a recovery seam. Adding or removing a way to unlock re-wraps the key only; our content is never re-encrypted.
We can always get back in recovery key
Turning on encryption forces us to export a recovery key and writes a plain DECRYPT.md with the exact commands to open the vault using stock tools. The model never sees ciphertext, and a crash mid-encrypt resumes cleanly.
08 · Durability & media
Built to outlive the app
The vault survives uninstall S7
Delete the app and the vault is still a folder of Markdown and assets we can read with anything. The runtime index is disposable and lives outside the vault, so nothing we care about depends on Enliner still being installed.
Web pages, captured whole single-file HTML
Saving a web page produces a self-contained .html with its CSS and media inlined as data URIs, stored content-addressed. Ten years on it opens with no server and no dead links, because there is nothing left to fetch.
Media is content-addressed sha256
Pasted and remote images are copied into the vault and named by their hash, which deduplicates identical files automatically. Remote images are fetched once and localized, so a link that dies upstream does not take our note with it.
09 · Graph view
A force graph, drawn natively
A pure-Swift engine GraphEngine
The graph is its own Swift package with the force simulation, a quadtree, and breadth-first search, and no UI imports. LogSeq's WebGL and d3 stack was deliberately avoided; there are no new dependencies here.
Sixty frames at two thousand nodes Canvas
The simulation runs off the main thread and hands a position buffer to a SwiftUI Canvas, which redraws each tick. The target is 60 fps at 2,000 nodes and 6,000 edges, with the main thread never blocked past a frame.
A ported force layout Barnes-Hut
The layout is a compact d3-force port: link distance, many-body repulsion with a Barnes-Hut approximation, collision, and centering. Nodes seed by a deterministic phyllotaxis pattern from a page-id hash, and the layout reheats when the index changes.
Pages are the nodes page-level
Nodes are pages, never individual blocks. Wikilinks, tags, and block references all resolve to page-level edges in one SQL pass, and node size scales by the cube root of degree so hubs read without swamping the view.
10 · Voice & calls
Recording that survives a crash
Both sides of the call process tap
Our voice comes from the mic; the far end comes from a Core Audio process tap. The two combine into an aggregate device with hardware drift compensation, so the tracks stay aligned to about 200 ms over an hour.
A recording that cannot be truncated to nothing CAF
The master is a native-rate 16-bit CAF written to be read to end-of-file, so a recording cut off by a crash is still a valid file. A manifest is written before the first audio buffer, tying stray files to the page we armed.
Recovery is idempotent recovery
Recordings stage in a non-purgeable recovery folder with an unflushed window under 100 ms. After a crash the app offers to recover, rebuilds the header from the manifest, and finalizes exactly as a clean call would. Running recovery twice is safe.
11 · Performance
Budgets we can fail a build against
Seven criteria, written down S1–S7
The spec fixes the numbers: open a 10,000-block vault within 1.5 s, answer a keystroke within one frame, move a row within a frame with no cursor jump, round-trip byte for byte, resolve backlinks within 100 ms, save a self-contained snapshot, and survive uninstall.
One source of truth for budgets PerfBudgets
The budgets live in one PerfBudgets type, and a headless harness plus a small perf-run command check the real numbers against them and print a pass-or-fail table. A regression is a failing build, not a feeling.
Measured on real hardware signposts
Latency is attributed with os_signpost on the running app and the wired path, not on isolated probes, which have mispredicted by threefold. The lesson is written into how the project works.
12 · How it is built
An agent pipeline, with receipts
Most of this code was written by AI agents running a supervised loop. The discipline around that loop is the reason it holds up.
Design, code, review, fix, verify pipeline
Every task runs the same loop, and the review is done by a different agent than the one that wrote the code, so the adversarial read is real. One task is one commit, prefixed with its phase.
Decisions are locked, and logged D0–D31
Thirty-plus locked decisions are not reopened in code. Anything that changes one lands as a dated entry in the decision log first. That log is public, below.
Direct, signed, and offline notarized .dmg
Enliner ships as a notarized, Developer-ID signed disk image, updated through Sparkle with signed packages. The license check is verified offline and never phones home at launch, and it gates the app, never our .md files.
Direct download, by design direct
There is no App Store build, because the payment path cannot coexist with the store's mandated in-app purchase. The distribution choice and the ownership model are the same decision.
The paper trail
Read the decision log
Every non-obvious choice in Enliner is written down the day it is made, with the reasoning and the tradeoff. Browse the full log: locked decisions, reversals, spikes, and the arguments behind them. Pricing and licensing entries are omitted from this public copy.