Member faces and roster miss documents whose workspace slug is the human slug #574
Description
Problem
Settings → Documents and Home show a row of member faces on each document. On production the faces are missing on most documents, the /demo pad among them. The roster popover and the admin dashboard member count fail the same way.
Nothing on screen says anything is wrong. The list reads only the data from the faces request, so a missing row looks the same as a document with one member.
Evidence
Captured on production on 2026-10-23, signed in as a normal account, on Home:
POST /rest/v1/rpc/get_document_member_previewsanswered200.- The request named 8 documents. Six of them sit in that account's Joined list, so the account is an active member of each.
- The response held one row: a document made on 2026-09-26, with 20 members.
- It held no row for the demo (
p3672b330b8af90pvi2) or the other five Joined documents. A document the caller has joined should come back with at leastmember_count: 1.
Cause
All three functions find the workspace by workspaces.slug. The client sends lower(documentId) as that slug.
get_document_member_previews(p_slugs): the faces (packages/supabase/scripts/10-8-func-workspace_members.sql,where w.slug = any(p_slugs))get_document_members(p_slug): the roster popover (where w.slug = p_slug)admin_get_document_member_counts(p_slugs): admin dashboard counts (where w.slug = any(p_slugs))
But workspaces.slug does not always hold lower(documentId):
- From 2024-02 until 2026-08-09, the pad created the workspace from the browser with
slug: docMetadata.slug, the human slug (for exampledemo). Commiteb47e7c3d(2024-02-08) first wrote that call,1e7c265d9(2024-11-14) kept it, and7d56a29d4(2026-08-09) removed it. - That upsert used
ignoreDuplicates. So a workspace made this way keeps its human slug for good. join_workspacewriteslower(_workspace_id)only when no workspace exists yet. Only newer workspaces have that value.
So every workspace made before 2026-08-09 is invisible to these three functions. packages/supabase/CLAUDE.md says the column "always" holds lower(documentId). Production shows that is wrong.
The slug value of the demo workspace on production is read from code, not measured. This read-only query confirms it:
select id, slug from public.workspaces where id = 'p3672b330b8AF90PvI2';
select count(*) as total, count(*) filter (where slug <> lower(id)) as mismatched from public.workspaces;Fix
Key all three functions on the documentId, which is workspaces.id, verbatim and in exact case. Never key them on the slug.
- Migration: drop and re-create the three functions.
- Take
p_document_ids text[](orp_document_id textfor the roster). - Match
w.id. - Return
document_idinstead ofslug. - Re-grant explicitly, because a drop loses grants:
authenticatedandservice_rolefor the two user functions, andservice_roleonly for the admin one.
- Take
- Mirror the change in
scripts/10-8-func-workspace_members.sql, and fix the comments that call the slug the key. - Regenerate
apps/webapp/src/types/supabase.ts. - Webapp:
useDocumentMembers,useDocumentRoster,DocumentListRowandDocumentGridTilesend and key ondoc.documentIdas is, with no.toLowerCase(). - Backend:
adminStats.service.tssends exact documentIds toadmin_get_document_member_countsand maps bydocument_id. - Correct the rule in
packages/supabase/CLAUDE.md: key workspace and member lookups onworkspaces.id.
The pad URL keeps the human slug. This change is about data keys only.
Decisions
- Rename the parameters, or keep the old names?
Recommended: rename them, and cut over in one go. Renaming is a drop and re-create, so an old client fails until the webapp and the backend deploy. That failure is what users see today anyway: hidden faces, plus one admin log line. Keepingp_slugswhile passing ids would be a name that lies. - Backfill
workspaces.slugtolower(id)as well?
Recommended: no. The column is unique and has a check constraint, and other readers may still expect the human slug (see below). Keying onw.idmakes the slug value irrelevant to these functions.
Out of scope (follow-up)
These readers also use workspaces.slug. Old workspaces give them the human slug and new ones give lower(documentId), so they can disagree. List them, but leave them for a separate change:
- Digest email links:
workspace_sluginscripts/07-5-email-notifications-pgmq.sqlandapps/hocuspocus.server/src/lib/email/digestDocuments.ts. - Bookmarks:
workspace_sluginscripts/07-bookmark-functions.sql. - Chat media stats:
w.sluginscripts/10-4-func-chat-media-features.sql. - User retention:
w.sluginscripts/22-user-retention.sql.
document_view_stats is keyed by lower(documentId) from the room name, not from workspaces.slug, so it is correct as it is.
Related: a failed faces request shows no error on the list (DocumentsList.tsx reads only data). Signed-out visitors never become members, so the faces show signed-in visitors only. That is by design.
Activity
HMarzban commentedon Oct 23, 2026
Maintainer ruling, 2026-10-23
workspaces.id. This also stands as a standing rule for any new workspace or member lookup.workspaces.idmakes theslugvalue irrelevant to these functions.(build): back frontdeploy, the faces, the roster and the admin counts fail for every document until the other side lands.The change is written on local
main, not committed or pushed.HMarzban commentedon Oct 23, 2026
Follow-up list, found in code review. These comments still say
workspaces.slugequalslower(documentId). They sit on readers this issue leaves out of scope, so they change with those readers:apps/hocuspocus.server/src/lib/push/chatActionUrl.ts:3apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:115apps/hocuspocus.server/src/lib/email/digestDocuments.ts:69apps/hocuspocus.server/src/lib/documentId.ts:4Correction to the issue body:
eb47e7c3d(2024-02-08) first wroteslug: docMetadata.slug.1e7c265d9kept it. The body is updated.docs(supabase): record the documentId rule for workspace lookups
docs(changelog): note the member faces fix and its migration