# Design note — read state

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

---

## The job to be done

Convoro has no idea what anybody has already read. There are no unread markers,
no way to jump to the first reply you have not seen, and no "mark everything
read". `/activity` is a chronological list with a forum filter.

That is not a missing decoration. It is the daily loop of using a forum: arrive,
see what is new, read it, clear it. Somebody who has used any mature forum
software has muscle memory for that loop, and without it they have to work out
where they got to by reading timestamps.

It is also the foundation for three other things — unread streams, the
first-unread jump, and per-forum "mark read" — none of which can exist without
it.

## Decision 1 — a timestamp, not a row per topic

The obvious model is one row per member per topic. On a forum with ten thousand
members and a hundred thousand topics that is a billion rows to say almost
nothing, and it grows forever.

So read state is answered from **three** places, cheapest first:

1. `users.marked_read_at` — everything before this is read. One column.
2. `forum_reads` — one row per member per forum they have marked read. Bounded
   by the number of forums, which is small.
3. `topic_reads` — one row per member per topic, but **only** for topics with
   activity since that member's marked-read timestamp.

A topic is unread when its last post is newer than **all three** of those
marks. Most members most of the time are answered by the first, which is a
column already on the row that has been loaded.

## Decision 2 — old is read, whatever the marks say

Anything whose last post is older than **thirty days** counts as read, for
everybody, without consulting anything.

Two reasons, and the second is the real one. It bounds `topic_reads` — a row
older than the cutoff can be deleted because it can no longer change an answer.
And "unread" from six months ago is not information anybody wanted; a member
returning after a break wants to see this week, not a thousand bold rows they
will never open.

## Decision 3 — reading a topic writes one row, and often not even that

Opening a topic upserts a `topic_reads` row. That is a write on a read request,
which is worth being deliberate about:

- **Guests write nothing.** There is no account to remember anything against.
- **Nothing is written when the topic is already read**, which is the common
  case for anybody scrolling back through a thread.

## Decision 4 — marking read is a timestamp, never a fan-out

"Mark this forum read" must not write a row for every topic in it. A forum with
forty thousand topics would be forty thousand inserts in a request somebody
expects to be instant.

It writes **one** row: this member marked this forum read at this moment. Same
for the whole site — one column. Both then win over any older per-topic row,
and the stale per-topic rows are pruned on a schedule rather than in the
request.

## Decision 5 — the indicator is the link, and it goes where you stopped

The unread marker on a topic is not decoration next to the title; it **is** a
link, and it goes to the first post that member has not read rather than to the
top of the thread.

That is the single most-used control in a forum for anybody following more than
a couple of conversations, and sending them to post #1 of a 200-post thread
makes the marker worse than useless.

The title still goes to the top, because sometimes you do want the beginning.

## Decision 6 — a member can only mark their own things read

Obvious, but worth stating because these are unauthenticated-looking GETs in
most software and the temptation is to accept an id from the URL. Every mark is
scoped to the signed-in member, and there is no route that marks anything on
somebody else's behalf.

## What this does not do

- **No per-post read tracking.** Where somebody got to *within* a topic is
  approximated by the topic's read timestamp against post dates. Tracking each
  post individually is a row per post per member, which is the problem this note
  exists to avoid.
- **No cross-device precision.** Read state is per account, so reading on a
  phone marks it read on a laptop. That is the behaviour people expect.
- **No "unread since my last visit" as a separate axis.** One notion of read,
  not two.
- **No unread counts per forum.** Counting unread topics per forum on every
  index render is a query nobody needs badly enough. The forum shows whether it
  has anything unread, not how much.
