fix(mobile): clamp future-dated read markers instead of adopting them - #6214
Open
mfethe1 wants to merge 1 commit into
Open
fix(mobile): clamp future-dated read markers instead of adopting them#6214mfethe1 wants to merge 1 commit into
mfethe1 wants to merge 1 commit into
Conversation
A read marker decides unreadness by `createdAt > readAt`, so a marker pushed past the present suppresses every genuinely newer message until wall-clock catches up. Markers are monotonic — `_advanceContext` here and `copyWithContext` in read_state_provider.dart both refuse to move backwards — so a poisoned value cannot be lowered again by ordinary use, and it survives both persistence and sync. Reported as block#6046; block#6063 fixes the desktop half. Implausible values are clamped to `now`, never dropped. Plausibility is judged against the local clock, which cannot distinguish "their clock is fast" from "mine is slow", so a device booting before its first NTP sync would otherwise discard read state it has no way to recover. Clamping keeps the invariant that matters — a marker at `now` cannot hide a message arriving after `now` — with no data loss, and self-corrects if the local clock was at fault. It only ever lowers a marker, so it strictly widens what counts as unread and can never swallow a thread reply. The tolerance is 900s, matching the relay's own ingest gate (MAX_TIMESTAMP_DRIFT_SECS in crates/buzz-relay/src/handlers/ingest.rs, applied symmetrically to every event after signature verification on both transports). It is deliberately not the 120s MAX_COMMAND_SKEW_SECS from handlers/moderation_commands.rs, which is a replay window for moderation kinds that are never stored; using that would reject timestamps the relay legitimately accepted and leave a phantom unread badge re-arming on every open. All eleven markContextRead call sites derive their timestamp from event data and none passes a trusted clock reading, so the repair goes in one funnel — _advanceContext, the sole writer reaching _effectiveState for both markContextRead and seedContextRead — plus the two paths that bypass it: _applyRemoteContextTimestamp for peer-synced markers and _hydrateFromLocalStorage for values written before this policy existed. clearObservedUnreadCoveredByRead needed the same repair. It is a second, independent suppression path: a poisoned readAt makes `latest <= readAt` trivially true and wipes the observed-unread evidence, so clamping the marker alone would have left the sidebar dot suppressed anyway. Verified: dart format 411 files 0 changed, flutter analyze clean, file-size guard passes, flutter test 1474 passed. Each new test was confirmed to fail with the clamp disabled and pass with it restored; the tests asserting that in-tolerance values are left untouched correctly keep passing either way, so the helper is neither always-true nor always-false. Signed-off-by: Michael Feth <michael@jira-flow.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The mobile half of #6046. #6063 fixes desktop; this is the client the bug was originally
traced on, and it is currently unpatched.
A read marker decides unreadness by
createdAt > readAt(
mobile/lib/shared/read_state/message_read_state.dart), so a marker pushed past the presentsuppresses every genuinely newer message — no badge, no divider — until wall-clock catches up.
Markers are monotonic (
_advanceContexthere,copyWithContextinread_state_provider.dart),so a poisoned value cannot be lowered again by ordinary use, and it survives both local
persistence and sync.
Scope, stated honestly: the relay already bounds
created_atto ±900s for every event(
crates/buzz-relay/src/handlers/ingest.rs, after signature verification, both transports), sorelay-admitted poison is capped at
now + 900and self-heals within 15 minutes. I originallyreported this as unbounded in #6046 and have corrected that there. What the ingest gate does
not cover is what this PR is for: markers that persist, markers that sync from a client which
never applied the policy, and markers written before the gate existed.
Evidence
Channels list— A message dated a year ahead poisons the channel read marker. A genuine message arriving moments later is then classified as read and the channel shows no unread treatment. With the clamp, the marker is repaired to now and the real message reads as unread again.before:
generalin regular weight — the later message is suppressed · after:generalin bold — the later message reads as unreadCaptured 2026-08-18T13:19:56+00:00 · base
mainatf8692fa9→ headd07a795f.Approach
Clamp, never drop. Plausibility is judged against the local clock, which cannot tell "their
clock is fast" from "mine is slow". A device booting before its first NTP sync, resuming from
suspend, or holding a dead RTC would judge every stored marker implausible — and dropping them
would destroy read state it has no way to recover. Clamping preserves the invariant that matters
(a marker at
nowcan never hide a message arriving afternow) with zero data loss, andself-corrects on the next run if the local clock was the thing at fault.
Clamping only ever lowers a marker, so it strictly widens what counts as unread. It cannot
cause a channel marker to swallow a thread reply.
Tolerance is 900, not 120. It matches
MAX_TIMESTAMP_DRIFT_SECSiningest.rs— the actualbound on stored events. It is deliberately not
MAX_COMMAND_SKEW_SECS(120) fromhandlers/moderation_commands.rs, which is a replay window for moderation kinds that are neverstored; using that would reject timestamps the relay legitimately accepted and leave a phantom
unread badge re-arming on every channel open.
One funnel, plus the paths that bypass it. All eleven
markContextReadcall sites undermobile/libderive their timestamp from event data and none passes a trusted clock reading, sothe repair goes in
_advanceContext— the sole writer reaching_effectiveStatefor bothmarkContextReadandseedContextRead— plus_applyRemoteContextTimestamp(peer-syncedmarkers) and
_hydrateFromLocalStorage(values written before this policy). No feature callsites are touched.
A second suppression path also needed it.
clearObservedUnreadCoveredByReadinchannels_provider.dartis independent of the read marker: a poisonedreadAtmakeslatest <= readAttrivially true and wipes the observed-unread evidence. Clamping the markeralone would have produced a fix that looks correct and still leaves the sidebar dot suppressed.
Related issue
Fixes the mobile half of #6046. Companion to #6063 (desktop) — that PR should not close #6046 on
its own. No other open PR touches mobile read-state clamping.
Testing
dart format --output=none --set-exit-if-changed .— 411 files, 0 changedflutter analyze— No issues foundnode ./scripts/check-file-sizes.mjs— passesflutter test— 1474 passedEach new test was proven to discriminate. With the clamp disabled, exactly the three tests
that assert repair fail:
and the six asserting non-interference keep passing, as they should. Both polarities behave
correctly, so the helper is neither always-true nor always-false — a green suite here means the
behaviour is present, not merely that an assertion exists.
Coverage includes: a value inside tolerance left byte-identical, exactly the 900s bound accepted,
one second past it repaired, a year-ahead value repaired to
nowrather than to the bound (sothe repair does not itself manufacture a future frontier), keys never pruned, and markers never
raised.