Under the hood

The `outl-actions` public surface

The outl-actions public surface

Module by module: what each one owns and the shape of its API.

This is a reference, and it answers a different question from the shared primitives catalog, which is organised by intent (“I need to do X — what already does it?”). Both used to live in primitives-actions.md, which reached 77k characters against a 50k ceiling because one document was carrying two taxonomies. Split out so each can grow on its own terms.

Looking for something to reuse? Start at the catalog — it is keyed by what you are trying to do. Come here when you already know the module and want its surface.

Moved here from crates/outl-actions/CLAUDE.md, which had grown past the 40,000-char ceiling the markdown-size-guard hook enforces (issue #216). A per-crate CLAUDE.md loads whole into an LLM’s context on every task in that crate; a function catalogue is reference material, consulted when you need a specific entry, so it belongs in docs/ where the one owner per fact rule already puts tables.

The canonical reuse index for the whole workspace is the Shared primitives catalog — index plus three parts (core, markdown, actions), mirrored in condensed form at .github/copilot-instructions.md §5.1. This crate’s rows live mostly in the editing actions part. The table below describes this crate’s surface in module-by-module detail; the catalog is the “intent → use this” cross-crate index you should grep first when adding any helper. The backlinks / backlinks_index / backlinks_sort rows below are one definition of a mention shared by four clients — the reasoning is RFC 0169.

ModuleWhat it owns
blockappend_block, create_before, create_after, create_under, edit_text, split_block (splits a block at a character offset — the caret — into head + a new sibling carrying the tail; children stay with the head; the canonical “press Enter mid-text” op every GUI client and CLI/MCP wraps. Uses the pub(crate) text::split_at_char helper, shared with paste), toggle_todo, toggle_quote, delete, indent, outdent, move_up, move_down, move_under (re-parent under an arbitrary page/block — the cross-page move the plugin host applies for Move intents)
collapsedset_block_collapsed, toggle_block_collapsed. Both generate Op::SetCollapsed and route it through Workspace::apply, so the fold flag converges between devices on top of the existing per-actor jsonl + HLC infrastructure. Never write fold state to the sidecar — that’s last-write-wins per file under iCloud and loses concurrent flips. See the root CLAUDE.md invariants.
treeRead-only helpers: children_of, page_slug_of (enclosing_page_id + the page’s page-slug — the pair three callers were writing out by hand). renderable_prop_value (single owner of “how does a PropValue render in the .md dialect” — Text / PageRef / Tag keep their string form since the parser reads them back into the same shapes; List has no syntax and is dropped) and is_page_model_key (page-slug / page-kind are crate::page’s book-keeping, never a user property). Both are shared by the page renderer, the block renderer and the tree-derived index, so a block’s properties read the same whichever way they arrive. Note text_properties_of keeps Text only — it and the renderer have disagreed since before either was documented as an owner, and whether the clipboard should drop PageRef / Tag is an open question, not this rule. Sibling / fractional-position helpers (previous_sibling, next_sibling, position_before, position_after, position_for_new_last_child) — position_before and position_after are pub; the rest remain pub(crate) until a real caller asks.
execrun_code_block, ExecOutputDto, RunCodeBlockOutcome. Derives a WorkspaceIndex (index::derive) and hands it to outl_exec::run_block_at_index, so the query runtime stops rebuilding one off disk per fence; the derivation is scoped to the single call, and a client running many blocks should derive once and call outl-exec directly (the TUI’s auto-run loop passes its own long-lived App::index). Shared “run a fence” orchestration: walks DFS for the block’s flat index (outline::flat_index_for_block), resolves the page’s .md path via journal::page_md_path, calls outl_exec::run_block_at_index, returns a Serde-friendly outcome. Every client (TUI, desktop, mobile) wraps this function instead of re-implementing the flow. The runtime catalog is selected by the consuming binary’s outl-exec features — outl-actions declares the dep with default-features = false so the mobile IPA never picks up wasmtime by accident.
todoTodoState (Todo / Doing / Done), split_todo, cycle_todo, set_todo — task state encoded as a text prefix (TODO / DOING / DONE ). cycle_todo advances one state (None → TODO → DOING → DONE → None), set_todo sets one outright; a caller that means “mark done” must use the latter, since one cycle from an unmarked block lands on TODO. The prefixes differ in width — measure TodoState::prefix, never assume five characters
propertyknown_keys (the workspace’s property keys ranked by use, grouped case-insensitively so Remind:: and remind:: are one entry, ties broken alphabetically so a menu does not reshuffle between openings) and page_properties (a page’s own key:: value pairs, alpha-sorted). page_properties filters through tree::is_page_model_key rather than growing its own deny-list: page-slug / page-kind live in the same property map but are the page’s identity, and a typo in page-slug is a page no [[ref]] finds. Renaming is page_rename, never a property edit. Exists because all three clients ask “which key?” when adding a property, and a per-client list would drift
quoteQUOTE_PREFIX, is_quote, split_quote, toggle_quote — CommonMark "> " prefix encoding a per-block blockquote marker. Same wire-format policy as todo (text prefix, no AST field, every client renders its own visual).
outlineOutlineNode DTO + project_outline — UI-friendly tree projection. Also owns flatten_subtree_paths and flat_index_for_block (the DFS index used by exec::run_code_block). project_parsed_subtree(children: &[outl_md::OutlineNode]) -> Vec<OutlineNode> projects a parsed subtree (the .md AST, no sidecar) into wire OutlineNodes with tokens attached — ids are transient (fresh per call), so it’s for read-only surfaces that re-resolve on navigation, specifically the !((blk)) embed subtree expansion (resolve_embeds populates EmbedContent.children with it). Re-exported at the crate root. PageOutline { nodes, warnings } + read_page_outline / read_page_outline_with_workspace bundle the outline with parser recovery records (outl_md::ParseWarning) so every client surface (banner, status line, doctor) can warn the user that their .md has lines outside the dialect — without re-parsing the file. The legacy read_page_view* shims silently drop warnings for back-compat.
pagecanonical_root_for_slug(slug, candidates) — the single owner of the split-brain tie-break (page_id_from_slug if present, else smallest NodeId); find_by_slug and index::derive both call it instead of keeping a copy. PageMeta (id, slug, title, kind, icon, pinned — surfaced from the pinned:: page property so every client that consumes list_all sees the flag without re-querying the workspace index), PageKind (Page / Journal), open_or_create, open_journal, open_today, today (delegates to clock), find_by_slug, list_all, migrate_legacy_into_today, page_id_from_slug (deterministic ID derivation so two peers agree on a fresh page’s NodeId), delete(ws, hlc, slug) -> Result<PageMeta, ActionError> (moves the page root to NodeId::trash() via a single Op::Move; the whole subtree travels with it; returns the meta so callers can drop projections + navigate away). merge_duplicate_slug_roots(ws, hlc) -> Result<usize, ActionError> (split-brain repair: when >1 root shares a slug, re-parents every child under the canonical root — page_id_from_slug id if present, else most-descendants / smallest-id — and trashes the emptied duplicates via Ops, so it converges on every device; idempotent, returns the count merged. Impl lives in the sibling page_merge module, re-exported through page + the crate root. Clients call it on boot alongside migrate_legacy_into_today). open_or_create creates the root with no text and, when title != slug, stores the title in the title:: property (TITLE_KEY, Op::SetProp, last-write-wins by HLC) instead of the root’s Yrs text — two devices minting the same deterministic root offline used to run concurrent Yrs text inserts that concatenated ("2026-06-252026-06-25"); a property converges to one value instead. Journals (journal_title == slug) never get a title:: property — page_meta falls back to the slug, so a journal’s .md stays title-line-free. Regular pages created in-app now render a title:: <title> line at the top of their .md. Journal date labels live in dates; user-typed name/ref resolution lives in resolve
page_repair_titlesrepair_doubled_journal_titles(ws, hlc) -> Result<usize, ActionError> — repairs journal roots corrupted by the pre-title:: concurrent-create bug above: any journal whose root text is its slug repeated k >= 2 times gets that text cleared via edit_text (an Op, so the fix converges to every device through the op log). Idempotent (0 on a clean workspace), journal-only (a regular page’s title is never a slug repetition by construction). Sibling of page_merge — same “background-pass repair, not boot-path” pattern. Clients run it on their background reconcile pass (desktop: spawn_background_reconcile; mobile: after reconcile_orphan_md in spawn_workspace_opener), never the synchronous boot path, since it scales with page count.
resolveopen_or_create_by_name (slugifies a human-typed name + keeps it as the title — drives [[ref]]/#tag click handlers in TUI + mobile), open_or_create_by_ref (the canonical “user tapped a ref” decision tree — date → journal, @ mention → person, else the shared ladder), resolve_or_create_by_name (pub(crate) — literal slug → slugified → case-insensitive title → create; consumed by both open_or_create_by_ref and person::ensure_person_by_name so the two resolution paths cannot drift)
open_withread_source, resolve_target, import_into, OpenWithTarget, is_supported, OPEN_WITH_NAMESPACE, SOURCE_KEY, SUPPORTED_EXTENSIONS, MAX_IMPORT_BYTES — importing an external .md / .txt the OS handed a client (“Open With → outl”). The page lands under the open-in/<stem> title namespace (never a slug: slugify folds the /, so it projects to pages/open-in-<stem>.md and open-in becomes a real parent page). A page-source:: property records the canonicalised path (internal, filtered out of the .md by tree::is_page_model_key — it is a local absolute path and outl export hugo would publish it), which is what makes re-opening the same file navigate (OpenWithTarget::Existing) rather than import a duplicate, and what keeps two different files named notes.md on separate pages (open-in/notes 2). OpenWithTarget::page_id() answers before the page exists — ids derive from the slug — which is what lets a client run create + import inside one commit_page. Content goes in through paste::paste_markdown, so a bulleted file becomes an outline and a bullet-free one becomes one block per line, exactly as a paste of the same text would. read_source owns the three refusals (extension, 16 MiB cap, non-UTF-8) so the wording exists once. A new import also appends [[<title>]] to today’s journal (outl is journal-first, and a page reachable only by search is one the user forgets); import_into returns ImportOutcome { page, journal } because that dirties two pages and commit_page is scoped to one. Re-opening resolves to Existing, imports nothing and writes no second entry.
deeplinkparse_deep_link, DeepLinkTarget (Today / Daily(NaiveDate) / Page(slug)), DeepLinkError, DEEP_LINK_SCHEME. Pure outl:// URL → navigation-target parser shared by the desktop + mobile deep-link handlers. Validates ISO dates via chrono and page slugs per-segment via page::is_valid_slug (rejects .. / control chars / empty segments, allows / nesting). Touches no Workspace, no storage, no Tauri type — each client maps the parsed target onto its own open_* command. One parser, every client; never reparse outl:// per client.
mentionsextract_refs (public; re-exported from backlinks), extract_refs_and_tags (crate-private). The single owner of what a block’s text mentions, for both the backlink index and the reminder scanner’s date anchor. One outl_md::inline::tokenize walk feeds both the [[ref]] and the #tag channel, descending through Bold / Italic / Strike / Highlight (whose inner is itself tokenized) and stopping at Code / Link / Image. The traversal’s match is exhaustive so a new token variant cannot be added without someone deciding which side it is on
backlinksBacklink, backlinks_for_target, backlinks_for_page (a mention is a [[target]] token or a #tag whose slug form resolves to the page — both read off one outl_md::inline::tokenize walk in outl_actions::mentions, and the tag goes through the same slugify rule a tag click does, so navigation and “Linked from” can’t drift), extract_refs (re-export of outl_actions::mentions::extract_refs). Both backlinks_for_target/backlinks_for_page are now thin one-shot wrappers: they build a fresh BacklinkIndex (see backlinks_index below) and look it up — fine for a single call (CLI, tests), but a repeated reader should hold an index instead. Backlink::ancestors: Vec<BacklinkCrumb> carries the citing block’s ancestor chain (root-first, page root excluded, empty when the block sits at the page root). BacklinkCrumb { id, text } is plain text (no TODO/DONE prefix). Every client renders it as a dimmed breadcrumb above the citing block; @outl/shared/outline::sameCrumbTrail (mirrored by outl-tui’s local same_trail) drives collapsing consecutive same-branch references so the trail only shows once per branch. The index stores every source_block as a shallow leaf (no children) — every client renders a backlink row from source_block.tokens, and materializing the subtree of every referencing block across the workspace (under the workspace lock) is what froze input, so it never happens. Backlink::into_shallow() (drops source_block.children, keeps the leaf + ancestors) stays as a defensive no-op on the GUI wire path.
backlinks_indexBacklinkIndex (inverted target key -> referencing blocks map). build_backlink_index_from_disk(metas, root) is the client-facing builder — reads each page’s .md + sidecar off disk (read_page_outline), touches no Workspace, holds no lock, and is Send. Reading block text through Workspace::block_text on the O(blocks) walk (what the from-workspace builder below does) forces a lazy-boot vault (#179) to materialize entirely and holds the workspace lock across the whole walk — together, the “opening the journal / pressing Esc freezes” regression. build_backlink_index(workspace, root) (from-workspace) still exists but is used only by the one-shot wrappers (backlinks::backlinks_for_page / backlinks_for_target, CLI/tests with an in-memory workspace that has no .md on disk) — clients build the index from disk, never from the workspace. Entries are shallow leaves (project_outline_node_shallow / the from-disk twin shallow_parsed — body + tokens + props, no subtree), so neither walk descends every referencing block’s children (the full-subtree materialization under the workspace lock was the input-freeze). BacklinkIndex::for_page / for_target are O(refs) lookups; count_for_page counts without cloning. Both rules that decide what a backlink is (mentions_of, keys_for_page) moved to the backlinks_keys row below — this module stores and traverses, it does not decide. backlinks::backlinks_for_page / backlinks_for_target delegate here so the on-demand path and the indexed path can never fork on what counts as a mention. The index is a pure projection (never persisted, never an Op): rebuild it whenever the tree changes, same as .md files and WorkspaceIndex.
backlinks_keysTargetKey, mentions_of, keys_for_page (all pub(crate)) — the two halves of what a backlink is, kept together and apart from the index that stores them: a key mentions_of emits that keys_for_page never asks for is a mention nobody can find, and the reverse is a page that finds nothing. Five channels: Ref (literal [[X]]), Tag (#tag via slugify), Namespace (a proper ancestor of a namespaced mention, so #os/linux reaches os — see the namespace row), Call and Provenance (the two template channels).
indexderive(workspace, root) -> WorkspaceIndex — builds the whole workspace index from the op-log tree instead of walking pages/ + journals/, so the index derives from the source of truth rather than from the projection (root CLAUDE.md invariant 1). The type stays in outl-md; only the tree-side constructor lives here, because outl-md cannot depend on this crate and a derive there would have to re-implement page_meta + children_of + the tree→AST projection. Same split as build_backlink_index / build_backlink_index_from_disk. Two PropertiesIndex/ChildrenIndex maps are load-bearing, not optimisations: Tree::properties_of and children_of each rescan a workspace-wide map per call, so the naive walk is O(nodes × properties) — measured 2.4× slower than the disk walk it replaces before the maps went in. It is not faster than the disk build (650 ms → 655 ms on a real 2,835-page / 216k-op workspace); what it buys is the invariant, no filesystem dependency, and immunity to the disk path’s positional sidecar skip. Callers: outl search, outl backlinks and the MCP index cache, and nothing else. Every GUI exec path uses WorkspaceIndex::build instead, because they all run holding their client’s workspace mutex and deriving reads block_text per node — the lazy-boot materialization that froze the app (#179), the same reason build_backlink_index_from_disk exists. Roots are grouped by slug and resolved through page::canonical_root_for_slug first: split-brain leaves several live roots on one slug, and this index is keyed by slug in three places, so indexing both would let one root win the page entry while the other’s blocks collided with it on (slug, dfs_path).
backlinks_sortsort_backlinks(links: &mut [Backlink], newest_first: bool) — group-stable chronological order for a backlinks list (issue #142): each source page’s blocks stay contiguous, pages sort by their most-recently-referenced block (block_id is a ULID, so lexicographic order tracks creation time), blocks within a page keep DFS order. Driven by [display] backlinks_order in outl-config; every client (TUI, desktop, mobile) calls this one function so the direction can’t drift.
namespacesegments, key, ancestors, descendants, NamespaceChild — the single owner of page namespaces (os/linux/debian, issue #275). The hierarchy is derived from the page title, never the slug: outl_md::slug::slugify folds / to - because page::is_valid_slug rejects it (a slug is one path component of pages/<slug>.md), so the / a user typed survives only in the title. Comparison is per slugified segment, so OS/Linux and os/linux are one namespace while oscar/wilde is not under os. descendants(pages, name) returns every nested page title-sorted with its depth (1 = direct child) and label (trailing segment) already computed, so the TUI section and the desktop / mobile <NestedPages /> render the same rows without three copies of the split rule. ancestors is what backlinks_keys::mentions_of indexes a namespaced mention under (TargetKey::Namespace), which is how #os/linux reaches the os page.
journalSplit into journal/{paths,render,apply}.rs (mod.rs re-exports keep every existing path compiling). render_page_md (projects page-level and block-level properties back into the .md, alpha-sorted — block props are Op::SetProp on the block node, and skipping them on render was a convergent-data-loss bug the importer surfaced), apply_page_md, apply_page_md_with_sidecar, apply_page_md_with_sidecar_rendered(workspace, root, page_root, md: &str) (same as apply_page_md_with_sidecar but reuses an already-rendered .md string instead of rendering the page again — the GUI commit path renders once for the undo diff and reuses it, so a mutation stops re-rendering the page 3x), apply_page_md_with_sidecar_if_absent (lazy: projects only when .md is absent — use on read paths to avoid sidecar churn), apply_page_md_with_sidecar_if_stale (the re-projection counterpart: projects when the .md is absent OR the tree has moved past a faithful projection — a peer’s ops landed but the .md the view reads was never refreshed, issue #166; a no-op on an in-sync page, and never clobbers a .md whose hash no longer matches its sidecar since that is a pending external edit the .md → tree reconcile owns. Every GUI open path — open_journal_for, open_today_journal, open_page_by_slug, open_ref — calls this before build_page_view, which reads the .md. The hash gate is not sufficient on its own: faithful proves the sidecar agrees with the bytes on disk, never that those bytes came from the log — a reconcile_md that rewrote the sidecar without emitting ops for everything it read leaves a page in exactly that state, and re-rendering the tree over it deletes the difference for good while every check afterwards agrees the page is healthy, because the new sidecar is built from the same render. So it also asks content_lines_missing_from(disk, &sidecar.blocks) and returns ActionError::PageMarkdownAheadOfLog instead of writing; measured on a real 2.5k-page workspace that is 233 pages holding 1,426 lines the log had never seen, every one of which a plain page open used to delete. outl reconcile --ahead-of-log forces the .md → tree direction on exactly those pages and is what brings them in), content_lines_missing_from(disk, sidecar_blocks) -> Vec<String> (re-exported from outl_md::unlogged, which is where it lives so reconcile_md — the producer, one crate down — can ask the same question before advancing last_synced_hash; sidecar_can_answer rides along for the same reason. The single owner of that verdict; outl doctor calls it so its read-only listing and --repair cannot disagree. The reference is the sidecar’s blocks, never a fresh render — the sidecar is what the log held at the last agreement, so it answers “does the log know this line”, while a render answers “do disk and tree disagree”, which is also yes for every remote edit, remote delete and reorder; a guard built on the render froze any page a peer had touched, which is issue #166 with the blame moved. Compared as a multiset; the bullet marker, indent and trailing whitespace are normalised away because they are layout, not content, and key:: value lines are skipped since a property is never part of a block’s text. A sidecar whose blocks carry text: "" — every one written before 0.11 — cannot answer the question, so the verdict comes back empty: answering anyway flagged 615 pages / 35,261 lines on that same workspace. An empty verdict from a reference that cannot answer is not “nothing at risk”, and apply_page_md_with_sidecar_if_stale therefore declines the write (Ok(None)) rather than reading it as permission — otherwise a peer still on an older binary re-arms the loss just by rewriting a sidecar without text. Quiet on purpose: such a sidecar necessarily carries a stale pipeline_version, so scan_for_orphans already has the page queued and its reconcile rewrites the sidecar with text, which arms the real check. An empty block list is the opposite case and must keep writing — a page with no blocks has nothing on disk to lose), apply_all_pages_md, mutate_page_md, journals_dir, pages_dir, page_md_path, write_md_atomic, remove_page_projection(root, meta) -> io::Result<()> (the inverse of apply_page_md_with_sidecar — removes the page’s .md and .outl from disk; idempotent on missing files; pairs with page::delete so a client can drop the projection after the trash op lands)
commitcommit_page(ws, hooks, page, mutate) + the CommitHooks trait — the single owner of what happens around a page mutation: pre-mutation undo snapshot (kept only when the render actually changed), the mutation, backlink-index invalidation, the peer announce, then the .md + sidecar projection. Takes &mut Workspace, so the TUI and the CLI can run it too; the Tauri clients wrap it in finish_in_page_with, which adds only the PageView. project is the one required hook — the other four (records_undo, record_undo, invalidate_backlinks, announce) are defaulted, so a caller without undo or a backlink cache implements one method. Only the mutation can fail the commit; a projection failure lands after the log already holds the change, so the hook keeps it and the caller reports it. Reach for this instead of calling apply_page_md_with_sidecar_guarded directly (#264)
historyHistoryStacks<T> (bounded undo / redo stacks, vim semantics: a new edit clears redo), DEFAULT_HISTORY_CAP, restore_page_md (write a previously-rendered .md snapshot + reconcile it back — the restore is new ops through Workspace::apply, never a log rewrite). Drives the desktop’s Cmd+Z / Cmd+Shift+Z; per-keystroke undo inside an uncommitted draft stays in the client’s editor widget.
desyncscan_for_desynced_projections, recover_desynced_projection. Detection + repair for projections that ran ahead of the op log: .md + sidecar written but the ops append lost (app killed mid-commit), so the sidecar is hash-in-sync while its ids exist in no op log — the state the hash gate in sync is structurally blind to. Recovery is strictly additive: recreates the missing blocks preserving the sidecar ids (ref handles keep resolving), never touches blocks the tree knows (a trashed block is not resurrected — a remote delete IS an op), then re-projects the merged page. The re-projection is withheld when a tree-known block’s text disagrees with the .md: “additive” covers structure, not text, so that render would overwrite a rewording whose Op::Edit was lost with the rest of the commit — content in no op, i.e. nowhere else. The shared verdict can’t catch it (the sidecar was written with the file, so it accounts for every line on disk), and re-emitting the Edit isn’t the answer either: a peer edit that landed while the projection was frozen is indistinguishable, and writing the disk text back reverts that peer permanently on an append-only log. Ops recovered, file left alone. Wired into the GUI boot via outl-tauri-shared::workspace_open::reconcile_orphan_md.
syncorphans_log_path(root) / SyncEngine::orphans_log() — the single owner of “where does this workspace’s orphans.log live”. Every outl_md::reconcile_md caller passes it; None is a bug outside tests. Matching level 3 moves the blocks it can’t place to the trash, and outl-md’s hard rule is that they appear in the log before that happens — the desktop, mobile and undo paths all passed None, so a boot reconcile over a half-synced .md deleted with no record anywhere. It lives here rather than in outl-ws because the GUI clients and this crate reconcile without depending on that crate. SyncEngine, OpsFileSnapshot, SyncTransport, FileSyncTransport. Reload workspace from disk, re-project a page’s .md + sidecar, snapshot peer jsonls (skipping own), scan for orphan .md files (no sidecar / stale hash); SyncEngine::scan_for_desynced_projections(ws) is the workspace-aware companion scan (see desync). Shared by TUI poller + mobile iCloud watcher. SyncTransport abstracts how ops travel between devices (iroh QUIC is the default transport; FileSyncTransport is the opt-in filesystem/iCloud polling alternative) — both end up writing ops-<peer>.jsonl to disk, so reload_workspace is transport-agnostic. SyncEngine::with_transport binds one in; start_transport spawns its background tasks; announce_local_ops is the post-commit hook (no-op for files, gossip for iroh). SyncProgress (tagged by phase: Connecting/Snapshot/ReceivedOps/PushedOps/Synced/Interrupted/Failed; Interrupted is a peer suspended mid-exchange — amber, retried, NOT an error the user must act on, unlike Failed) is the pairing-screen progress feed’s payload — SyncTransport::set_progress_sink (default no-op) registers a channel a transport pushes it through; purely cosmetic, never the reload trigger (see docs/clients.md → “Sync progress feed”). reload_workspace(hlc) seeds the caller’s clock: it raises the passed HlcGenerator above every op the merge just landed (Workspace::seed_clock), because boot seeding only covers what was on disk at boot and a peer ahead of our wall clock arrives later — the next local op would otherwise sort below it and pay the paper’s undo/redo window on a foreground keystroke (a cost, not a convergence bug). It lives here, not at the four call sites, so no client can silently lack it. reload_workspace write-through snapshots: peer-sync ingest writes ops straight to disk (never through Workspace::apply), so the background snapshot writer never fires on a receive-only device and every reload full-replays the whole log — which the GUI does every few seconds, pinning the CPU on a 200k-op synced workspace. So reload_workspace persists ONE snapshot after the first replay that found none on disk (log ≥ 10k ops), letting subsequent reloads boot O(delta); a stale/corrupt snapshot is always safe (boot falls back to full replay), so it can never corrupt state.
pastepaste_markdown, paste_plain, PasteAnchor, PasteOutcome, normalize_external_syntax. paste_markdown converts external clipboard markdown (Roam {{[[TODO]]}}, GitHub [ ]/[x], Logseq id::, 4-space indent; multi-paragraph plain text → one block per paragraph) into outl syntax and grafts the bullet structure as blocks. paste_plain inserts raw text as a single block at the anchor with no normalisation or paragraph splitting — the “without formatting” path. Drives Event::Paste / Event::PastePlain in the TUI and the paste_markdown_at / paste_plain_at Tauri commands.
clipboardcopy_markdown. The inverse of paste: serializes a block selection (each root + its full subtree) to clean canonical outl markdown for the OS clipboard — - bullets, 2-space indent, inline block props (alpha-sorted), TODO/DONE/> prefixes verbatim. Copy-out then paste-in reconstructs the same tree (tested as a pair against paste_markdown). Core emits only the canonical format; other output formats are the domain of optional format plugins (see docs/design/clipboard.md).
assetimport_asset(root, source, max_bytes) -> Result<ImportedAsset, ActionError> — copies an uploaded file into <root>/assets/<hash>.<ext> (content-addressed on the hex SHA-256 of its bytes, atomic tmp+rename write, max_bytes from [assets] max_bytes in outl-config, 0 = unbounded) and returns ImportedAsset { rel_path, display_name, is_image, markdown } ready to insert as a block. resolve_asset_path(root, url) -> Result<Option<PathBuf>, ActionError> maps a [name](assets/…) link back to an on-disk path for “open outside outl” handlers, rejecting anything outside assets/ (traversal, absolute paths, external schemes) via ActionError::InvalidAssetPath. link_markdown, assets_dir are the small helpers underneath. Wraps the pure outl_md::asset primitives (hash_bytes, asset_rel_path, is_asset_link) — this module owns the filesystem side only; see RFC 0202 for why this is the one deliberate exception to that rule. The outl-actions/CLAUDE.md section that carried the bytes-versus-state argument inline no longer exists — the RFC preserves the summary, including its closing “don’t use this as precedent for a second exception without the same argument”.
datesThe pure date domain. parse_flexible_date, parse_date_label — the one owner of “human-typed date → NaiveDate / ISO label” (April 22nd, 2026, Sept 3rd, 2025, 2026/04/22, 22/04/2026, 22 April 2026, ISO); used by paste::normalize for [[date]] rewriting and by the CLI (daily, import, Obsidian frontmatter). parse_date_arg layers relative offsets (+3d, -2w, +1m, bare 5d) on top for slash-command / CLI arguments. Journal labels: journal_slug, journal_title, journal_ref ([[YYYY-MM-DD]]), date_from_slug, previous_journal_date, next_journal_date. Week arithmetic: week_tag (#YYYY-Www, ISO %G), days_until_next_weekday (same weekday → 7, never 0). Everything pure, chrono-validated (February 30th is not a date); no clock — functions take the anchor date as a parameter, and keyword shortcuts (today / yesterday) stay in the caller because “what does today mean” belongs to clock.
clockinit, now_local, today — process-wide “now”/“today” in the user’s configured timezone ([calendar] timezone, DST-aware via chrono-tz; OS local when unset). A client calls init(tz) once at boot; every “today” goes through here (page::today delegates) so the journal date + status-line clock honour the configured zone instead of trusting chrono::Local, which reads UTC inside containers / Crostini (issue #107).
remindersremind:: scheduling. schedule::next_fire_at(rule, anchor_date, state, quiet, now) -> Option<NaiveDateTime> is the single owner of the schedule math — pure, clock-free, takes now as a parameter. Every surface calls it: the TUI overlay, the desktop panel, the mobile sheet, and each OS notification bridge. A second opinion in TS or Swift about when a reminder fires is drift that reaches the user before it reaches a test. scan::scan_reminders(ws, root, fired, quiet, now) -> Vec<Reminder> is driven off the tree, not the .md: the GUI clients project asynchronously, so a rule authored a moment ago isn’t on disk yet and reading disk showed the user an empty list right after they added a reminder. It finds carriers via Tree::nodes_with_property(REMIND_KEY) — a property-map scan — so it materializes text for those blocks only and never forces a lazy-boot vault (#179) to materialize under the lock. The sort is total (next fire → text → block id → anchor date) because the carriers come out of a HashMap and a list that reshuffles under the cursor is unusable. snooze / snooze_until write Op::SnoozeRemind, so silencing a nag on the phone silences the laptop; the device-local “already fired” half (FiredLog) is deliberately NOT an op and is owned by the client. local_naive_to_epoch_ms / epoch_ms_to_local_naive resolve through clock, never chrono::Local. See docs/reminders.md.
backupLocal, versioned git snapshots of a workspace: init, snapshot, snapshot_best_effort, list, restore, repo_dir, git_available, is_initialized, BackupRepo, BackupEntry, BackupError, plus the automatic pass (spawn_auto_pass, maybe_snapshot, STARTUP_DELAY). The git dir lives outside the workspace (outl_core::device_dir()/backups/<slug>-<hash>.git, workspace as --work-tree) — the workspace is a sync surface, and a git init in its root used to adopt the repo the user already kept there (their index, branch, hooks and signing key). A workspace .gitignore cannot drop ops/ (force-staged), and every snapshot verifies the op log landed in the commit (BackupError::OpLogNotCaptured). spawn_auto_pass is the single client wiring point — background thread, never the edit or quit path; the TUI calls it, the GUI clients do not yet. See docs/clients.md → Automatic backups.
recoverRecovers block text an Op::Edit truncated — the mirror of journal’s content_lines_missing_from, which reads the .md; this reads the op log, since the producer bug that caused issue #210 wrote the truncated text as a real Op::Edit, so the edit before it — carrying the full text — is still in the append-only log. scan_truncated_blocks(ws, min_lost_lines) -> Vec<TruncatedBlock> finds every block whose current text is a proper prefix of an earlier revision (a trashed block is skipped — its history is a deliberate deletion, not damage). restore_truncated_block writes the recovered revision back as a new Op::Edit, refusing when the block changed since the scan so the write stays additive. Backed by outl_core::Workspace::block_text_history, which replays a block’s Edits from storage (never the resident log / text cache) so a snapshot-boot device can’t silently see a shorter history. Driven by outl recover [--apply] [--min-lines N].
timelineA page’s history out of the op log: page_timelinePageTimeline, block_timeline, Change. Read-only — restoring a revision is recover’s job for the one case with a proven-additive rule, and a general restore needs its own safety argument. Not history (that is this session’s undo stack; this is every device’s, from the start of the workspace). Owns two rules no client re-decides: which blocks are the page’s — the live subtree plus everything deleted out of it, because a history that omits deletions omits the change people came for; a block moved to another page goes with it, and block_timeline follows one block wherever it has lived. And what is not an eventOp::SetCollapsed / Op::SnoozeRemind (view state, reminder bookkeeping), page-slug / page-kind writes, an Op::Edit that re-emitted the block’s existing text, and a re-emitted Create / Move that changed nothing. A reconcile produces all four in volume: on the reference workspace one block’s history was six rows of them around a single real edit. Never reads Move.old_parentdo_op fills that on the copy reaching the in-memory log while Workspace::apply persists the caller’s original, so 99% of the stored Move ops there say root regardless of where the block was; the parent trail is folded from Create.parent / Move.new_parent, which are the op’s own effect.
errorActionError (incl. PageNotFound(String) returned by page::delete when a slug doesn’t resolve, and PageMarkdownVanished / PageMarkdownNotDownloaded — a .md that reads as absent while its sidecar is present, or while an iCloud .foo.md.icloud placeholder is, is a lost/undownloaded file, never a new page; mutate_page_md refuses rather than projecting one block over N and orphaning the rest on the next reconcile. Also PageMarkdownAheadOfLog { path, lines, sample } — see the journal row’s re-projection guard — and PageSidecarUnreadable(String), its deliberate twin for the “I cannot tell” case: same refusal, different condition, since a missing / corrupt / newer-version sidecar establishes neither that the file holds unlogged lines nor that outl reconcile --ahead-of-log is the recovery. It is local and transient (the orphan pass rebuilds the sidecar), so it must never reach the “this page stopped syncing” banner)
templateTemplate engine — a page with a non-empty template:: property is a template; its outline is the body. Split across list.rs (list_templates, TemplateEntry), vars.rs ({{date}}/{{today}}/{{yesterday}}/{{tomorrow}}/{{page}}/{{time}} substitution, pub(crate)), call.rs (resolve_call, CallResolution, parse_call_params, call_target_name, inject_call_params), instantiate.rs (instantiate_template), run.rs (parse_call_invocation, run_callable_block — the shared “detect + execute a call: block” every client wraps; also intercepted inside exec::run_code_block so desktop/mobile get call: for free). Two invocation modes: structural (/template <name> deep-copies the subtree, stamping from-template:: <slug> on each root clone) and callable (a ```call:<name> fence resolves the template’s first code block for execution with params — inject_call_params injects a params binding via serde_json, so a quote/newline in a param value can’t break or inject into the generated program; language is canonicalized first via outl_md::lang::canonical so aliases like py/node still get the prelude). JOURNAL_TEMPLATE_NAME = "journal" is the reserved name page::open_journal auto-instantiates (untraced) into every fresh daily note. backlinks::backlinks_for_page surfaces a template page’s render/instantiation sites (from-template:: property or call:<name> fence) so the template’s backlinks panel lists every place it fired.