Future-dated created_at suppresses unread state for every later message in a channel
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
A single message with a `created_at` in the future silently marks **every subsequent
message in that channel as already read**, until wall-clock time catches up with the bad
timestamp. The reader gets no unread badge, no unread divider, and — since #5983 — no
thread resume, because the channel looks fully caught up.
Nostr `created_at` is self-asserted by the sending client. The relay bounds it for
moderation commands but not for ordinary messages, so any client with a skewed clock (or
one that simply sets the field) can poison a channel's read state for everyone who opens it.
## Mechanism
Read state is written from the newest message's own timestamp, not from the current time:
`mobile/lib/features/channels/channel_detail_page.dart:100-123`
```dart
int? _channelReadTimestamp({...}) {
...
var latest = 0;
for (final event in events) {
if (event.threadReference.parentId != null) continue;
if (event.createdAt > latest) {
latest = event.createdAt; // <-- absorbs a future createdAt verbatim
}
}
...
}
```
which is then committed as the channel marker (`channel_detail_page.dart:295`):
```dart
.markContextRead(channel.id, readTimestamp);
```
Unreadness is a strict comparison against that marker
(`mobile/lib/shared/read_state/message_read_state.dart:43`):
```dart
return readAt == null || createdAt > readAt;
```
So once the marker holds a future value, a genuinely new message with a correct
`createdAt` fails `createdAt > readAt` and is classified **read**. Desktop compares the
same way (`desktop/src/features/messages/lib/unreadMarker.ts:131` and `:64`), so the read
side is identical; I did not trace desktop's mark-read write path, so whether it derives
the marker the same way needs confirming.
Markers are monotonic, so this does not self-correct and the reader cannot clear it from
the UI — marking the channel read again just re-commits the same future value. The channel
stays silent until real time passes the bad timestamp.
## Why this is reachable
`created_at` is not validated against the clock on the ordinary message path. The codebase
already treats skew as a real threat, but only for moderation:
`crates/buzz-relay/src/handlers/moderation_commands.rs:81`
```rust
const MAX_COMMAND_SKEW_SECS: i64 = 120;
```
```
event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)
```
That bound is applied to moderation commands and nothing else. The precedent exists; it
just is not applied where read state depends on it.
## Impact
- Unread badges stop appearing for the affected channel.
- The unread divider stops rendering.
- Thread resume (#5983) tail-pins, because every reply looks read.
- Affects any reader who opens the channel, not only the client with the bad clock.
- Not recoverable through the UI.
## Not introduced by #5983
This predates that PR and is orthogonal to it. #5983 only makes the symptom more visible,
because it added a third behaviour that keys off the same comparison. Filing separately as
discussed there rather than expanding that PR's scope.
## Possible fixes
1. **Bound `created_at` on ingest**, reusing the existing skew concept — reject or clamp
events whose timestamp is more than some tolerance ahead of relay time. Most direct, and
consistent with `MAX_COMMAND_SKEW_SECS`. Rejecting is a protocol-visible behaviour change,
so clamping-on-store may be preferable.
2. **Clamp when deriving the read marker** — take `min(max(createdAt), now)` in
`_channelReadTimestamp` and desktop's equivalent, so a bad event cannot push the frontier
past the present. Smallest client-side change; leaves the bad event itself unread-forever
rather than suppressing everything after it.
3. **Store a received-at alongside `created_at`** and compute unreadness from that.
Most correct, largest change, and it is what removes the client's ability to influence
another reader's read state at all.
I would lean toward 1 plus 2 — the relay bound stops propagation, and the client clamp
protects readers against events already stored.
## Reproduction
1. Publish a message to a channel with `created_at` set well ahead of now (e.g. `now + 86400`).
2. Open that channel so the read marker is committed.
3. Have another user post a normal message.
4. The new message shows as read: no badge, no divider, and opening its thread pins to the tail.
## What I verified
- The mobile derivation and comparison above, by reading the code paths cited.
- That `MAX_COMMAND_SKEW_SECS` appears only in `moderation_commands.rs`
(`grep -rn MAX_COMMAND_SKEW_SECS crates/`).
- That desktop's unread comparison has the same `createdAt > readAt` shape.
## What I did not verify
- Desktop's mark-read write path, i.e. whether it derives the channel marker from
`max(createdAt)` the way mobile does.
- Whether any relay-side path other than moderation commands bounds `created_at`.
- Actual end-to-end reproduction against a live relay — this is from code reading.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Contributor guide
Research direction
Start with mobile/lib/features/channels/channel_detail_page.dart:100-123 and :295, then read mobile/lib/shared/read_state/message_read_state.dart:43 and the desktop comparisons in desktop/src/features/messages/lib/unreadMarker.ts:131 and :64. Reproduce the future-dated message scenario and trace desktop's mark-read path plus relay handling in crates/buzz-relay/src/handlers/moderation_commands.rs. Done means a future timestamp can no longer suppress unread state for later normal messages across the affected paths.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, rust, typescript
- Domain
- backend-api-design, desktop-dev, mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100