What happens
canAccessThread (convex/lib/rls/auth/can_access_thread.ts) resolves a thread by reading the threadMetadata table. The rewritten chat pipeline never writes that table — convex/tasks/internal_mutations.ts:947 is the writer, so task and discussion threads have a row and chat threads do not.
For a chat thread it therefore returns null every time. That is not restricting access; it is denying all of it.
Why it is easy to miss
A query gated on it answers "nothing here" rather than "you cannot see this". Absence of data and absence of permission look identical to the caller, so the surface renders an empty state and nothing errors. Typecheck, type-aware lint and the unit suites stay green throughout, because nothing exercises those queries against a thread that actually exists.
Where it is used
Thread-scoped readers gated on it:
convex/approvals/queries.ts — three call sites (~L237, ~L283, ~L329)
convex/approvals/human_control_mutations.ts
convex/approvals/plan_mutations.ts
convex/http.ts
convex/lib/rate_limiter/index.ts
Each is correct for a task or discussion thread and silently empty for a chat thread. Some may be unreachable for chat threads today — worth confirming per call site rather than assuming, since the failure mode is invisible either way.
What the chat lane does instead
convex/chat/messages.ts (listMessages) gates on the chat domain's own shape: an org-membership check, then ctx.db.normalizeId('threads', …) plus a match on organizationId and userId, treating a non-undefined lifecycleStatus as gone. Any thread-scoped read that must serve chat threads needs that gate, not canAccessThread.
Suggested direction
Either give canAccessThread a chat-thread branch so one helper covers both thread kinds, or leave it as the task/discussion gate and audit the call sites above for ones that must also serve chat. The first is less likely to rot, since the current split is invisible at the call site.
Worth a test that drives at least one of these queries against a real chat thread — the absence of such a test is the reason this survived.
What happens
canAccessThread(convex/lib/rls/auth/can_access_thread.ts) resolves a thread by reading thethreadMetadatatable. The rewritten chat pipeline never writes that table —convex/tasks/internal_mutations.ts:947is the writer, so task and discussion threads have a row and chat threads do not.For a chat thread it therefore returns
nullevery time. That is not restricting access; it is denying all of it.Why it is easy to miss
A query gated on it answers "nothing here" rather than "you cannot see this". Absence of data and absence of permission look identical to the caller, so the surface renders an empty state and nothing errors. Typecheck, type-aware lint and the unit suites stay green throughout, because nothing exercises those queries against a thread that actually exists.
Where it is used
Thread-scoped readers gated on it:
convex/approvals/queries.ts— three call sites (~L237, ~L283, ~L329)convex/approvals/human_control_mutations.tsconvex/approvals/plan_mutations.tsconvex/http.tsconvex/lib/rate_limiter/index.tsEach is correct for a task or discussion thread and silently empty for a chat thread. Some may be unreachable for chat threads today — worth confirming per call site rather than assuming, since the failure mode is invisible either way.
What the chat lane does instead
convex/chat/messages.ts(listMessages) gates on the chat domain's own shape: an org-membership check, thenctx.db.normalizeId('threads', …)plus a match onorganizationIdanduserId, treating a non-undefinedlifecycleStatusas gone. Any thread-scoped read that must serve chat threads needs that gate, notcanAccessThread.Suggested direction
Either give
canAccessThreada chat-thread branch so one helper covers both thread kinds, or leave it as the task/discussion gate and audit the call sites above for ones that must also serve chat. The first is less likely to rot, since the current split is invisible at the call site.Worth a test that drives at least one of these queries against a real chat thread — the absence of such a test is the reason this survived.