Activity · Timelines
Ordering, fan-out, and rebuild
Activity stores canonical posts separately from viewer timelines. Timeline entries are derived projections. They may arrive after the post write, but retries and repair converge on one entry for each viewer and post.
Publish transaction
├─ canonical Post
├─ author self entry
├─ initial PostStats
├─ fan-out job
└─ outbox event
Fan-out worker
└─ follower TimelineEntry rows Private posts skip the fan-out job. The author’s self entry still exists.
Timeline order
The home timeline is reverse chronological. Activity sorts entries by:
ORDER BY ranked_at DESC, id DESC ranked_at is copied from the source post’s published_at. The timeline entry UUID is the stable tie-breaker when multiple entries share the same timestamp.
This is a materialized chronological order, not a recommendation score. The host owns ranking policy that has not been explicitly delegated to Activity.
Cursor pagination
Request the first page with an optional limit:
GET /api/v1/timeline/home?limit=50 The public contract accepts limits from 1 to 100 and uses 50 by default. When another page exists, page.next_cursor contains an opaque URL-safe value.
{
"data": [],
"page": {
"next_cursor": "eyJhdCI6IjIwMjYtMDctMTRUMDQ6MTA6MDBaIiwiaWQiOiIuLi4ifQ"
}
} Pass that value unchanged in the next request:
GET /api/v1/timeline/home?limit=50&cursor=<next_cursor> Sign the exact query string sent over HTTP. Do not decode the cursor, change its padding, or use it with a different pagination contract.
An invalid cursor returns 400 invalid_cursor. A final page returns "next_cursor": null.
Stable page boundary
The cursor records both the last entry’s ordering time and UUID. The next page selects entries below that pair:
ranked_at < cursor time
or
ranked_at = cursor time and id < cursor id This tie-break prevents entries with the same ranked_at from being repeated or skipped while walking a stable result set.
Timeline reads are not snapshots. A new post or a policy change may alter the materialized set between requests. Consumers should treat each cursor chain as a forward pagination session, not as a permanent archive position.
Fan-out convergence
The publish transaction creates the author’s self entry and schedules follower fan-out. Followers may not see the post when the publish response first returns.
Each follower projection uses an upsert backed by this database identity:
(tenant_id, viewer_id, post_id) Duplicate jobs may run, but they update the same timeline entry instead of creating another one. Job uniqueness is not the public guarantee. Projection uniqueness is.
Before fan-out, the worker reads the current post and skips work when the post is already:
- soft deleted
- in a moderation state other than
visible private
It also uses the current active follow graph and block state when choosing viewers.
What rebuild restores
The internal repair worker rebuilds one viewer at a time. In one database transaction it:
- Deletes that viewer’s
followingentries. - Reads canonical posts from currently active follows.
- Excludes private, deleted, hidden, blocked, and non-visible posts.
- Recreates one
followingentry per eligible post.
Rebuild leaves the viewer’s self entries intact. Recreated entries keep the source post’s published_at as ranked_at, so the normal read order is preserved.
The current rebuild primitive is an internal Oban worker. It is not exposed through the public /api/v1 contract. A host cannot request a rebuild through the customer HTTP API today.
Guarantee matrix
| Situation | Current behavior |
|---|---|
| Publish succeeds | Canonical post and author self entry are committed |
| Follower reads immediately | The new entry may still be pending fan-out |
| Fan-out job runs twice | One viewer-post entry remains |
| Post is already deleted before fan-out | Worker skips materialization |
| Cursor is retried | Read is safe when the request is signed again |
| Publish times out | Duplicate-safe retry is not guaranteed |
| Viewer projection drifts | Internal rebuild can reconstruct following entries |
Proof boundary
Repository tests cover same-timestamp pagination, duplicate fan-out execution, stale fan-out after deletion or moderation, private-post exclusion, and viewer rebuild. The FeedCore TLA+ model checks tenant safety, projection uniqueness, visibility safety, idempotent job effects, and eventual convergence under its modeled fairness assumptions.
That proof does not establish production fan-out latency, queue capacity, recovery time objectives, or public rebuild operations. Those remain productization work.