blog · · 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.

A
14 min read

The nested-pages section shipped, and my workspace listed one nested page.

2,575 pages. Sixty-five of them named buser/ something. Under buser, the section was empty. The other half of the same feature, merged in the same pull request, credited 3,221 blocks to that exact page.

Two halves of one feature, reading one fact, disagreeing completely.

the slash survives in one place, because a slug is one path component

#os/linux/debian had parsed as a single tag for a long time. It resolved to a page, kept its name verbatim, and rendered fine. What it did not do was anything a hierarchy implies: the os page had no idea os/linux existed, and a block tagged #os/linux was invisible to it. The feature looked present and behaved like a coincidence, a page whose name happens to contain a slash.

The cheap way to make the slash mean something is to not store anything. A page’s slug is joined into pages/<slug>.md as a single path component, so page::is_valid_slug in crates/outl-actions/src/page.rs rejects / outright, and outl_md::slug::slugify folds it to -. os/linux lives at pages/os-linux.md, flat, and always has. The / the user typed survives in exactly one place: the title:: property.

Read the hierarchy from there and you need no new op, no new on-disk field, no migration. That is not a small thing. Every piece of convergent state in outl goes through the op log, which means a new field is a new operation, a new wire shape, and a version of the format that old peers do not understand. A projection over a field that already exists costs none of it.

The title is a property rather than the page node’s text for a reason I paid for once already: when it lived in the block text, two devices opening the same journal offline both inserted the date at position zero, and a sequence CRDT keeps both. That is how I got a sidebar page called 2026-06-252026-06-25. Moving titles to an Op::SetProp register fixed that, and the side effect at the time was that pages created in the app started writing a title:: line into their .md. Hold that thought.

crates/outl-actions/src/namespace.rs is the whole reading side. The comparison is the part worth quoting, because getting it wrong would have been indistinguishable from the feature being broken:

let segs = segments(&page.title);
let label = (*segs.last()?).to_string();
let page_key: Vec<String> = segs.into_iter().map(slugify).collect();
if page_key.len() <= root.len() || !page_key.starts_with(&root[..]) {
    return None;
}

A name is compared as a vector of slugified segments, never as a raw string prefix. oscar/wilde starts with os as a string and is not in the os namespace. A starts_with would have put every os-prefixed page under os and been reported as “nested tags are broken” rather than as a matching bug, which is a much worse bug report to receive. The same per-segment fold is what makes OS/Linux and os/linux one namespace: they resolve to the same page, so they had better nest in the same place. a_sibling_sharing_a_string_prefix_is_not_nested pins it.

ancestors is a proper prefix list, os/linux/debian giving ["os", "os/linux"] and never the name itself. Including it would index #os/linux under the namespace key os-linux, and the os/linux page would then list its own mention twice, once as a tag and once as its own namespace.

the two halves read different things, and only one of them was there

The feature is two features sharing one source. The parent lists its descendants, from titles. The parent collects their mentions, from a new TargetKey::Namespace channel in crates/outl-actions/src/backlinks_keys.rs:

let mut push = |key: TargetKey, name: &str| {
    if name.contains('/') {
        for anc in crate::namespace::ancestors(name) {
            keys.push(TargetKey::Namespace(outl_md::slug::slugify(&anc)));
        }
    }
    keys.push(key);
};

Look at what each half reads. The collecting half reads name, the text of the mention, as the user typed it inside [[…]] or after a #. The slash is right there in the block, character for character. The listing half reads page.title, which page::page_meta resolves by precedence: the title:: property, then the page node’s own text, then, failing both, the slug.

A page that arrived as a .md on disk has an empty root text and no title:: line. So page_meta falls back to the slug, buser-tech-data, which carries no / and therefore reads as one flat segment and nests under nothing.

On my workspace that was 14 pages with a title, out of 2,575. The sixty-five buser-* pages that are the namespaced ones had none of them. So descendants returned empty for every namespace, while the backlinks channel credited 3,221 blocks to buser alone and looked entirely healthy.

The test I had pointed at was pinning the wrong end of the pipe. namespaced_page_keeps_title_and_flattens_slug, in crates/outl-import/tests/roam_e2e.rs, imports a Roam page called buser/tech/data and asserts pages/buser-tech-data.md contains title:: buser/tech/data. It passes. It has always passed. It proves what the importer writes today, and it proves exactly nothing about the files already sitting on my disk, whatever route they took there. A green test on the producer is not a measurement of the store, and I read one as the other because they name the same field.

That is the error, and it is not a coding mistake. The design rule was written down correctly, in a module doc, as an invariant: the hierarchy comes from the title, never from the slug. I then never asked how many pages had a title.

the name is still written down, in the mentions

The recovery is the part I would actually defend.

A page whose title:: is missing is not a page whose name was lost. Every block that references it spells the name out in full, [[buser/tech/data]], slash and all, and slugify maps that string onto exactly the slug the ingested page already carries. So the two sides can be joined:

if !name.contains('/') {
    continue;
}
out.entry(outl_md::slug::slugify(&name))
    .or_default()
    .insert(name);

That is collect_namespaced_mentions in crates/outl-actions/src/page_repair_namespaces.rs, building a map from slug to the set of spellings the workspace has for it. repair_namespaced_titles then walks the page list, and for every page that map has a name for and that has no name of its own, writes the title back through an ordinary Op::SetProp.

This is recovery, not invention, and the difference is the contains('/') line. The pass never derives a hierarchy from a slug. meu-projeto stays one segment forever, because no mention anywhere ever spelled it meu/projeto. Had I gone the other way and taught the comparison to treat - as a nesting separator, that page becomes a child of meu, and so does roughly every other two-word page name in a Portuguese workspace.

Measured on the same 2,575-page workspace: 175 titles recovered, nested pages from 1 to 100, second pass a no-op. It runs on the background reconcile both GUI clients already do, not on the boot path, for the reason the journal-title repair rides there too: it scales with pages, and opening the app has to stay instant.

what it refuses is the design, not the caveat list

Three refusals, and each one is a case where doing the obvious thing writes a wrong fact into an append-only log.

A page somebody already named is left alone. The check reads the two rungs directly rather than comparing meta.title against meta.slug:

fn has_own_title(workspace: &Workspace, id: NodeId) -> bool {
    if let Some(PropValue::Text(s)) = workspace.tree().property(id, TITLE_KEY) {
        if !s.trim().is_empty() {
            return true;
        }
    }
    workspace
        .block_text(id)
        .is_some_and(|t| !t.trim().is_empty())
}

The comparison version has a trap in it. A hand-written title:: os-linux resolves to the same string the slug fallback produces, so a title-versus-slug test reads that page as titleless and overwrites a human’s choice with os/linux. Same for the legacy shape, where in-app creation wrote the typed name into the root’s text. Both are pinned: an_explicit_title_spelled_like_the_slug_is_still_a_title and legacy_root_text_spelled_like_the_slug_is_still_a_title.

A flat mention writes nothing. [[notes]] would set title:: notes, which is what page_meta already falls back to: an op that changes the rendered state not at all, emitted once per page, on every page in the workspace.

A slug that two spellings claim is reported, not guessed. buser/tech and Buser/Tech both slugify to buser-tech. Picking one is a coin flip written permanently into the op log, so the pass collects them into NamespaceTitleRepair::ambiguous and moves on. My workspace had three.

And here is what that reporting actually costs today, because “reported” is doing a lot of work in that sentence. Both clients handle it like this, in crates/outl-desktop/src-tauri/src/workspace_open.rs:

// Reported, never guessed: two spellings of one slug
// would make the choice a coin flip in the op log.
for (slug, names) in &report.ambiguous {
    warn!("namespace title for `{slug}` is ambiguous: {names:?}");
}

A warn! is a line in a log the user does not open. Those three pages stay un-nested, with nothing on screen saying why, which is the same shape of failure this codebase has an entire invariant about for pages that stop syncing. The refusal is right. The surface is not, and it is not fixed.

The second gap is larger: repair_namespaced_titles is called from the two GUI clients and from nowhere else. The TUI renders the nested-pages section, out of crates/outl-tui/src/view/namespace.rs, and never runs the repair. Open an imported graph only in the terminal and the section is empty forever, exactly as it was for me before the pass existed. There is no outl doctor check for it either.

a namespace’s mentions have no natural size

The collecting half needed the opposite treatment. Folding 3,221 blocks into a backlinks panel makes it unreadable, and at roughly 292 KB of block text before the DTO envelope it also rides the IPC on every single page open.

BacklinkIndex::for_page_split partitions the page’s lookup keys, answers the Namespace ones separately, and drops anything already present in the direct set. The reply in crates/outl-tauri-shared/src/commands/page_backlinks.rs ships the direct backlinks in full and caps the namespace set at 50, with one line that matters more than the cap:

// Sort before truncating, or the 50 that ship are whichever the
// walk happened to reach first rather than the newest.

Clients render it as its own collapsed section reading “showing 50 of 3,221” instead of a list that implies it is complete. buser names 448 sources directly; those all still arrive.

The TUI lists nested pages and cannot open one from that list. Focus there has two variants, Outline and Backlink, and a third is a decision about what d, x and i mean when the cursor is parked on a page row. So the section is painted without a cursor in it, the header says to use the picker instead, and the gap is recorded as Capability::NestedPages set to Partial in crates/outl-shortcuts/src/capability_support.rs, which is an exhaustive match, so it is in the generated parity table rather than in a user’s guesswork.

the same asymmetry again, in open-in/<file name>

Three days later the mechanism paid for itself. “Open With → outl” on the desktop takes a .md or .txt from anywhere on the machine and lands it as a page titled open-in/<file name>, linked from that day’s journal. open-in becomes a real parent page listing everything ever opened this way. No new op, no new field, no migration, because the namespace is a title and the title already exists.

It also produced the sharpest failure the slug-is-lossy asymmetry has. slugify keeps ASCII alphanumerics, folds a fixed set of Latin accents, turns everything else into a separator and trims the trailing ones. So:

slugify("open-in/会議メモ") == "open-in"

That is the namespace index page. Page ids derive from slugs, so importing there would have replaced open-in itself with one file’s contents and hung every later import under it. For someone who names files in their own script that is not an edge case, it is every file. crates/outl-actions/src/open_with.rs refuses the collision:

fn slug_for(title: &str) -> String {
    let slug = outl_md::slug::slugify(title);
    let namespace = outl_md::slug::slugify(OPEN_WITH_NAMESPACE);
    if slug == namespace {
        format!("{namespace}-{UNTITLED_STEM}")
    } else {
        slug
    }
}

Pinned by a_non_latin_file_name_never_lands_on_the_namespace_page in crates/outl-actions/src/open_with/tests.rs, which also checks that a second non-Latin file gets its own page rather than colliding with the first.

The collision is refused. The name is still not on disk. That page’s title is open-in/会議メモ and it projects to pages/open-in-untitled.md, and because the journal link has to use whatever actually resolves, the entry written into today’s journal is [[open-in-untitled]] rather than the file’s name. The nested-pages list under open-in shows 会議メモ correctly, because that list reads the title. Every other surface that goes through the slug shows untitled. That is the asymmetry stated as plainly as it gets: the slug is lossy and the title carries the truth, and every place the two disagree, something downstream picks the lossy one.

what a derived field is actually worth

Deriving the hierarchy from title:: was the right call and I would make it again. It cost no operation, no format version, no migration, and no compatibility story with peers on an older binary. namespace.rs is 291 lines, 144 of them tests and 49 of them the module doc, and the functions in between are pure: they take a page list and a name and return rows, touching no storage and holding no state.

What it cost instead was a dependency that does not appear anywhere in the design. A stored field is present by construction: you write it, it is there. A derived field is only as good as the coverage of whatever it derives from, and coverage is a property of the data on real disks, not of the code. 2,439 insertions across 58 files passed every test I had and delivered one nested page, because nothing in that change was wrong and 2,561 pages simply had nothing to read.

So the question I did not ask up front, and now ask before any projection ships: what fraction of the rows actually carry the thing this reads, on a workspace nobody built for this feature? It is one query. It would have moved the repair pass from a follow-up commit into the original design, where it belonged, because the repair is not a patch on the mechanism. It is the other half of it.

The namespace code is in crates/outl-actions/src/namespace.rs, the repair in crates/outl-actions/src/page_repair_namespaces.rs, and the original report is issue #275.