Feeds #
Surfaces #
Every post list is a Postgres query in the AppView. Coves does not run atProto feed generators and serves no getFeedSkeleton.
| Route | Auth | Scope |
|---|---|---|
/xrpc/social.coves.feed.getTimeline |
RequireAuth |
Communities the viewer subscribes to |
/xrpc/social.coves.feed.getDiscover |
OptionalAuth |
Every community |
/xrpc/social.coves.communityFeed.getCommunity |
OptionalAuth |
One community |
/xrpc/social.coves.feed.searchPosts |
OptionalAuth |
Every community, or one |
On the OptionalAuth routes a signed-in viewer gets their own vote state and block filtering. An anonymous caller gets neither.
The community feed is served as social.coves.communityFeed.getCommunity, but its lexicon is social.coves.feed.getCommunity and no communityFeed.* lexicon exists. The frontend and mobile clients call the served name; a third-party client that follows the published lexicon gets a 404. feed/getAll.json is also published with no route. The fix is an alias route under the lexicon name, then a client switch, then removing the old path. Register every new endpoint under its lexicon id.
Visibility #
Every post read goes through visiblePostsPredicate in internal/db/postgres/post_visibility.go, bound by visiblePostsJoin in the feed queries and Discover Hot; only the Discover Hot baseline calls the predicate directly. The public sees accepted posts whose accepted CID matches the post's CID, and legacy posts with no admission row. The author also sees their own posts in other admission states. An active moderation removal hides a post from everyone. post.get's moderated-post gate and notifications use admittedPostsPredicate, the admission half without removal hiding. A new read path, count or search must use this predicate rather than restate it, or pending and removed posts leak through that path while the feeds hide them. The admission rules are in POSTS.md.
Community visibility (public, unlisted, private) is not consulted on any read path. Posts in a private community appear in Discover, Timeline, the community feed and search for anyone. This is an open backlog issue.
Blocks #
viewerBlockFilters in internal/db/postgres/viewer_block_filter.go renders both block kinds. Each caller names its surface:
aggregateSurface(Discover, Timeline, searchPosts withoutcommunity): author blocks and community blocks apply.explicitSurface(the community feed, searchPosts withcommunity): author blocks only.
A community block is an aggregate-feed mute. This is a product decision that matches Reddit and Lemmy: the community's own feed, post permalinks and comment threads stay reachable. Comment queries filter author blocks with their own clauses on commenter_did and ignore community blocks. An authenticated community.get returns viewer.blocked so clients can draw the block toggle.
Cursors #
Every feed cursor is base64(payload::hex(HMAC-SHA256(payload))) keyed by CURSOR_SECRET. Production config refuses to start when CURSOR_SECRET is unset (it falls back to a public dev constant), a CHANGE_ME placeholder, or shorter than 16 characters. Rotating it invalidates every outstanding cursor on every feed and reshuffles the Discover hosted bonus. A tampered or malformed cursor returns 400 InvalidCursor.
Timeline and community Hot cursors carry created_at::uri::query_time, not the rank. The next page recomputes the anchor post's rank from its current row at the pinned query time with the same SQL expression as the candidates, so float rounding is identical on both sides of the comparison and posts do not drift across pages as time passes.
Hot rank #
(SIGN(score) * LN(ABS(score) + 1) + 1) / (GREATEST(age_hours, 0) + 2)^1.5
The numerator is log-damped because bridged and federated communities import vote counts in the hundreds: going from 0 to 10 votes counts as much as going from 10 to about 120, so a day-old bridged post cannot bury fresh native posts. A zero-vote post keeps a numerator of 1. SIGN/ABS keep the expression defined for negative scores, since LN of a non-positive value errors in Postgres. GREATEST(age, 0) ranks a future-dated post as brand new instead of boosting it, and ingestion also clamps future createdAt.
hotRankSQL in internal/db/postgres/feed_repo_base.go is the only builder of this expression. The live ORDER BY and the cursor comparison both call it so they cannot drift. Timeline Hot and community Hot use it. Discover Hot ranks in Go and must equal this formula at neutral settings. Do not change the shared formula to tune Discover.
Sorts and parameters #
Feeds sort hot (default), top or new. top takes a timeframe of hour, day, week, month, year or all, and the feeds default it to day. limit is 1 to 50 with default 15. A limit above 50 is a 400, not a silent cap; zero, negative, non-numeric or absent becomes 15. ORDER BY clauses come only from the feedSortClauses whitelist, and ties break on created_at DESC, uri DESC.
The community parameter is a plain string, not at-identifier. It accepts a DID, a handle with or without the c- prefix, name@origin, !name@origin, or a bare local name, all resolved by ResolveCommunityIdentifier (see COMMUNITIES.md). Dropping at-identifier was an accepted lexicon breaking change.
Search #
social.coves.feed.searchPosts lives under feed.* because that is the read-many namespace that returns {feed, cursor}, and it mirrors app.bsky.feed.searchPosts. community.post.* is single-post CRUD. The old community.post.search lexicon was deleted and unpublished in 2026 (see LEXICON_PUBLISHING.md). Results use the feed shape so clients reuse their feed parsers.
The index is posts.search_vector, a stored generated tsvector (title weight A, content weight B) with a partial GIN index, added in migration 044. A stored column was chosen over an expression index so the planner always uses the GIN index and ranking never re-tokenises bodies. Title and content are cut to 3,000 and 100,000 characters to stay under the 1 MiB tsvector limit, because federated records from the firehose never passed the local API's length caps. The text search config is english because posts store no langs column (only comments do), so per-language configs are not possible.
Queries go through websearch_to_tsquery, which never errors on user input and supports quoted phrases, -negation and OR. Relevance is ts_rank_cd(..., 1), which divides by log length so long bodies do not dominate. A stopword-only query parses to an empty tsquery and a negation-only query (-rust, or fox OR -red) would match the whole corpus; SQL refuses both with querytree(...) NOT IN ('', 'T') and returns an empty feed.
Sorts are relevance (default), new and top; hot is rejected. timeframe defaults to all and applies only to top. q is trimmed and must be 1 to 500 bytes. A supplied limit that is non-numeric or below 1 is a 400, which is stricter than the feeds.
A search cursor is a signed search::<scope hash>::<position> envelope. The scope hash covers q, the resolved community DID, the sort, and the timeframe when the sort is top. Replaying a cursor with any of those changed returns InvalidCursor. A relevance position carries the float4 rank, which round-trips exactly through text because lib/pq sets extra_float_digits, so the filter compares with = $n::real.
Rate limits #
A global limiter allows 100 requests per minute per client IP on every route. Timeline, Discover and the community feed have no route limiter of their own. searchPosts adds a named postSearch limiter of 30 per minute per IP, registered in internal/api/routes/communityFeed.go. Discover Hot also has its own build budget, below.
Discover Hot #
Only Discover with sort hot, including the default sort, uses this ranking. Discover New and Top use the shared feed query. It exists because high-vote bridged communities took most of Discover: in a September 2026 sample two communities held 60% of the first 50 posts.
Ranking #
The ranking time T is captured with the snapshot. The baseline cohort is posts created in [T - 14 days, T - 24 hours) that are publicly visible and not deleted. Viewer blocks do not change it and zero-score posts count. Each post contributes ln(1 + max(score, 0)).
G is the median of per-community means over communities with at least five cohort posts. Each community counts once, so a prolific community cannot pull the reference toward itself. A community's smoothed baseline is B_c = (sum_c + 20G) / (n_c + 20), and its adjustment is clamp(sqrt(max(G, 1) / max(B_c, 1)), 0.5, 1). The adjustment applies only to positive scores: the numerator is 1 + adjustment * ln(1 + score). Zero gives 1 and a negative score gives 1 - ln(1 + |score|), as in the shared formula. No community gets a multiplier above 1. With no qualifying community, or for a community with no or all-zero history, the adjustment is 1.
The hosted bonus (algorithm version 2, approved 2026-09-18) adds 3.0 * u to the numerator of posts with score at least 0 in hosted communities. u is the first 8 bytes of HMAC-SHA256(CURSOR_SECRET, "discover-hot-hosted-bonus\0" + URI), big-endian, shifted right 11 and divided by 2^53. Hosted posts sit at 0 or 1 votes while bridged posts import hundreds, so fresh hosted posts ranked below much older bridged ones. A flat boost was rejected because it would stack every fresh hosted post at the top. The bonus is keyed because authors choose their rkeys, and an unkeyed digest could be ground offline in about 1,000 tries for a near-maximum bonus. The label prefix separates it from cursor signatures, which use the same secret. It is deterministic, so snapshot rebuilds do not reshuffle the feed. "Hosted" means pds_refresh_token_encrypted IS NOT NULL (see COMMUNITIES.md). The 3.0 maximum came from a production simulation where hosted posts reached about a fifth of the first 50 without lengthening the longest hosted run.
Selection is sequential with a repetition penalty. The window is the last 20 emitted posts, not the HTTP page. A candidate's penalty is 1 + 0.5 * count of its community in the window; a rank at or above 0 is divided by it and a negative rank multiplied, so repetition never helps a downvoted post. Ties break on created_at DESC, uri DESC. This is a soft penalty, not a quota, and a single community's supply still fills pages.
The rank is computed in Go (internal/core/discover/hot_ranking.go), stored as double precision, and reused verbatim on continuation. A nonfinite rank is an error. discover_hot_rank_sql_equivalence_test.go pins the Go rank to hotRankSQL within 1e-12 at neutral normalization with no bonus.
Snapshots and checkpoints #
A keyset cursor would filter on the last post's rank, but diversity selection defers higher-ranked posts, and advancing that boundary would lose them. Discover Hot instead freezes candidate metadata and ranks in a Postgres snapshot (migration 048). Each page stores an immutable checkpoint of stream positions and the emitted-community window. The cursor (discover-hot-v1, signed, at most 500 characters, bound to the viewer) names a snapshot and checkpoint. Snapshots and continuations are keyed by discoverHotAlgorithmVersion; bump it for any change to base-rank inputs or checkpoint semantics.
Checkpoints were chosen over a precomputed diversified order because a post hidden before delivery must not influence the diversity of what follows. Every page re-checks current visibility, blocks and deletions, skips excluded candidates and refills. If a selected post fails hydration because it became ineligible, it is marked excluded and selection reruns from the input checkpoint. Identical checkpoints are deduplicated. A GET never advances shared state, so replays and concurrent retries return the same page.
A request with no cursor reuses its scope's snapshot when it is under 30 seconds old. The scope is the anonymous public (shared) or one viewer DID, so author-only candidates never cross viewers. Reuse does not extend the snapshot's one-hour absolute lifetime.
Budgets #
| Budget | Limit |
|---|---|
| Snapshot reuse per scope | 30 seconds |
| Snapshot lifetime | 1 hour, absolute |
| Candidates per snapshot | 100,000; more fails with 503 rather than truncating |
| Stored candidates, all snapshots | 10,000,000 |
| Stored checkpoints | 20,000, each at most 64 KiB (database constraint) |
| Snapshot builds | 20 per minute globally, failed and cancelled builds included; one at a time under an advisory lock |
| Work deadline | 5 seconds, or the caller's earlier deadline |
| Cleanup | Every minute, 10,000 rows per batch |
The caps are enforced atomically in the database so several AppView processes share them.
Errors #
An expired, missing, malformed, tampered, wrong-viewer, wrong-sort or old-version cursor returns 400 InvalidCursor, and clients restart from page one. Bumping discoverHotAlgorithmVersion invalidates every outstanding cursor, and the old version's candidate rows keep counting against the stored-candidate cap for up to an hour.
Capacity refusal or a missed deadline returns 503 DiscoverUnavailable with Retry-After: 30. It never returns a truncated or empty page and never falls back to the legacy ranking. A continuation that fails this way does not advance its checkpoint. When the caller's own deadline expires, the error matches both context.DeadlineExceeded and ErrDiscoverUnavailable; a plain cancellation stays a cancellation. A pooled connection that held an advisory lock is discarded after an acquisition error, a cancellation or a failed unlock, so a lock can never leak back into the pool.
The frontend and mobile 1.4.0+11 restart from page one on InvalidCursor and honour Retry-After. Older mobile installs need a pull-to-refresh after a cursor expires.
Known limitations #
- A community with no mature baseline keeps its full raw engagement during its first day. Immature posts are deliberately not used as a provisional baseline.
- The baseline uses the current scores of mature posts, not their scores at 24 hours, because historical aggregate votes are not stored.
- Every post in a hosted community gets the bonus, aggregator posts included. Revisit this as native posting grows.
- The first downvote on a zero-score hosted post removes the whole bonus, dropping the numerator from up to 4.0 to
1 - ln 2(about 13x), against about 3.3x for a non-hosted post. One account can do this. A taper over the first few negative scores is the fallback if it is abused.