Found + VERIFIED in the impl-pass security review over the merged #163 fence. CRITICAL.
Bug
shared/sync/src/kb.rs::update_new_op_authors attributes new-op authors by diffing yrs state vectors
(before/after apply) ∪ the update's own SV. In YATA a deletion is a tombstone in the delete-set, not a
clock-advancing struct — so a pure-delete update advances no client's clock and contributes nothing to
either signal. update_new_op_authors returns [], enforce_epoch_fence finds no stale author → Ok.
Verified
A probe (stale client deletes a node body via set_body("")) returns authors = [] for both an existing-lineage
client and a fresh client:
PROBE pure-delete authors = []
PROBE client-999 delete authors = []
Attacker path
A demoted-not-removed (or viewer-era) client at a stale epoch authors a DELETE of base content. kb_access(Edit)
passes (they're a current editor), enforce_epoch_fence passes (delete → no authors) on both the hub
kb/node_update and the mesh dialer paths (both consume update_new_op_authors). The censoring deletion lands +
broadcasts. (A removed member is still caught by admit/NotAMember on the signed path; this is the
demoted/unsigned-hub gap.)
Fix
In update_new_op_authors, also attribute delete-set authors: decode yrs::Update::decode_v1(update), iterate
its delete_set per-client ranges, and union every client id whose deletions aren't already covered by
base_state's delete-set. Add regression tests: a pure remove_range/remove_tag/set_body("") under a stale
client id is reported, and an end-to-end enforce_epoch_fence test that a stale-epoch delete is REJECTED.
Test gap (principle #14)
Every B-19/B-20 fence test uses set_title/set_body with non-empty text — remove_range THEN insert, and the
insert masks the gap. None exercises a delete-dominant op. The chosen inputs all happen to insert (the "unicorn
value" failure mode).
Found + VERIFIED in the impl-pass security review over the merged #163 fence. CRITICAL.
Bug
shared/sync/src/kb.rs::update_new_op_authorsattributes new-op authors by diffing yrs state vectors(before/after apply) ∪ the update's own SV. In YATA a deletion is a tombstone in the delete-set, not a
clock-advancing struct — so a pure-delete update advances no client's clock and contributes nothing to
either signal.
update_new_op_authorsreturns[],enforce_epoch_fencefinds no stale author → Ok.Verified
A probe (stale client deletes a node body via
set_body("")) returnsauthors = []for both an existing-lineageclient and a fresh client:
Attacker path
A demoted-not-removed (or viewer-era) client at a stale epoch authors a DELETE of base content.
kb_access(Edit)passes (they're a current editor),
enforce_epoch_fencepasses (delete → no authors) on both the hubkb/node_updateand the mesh dialer paths (both consumeupdate_new_op_authors). The censoring deletion lands +broadcasts. (A removed member is still caught by
admit/NotAMember on the signed path; this is thedemoted/unsigned-hub gap.)
Fix
In
update_new_op_authors, also attribute delete-set authors: decodeyrs::Update::decode_v1(update), iterateits
delete_setper-client ranges, and union every client id whose deletions aren't already covered bybase_state's delete-set. Add regression tests: a pureremove_range/remove_tag/set_body("")under a staleclient id is reported, and an end-to-end
enforce_epoch_fencetest that a stale-epoch delete is REJECTED.Test gap (principle #14)
Every B-19/B-20 fence test uses
set_title/set_bodywith non-empty text —remove_rangeTHENinsert, and theinsert masks the gap. None exercises a delete-dominant op. The chosen inputs all happen to insert (the "unicorn
value" failure mode).