Skip to main content
← Back to list
01Issue
BugClosedSwamp Club
Assigneeskeeb

Relationships

↔ sibling #1058

#978 Leaderboard search excludes ghosts — a visible ghost can't be found by its alias

Opened by keeb · 7/5/2026

Description

Ghost operatives appear on the ranked leaderboards (all-time, windowed, streak) with a stable, swamp-flavored alias like 👻 murk-wisp-9dc54f2a and each has a clickable /ghost/<slug> profile. But the leaderboard search box deliberately excludes ghosts, so a ghost you can plainly see on the board returns zero results when you type its alias into search. The rankings and search disagree about who exists on the board.

Root cause

ClickHouseLeaderboardQuery.board() in lib/infrastructure/clickhouse/clickhouse-score-reads.ts:913-921 appends AND is_ghost = 0 to the search filter, and matches owner ILIKE {q} against the raw owner column (the distinct_id), never against the rendered alias:

// Ghosts are excluded from SEARCH (not from ranked boards): the match
// would run against the raw distinct_id — which never renders — so a
// hit both confuses (alias doesn't contain the query) and lets a
// caller probe raw distinct_id contents by substring enumeration.
ownerFilter = `AND owner ILIKE {q:String} AND is_ghost = 0`;

The exclusion is intentional and the reasoning is sound as written: matching the query against the raw distinct_id would (a) never match the visible alias and (b) let a caller enumerate raw distinct_id substrings. But the conclusion — drop ghosts from search entirely — leaves them unsearchable rather than searchable by their alias.

findScoreTarget() (the /locate jump-to-me path, clickhouse-score-reads.ts:693-740) carries the same exclusion, so locate can't jump to a ghost either.

The alias is ghostSlug(distinctId) (lib/ghost-name.ts) — a deterministic FNV-1a hash, one-way by design, so a query fragment can't be reversed to a distinct_id in SQL. That's why the naive "just match owner" approach was rejected. A real fix has to match against the slug, not the distinct_id.

Steps to reproduce

  1. Open /leaderboard on prod (SCORE_READS=clickhouse, serving from the ClickHouse ledger).
  2. Note a ghost row, e.g. 👻 murk-wisp-9dc54f2a (visible with a /ghost/... link).
  3. Type its alias fragment (murk-wisp) into the leaderboard search box.
  4. Expected: the ghost row appears, matching what's on the board.
  5. Actual: no results — search silently drops all ghosts.

Environment

  • Path: ClickHouse read flip (SCORE_READS=clickhouse), prod. The Mongo path (mongo-leaderboard-query.ts) has no ghost concept (username: {$exists: true} filter), so this is CH-specific.
  • Files: lib/infrastructure/clickhouse/clickhouse-score-reads.ts (board() ~L913-921, findScoreTarget() ~L693-740); alias grammar in lib/ghost-name.ts.

Fix direction (not prescriptive)

Match search against the ghost slug rather than excluding ghosts: e.g. resolve the candidate ghost owner set app-side, compute ghostSlug() for each, and match the query against the slug — never exposing or probing the raw distinct_id. This keeps the anti-enumeration property (query never touches distinct_id contents) while making a visible ghost findable by the exact text shown on its row.

02Bog Flow
OPENTRIAGEDIN PROGRESSCLOSED+ 1 MOREASSIGNEDCLASSIFICATION

Closed

7/10/2026, 4:41:03 AM

No activity in this phase yet.

03Sludge Pulse
keeb assigned keeb7/10/2026, 4:12:14 AM
keeb linked related to #10587/10/2026, 4:41:03 AM
Editable. Press Enter to edit.

keeb commented 7/10/2026, 4:40:51 AM

Folding into #1058 and closing as resolved-under-#1058.

Both fixes rewrite the same code — findScoreTarget() and the board() SEARCH filter in lib/infrastructure/clickhouse/clickhouse-score-reads.ts — so they ship as one change rather than two conflicting rewrites of findScoreTarget.

#1058 (serve leaderboard locate live + asOf, retiring the per-replica standings cache) will replace the ghost exclusion here with slug matching: resolve the candidate ghost owners app-side, compute ghostSlug() for each (the buildAliasIndex()/resolveGhostAlias machinery already exists in the same file), and match the query against the slug — never against the raw distinct_id, so the anti-enumeration property this issue calls out is preserved. The fix and its verification land in the #1058 PR.

Sign in to post a ripple.