blog · · engineering

Preserving history is not giving it back

outl's invariant 6 says delete is Move(node, TRASH_ROOT), not physical removal, because it preserves history. On a real workspace that meant outl doctor could count 683 blocks across 393 deletions and print their text, while nothing else in the binary could name one of them. Building outl trash list and outl trash restore is what showed what the invariant had actually bought: the bytes are still on disk, which is much weaker than getting them back. Where a block came from cannot be read off Move.old_parent, because 65,141 of the 65,703 stored Moves say root regardless of the truth, so the origin is folded from Create.parent and Move.new_parent. That fold is only correct if it replays each placement against the ancestors the target had at that instant, because invariant 4 keeps cycle-refused Moves in the log, and the first version of this checked against today's tree and would have restored a block under its own former child.

A
12 min read

outl doctor counted 683 blocks in the trash, across 393 top-level deletions, and printed the first line of each one.

Nothing else in the binary could name a single one of them. No subcommand, no MCP tool, no screen in any client. The doctor would show me the text of a block I deleted two years ago and there was no way to ask for it back.

Invariant 6 of outl’s CLAUDE.md has said the same thing since the beginning: delete is Move(node, TRASH_ROOT), not physical removal, because it “simplifies the algorithm and preserves history”. I had read that for years as nothing is lost. The preserving half has worked since day one. The reading half did not exist, and what the invariant actually bought was that the bytes are still on disk, which is the difference between a recycle bin and a deleted file on a drive you have not overwritten yet.

outl trash list and outl trash restore <id> exist now, with outl_trash_list and outl_trash_restore behind them so an agent that deleted a block can undo it. Writing them is what showed me the gap was not a missing command. It was a missing fact.

the field that names the old parent is scratch, not a record

Putting a block back needs one thing the tree no longer holds: where it was. The operation that moved it looks like it carries exactly that.

Op::Move {
    node,
    new_parent,
    position,
    old_parent,
    old_position,
}

old_parent is not a description of the move. It is the originating replica’s local bookkeeping for undo_op, filled in by Tree::do_op so the reorder loop can put the node back when it rewinds. block::moves::move_to reads it off the tree and gets it right. The reconcile and import paths construct the op themselves and pass root, because nothing they do ever reads it.

On my workspace that is 65,141 of 65,703 stored Move ops naming root as the old parent regardless of where the block was, which is a number I only know because the same measurement is what turned up an append-only log that had been lying about every old parent while page history was being built. Workspace::apply persists what the tree recorded now. The ops already on disk stay wrong, because append-only means append-only, and there is no migration to write.

So the rule for a reader of the log is the one that fell out of that: derive from the fields that describe an op’s own effect, never from the fields describing what it replaced. For a restore, that means Create.parent and Move.new_parent, and nothing else.

so the origin is folded, and the fold is the whole feature

trash::parent_at_deletion is the single owner of “where did this block live when it went”. It walks the node’s placements in order and keeps the parent that was in force immediately before the move into the trash:

for &parent in replay.trail(node)? {
    // Re-emitting the trash as the parent of a block already there
    // moves nothing, and must not overwrite the answer with the
    // trash itself.
    if parent == Some(NodeId::trash()) && previous != parent {
        at_deletion = previous;
    }
    previous = parent;
}

Two things in five lines. The guard is there because a reconcile re-emits ops that change nothing, and a second Move naming the trash for a block already in the trash would otherwise set the origin to the trash root. And the assignment is unconditional rather than a first-match, so a block deleted, restored and deleted again somewhere else is attributed to the parent it left last.

Create needs the same care for a different reason. do_op(Op::Create) only seeds a node that is absent, so a reconcile re-emitting Create for a block that already exists names a parent the tree discards. Five re-emissions for some blocks on my workspace. A fold that took the latest Create at face value would attribute the deletion to a parent the block never had, which is the same idempotence that produced a much worse bug when do_op skipped a duplicate Create and undo_op removed it anyway. parent_at_deletion_ignores_a_re_emitted_create pins it.

invariant 4 keeps the moves the tree threw away

This is where the fold stops being an accumulator.

Invariant 4: a Move that would create a cycle is a no-op on the materialized tree, and the op still goes into the log. Removing it would break the correctness of future reordering, so it stays. The consequence for anyone reading the log as data is that “the ops that name this node” and “the parents this node actually had” are two different lists, and only one of them is the tree’s.

A fold that replays new_parent unconditionally disagrees with the tree on exactly those ops. The disagreement is not academic: a refused move’s target is, by definition, inside the subtree of the node being moved. Believing it produces a restore that puts a block under its own descendant, or, once landing_for catches that, a refusal reading “restore X first” where X is the block you just asked for.

It is reachable today, which is why it is a test and not a comment. outl block move pre-checks for cycles before emitting. outl-plugins’ host API calls block::move_under without that check, so a plugin can put a refused Move in the log any time it likes.

a cycle is a question about the tree at the time, and I asked today’s

The first version of this got that far and then asked the wrong tree.

Checking a folded placement against the tree as it stands now seems obviously sufficient. If B is under A today, then Move(A, B) was a cycle and the tree refused it. The hole is that a cycle is not a property of the op. It is a property of the tree at the instant the op ran.

Move(section, item)   refused: item is under section
Move(item, page)      item leaves
Move(section, trash)  section is deleted

By the time anybody asks, item sits on the page, nothing is under anything, and today’s tree has no record that the first move was ever refused. A check against it reads Move(section, item) as a move that happened, concludes that section lived under item, and restores it there. That is a_move_refused_into_a_descendant_stays_refused_after_the_descendant_leaves, and it was a real failure in a real commit before it was a test name.

crates/outl-actions/src/trash/replay.rs decides each placement the way do_op decided it, against the ancestors its target had just before its own timestamp:

/// `Tree::creates_cycle`, asked of the tree as it stood just before `at`.
fn creates_cycle(
    &mut self,
    node: NodeId,
    target: NodeId,
    at: Hlc,
) -> Result<bool, ActionError> {
    let mut current = target;
    loop {
        if current == node {
            return Ok(true);
        }
        match self.parent_before(current, at)? {
            Some(parent) => current = parent,
            None => return Ok(false),
        }
    }
}

parent_before resolves that node’s own placements up to at, each of them decided by the same test against its target’s ancestors just before its timestamp. Every question asks about a strictly earlier instant, so the recursion terminates, and it reads only the ops of the nodes on those ancestor chains rather than replaying the whole log. The sentinels short-circuit: root and the trash root have no parent, and loading every op that names one of them would mean reading everything.

The honest thing to say about this is that it is a second place that answers “would do_op have applied this move”. timeline deliberately refuses to reconstruct a page at a past instant for exactly that reason, because a second materialization path is a place where two answers can disagree. This one is narrower, per node and per ancestor chain rather than a whole-tree replay, and the only thing holding it to the first answer is the three tests that compare it against what the tree did. If the cycle rule ever changes, two pieces of code have to change together.

one function decides, because a listing that decides for itself drifts

landing_for returns either the parent a block would land under or the error explaining why it would not. restore asks it before touching anything. list asks it per entry and carries the result into the listing. refusal_for is its read-only half for callers that want the verdict without the destination.

A listing that worked out “restorable” for itself would be a second owner of that rule, and the direction it drifts is towards offering a user an action that then fails. That is the same shape as outl’s invariant 8, where a read-only report promising a repair the writing pass refuses is the failure mode the single-owner rule exists to prevent. a_listing_carries_the_same_refusal_restore_would_return runs the whole listing and then attempts each entry, asserting the sentence shown is the exact error returned.

The ordering inside that function matters more than it looks. It proves the node is in the trash first, and only then folds. Which means that once the fold comes back empty, answering NotTrashed contradicts something already established: the listing shows the block, and the line under it says the block is not in the trash. That case is TrashOriginUnknown now, carrying which of three shapes it is in the user’s words, so a damaged op log reads differently from a parent that is merely gone:

let unknown = |why: &str| ActionError::TrashOriginUnknown {
    node: node.to_string(),
    why: why.to_string(),
};

Each refusal also carries its own stable code in crates/outl-cli/src/cmd/trash.rs, so an agent can tell them apart without parsing prose. The message stays the one outl_actions::error wrote, because a second wording in the CLI is a second owner of the explanation. The human listing got that wrong first and printed cannot restore: cannot restore <id>: …, which is pinned now by the_human_listing_states_each_refusal_once.

the fold had a second caller, and shipping restore made its bug reachable

timeline::came_from decides which deletions belong to a page’s history. It had its own copy of the fold, and the copy answered a slightly different question: it returned true on the first move to the trash whose parent matched, which is the first page a block was ever deleted from.

That was harmless for exactly as long as a block could not come back. Delete, restore, move somewhere else, delete again, and the first page keeps claiming a block it no longer holds. Nothing could produce that sequence until restore shipped, so the fix and the feature are the same change: came_from calls parent_at_deletion, and a_deletion_is_attributed_to_the_page_the_block_left_last pins the corrected attribution.

The doctor’s trash section had the same shape, a traversal of its own that counted what no other surface could name. It renders trash::list now, and tells you how many of them can be put back.

286 of 393, and what the other 107 are told

Measured against the real deletions: 286 restorable, 89 refused because the parent is in the trash too, 18 refused as pages.

The 89 are a block deleted on its own, whose parent was then deleted after it. Both are direct children of the trash root, so both show up in the listing, and restoring the child alone would succeed structurally and change nothing the user can see. A block put back under a parent that is itself in the trash is still invisible, and the user would be told it worked. The refusal names the ancestor to restore first:

$ outl trash list
393 deletion(s) in the trash
  01KVWQX6G8MAS26T1BENQ61ET7  asdasdasd
  01KWEVH8CD7YE79RD2AZAJPT5C  dkfjdskjf
      cannot restore 01KWEVH8CD7YE79RD2AZAJPT5C: the block it was deleted from (01KWEVH5EVX4A533ER9SYBCW8R) is in the trash too — restore 01KWEVH5EVX4A533ER9SYBCW8R first

Answering that question needs an ancestor walk rather than a parent comparison. Deleting a subtree is one Move on its root, so every block inside it keeps pointing at a live-looking parent, and tree().parent(node) == trash is false for the majority of what a user deleted. is_trashed in crates/outl-actions/src/tree/traverse.rs was already that walk; the restore needed the same walk with the sentinel as a parameter, to ask whether a folded origin has since moved inside the subtree being restored.

The 18 are deleted pages, and that refusal is a scope decision rather than a missing branch. Restoring a page is the Move plus a re-projected .md, and 16 of the 18 deleted pages on my workspace have their slug taken by a live page today. Picking a free slug would make the trash module a second owner of the slug rule. So it refuses, names the slug, and points at outl block tree <id>, deliberately not at outl page history <slug>: history resolves slugs among the live pages, so it cannot find a deleted one, and where the slug was reused it would show the replacement’s history instead.

And a restored block lands as the last child, not in the slot it held. Move.old_position carries exactly the same caveat as old_parent, so the original position is not recoverable from the log as data. Last child is the honest answer and the documentation says so rather than leaving it to be noticed.

a capability nobody can reach, declared rather than discovered

No client has a trash surface. That is recorded as Capability::Trash rather than left to be found by someone looking for a menu item.

Recording it needed a new mechanism, because the catalog’s own test, no_capability_is_out_of_reach_on_every_client, is right about the general case: a capability nobody can reach is normally a feature that does not exist. A CLI-only capability is the one case where “missing on all three clients” is a true and useful statement rather than a bug. So crates/outl-shortcuts/src/capability_support.rs carries a declared row with a reason, the same shape the Tauri surface’s DECLARED_GAPS uses, instead of weakening the assertion. A row there is a decision; an empty reason is not one. It is the same move as the exhaustive match that stopped three clients disagreeing about which shortcuts they implement, applied to the gap this change deliberately left open.

what it costs

There is no trash empty, and that is the part I expect to be asked about. Emptying is the only operation in this area that actually destroys, so it belongs with op-log compaction rather than as an rm. Until that lands, retention is unbounded, nothing ever leaves the trash on its own, and a workspace’s trash only grows. That is a policy, and docs/cli.md now states it rather than leaving a user to infer it from the absence of a command.

The fold is not free either. list asks landing_for once per entry, and each ask walks that node’s placements plus the placements of everything on its ancestor chains. On 393 deletions it is invisible. Nothing caps it, and the cost grows with how tangled a block’s history is rather than with how many blocks there are, which is the kind of cost that stays invisible until one workspace has a pathological node.

TrashOriginUnknown is a real dead end rather than a diagnostic. When the log cannot place a block, the answer to the user is that the text is in the listing and recovering it is a copy and paste. Zero entries hit it on my workspace, which is the only reason I shipped it as a refusal rather than as a repair.

And the position is gone, the pages cannot come back, and the 89 blocks with a trashed parent need two commands rather than one.

The lesson I would take out of this is not about trash. An append-only log guarantees that the operations survive. It does not guarantee that the question you will eventually want to ask is answerable from them, and the gap between those two is invisible for as long as nobody asks. Invariant 6 was correct the entire time, and the sentence I had been reading into it, that nothing is lost, was doing work the code had never been asked to do. A safety property is only worth what its reading half can get back out.

The trash lives in crates/outl-actions/src/trash.rs, the replay that decides which moves the tree applied sits beside it in replay.rs, the tests that keep the two honest are in crates/outl-actions/src/trash/tests.rs, and the issue is #287 in the outl repository.