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

A
13 min read

The first bullet on the page was - ---.

Under it, - title: My Note, then - tags: [a, b], then another - ---. Four lines of YAML that a different tool had written at the top of the file were now four blocks of an outline, each with a bullet marker in front of it. Nothing had crashed. The write that did this had already been refused once, correctly, by the guard that exists for exactly this, and the check that had been naming the page went quiet the moment the write landed.

the file, and what one append made of it

The reported file, verbatim, is now a constant in crates/outl-md/tests/frontmatter_roundtrip.rs:

---
title: My Note
tags: [a, b]
---

- body

outl’s own page metadata is key:: value, and the dialect has no --- delimiter at all, so a fence in the file belongs to somebody else’s tool. A workspace folder that is also an Obsidian vault is not misuse of outl, it is the interop story that transport = "file" exists for. That vault’s aliases, tags, cssclass and publish keys are supposed to survive.

The parser read them as content. --- matched no rule of the dialect, so permissive parsing recovered it verbatim as a block, and the same happened to each key line. Four lines became four blocks, two of them a bare ---, and the page the op log held was five blocks where the file had one. Then a mutation landed, the page was projected from the tree back to disk, and the renderer wrote each of those blocks out the only way it writes a block:

- ---
- title: My Note
- tags: [a, b]
- ---
- body

The sequence in the issue was four commands, and the order is the whole story. While the op log had not seen those lines, every projection of the page was refused and the file stayed exactly as written. outl doctor named the page and counted the lines that existed in no operation. Then one outl block append put those lines into the log as bullets, the guard’s answer flipped from no to yes, and the projection that closes every mutation wrote the tree’s reading of the file back over the file.

the guard asks the sidecar, and the reconcile writes the sidecar

outl’s eighth invariant exists because of a worse version of this. A matching sidecar hash proves outl wrote a file’s bytes last; it does not prove those bytes came from the op log, and reading the first as the second cost 233 pages holding 1,426 lines of my own notes that existed in no operation. The guard that came out of that asks a different question: of every content line on disk, does the op log know this one?

The reference it asks is not the op log. It is the sidecar’s block list, and that distinction is load-bearing in the direction that matters. crates/outl-md/src/unlogged.rs says why in its own doc comment: the sidecar’s blocks are what the log held when the two last agreed, so comparing against them answers “does the log know this line”, while comparing against a fresh render answers “do disk and tree disagree”, which is also yes for every remote edit, every remote delete and every reorder.

That is correct, and it is also the reason this bug reaches the disk. The sidecar is a projection. reconcile_md is what writes it, in the same pass that emits the operations for what it just read. So the reference and the log move together, by construction, and the guard’s answer for any line the parser can turn into a block is yes shortly after that line is first seen.

Which means invariant 8 was never defending the fence. It defends bytes the parser cannot represent: a blank line inside a block’s text, an over-indented continuation line, the shapes that made render → parse lossy. The fence was the opposite failure. The parser had an opinion about those four lines, the opinion was wrong, and a guard whose question is “does the log know this” cannot tell a line the log knows from a line the log misunderstands.

a warning with a shelf life

The part I keep coming back to is that every surface was working.

doctor printed the finding it is supposed to print, with the recovery it is supposed to name. The wording lives in crates/outl-cli/src/cmd/doctor/tree.rs:

{}: `.md` holds {lines} line(s) that exist in no op (e.g. {sample:?}) — `--repair` will not touch it, run `outl reconcile --ahead-of-log` so they enter the op log first

Read that with the mechanism in hand. The condition it reports is the only thing keeping the file intact, and the action it recommends is the action that removes it. The warning was not a wall, it was a countdown, and running the command it named would have spent it deliberately.

Then the second half, which is worse. After the projection wrote five bullets over the metadata, the file was valid outl dialect. Every line on disk matched a block the log held, the hash matched the sidecar, and the page classified as in sync. There was nothing left to warn about. The signal existed for as long as the loss was preventable and went silent at the moment it became permanent, which is the exact inversion of what an integrity check is for.

the fence travels the op log, because preserving it in the parser picks the other failure

The parser half is small: parse in crates/outl-md/src/parse.rs splits a leading fence off through one shared scan and hands the outline grammar only the body, so --- never becomes block text and the fence comes back byte for byte out of render. parse_fragment deliberately does not do this, because a leading --- in clipboard text is content the user copied and there is no page for it to be metadata of.

Stopping there would have been the trap. A fence preserved by the parser alone is content the op log has never seen, and invariant 8 then has two moves and both are bad: refuse every re-projection, so no vault page can ever be appended to again, or allow one, so the fence is deleted the first time any client renders the tree over the file. Those are the two failures the issue asked us to choose between.

So the fence goes through an operation. Page-level state that must converge between devices belongs in the log, never in a file with last-write-wins semantics, and the page-property channel already is one. sync_page_frontmatter in crates/outl-md/src/reconcile/page_root.rs emits a single Op::SetProp on the page root under page-frontmatter, carrying the fence’s body verbatim. One comparison covers set, update and clear, because the user deleting the fence in an editor has to clear the property too; a stale one would grow the fence back on the next projection.

two channels, because a block list cannot answer for a fence

Moving the fence onto the property channel makes it invisible to the guard that reads block text, and that is deliberate in both directions. content_lines_missing_from now skips the fence region, because the alternative is the expensive false positive: every page of an Obsidian vault reporting four lines of unlogged content, refused in both directions, with nothing wrong with it.

Skipping it leaves the question unanswered, so the fence gets its own verdict in the same file:

pub fn frontmatter_lines_missing_from(disk: &str, rendered: &str, last_synced_hash: &str) -> usize {
    let lines = crate::frontmatter::frontmatter_line_count(disk);
    if lines == 0 {
        return 0;
    }
    if crate::frontmatter::frontmatter_line_count(rendered) == 0 {
        return lines;
    }
    if crate::sidecar::file_hash(disk) == last_synced_hash {
        return 0;
    }
    let (on_disk, _) = crate::frontmatter::split_frontmatter(disk);
    let (in_render, _) = crate::frontmatter::split_frontmatter(rendered);
    if on_disk == in_render {
        0
    } else {
        lines
    }
}

Two shapes count as a loss. A render carrying no fence at all is the upgrade window: the log holds the fence as bullets from the older parser, so the render has none, and writing it back is the reported rewrite happening once more. A render carrying a different fence over bytes outl did not write last is an edit made on disk that no reconcile has read yet, which is why the hash is a parameter: the sidecar keeps no copy of the fence, so last_synced_hash is the only witness for “the log held the fence that is on disk now”.

A differing fence over bytes outl did write last is not a loss, and that line is where the narrowness is bought. Those bytes came from the log, so the render differs only because a peer edited the frontmatter, and refusing that would freeze the page for the most ordinary sync case there is. Both write gates ask this question, and journal/survey adds the same count to what a stale page reports, so the read-only listing cannot promise a repair the writing pass then refuses.

the key has to be hidden, and hiding it first deletes the fence

page-frontmatter is reserved, like page-slug and page-kind, and is_page_model_key in crates/outl-actions/src/tree/props.rs is the single owner of which keys are the user’s. Every reader of that predicate wants the fence hidden, including the renderer, which is where the trap sits. The projection has to lift the key out before it filters:

    let frontmatter = properties
        .iter()
        .position(|(k, _)| k == outl_md::PAGE_FRONTMATTER_KEY)
        .map(|at| properties.remove(at).1);
    properties.retain(|(k, _)| !is_page_model_key(k));

Swap those two statements and the fence is deleted from every projection, which is the write the issue reported, reached through the fix for it. the_render_lifts_the_fence_before_hiding_the_page_model_keys in crates/outl-actions/src/journal/tests/frontmatter.rs is the pin.

Leaving the key out of that predicate was worse than a naming slip, and this is the part I got wrong first. The property panel showed the fence as an editable chip and the suggestion menu offered the key. Deleting the chip emitted SetProp(page-frontmatter, None), after which the render carries no fence, the file still does, and every projection is refused as content the log lacks. The page stops syncing in both directions until outl reconcile --ahead-of-log runs, and that recovery re-reads the fence and puts the chip back.

a sentence cannot fail

journal/guard.rs now owns all three invariant-8 questions, and its module doc used to carry a sentence I had written and believed: both write gates in super::apply run all three. Nothing in it was false. crates/outl-actions/src/journal/apply.rs held six public writers and two of them were gates.

It still read as a census, and that is how apply_page_md_with_sidecar_rendered shipped next to it: public, re-exported from lib.rs, straight through to the projection writer, asking none of the three, with no caller anywhere in the repository. During the #281 upgrade window that call is the write that deletes the fence. The same bug, through a door nobody had counted.

So the count is a test now. crates/outl-actions/tests/projection_writer_gates.rs reads the public functions out of apply.rs and compares them against a table:

enum Gate {
    Guarded,
    Delegates,
    Ungated,
}
a public writer in journal/apply.rs with no recorded verdict: {undeclared:?}.
Add a row to WRITERS saying whether it runs the invariant-8 guards, and why
that is correct. A writer nobody counted is how
`apply_page_md_with_sidecar_rendered` shipped guard-free.

It checks both directions, so a row for a function that no longer exists fails too, and a second test asserts the doc names all six writers rather than only the interesting ones. The guard-free writer is gone. This is the same move as turning client parity into an exhaustive match: prose that lists some of a set is indistinguishable from prose that lists all of it.

config.toml had the same shape, one bad character wide

The second issue in this change is the same defect in a much smaller file, and it cost a whole set of preferences rather than a page.

Every client loads the global config, changes one field and saves, and save serialises the whole struct. The load returned a Config and nothing else, so a TOML syntax error came back as defaults plus a tracing::warn! in a log nobody opens. One bad character plus one toggle in a settings modal, and the theme, vim_mode, timezone and [sync] transport the user had chosen were replaced by values nobody chose. The file they could have fixed was gone.

The load in crates/outl-config/src/lib.rs carries a verdict now, because the difference between two of these states is the whole point:

pub enum ConfigSource {
    Missing,
    Parsed,
    Unreadable(String),
}

Missing is a first launch, the one state where defaults are what the user asked for. Unreadable is the user’s own values sitting on disk where nothing can see them, and save_to refuses it with a message naming the path and the failing line. The check lives in the writer rather than in the callers, because a caller that loaded hours ago, or never loaded at all, cannot answer whether the file on disk parses. There is no force variant: the escape hatch is the file, which is hand-editable by design and still byte-for-byte intact.

The desktop had a mitigation for this that the same failure had quietly disarmed. save in crates/outl-desktop/src-tauri/src/settings.rs copies the sections its wire struct does not model out of the config on disk, so nothing outside the settings modal is lost. For an unparseable file, the config on disk is Config::default(), so the preservation faithfully preserved nothing.

Then the door the guard does not watch. This file has several writers by design, since the terminal app and the desktop app read and write the same path, and one shared scratch name is one shared inode: writer B’s File::create truncates the body A already fsynced, A’s rename publishes those zero bytes, and B goes on writing through a descriptor that now points at the published file while its own rename fails with ENOENT. The user sees “could not write”, and what is on disk is a zero-byte config.

A zero-byte config is the worst landing spot this crate has, and it is the #284 loss reached through the guard’s blind side. Every field carries a serde default, so an empty file deserialises into a whole Config: the file is Parsed, the write guard finds nothing to refuse, and the next save writes defaults over everything. Calling zero bytes unreadable would not be the fix either, because an empty config is a legitimate config meaning “all defaults”, and touch is how a user starts one by hand. Refusing to save over it would lock that user out of every toggle with a message telling them to repair a file that is not broken: a guard turned into a wall.

So the length stays uninterpreted and the cause is removed. crates/outl-config/src/atomic.rs composes into .config.toml.tmp.<ulid>, a name nobody else can be holding, and hands back the question a unique name always hands back: what cleans it up. A guard type unlinks the scratch on every in-process exit path, and a sweep unlinks siblings older than 24 hours on a later save, which is a margin rather than a deadline, because unlinking a live writer’s scratch fails that writer’s rename with the very error this exists to stop producing. crates/outl-config/tests/concurrent_save.rs runs eight writers at forty saves each with two reader threads counting empty and half-written reads, and asserts on the per-save result rather than on the final file, because a late rename papers over an earlier zero-byte publish.

what it cost

A frontmatter edit is absent from the page’s history, because timeline filters by the same predicate that hides the key everywhere else. That matches page-slug and page-kind, and the fence is not part of what the page says, but it is a real gap and admitting one key back would make that module a second owner of “which properties are the user’s”.

apply_page_md_with_sidecar_if_absent is recorded as ungated rather than fixed. It writes only when the file is absent, and it does not ask whether an absence is really an absence, so it can still write over an undownloaded iCloud placeholder. The gate that subsumes it is what the read paths call. The argument is a column in a table now instead of a thing nobody had written down.

The unreadable-config notice reaches the terminal app on its first frame and outl doctor on every run. The desktop and mobile apps still only speak when a write is attempted, so a GUI user whose config stopped parsing sees the app come back on defaults with no explanation until they try to change a setting.

And a page whose op log holds its fence as bullets is refused, not repaired, until outl reconcile --ahead-of-log reads the real fence back in. That is a page frozen in both directions on purpose, reachable in one situation, named by doctor. I would rather freeze a page and say so than write over four lines of somebody’s metadata.

Both fixes cut across modules already past the size guard, so nine of them became directories split by question rather than by line count, and .github/file-size-baseline.txt went from 58 rows to 47 with no row growing.

the question to ask a new guard

The op log is the truth and everything else is a projection that can happen later. That rule is what makes outl fast, and this bug is its bill. A guard that reads a projection is asking a question whose answer another part of the system is free to change, and the part that changed it here was the reconcile: the one component whose entire job is making the derived state agree with the file.

So the thing to ask of a guard is not whether its answer is right today. It is which operation can flip the answer, and whether that operation is the one you were afraid of. If the answer to the second question is yes, what you have written is not a wall. It is a countdown, and something will spend it.

The fence handling lives in crates/outl-md/src/frontmatter.rs and crates/outl-md/src/reconcile/page_root.rs, the three write-side questions in crates/outl-actions/src/journal/guard.rs, and the census that keeps them honest in crates/outl-actions/tests/projection_writer_gates.rs. The two issues are #281 and #284.