Skip to content

fix(mobile): clamp future-dated read markers instead of adopting them - #6214

Open
mfethe1 wants to merge 1 commit into
block:mainfrom
mfethe1:fix/mobile-read-marker-skew
Open

fix(mobile): clamp future-dated read markers instead of adopting them#6214
mfethe1 wants to merge 1 commit into
block:mainfrom
mfethe1:fix/mobile-read-marker-skew

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 18, 2026

Copy link
Copy Markdown

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 present
suppresses every genuinely newer message — no badge, no divider — until wall-clock catches up.
Markers are monotonic (_advanceContext here, copyWithContext in read_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_at to ±900s for every event
(crates/buzz-relay/src/handlers/ingest.rs, after signature verification, both transports), so
relay-admitted poison is capped at now + 900 and self-heals within 15 minutes. I originally
reported 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 After

before: general in regular weight — the later message is suppressed · after: general in bold — the later message reads as unread

Captured 2026-08-18T13:19:56+00:00 · base main at f8692fa9 → head d07a795f.

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 now can never hide a message arriving after now) with zero data loss, and
self-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_SECS in ingest.rs — the actual
bound on stored events. It is deliberately not MAX_COMMAND_SKEW_SECS (120) 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 channel open.

One funnel, plus the paths that bypass it. All eleven markContextRead call sites under
mobile/lib derive their timestamp from event data and none passes a trusted clock reading, so
the repair goes in _advanceContext — the sole writer reaching _effectiveState for both
markContextRead and seedContextRead — plus _applyRemoteContextTimestamp (peer-synced
markers) and _hydrateFromLocalStorage (values written before this policy). No feature call
sites are touched.

A second suppression path also needed it. clearObservedUnreadCoveredByRead in
channels_provider.dart is independent of the read marker: a poisoned readAt makes
latest <= readAt trivially true and wipes the observed-unread evidence. Clamping the marker
alone 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 changed
  • flutter analyze — No issues found
  • node ./scripts/check-file-sizes.mjs — passes
  • flutter test1474 passed

Each new test was proven to discriminate. With the clamp disabled, exactly the three tests
that assert repair fail:

-1  repairs one second past the bound
-2  repairs a year-ahead marker to now rather than to the bound
-3  clampReadMarkers repairs values without ever pruning a key
      Expected: <1750000000>
        Actual: <1781536000>

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 now rather than to the bound (so
the repair does not itself manufacture a future frontier), keys never pruned, and markers never
raised.

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>
@mfethe1
mfethe1 requested a review from a team as a code owner August 18, 2026 13:27
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.

Future-dated created_at suppresses unread state for every later message in a channel

1 participant