block / block/buzz

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

Open
#6,046 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.