Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

fix(dm): stop ordinary sync churn from destroying DM archives - #529

#529
Merged
sanity merged 1 commit into
mainfreenet/river:mainfrom
fix/526-dm-archive-survives-relanded-dmsfreenet/river:fix/526-dm-archive-survives-relanded-dmsCopy head branch name to clipboard
Jul 28, 2026
Merged

fix(dm): stop ordinary sync churn from destroying DM archives#529
sanity merged 1 commit into
mainfreenet/river:mainfrom
fix/526-dm-archive-survives-relanded-dmsfreenet/river:fix/526-dm-archive-survives-relanded-dmsCopy head branch name to clipboard

Conversation

@sanity

@sanity sanity commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Archiving a DM writes a (room, peer, hidden_at_ts) entry and the rail hides the thread while the peer's newest message is at or below it. A new inbound DM is meant to revive it (#267), but the revival was implemented as a hard delete of the archive entry, fired whenever a DM crossed the merge dedupe.

"Crossed the dedupe" means "was not in LOCAL state a moment ago", which is a different question from "is new content" — and the decision never consulted hidden_at_ts at all. Two ways it misfired:

  • Ordinary sync churn. post_apply_cleanup sweeps DMs whose counterparty is momentarily absent from members on every merge. Peers re-offer them, they re-land with their original timestamps, and the diff can't tell them from a new message.
  • Empty pre-merge snapshot. On the full-state path, when the room is in ROOMS but its DM state hasn't hydrated, every DM in the incoming state reads as newly landed — so every archive in the room was destroyed at once.

unhide_dm_thread also writes a session RECENTLY_UNHIDDEN tombstone and queues a delegate save, so the loss was persisted and a later hydration carrying the archive was suppressed. Archiving looked like it did nothing.

Approach

ui/-only — no common/, delegate, or contract WASM change, so no migration.

unhide_dm_thread_if_dm_is_newer replaces the unconditional call at both inbound-sync sites, dropping the entry only when the DM's timestamp is strictly past the cutoff. A pair with no entry is inert — deliberately including "hasn't hydrated yet", so no unhide and no tombstone, which defuses the startup race. fold_newly_landed_to_max_ts reduces a merge batch to one entry per sender keeping the largest timestamp.

The archive clock is inbound-only

A gate is only as good as its cutoff. last_any_ts covers our own outbound DMs too, and an outbound DM's timestamp is our local wall clock. So a thread where we replied last was archived against our clock while the gate compares a sender's timestamp — and a peer DM already in flight across the click landed at or below the cutoff and went invisible on every surface: no rail row, no unread badge (count_unread_dms_with applies the same filter), no notification (DMs raise none).

The clock is now last_inbound_ts everywhere the archive decides visibility: filter_rail_entries, both archived-view projections, and the unread tally. Outbound revival comes solely from the unconditional unhide_dm_thread in do_send / send_structured_dmon the sending device only; hydration never propagates a removal and riverctl dm send doesn't call it, which the docs now state rather than gloss.

The cutoff is also recomputed from live ROOMS state, maxed with the rendered row so a contended read falls back. The row can lag, and while the membership sweep is atomic per pair, the re-land is not — a peer restoring only a subset would otherwise leave the cutoff low.

Why the cutoff is NOT clamped

An earlier revision clamped it at now + MAX_DM_FUTURE_SKEW_SECS to blunt a forged-future timestamp. Two reviewers independently found that this broke the load-bearing invariant every existing inbound DM satisfies ts <= cutoff: the rail filter compares the unclamped observed clock, so Archive became a visible no-op that still showed a success toast, and the next sweep-and-re-offer saw ts > cutoff and destroyed the persisted archive — this bug again, narrowed to skew-violating peers. Clamping one side of a comparison was what created the hole.

The forged-timestamp concern is handled where it cannot break the invariant: should_unhide_for_inbound_dm clamps the incoming timestamp, so an implausible-future DM can never trigger an unhide.

Accepted residuals

All defer messages from one peer the user already chose to archive; none destroys state.

  • A new inbound DM in the same unix second as the peer's previous one.
  • A peer whose clock moves backward relative to their own previous message.
  • A peer who stamps far in the future: their later genuine DMs wait until real time passes it.
  • One never-rendered DM that the live recompute may cover.

The real remedy for a hostile peer is a block/ignore list — #461.

Testing

cargo test -p river-ui --bins — 768 pass; 772 with --features example-data,no-sync. cargo fmt --all -- --check clean, wasm target clean. Full Playwright suite green (605 passed, 15 skipped) across Chromium, Firefox, WebKit, mobile Chrome and mobile Safari.

Note the example-data build contains no DMs (populating them needs a real ECIES envelope per DM), so Playwright cannot drive the archive flow end-to-end; it covers the surfaces this PR touches against regression, and the behaviour is pinned in Rust.

Mutation-checked, each reverted independently against the committed fix — all caught:

Mutation Result
gate always true (pre-fix behaviour) red
gate deleted from the wrapper body red
sync site calls bare imported unhide_dm_thread red
filter compares last_any_ts (the outbound-anchor bug) red
accumulator hoists the inbound bump out of is_self_recipient red
row passes last_any_ts to archive_row red
archived-count projection reverts to last_any_ts red
no live recompute (trust the rendered prop) red
wall-clock clamp reintroduced on the cutoff red
do_send routed through the gated unhide red
Undo toast routed through the gated unhide red
determinism sort dropped red

Two mutations initially reported green and both turned out to be measurement bugs worth recording: one didn't compile (so no tests ran), and one was inert because the pin's needle matched its own explanatory comment rather than the code. Both are fixed; the clamp is now pinned structurally, because archive_cutoff takes no injectable now and fixture timestamps sit far below real wall-clock time, which makes any re-introduced clamp invisible to a behavioural test.

Repaired two vacuous pins

The existing #267 pins searched include_str! for literals their own assertions contained, so all three assertions in each were tautologies. They now cut at mod tests, match whitespace-stripped, assemble needles at runtime, and the delta-path pin is sliced to its own function.

Review

Three full rounds, independent blind lenses each time. Every round found a real defect in my own work; all are addressed:

  • Round 1 — killed a max(last_any_ts, now) cutoff as unnecessary and harmful.
  • Round 2 — found that removing it wasn't enough, because last_any_ts includes outbound. That produced the inbound-only rework above.
  • Round 3 — found the one-sided clamp (HIGH, both reviewers independently), the unpinned inbound producer and outbound-send sites (HIGH), a dead segment bound, a stale test asserting removed behaviour, a probabilistic determinism test, and several inaccurate comments including a rules-doc citation of a renamed test. All fixed.

Also filed #530 — a pre-existing bug two reviewers surfaced: a save that runs before the delegate blob hydrates persists a truncated store, permanently losing archives and outbound-DM plaintexts. It reproduces two of this issue's symptoms by a different mechanism, so #526 alone does not fully close the user complaint.

Closes #526

[AI-assisted - Claude]

@sanity

sanity commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Blocking finding from re-review — do not merge as-is

Two independent reviewers converged on the same live defect in the current commit (ba08ddae), and I verified the mechanism directly.

The defect

archive_row anchors hidden_at_ts at last_any_ts, which accumulate_peer_activity computes over both directions — the if timestamp > acc.last_any_ts bump at dm_rail_section.rs:564-566 sits outside the is_self_recipient branch. An outbound DM's timestamp is unix_now(), the local user's own wall clock (direct_messages.rs:462, :556-562).

So whenever the user replies and then archives, the cutoff is receiver-clock now — precisely the condition this revision removed the max(last_any_ts, now) anchor to avoid. The strict > gate then swallows any inbound DM stamped at or below it.

Scenario (no clock skew needed):

  1. t=1010 Bob sends a DM, ts=1010, still propagating.
  2. t=1012 Alice replies; her outbound ts=1012 is applied to local state immediately (dm_thread_modal.rs:539-560).
  3. t=1013 Alice archives. last_any_ts = 1012hidden_at_ts = 1012.
  4. t=1020 Bob's DM lands. Gate: 1010 > 1012 → false. No unhide.
  5. Rail: thread max is still 1012 → 1012 <= 1012 → hidden.

Bob's message has no surface: no rail row, no unread badge (count_unread_dms_with applies the same filter), no notification (DMs never raise one), and the Archived panel shows no unread indicator. It stays invisible until Bob sends something stamped above 1012 — indefinitely if he doesn't.

On main step 4 fires the unconditional unhide and the thread reappears, so this is a regression, not a residual. The PR's "one second, self-healing" claim is wrong: the window is (peer clock lag + propagation delay), and backward clock skew is unbounded — check_dm_future_skew bounds only the future direction, only at compose time, only against the sender's own clock.

What the fix should be

Make archive inbound-anchored — "hidden until they write again":

  • PeerDmActivity / DmRailEntry gain last_inbound_ts (max ts where recipient == self).
  • archive_row anchors the cutoff on that.
  • filter_rail_entries, count_currently_archived, build_archived_rows, and document_title::count_unread_dms_with compare last_inbound_ts against hidden_at_ts.

The user's own outbound already revives the thread through the unconditional unhide_dm_thread in do_send, so nothing is lost by taking outbound out of the filter — and the semantics get more coherent, not less. Re-landed old inbound DMs stay inert (their ts is at or below the inbound max at archive time), which is what keeps #526 closed.

Residual after that fix: a peer whose clock moves backward relative to their own previous message. Irreducible under timestamp gating, and far narrower than the current hole.

Other findings to fold in

  • gated_unhide_wrapper_consults_the_cutoff pins the gate condition and the cutoff read, but not that unhide_dm_thread(room_owner_vk, peer) is still inside the if — deleting that line kills bug(dm): same-second inbound DM stays hidden after hide cutoff #267 revival entirely with a green suite.
  • Same test: assertion 2 pins that the cutoff is read, not that it is passed; should_unhide_for_inbound_dm(None, ts) satisfies both and makes the wrapper a permanent no-op.
  • dm_rail_section.rs:1120rest.find("fn ") can never match, because the haystack is whitespace-stripped. The segment bound is dead code and seg spans the whole production half.
  • chat_delegate.rs:2236rest.find("pubfn") misses pub async fn, so the segment is ~110 lines rather than the ~19 the comment claims.
  • chat_delegate.rs:2152-2156 — assert message still cites max(newest, now) from the reverted revision, contradicting the sibling pin that now forbids it.
  • Nothing pins that the explicit-user-action sites (Undo toast, Un-archive, outbound send) keep the unconditional unhide; routing Undo through the gated form would make it a silent no-op.
  • apply_delta_inner_revives_... doesn't slice at the function head, so it doesn't actually scope to apply_delta_inner (mitigated by the count == 2 assertion).

Verified clean by the re-review

The all-or-nothing-per-pair sweep claim holds. trim_pairs_to_cap / trim_to_global_cap drop oldest-first so the surviving max is untouched; purge tombstones can lower the max but purged messages are gated at ingestion and re-dropped after merge, so they cannot re-land. Recipient filtering at both collection sites is correct. fold_newly_landed_to_max_ts is correct and deterministic. The signal-safety of try_read + explicit drop + deferred with_mut is correct, and the drop is load-bearing on non-wasm where defer runs synchronously. The two repaired #267 pins are genuinely non-vacuous.

Moving to draft until the inbound anchoring lands.

[AI-assisted - Claude]

@sanity
sanity marked this pull request as draft July 28, 2026 16:40
@sanity

sanity commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Addendum — two further residuals from the third lens

Both are in the same family (timestamp gating leaking), and both need to be folded into the inbound-anchored rework rather than fixed separately.

The sweep is all-or-nothing, but the RE-LAND is not

The safety argument I wrote says "every DM in the thread satisfies ts <= cutoff, by construction". That holds in the steady state only. It needs a second premise the comments never state: local state must hold the pair's complete set at click time.

The sweep removes a pair atomically, but restoration is delta-driven and unordered. A peer can re-offer a subset:

  1. Thread holds ts=800 and ts=1000. Membership flicker sweeps both.
  2. A peer re-offers only ts=800 (the only one it holds, or delta batching). The rail row reappears with last_any_ts = 800.
  3. User archives → cutoff = 800.
  4. ts=1000 re-lands from another peer → 1000 > 800 → gate fires → archive destroyed and persisted.

That is #526 unchanged, reached through the fixed code. Narrower window, same outcome. A second, smaller instance of the same shape: archive_row is fed the rendered prop (dm_rail_section.rs:184:205), so a DM that has landed in ROOMS but not yet re-rendered leaves the cutoff stale.

Proper close: have hide_dm_thread recompute the cutoff from live room state rather than trusting the rendered prop. That also subsumes the LAST_GOOD_RAIL staleness path.

A future-dated DM makes a thread permanently invisible

check_dm_future_skew runs only at compose time on an honest client (direct_messages.rs:522); verify deliberately does not enforce it, and nothing bounds timestamp on the ingestion path.

  1. A peer with a broken or hostile clock sends a DM stamped now + 1 year. It arrives, ts > cutoff, thread revives.
  2. User archives again → cutoff is now that forged timestamp.
  3. Every subsequent genuine DM from that peer has ts <= cutoff: gate blocks the unhide, rail filter hides the row, unread badge skips it.

Invisible on every surface for a year. This is a regression vs main, where the unconditional unhide deleted the entry on the next inbound DM. Unlike the same-second case it is neither bounded nor self-healing.

Mitigation: clamp the cutoff at now + MAX_DM_FUTURE_SKEW_SECS when writing it in archive_row.

Smaller items to fold in

  • chat_delegate.rs:4460-4462 lists three explicit-user-action call sites; there are four — direct_messages::send_structured_dm (invite-via-DM) is missing.
  • chat_delegate.rs:4507-4508 justifies the drop(hidden) with "before unhide_dm_thread defers a with_mut". On wasm the defer alone would suffice; the real reason is that defer runs synchronously on non-wasm (util.rs:75-77), which is how the test suite runs — so without the drop it is a re-entrant borrow panic. Worth saying, or the drop reads as deletable.
  • .claude/rules/direct-messages.md:165 refers to chat_delegate::is_thread_hidden; no such function exists (it is direct_messages::is_thread_hidden_for). Pre-existing, six lines above the new section.

Net assessment

The gate is the right mechanism and closes the reported bug for the common case. But anchoring it on a timestamp — any timestamp — keeps leaking, in both directions: too low a cutoff destroys archives, too high a cutoff hides real messages. The inbound anchoring plus a future-skew clamp plus recomputing from live state closes the ones found so far. Whether that is the right stopping point, versus recording arrival identity, is worth deciding deliberately before the next revision.

[AI-assisted - Claude]

@sanity
sanity marked this pull request as ready for review July 28, 2026 18:13
@sanity
sanity force-pushed the fix/526-dm-archive-survives-relanded-dms branch 2 times, most recently from 776e257 to c8f7882 Compare July 28, 2026 18:33
Archiving a DM writes a `(room, peer, hidden_at_ts)` entry and the rail
hides the thread while the peer's newest message is at or below it. A new
inbound DM is meant to revive it (#267), but the revival was implemented
as a hard delete of the archive entry, fired whenever a DM crossed the
merge dedupe.

"Crossed the dedupe" means "was not in LOCAL state a moment ago", which
is not the same question as "is new content", and the decision never
consulted `hidden_at_ts` at all. Two ways that misfired:

* `post_apply_cleanup` sweeps DMs whose counterparty is momentarily
  absent from `members` on every merge. Peers re-offer the swept DMs and
  they re-land carrying their original timestamps, indistinguishable
  there from a genuinely new message.
* On the full-state path the pre-merge snapshot is empty whenever the
  room is in `ROOMS` but its DM state has not hydrated, so every DM read
  as newly landed and every archive in the room was destroyed at once.

`unhide_dm_thread` also writes a session `RECENTLY_UNHIDDEN` tombstone
and queues a delegate save, so the loss was persisted and a later
hydration carrying the archive was suppressed. Archiving looked like it
did nothing.

`unhide_dm_thread_if_dm_is_newer` replaces the unconditional call at both
inbound-sync sites, dropping the entry only when the DM's timestamp is
strictly past the cutoff. A pair with no entry — including "the archive
list has not hydrated yet" — is inert and writes no tombstone, which
defuses the startup race. `fold_newly_landed_to_max_ts` reduces a merge
batch to one entry per sender keeping the largest timestamp.

The gate is only as good as its cutoff, so the archive clock is now
INBOUND-only and recomputed from live state:

* Inbound-only. `last_any_ts` also covers our own outbound DMs, whose
  timestamp is our LOCAL wall clock. A thread where we replied last was
  therefore archived against OUR clock while the gate compares a SENDER's
  timestamp, so a peer DM already in flight across the click landed at or
  below the cutoff and went invisible everywhere: no rail row, no unread
  badge, no notification (DMs raise none). `filter_rail_entries`, both
  archived-view projections and `count_unread_dms_with` all compare the
  inbound clock. Outbound revival now comes solely from the unconditional
  `unhide_dm_thread` in `do_send` / `send_structured_dm` — on the sending
  device only, which the docs state rather than gloss.
* Recomputed from live `ROOMS`, maxed with the rendered row so a
  contended read falls back to it.

The cutoff is deliberately NOT clamped against wall-clock time. An
earlier revision clamped it at `now + MAX_DM_FUTURE_SKEW_SECS`, which
broke the invariant "every existing inbound DM satisfies `ts <= cutoff`":
the rail filter compares the UNCLAMPED observed clock, so Archive became
a visible no-op that still showed a success toast, and the next
sweep-and-re-offer destroyed the persisted archive. Clamping one side of
a comparison was what created the hole. The gate bounds the INCOMING
timestamp instead, where it cannot break the invariant and is
safe-directional (the bounded value is never above the raw one).

Residuals are documented rather than claimed closed, including the two
that can still cost an archive entry: a cutoff taken inside a partial
re-land window, and a DM the live read missed because `ROOMS` was
contended AND the rendered row lagged. Both need an archive clicked
inside a specific race; neither is the systematic churn this fixes.

Also repairs the two #267 source pins, which searched for a literal their
own assertions contained and so passed regardless of the production code,
and pins every spot where a one-line edit re-opens the bug: the gate
condition and that its branch still calls unhide, the inbound producer,
the row's argument, the filter, both archived projections, the live
recompute, clamp-freedom of the cutoff, and the four explicit-user-action
unhides (each pinned in its own file).

`common/` change is a doc comment only — no WASM change, so no migration.

Closes #526
@sanity
sanity force-pushed the fix/526-dm-archive-survives-relanded-dms branch from c8f7882 to 1b9c348 Compare July 28, 2026 18:49
@sanity
sanity merged commit 865c044 into main Jul 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(dm): archiving a DM is undone by ordinary sync churn — re-landed old DMs hard-delete the archive entry

1 participant

Morty Proxy This is a proxified and sanitized view of the page, visit original site.