A content write published by one device silently and permanently overwrites a version another device published in the meantime. Nothing detects the race, nothing dead-letters, nothing warns, and the superseded bytes are not recoverable.
Surfaced while triaging a CodeRabbit finding on #1105 (TextEditorDialog), but the gap is entirely in crates/engine — the web dialog only makes it easy to hit. The proposed fix there was rejected as out of scope for a web PR.
Verified mechanism
Traced end to end on main:
Engine::begin_write (crates/engine/src/facade.rs:2343) takes only (target, size). It checks node kind, the staging budget and the hosted quota — no version, no precondition. The wire protocol carries no version field either: crates/wasm/src/host.rs:241 and packages/client/src/broadcast.ts:55 are both { target, size }.
commit_write_inner (crates/engine/src/facade.rs:2658-2661) stamps base_sequence = self.base_sequence_for(node). That helper (crates/engine/src/facade.rs:3173) reads the local render's record sequence at commit time, not at the time the caller read the bytes it is about to replace.
rebase_update_content (crates/engine/src/sync/rebase.rs:528) receives the op but never reads op.base_sequence. If the target is present in the working (freshly resolved, gate-passing) snapshot it applies unconditionally and just bumps content_version. Contrast rebase_delete (crates/engine/src/sync/rebase.rs:344-356) — the only rule that consults the target's record sequence, and the one the blueprint calls the conditional delete.
publish_head (crates/engine/src/sync/drain.rs:2047) passes min_current_sequence: None, so the record CAS asserts no precondition. The publish takes the next sequence above the observed floor and wins.
Net: device A's version replaces device B's at a strictly higher sequence, and B's record is legitimately superseded from every reader's point of view.
There is no recovery path. Per #1094, v2 retains no prior versions and a superseded content root is orphan-GC collectable, so the overwritten version's fresh-keyed read-body is destroyed rather than merely unsurfaced.
Two distinct layers
Engine. A remote publish that lands between commit and drain is clobbered because the rebase ignores the base sequence. This half needs no protocol change — op.base_sequence is already on the op and already stamped.
Protocol. Even with the rebase honouring it, the base sequence is minted at commit from the engine's own current render, so a caller that read bytes at version N, held them across a snapshot update, and committed at version N+1 still looks perfectly fresh to the engine. apps/web/src/components/file-browser/TextEditorDialog.tsx is exactly that shape: it adopts the loaded text once and deliberately does not re-adopt when a newer snapshot lands mid-edit, so the user's draft is based on N while the op is stamped N+1. Closing this half means the expected version travels from the read (or from begin_write) into the op, which is a crates/engine + crates/wasm + packages/client protocol change.
Spec status
blueprint/engine.md:360-367 enumerates the per-op rebase rules (#33 D5): delete vs concurrent edit, rename vs rename, add vs add, move, dual-link. There is no edit-vs-edit row. The write plane's normative table is silent on this race, so this is a spec gap as well as an implementation gap — the resolution has to be decided, not just coded.
Scope
- Decide and record the edit-vs-edit rule in
blueprint/engine.md's rebase table.
- Make
rebase_update_content honour op.base_sequence — reject, dead-letter, or preserve the loser, per the decision above.
- Thread an expected version from the caller through
begin_write/commit_write and the beginWrite wire message so a caller holding a stale read cannot commit against a version it never saw.
- Cover both directions in the sync rebase suite: a remote publish landing before the drain, and a caller committing against a version the local snapshot has already advanced past.
Dependency
Blocks #1094. That issue requires restore to "go through the normal write path so it publishes as a new version" — a restore is an updateContent against a base the user deliberately chose, so it inherits this missing precondition and would clobber a concurrent publish with older bytes. The precondition should exist before restore is exposed.
Part of #992
A content write published by one device silently and permanently overwrites a version another device published in the meantime. Nothing detects the race, nothing dead-letters, nothing warns, and the superseded bytes are not recoverable.
Surfaced while triaging a CodeRabbit finding on #1105 (
TextEditorDialog), but the gap is entirely incrates/engine— the web dialog only makes it easy to hit. The proposed fix there was rejected as out of scope for a web PR.Verified mechanism
Traced end to end on
main:Engine::begin_write(crates/engine/src/facade.rs:2343) takes only(target, size). It checks node kind, the staging budget and the hosted quota — no version, no precondition. The wire protocol carries no version field either:crates/wasm/src/host.rs:241andpackages/client/src/broadcast.ts:55are both{ target, size }.commit_write_inner(crates/engine/src/facade.rs:2658-2661) stampsbase_sequence = self.base_sequence_for(node). That helper (crates/engine/src/facade.rs:3173) reads the local render's record sequence at commit time, not at the time the caller read the bytes it is about to replace.rebase_update_content(crates/engine/src/sync/rebase.rs:528) receives the op but never readsop.base_sequence. If the target is present in the working (freshly resolved, gate-passing) snapshot it applies unconditionally and just bumpscontent_version. Contrastrebase_delete(crates/engine/src/sync/rebase.rs:344-356) — the only rule that consults the target's record sequence, and the one the blueprint calls the conditional delete.publish_head(crates/engine/src/sync/drain.rs:2047) passesmin_current_sequence: None, so the record CAS asserts no precondition. The publish takes the next sequence above the observed floor and wins.Net: device A's version replaces device B's at a strictly higher sequence, and B's record is legitimately superseded from every reader's point of view.
There is no recovery path. Per #1094, v2 retains no prior versions and a superseded content root is orphan-GC collectable, so the overwritten version's fresh-keyed read-body is destroyed rather than merely unsurfaced.
Two distinct layers
Engine. A remote publish that lands between commit and drain is clobbered because the rebase ignores the base sequence. This half needs no protocol change —
op.base_sequenceis already on the op and already stamped.Protocol. Even with the rebase honouring it, the base sequence is minted at commit from the engine's own current render, so a caller that read bytes at version N, held them across a snapshot update, and committed at version N+1 still looks perfectly fresh to the engine.
apps/web/src/components/file-browser/TextEditorDialog.tsxis exactly that shape: it adopts the loaded text once and deliberately does not re-adopt when a newer snapshot lands mid-edit, so the user's draft is based on N while the op is stamped N+1. Closing this half means the expected version travels from the read (or frombegin_write) into the op, which is acrates/engine+crates/wasm+packages/clientprotocol change.Spec status
blueprint/engine.md:360-367enumerates the per-op rebase rules (#33 D5): delete vs concurrent edit, rename vs rename, add vs add, move, dual-link. There is no edit-vs-edit row. The write plane's normative table is silent on this race, so this is a spec gap as well as an implementation gap — the resolution has to be decided, not just coded.Scope
blueprint/engine.md's rebase table.rebase_update_contenthonourop.base_sequence— reject, dead-letter, or preserve the loser, per the decision above.begin_write/commit_writeand thebeginWritewire message so a caller holding a stale read cannot commit against a version it never saw.Dependency
Blocks #1094. That issue requires restore to "go through the normal write path so it publishes as a new version" — a restore is an
updateContentagainst a base the user deliberately chose, so it inherits this missing precondition and would clobber a concurrent publish with older bytes. The precondition should exist before restore is exposed.Part of #992