[Orgs] 3 Chat permission parity: chats join the project role ladder (stacked on #268)

✅ merged · #363 · open-legal-products/mike ← amal66/mike · opened 19d ago by amal66 · merged 6d ago by willchen96 · +3,836-304 across 36 files · ↗ on GitHub

From the PR description

Rewritten 2026-08-26 for the revised model. This PR was re-stacked onto #267's revised branch, which replaced the four-tier owner/manager/editor/viewer ladder with admin/member/viewer, dropped personal organizations, and moved project sharing onto project_access_grants. Everything below describes the branch as it stands at 71da5a13; the previous description (and the live rounds it linked) described the superseded model and no longer replicates.

Updated 2026-08-26 (evening). A post-review fix round landed, and the whole stack was rebased onto main at 1b58c7aa, which now carries #383 (model-selection policy). Two consequences run through this description: the chat migrations are renumbered 20260831_04/_05 (and #267's are 20260831_01..03), and get_chats_overview carries #383's model column in both signatures. See Post-review fix round.

Stacked on #268 (which is stacked on #267). The base of this PR is olp-pr/organizations-ui (#268's origin mirror, at tip 2b5d8280). The diff shown here is only this PR's commits - read git log on the branch: the original 5, plus the fix round and the #383 reconciliation, 16 commits, 25 files, +3281 / -246 (git diff 2b5d8280..71da5a13). Read #267 first: this PR invents no policy, it applies #267's model to the one resource family that was left out.

Summary

#267 gives projects and tabular reviews one permission system: a role ladder (admin / member / viewer), routes that declare a capability instead of comparing ownership flags, and list RPCs whose visibility predicate matches the detail routes branch for branch.

Chats had none of it. No org_id, no shared_with, an access helper private to routes/chat.ts, rename and delete implemented as raw .eq("user_id", userId) filters on the write itself, and a list RPC that disagreed with the detail route about which chats exist. This PR makes chats the third resource on the same schema - and, because a filter is not an authorization check, fixes the class of bug that shape produces: writes that answer 200/204 after matching zero rows.

Live demo (recorded 2026-08-26, revised model)

Ten flows recorded with a real browser driving the running product on this stack's current tip, against an upgrade-path database - main's schema.sql with migrations 20260831_01..05 applied on top, i.e. the path an existing deployment actually takes rather than a fresh install. The three below are the resources this PR brings onto the ladder.

Chat list/detail parity

An admin streams a chat in a shared project; the viewer sees that same chat listed and can open and read it, with the composer read-only - the list RPC and the detail route agree about which chats exist.

Admin deletes a chat they did not author

The admin deletes a member's chat and the row is really gone; the same delete as a viewer is refused and the refusal is surfaced - not a 204 over zero matched rows.

Tabular review gating

The unified member-tier details gate from the fix round, re-recorded after that fix: a member creates a review and edits its details from both the list row and the review page, while a viewer is refused at the same gate.

The remaining seven flows from the same round:

Source mp4s and the labeled per-checkpoint stills are on the media branch: assets/orgs-live-2026-08-26.

What changed

Schema (20260831_04). chats gains shared_with jsonb not null default '[]' and org_id uuid references organizations(id) on delete set null, plus idx_chats_org. org_id is backfilled with creation-time semantics: a chat inside a project inherits that project's org, everything else stays NULL. There is no personal organization to park a standalone chat in - org_id is null is the personal case.

One shared role derivation (lib/access.ts). ensureReviewAccess's body becomes ensureSharedRowAccess; ensureReviewAccess and the new ensureChatAccess both delegate to it. Reviews are unchanged byte-for-byte - the merge is the same code, the parameter is just named row rather than review. Branches, in order:

Branch Yields
row.user_id === caller admin, early return, is_owner: true
caller's email in row.shared_with (trim + lowercase) member
row.project_idcheckProjectAccess the project verdict (creator/grant/org, already merged strongest-wins)
row.org_idgetOrgRole (skipped when it equals the project's org) org admin → admin, org member → member

Every branch is a floor, never a ceiling. Being added to a chat's share list must not demote a project admin to member, and a project viewer named in a chat's share list must actually be able to write in it. Both directions are pinned by tests.

Every chat route now declares a capability.

Route Capability Deny
GET /chat/:id, GET /chat/:id/people project.view (viewer+) 404 Chat not found
POST /chat, POST /chat/:id/generate-title, PATCH (title, and - since the #383 merge - model / reasoningLevel) content.edit (member+) 403 You do not have permission to modify this chat
PATCH (shared_with) access.manage (admin) 403 Only a project admin can change who has access.
DELETE /chat/:id container.delete (admin) 403 You do not have permission to delete this chat

The two admin rungs are deliberate and match projects exactly: re-sharing decides who can reach the thread, deleting destroys the container. Everything else is collaboration.

Chats become shareable. PATCH /chat/:chatId accepts shared_with alongside title, normalized the way project sharing normalizes (trim, lowercase, dedupe, drop empties) so the SQL @> containment stays an exact match. It refuses self-sharing (400 You cannot share a chat with yourself.) and emails with no Mike account (400 <email> does not belong to a Mike user.). Its write is now scoped by chat id alone, because authorization already happened above it.

GET /chat/:chatId/people is new - creator plus every share recipient as {email, display_name, role}, the same roster shape GET /projects/:projectId/people returns, including its nullable owner, since a chat in an organization project outlives the account that wrote it.

GET /chat/:chatId returns access_role and is_owner, mirroring the project and review detail responses. is_owner stays provenance ("I started this thread"), not a fourth role - the creator branch derives admin on its own.

get_chats_overview gains the caller's email (20260831_05) and an is_owner column. Its predicate is ensureChatAccess, in SQL: a single call to review_access_role(c.user_id, c.project_id, c.shared_with, c.org_id, p_user_id, p_user_email), the same function tabular reviews use - chats carry no column it does not take. Verified row-identical to the previous hand-written four-branch predicate on a scratch Postgres (8 chats × 11 caller/email combinations, empty symmetric difference). Both signatures also keep #383's model column - see the fix round.

Review-chat writes are bound to their review (tabular.ts). DELETE and PATCH /tabular-review/:reviewId/chats/:chatId took the review id from the URL and never used it; the write was .eq("id", chatId).eq("user_id", userId), so the review path segment was decoration. ensureReviewChatWriteAccess now gates both: the review must exist and be reachable (404), the chat must belong to that review (404), and the caller must be the chat's creator (403 Only the chat's creator can modify it). Both writes additionally carry .eq("review_id", reviewId) so the statement expresses the binding the gate just proved. Review chats stay creator-write on purpose - collaborators read each other's threads but do not rename or delete them.

Two review reads (GET /tabular-review/:id/chats and .../chats/:chatId/messages) now select shared_with. Without that column ensureReviewAccess's direct-share branch could never fire, so a review-level collaborator 404'd on routes they were entitled to reach.

Account deletion scrubs the departing email from chats.shared_with as well as from projects and reviews. removeEmailFromSharedWith's type said "projects" | "tabular_reviews"; chats got a share list in this PR and were never added, so a chat the leaver created went with everything else they owned, while a colleague's chat they had been invited to survived with the departed person's address still in its share list - rendered by /people to everyone who can see the thread, forever.

PATCH /chat/:chatId validates the body's shape instead of coercing it. String(req.body.title) stored any JSON value; a non-array shared_with was silently skipped and fell through to a catch-all 400 "title or shared_with is required" naming the exact field the caller did send; and a non-string entry inside a well-formed array was dropped by continue, so the caller got 200 and a share list quietly missing a recipient they asked for - a partial success reported as a total one. All three are now explicit 400s. Separately, a failed write answers 500 rather than a false 404: by the time control reaches that line the chat has already been resolved and the caller's role checked, so error cannot mean "no such chat" - reporting it as 404 tells the user their colleague deleted the thread.

Post-review verification round (45bfe43c)

(Commit ids in this section are the pre-rebase ones; the branch has since been rebased onto main 1b58c7aa. This round is now 45bfe43c, and the state it was measured against - fd993cb5 below - is 8132c78a.)

An adversarial pass over this branch found one thing, and it is the same failure shape the rest of the PR exists to remove - a permission disagreement that loses data silently.

POST /projects/:projectId/chat was still judging writes on the caller's project role, plus a special case for the chat's own creator. That was a complete account of who could reach a chat right up until this PR gave chats a shared_with and an org_id of their own. After that it was not: a project viewer named on a chat's share list derives member from ensureChatAccess - the direct-share branch - so GET /chat serves them the thread, and the client, which gates on the role the server served, offers a composer. This route still saw a viewer on the project and returned 403. The message rendered locally and was gone on reload, because nothing had been persisted, and no error was shown to anybody.

Continuing an existing chat is now judged by ensureChatAccess - the same call GET /chat makes - so the two routes cannot drift again. It folds in every branch the project role alone cannot see (the chat's creator, its share list, its org, merged strongest-wins), and the creator special case disappears rather than moving, because that derivation already returns admin for a row's creator. Starting a new chat is still judged against the project: nothing exists yet to carry standing of its own, and creating a thread is an edit to the project. Fail-closed is preserved and now explicit - a chat that yields no verdict sets the role to null, and can(null, ...) is false, so an unreadable chat cannot be written through this door either.

Replication (fails on this branch as it stood at fd993cb5):

# Do this Expect
30 As A: create a chat in P, then PATCH /chat/<id> {"shared_with":["<V's email>"]} where V holds only a viewer grant on P 200
31 As V: GET /chat/<id> 200, access_role: "member" - the share promotes above the project verdict
32 As V: POST /projects/<P>/chat with that chat_id - i.e. send a message in the thread 200, and the message persists across a reload. At fd993cb5 this was a 403 while GET /chat still listed the thread and served it as writable - the client rendered the message and lost it
33 As V: POST /projects/<P>/chat with no chat_id (start a new thread) 403 - starting a thread is still an edit to the project, and a viewer cannot make one

Post-review fix round (2026-08-26)

A second review round found eight further defects; three more commits carry the rebase reconciliation with #383. Each is its own commit with its own regression test.

Commit What, and why it mattered
c32df551 Judge document-writing tools by the project role, not the chat's. Behaviour change (c) above - one gate on writeRole decided access to the entire tool set, so a project viewer named on one chat's share list could rewrite any document in the project through the assistant
4f87e9a0 Stop reporting a database failure as "chat not found". PATCH ended with if (error || !data) 404. By that line the chat has already been resolved and the role checked, so error means the database refused or the connection dropped - reported to the user as "your colleague deleted this thread". DELETE, a few lines below, already got this right
7b34546c Validate the PATCH body's shape instead of coercing it. Three soft spots - String(req.body.title) storing any JSON value, a non-array shared_with silently skipped into a catch-all 400 that named the field the caller did send, and a non-string entry dropped by continue so a 200 came back with a recipient quietly missing. The last is the same failure shape the rest of the PR removes: a partial success reported as a total one
f2e20eb0 Erase a deleted account's email from chat share lists too. removeEmailFromSharedWith was typed "projects" | "tabular_reviews". A colleague's chat the leaver had been invited to kept their address in its share list forever, rendered by /people to everyone who can see the thread. This is the ordinary cost of adding a column to a resource family - every place that enumerates "tables with share lists" is a place that has to learn
54f1bcde Don't lock a review chat forever when its creator leaves. chat.user_id !== userId is true for every caller alive once user_id is NULL, so the thread was refused to everybody, permanently. Now creatorScopedAllowed - behaviour change (d) above
562ce666 Tell PostgREST to reload its schema cache after these migrations. Prophylaxis for the other side of the deploy window: the 3-arg wrapper covers old code against the new database, but new code against a PostgREST that has not noticed the migration yet gets PGRST204/PGRST202, and no wrapper can help because the missing thing is the function itself. Both migrations now end with notify pgrst, 'reload schema';
3a421404 Gate the chat list's actions on the served role, not on ownership. The project chat list still asked chat.user_id !== user.id, wrong in both directions under this model: a project admin may delete a colleague's chat and was refused, while someone on a share list was offered a delete button the server answers 403 to. This is the commit that makes access_role/is_owner on GET /projects/:projectId/chats consumed rather than merely served
333f32cf Derive the chat list's visibility from one shared predicate. get_chats_overview restated the four branches by hand even though #267 had already shipped review_access_role(), which takes exactly those four inputs. Half the duplication was already avoided (the project arm called project_access_role); the other half was still copied out. Verified row-identical on a scratch Postgres before the swap - see Testing

One reported issue was investigated and deliberately not changed. The claim was that chats.shared_with needed normalizing on write and backfilling for existing rows. Neither holds: routes/chat.ts already normalizes (trim, lowercase, dedupe, drop empties) on every write, which is what keeps the SQL @> containment an exact match - and there is nothing to backfill, because 20260831_04 is the migration that introduces the column. Recorded here so the same reading does not have to be re-derived.

The #383 reconciliation (bfecd088, 71c5b62d, 71da5a13):

Commit What, and why it mattered
bfecd088 Renumber the chat migrations and teach the overview RPC #383's model column. Two independent collisions, neither of which a textually-clean rebase surfaces. Ordering: main has shipped 20260826_01...20260827_03, and deployments apply only files sorting after their recorded version - filed as 20260825_10/_11 these would have been silently skipped by every upgraded database, so chats would gain shared_with and org_id on fresh installs only. They move to 20260831_04/_05, directly after the org set. Shape: #383 gave chats a model column and put it in get_chats_overview's row, which the chat rail reads. This PR replaces that function, and migrations run in filename order - so a replacement written before #383 would run after #383's and strip the column the UI consumes, while fresh installs kept it. create or replace succeeds when the return type matches, so nothing errors at apply time; the column simply vanishes. The drift check is the only automated tripwire, and it fires on the fresh-versus-upgraded comparison, not in unit tests
71c5b62d Finish the semantic merge with #383 in the tests the rebase left behind. Four groups still assumed whichever side of the merge they were written on - each failing for a reason unrelated to what it pins, which is the worst kind of red because fixing it wrongly can quietly delete a guarantee. The streaming-tool-gating harness now passes a resolvable model (#383 validates inside the stream); #383's "persists the chat model and reasoning" test predated the review-chat write gate and 404'd before reaching the code under test, so it seeds the review row and an ok verdict; this branch's rename tests expected the old bare 204 and now expect #383's 200-with-row; and PATCH /chat persists a reasoning change from the parsed, typed value so the type system can see it is a ReasoningLevel
71da5a13 The deploy-window wrapper's pinned column set includes #383's model. Verified against a database built the way deployments actually get there - main's schema.sql, then this stack's five migrations, applied twice

Why it changed

Three of these are not "a missing feature" but concrete defects, all replicable below:

  1. List and detail disagreed about what exists. The 3-arg RPC's predicate was "chat creator OR project creator OR project-org member". GET /chat/:id resolved through checkProjectAccess, which also honours direct access grants. A colleague holding a grant on a shared project could open a chat by URL and get 200, while that same chat never appeared in their list. List/detail lockstep is exactly what the overview-RPC convention exists to enforce.
  2. A filter was doing an authorization check's job. When .eq("user_id", userId) matched zero rows, Supabase reported no error and the route answered 204/200. A project admin renaming a colleague's chat was told it worked and watched the change vanish on reload; a total stranger's DELETE returned 204. A silent success is worse than a refusal - the client has no way to know its optimistic update is about to disappear.
  3. A URL that names two resources verified one. Any chat id could be addressed through any review the caller had never seen, or through a review id that does not exist at all.

⚠️ Behaviour changes worth a reviewer's explicit attention

These follow from applying #267's revised model, and from the semantic merge with #383, rather than from local decisions - and each is wider than the original revision of this PR. Flagging them rather than letting them be discovered.

(a) Chats in grant-accessible projects now appear in the global GET /chat list. They were always openable by URL; they were simply unlisted, because the RPC had no email to match a grant with. Making the list agree with the detail route is the point of the change, but it is a visible difference in the sidebar. Disclosed in 20260831_05's header, and the now-stale comment on GET /projects/:projectId/chats is corrected in the same commit: that endpoint is a convenience scoping, not the only way to find a collaborator's project chat.

(b) An org admin can now delete a colleague's chat in an organization project. This is a widening versus the original #363, where container.delete was owner-only and org admins (then manager) were refused. It follows from the revised ladder with no special-casing: org admin ⇒ project admin (orgRoleToProjectRole), and container.delete requires admin (ROLE_RANK.admin = 2, REQUIRED_RANK["container.delete"] = 2). The justification is the same one #267 uses for projects - someone who can already delete the entire project, taking every chat in it, is not meaningfully restrained from deleting one chat inside it. If you want chats narrower than their container, that is a policy change in permissions.ts, not here. Pinned in both directions by lets an org admin delete a colleague's chat in the org's project (204) and 403s an org member deleting a colleague's chat.

(c) Document-writing tools are gated on the caller's PROJECT role, not on their standing in the chat. POST /projects/:projectId/chat gated the whole LLM stream on one flag derived from ensureChatAccess, and past that gate handed the model the full tool set - edit_document, replicate_document, generate_docx|excel|ppt - over a context built by buildProjectDocContext, which loads every document in the project with no per-caller filter. A project viewer invited to one chat derives member from the share branch, so they could rewrite any document in that project by asking the assistant to. A project viewer invited to a thread may still talk in it and read documents; they can no longer rewrite the project's documents through it.

(d) An admin may now rename or delete a review chat whose creator's account has been deleted. ensureReviewChatWriteAccess compared identities directly, and with chat.user_id NULL that comparison is true for every caller alive - so the thread was stranded permanently and nobody could touch it. It now goes through creatorScopedAllowed, the same helper documents and reviews use. A live creator's chat is still creator-only.

(e) From the #383 merge. PATCH /chat/:chatId accepts model and reasoningLevel alongside title and shared_with. Model and reasoning writes are gated on content.edit on the chat - a deliberate tightening: on main they follow mere reachability, so anyone who could open a thread could change the model it runs on. Flagged explicitly as a behaviour change against main. The review-chat rename answers 200 with the updated row (#383's contract, which the client uses to reconcile its cache), not the bare 204 this branch originally returned; the DELETE beside it still answers 204.


Base-case replication - the gap this closes

Run the stack from docs/local-development.md (docker compose up --build; frontend :3000, backend :3001, Supabase gateway :54321) on this PR's base branch, olp-pr/organizations-rbac - i.e. with #267 in place but not this PR. Apply 20260831_01..03. Bearer tokens come from POST http://localhost:54321/auth/v1/token?grant_type=password with the well-known local anon key from docker-compose.yml; send them to :3001 as Authorization: Bearer <token>.

Accounts: A (project creator ⇒ project admin), G (holds a member access grant on A's project P, no org membership), X (no access to anything).

  1. As A: create project P, then POST /projects/<P>/access {"email":"<G>","role":"member"}. Open P → Chat, send a message, note the chat id.
  2. As G: GET /chatA's chat is absent. Then GET /chat/<chatId>200 with the full transcript. The list and the detail route disagree - the RPC has no email, so it cannot see the grant.
  3. As A: PATCH /chat/<chatId> {"shared_with":["<G>"]} → the field is ignored; the handler reads only req.body.title, so this is 400 title is required. GET /chat/<chatId>/people404 (route does not exist). There is no sharing mechanism for chats at all.
  4. As X (no access to anything at all): DELETE /chat/<chatId>204 No Content. Nothing was deleted; the response is indistinguishable from success. Reload as A - the chat is still there.
  5. As an org admin on a colleague's chat in an org project: PATCH /chat/<chatId> {"title":"renamed"}404 Chat not found, even though GET /chat/<chatId> on the same id returns 200. The write filter, not the access rule, produced that answer.
  6. As A: create a tabular review R inside P, open its chat, note reviewChatId. Then DELETE /tabular-review/00000000-0000-0000-0000-000000000000/chats/<reviewChatId> - a made-up review id - → 204, and the chat is gone. The handler never read :reviewId.
  7. As a review collaborator who is not the chat's creator: PATCH /tabular-review/<R>/chats/<A's reviewChatId> {"title":"x"}204, and the title is unchanged. Success-shaped no-op.
  8. As A: share review R directly by putting G's email in tabular_reviews.shared_with. As G: GET /tabular-review/<R>200, but GET /tabular-review/<R>/chats404. The route selects the review without shared_with, so the direct-share branch cannot fire.

PR replication - the same stack on this branch

Check out olp-pr/chat-permissions (tip 71da5a13), apply 20260831_04 and _05 (idempotent; verified applying twice over a database already carrying #267's 20260831_01..03), restart the backend.

Accounts: A (project creator ⇒ project admin), D (org admin ⇒ project admin), M (plain org member ⇒ project member), V (holds a viewer access grant on P), G (holds a member grant on P, no org membership), X (outsider). P is an organization project.

List / detail agreement

# Do this Expect
1 As G: GET /chat A's project chat is listed, with is_owner: false - the change flagged as (a) above
2 As G: GET /chat/<chatId> 200, access_role: "member", is_owner: false
3 As M: GET /chat then GET /chat/<chatId> Listed, 200, access_role: "member" - org membership inherits
4 As V (viewer grant): GET /chat/<chatId> 200, access_role: "viewer"
5 As X: GET /chat and GET /chat/<chatId> Chat absent from the list; 404 on detail - no existence leak

The write-gate matrix

# As Attempt Expect
6 V (viewer) PATCH /chat/<chatId> {"title":...} 403 You do not have permission to modify this chat; the title is unchanged
7 V POST /chat {"chat_id":..., ...} (send a message) 403, same message
8 M (member) PATCH {"title":...}, POST /chat/<chatId>/generate-title 200 each - content collaboration is member-tier
9 M PATCH {"shared_with":["<X>"]} 403 Only a project admin can change who has access.
10 M DELETE /chat/<chatId> (a colleague's chat) 403 You do not have permission to delete this chat; the chat survives
11 D (org admin) PATCH {"title":"renamed"} on A's chat 200, and the rename persists on reload - this is the silent-no-op fix
12 D (org admin) DELETE /chat/<A's chatId> 204, and the chat is really gone - the widening flagged as (b) above
13 X DELETE /chat/<chatId> 404, not a success-shaped 204

Chat sharing

# Do this Expect
14 As A on a standalone chat: PATCH /chat/<id> {"shared_with":["<M's email>"]} 200, returns the normalized {id, title, shared_with}
15 As M: GET /chat and GET /chat/<standaloneId> Listed; 200; access_role: "member"; a message streams end to end; PATCH {"title":...}200
16 As M: DELETE /chat/<standaloneId> 403 - a share grants member, never admin
17 As D (org admin, chat not shared with them): GET /chat/<standaloneId> 404 - a standalone chat is org_id null, so org membership reaches nothing. Sharing a standalone chat is explicit and per-email
18 As A: PATCH /chat/<id> {"shared_with":["<A's own email>"]} 400 You cannot share a chat with yourself.
19 As A: PATCH /chat/<id> {"shared_with":["nobody@example.com"]} 400 nobody@example.com does not belong to a Mike user.
20 As A: GET /chat/<id>/people {owner: {email, display_name, role:"admin"}, members: [{email, display_name, role:"member"}]} - names, never bare UUIDs
21 Delete the account that created an org project's chat, then GET /chat/<id>/people as D owner: null, roster still renders; the chat survives (user_id is ON DELETE SET NULL per #267)

No-demotion invariant (strongest-wins in both directions)

# Do this Expect
22 Add D (org admin) to a chat's shared_with, then GET /chat/<id> as D access_role: "admin" - a share cannot demote
23 Add V (project viewer) to a chat's shared_with, then as V PATCH {"title":...} 200 - an explicit share is not shadowed by a weaker branch
24 As V (viewer): start your own chat in P, then PATCH {"title":...} on it 200 - the creator branch derives admin for that row, which is what keeps the streaming route and the CRUD routes from disagreeing

Review-chat binding

# Do this Expect
25 PATCH/DELETE /tabular-review/<bogus-uuid>/chats/<reviewChatId> 404 Review not found; the chat survives
26 Same with a real but different review id 404 Chat not found
27 As a review collaborator who is not the chat's creator: PATCH/DELETE /tabular-review/<R>/chats/<cid> 403 Only the chat's creator can modify it
28 As the chat's creator: the same calls 204, and the change persists
29 Share review R directly with G, then as G: GET /tabular-review/<R>/chats and .../chats/<cid>/messages 200 each (both were 404)

The failure ordering matters and is asserted: review-404 and chat-404 fire before the 403, so a bogus review id never leaks whether a chat exists.


Tradeoffs and design decisions

  • Chats keep shared_with; projects moved to project_access_grants. #267 moved projects onto role-carrying grant rows because a project is the container people administer - sharing one has to say what the recipient may do. A chat is a single thread, and every recipient of one is a content collaborator; there is no second thing a chat share could mean. A one-role grant table would be a whole second sharing mechanism with nothing to express. So chats use the same array tabular_reviews.shared_with already uses, and lib/access.ts maps a direct chat share to member. This is the one place the two resource families deliberately differ, and it is worth a second opinion.
  • The old 3-arg get_chats_overview survives as a delegating wrapper. Migrations land before the code that needs them, so for the length of a deploy there are API instances running old code against the new database. Dropping the 3-arg signature outright would take GET /chat down with PGRST202 on every not-yet-replaced instance from the moment the migration applied - a self-inflicted outage inside the deploy window. The wrapper is a deploy-window artefact with an explicit expiry recorded in the migration header; a follow-up migration drops it (from the migration and schema.sql) once no pre-#363 instance can still be serving.
  • p_user_email is REQUIRED, not defaulted - this is load-bearing. With p_user_email text default null the old three-key call matches both candidates and neither layer can choose: Postgres 42725 function ... is not unique, PostgREST PGRST203 Could not choose the best candidate function. Making it required means a three-key call can only be the wrapper (the new function has a required parameter it does not supply) and a four-key call can only be the new function (the wrapper has no such parameter). Every caller in the tree passes it explicitly, so requiring it costs nothing.
  • Caveat on that disambiguation: it holds for named arguments only. PostgREST always calls by name, so the deploy window is safe. A positional SQL call - select * from get_chats_overview('uid', null, 0) in psql - leaves argument 2's type open and resolves to the 4-arg function, not the wrapper. Recorded in the migration header; anyone hand-testing the wrapper must cast (null::text) or use named notation.
  • The chat's own org_id branch is inert in practice, and kept anyway. chats.org_id is only ever written at insert, always as the project's org or null, and no route re-parents a chat or a project. So the branch can never add a row the project branch does not already cover - the is_owner-parity argument for the wrapper depends on exactly that. It is kept for structural symmetry with reviews, so a future feature that stamps an org onto a chat directly does not have to remember to add a branch here.
  • The RPC predicate no longer duplicates ensureChatAccess. It delegates to review_access_role() - the SQL twin of ensureSharedRowAccess, which both ensureReviewAccess and ensureChatAccess delegate to - so the four branches exist once per language rather than once per resource. A single source of truth across languages would mean a SQL round trip on every access check, or fetching ids in TS and threading them into every list query; neither is worth it on the hottest path.
  • chats.shared_with gets a GIN index (chats_shared_with_idx). shared_with @> jsonb_build_array(...) cannot use a btree, and tabular_reviews_shared_with_idx exists for precisely this predicate. Be honest about what it buys, though: the list query cannot use it - the predicate now reaches the column through a function, and the hand-written version's project arm was an unindexable function call inside the same OR anyway. It serves the direct containment probes: account-deletion share scrubbing and the audit lookups.
  • GET /chat/:chatId still does select("*"), so shared_with is now visible to every viewer of a chat, and /people is gated only at project.view. Deliberate, and it matches projects: knowing who else is in the room is part of viewing it.
  • Review chats are not made shareable. They inherit review access for reads and stay creator-write. Only the path-binding hole and the silent 204 are fixed here. Giving review chats their own shared_with would mean a second sharing surface with no UI and no demand; the review's own roster is the intended boundary.
  • PATCH /chat/:chatId's two body-shape 400s fire before the access check. title is required and You cannot share a chat with yourself. are reachable by a caller who cannot see the chat at all and would otherwise get a 404. They leak nothing about the chat (both are decided from the request body alone), but the asymmetry is real and you need it to write assertions.
  • Out of scope: workflows (a third, unrelated access scheme - its own issue), chat message authorship, and the sharing UI. GET /projects/:projectId/chats now serves access_role/is_owner per row and the project chat list consumes them for its rename/delete affordances; the sharing UI and /people remain unconsumed.

Migrations

File Does
20260831_04_chat_permissions.sql chats.shared_with jsonb not null default '[]', chats.org_id uuid references organizations(id) on delete set null, idx_chats_org. Backfills org_id from the chat's project; standalone chats stay NULL. Idempotent - if not exists adds, backfill guarded by c.org_id is null. Ends with notify pgrst, 'reload schema'; so a new column is not PGRST204 until PostgREST restarts.
20260831_05_chats_overview_rpc.sql get_chats_overview(p_user_id, p_user_email, p_limit, p_offset) with an is_owner output column; re-creates the 3-arg signature as the delegating deploy-window wrapper; adds chats_shared_with_idx (GIN). Delegates the predicate to review_access_role; keeps #383's model column in both signatures; ends with notify pgrst, 'reload schema';.

Two nullability details #267's user_id relaxation forces: is_owner is coalesce()d, because a chat whose author's account was deleted has user_id null where = yields NULL; and the "not me" guards use is distinct from rather than <>, so a chat shared directly with the caller stays visible after its author is gone.

backend/schema.sql is updated in the same change so fresh installs and upgraded deployments converge.

Testing performed

Automated, on tip 71da5a13 (base 2b5d8280, stack rebased onto main 1b58c7aa):

  • cd backend && npx tsc --noEmitclean.
  • npx vitest run1067 passed | 31 skipped, exit 0, against a base (d6058c22) measured in the same working copy at 1009 passed | 28 skipped. So this PR adds 58 passing cases and 3 stack-gated ones. (The unhandled ERR_STREAM_WRITE_AFTER_END previously noted here as harmless is fixed in #267's fix round; it no longer appears.)
  • cd frontend && npx vitest run773 passed across 111 files - this PR's chat-list role gating adds a suite to #268's 766.
File New cases Pins
lib/__tests__/access.test.ts 8 The full ensureChatAccess branch/overlap matrix: creator ⇒ admin; case-insensitive direct share ⇒ member; project inheritance; the chat's own org with no project; no downgrade of a project viewer who is also in shared_with; no demotion of a project admin added to a chat's share list; cross-tenant and unshared-standalone denial; fail-closed when the caller has no email
integration/chat.routes.test.ts 22 (4 replacing rewritten cases; 18 net new) A write-recording Supabase harness captures every update/delete with its filters, so the tests assert filters === [{column:"id", value:"chat-1"}] - they prove the user_id scoping is really gone rather than testing a happy path. Plus the admin/member/viewer walk, the three new 400s, the /people roster, the exact 4-key argument object handed to get_chats_overview, and a nested standalone-share group (reads as member, may generate a title, may not delete)
integration/tabular.routes.test.ts 6 The review-chat binding gates: review-404, no-access-404, wrong-review-404, missing-chat-404, non-creator-403, creator-204
integration/projectChat.routes.test.ts 4 The verification round's write-path parity: a project viewer named on a chat's share list may continue that thread; a viewer with no standing on the chat is still refused; a chat that yields no verdict fails closed; and a new chat is judged against the project, not against any chat row
integration/chatsOverview.supabase.test.ts (new, stack-gated) 3 The deploy-window overload pair against a real Supabase stack - see below

Database, verified against a real Supabase stack built the UPGRADE way - main's schema.sql at 1b58c7aa (i.e. #383 already in place), then 20260831_01..._05 applied twice (idempotent, 0 errors on both passes). This is how deployments actually get there, and it is the only way the 42P13 return-type collision with #383 was visible at all:

  • The stack-gated suites run 22/22 green against that database.
  • Fresh-vs-upgraded schema fingerprints are identical, modulo one stub-environment pgcrypto/varchar artefact that is a property of the scratch container rather than of either schema.
  • A scratch Postgres was also used for a differential check on the RPC predicate: the new single review_access_role(...) call versus the previous hand-written four-branch predicate, over 8 chats × 11 caller/email combinations - empty symmetric difference in both directions.
  • Both get_chats_overview signatures are present afterwards, and p_user_email on the 4-arg carries no default:
    get_chats_overview(p_user_id text, p_limit integer DEFAULT NULL::integer, p_offset integer DEFAULT 0)
    get_chats_overview(p_user_id text, p_user_email text, p_limit integer DEFAULT NULL::integer, p_offset integer DEFAULT 0)
    
  • chats_shared_with_idx and idx_chats_org exist; chats.user_id is nullable with confdeltype = 'n' (SET NULL).
  • Schema-drift check replicated the way CI runs it: the upgraded database above versus a fresh install of this branch's schema.sql, each reduced by backend/scripts/schema-fingerprint.sql → identical apart from the stub-environment artefact noted above.

Stack-gated (SUPABASE_TEST_URL + SUPABASE_TEST_SERVICE_ROLE_KEY; run by npm run test:stack --prefix backend). chatsOverview.supabase.test.ts seeds two auth users, two orgs, four projects, one project_access_grants row and six chats - one per access branch (mine, inMyProject, inSharedOrgProject, inGrantedProject, sharedDirectly, strangers) - and pins:

Payload Resolves to Returns
{p_user_id, p_limit, p_offset} (pre-#363 instance) 3-arg wrapper the old six columns, exactly the pre-migration row set (inMyProject, inSharedOrgProject, mine)
{p_user_id, p_user_email, p_limit, p_offset} (this PR) 4-arg function the new columns incl. is_owner, plus inGrantedProject and sharedDirectly

The wrapper's pinned column set includes #383's model (71da5a13): the pin predated #383 and would otherwise have rejected the very column the deploy-window contract is required to keep serving.

strangers is never visible under either signature, and the wrapper's paging matches the new function's. Old-signature parity is therefore measured, not asserted: p_user_email => null switches off the chat-share arm and project_access_role's grant arm (both email-gated), collapsing the predicate to the old function exactly. Mutation-checked - re-introducing p_user_email text default null turns all three cases red with PGRST203, which is the regression they exist to catch.

Live. A ten-flow browser round against the revised model was recorded on 2026-08-26 - see Live demo above; the replication tables in this description are the script it follows. The 2026-08-21 round is retained for history, but it exercised the superseded owner/manager/editor/viewer ladder and personal orgs - its matrix no longer replicates and should not be read as evidence for this branch.

Note for CI: checks cannot run while the base is the olp-pr/organizations-rbac staging branch - the workflows trigger on PRs to main only. Retargeting to main after #267/#268 merge gives this PR its first CI run.

🤖 Generated with Claude Code


Second fix round - 2026-08-26 (architecture re-review)

Two corrections to this description first: the tip this body previously described (71da5a13) gained one further commit before this round - 02d48724 fix(chat): the sidebar speaks the ladder (role-gates SidebarChatItem on the served role via roleFrom, surfaces failed deletes, +4 tests). A re-review of that commit found it incomplete in two ways, both fixed here. The branch was also rebased onto #268's fix-round tip; the one conflict (6aac618b vs #267's gate-ordering fix) resolved to this branch's unified writeRole gate, which already sits before the model write - the ordering comment was kept, and the now-dead chatCreatorId local was removed. New tip: d835a516 (after one further test-only clock-pinning commit at the #267 layer).

Fix Commit Base case (at 02d48724) After
The sidebar gated on a column the RPC never returned. roleFrom prefers access_role; get_chats_overview served only is_owner, so every non-owned row fell back to member - a project viewer was offered a Rename the server 403s, and a project admin was told "Only an admin can delete this chat" about a delete the server accepts (making behaviour change (b) unreachable from the sidebar). All four sidebar tests passed access_role explicitly, so none exercised the real payload. 9d5907d2 In the global sidebar as a project viewer: hover a colleague's project chat → Rename offered → 403. As an org admin: Delete refused client-side. The RPC computes its visibility verdict once (lateral) and serves it as access_role in the 4-arg signature; the deploy-window wrapper keeps its pre-#363 seven columns. Safe to reshape in place: the 4-arg signature is introduced by this unmerged migration, so no deployed database holds a prior return type. schema.sql updated byte-identically. Stack test now pins the served role for all five access branches; the sidebar suite gains the two rows that were wrong (admin non-owner → delete allowed; row with no role fields → everything refused).
A freshly created chat was unusable by its own author. saveChat's optimistic row carried neither is_owner nor access_role; roleFrom fails closed to viewer, so the creator was refused rename/delete on their own new thread until a reload. 96a0ccd7 Create a chat from the sidebar → immediately try Rename → "Only a member can rename this chat." The optimistic row is stamped is_owner: true, access_role: "admin" - the same stamp ProjectWorkspace gives its optimistic project row, stating what the server will serve for this row on every future load. New provider-level test derives the role the gates consume from the row actually in state.
CI never ran the chats-overview stack suite. chatsOverview.supabase.test.ts (the overload-pair + access_role pins) was invoked by neither the stack workflow nor scripts/test-stack.sh. 5ff098f5 The suite runs only when someone invokes the file by hand. Both invocation lists gain the file, keeping script and workflow in lockstep.

Testing performed (this round): backend npx tsc --noEmit clean, npx vitest run → 1076 passed | 31 skipped; frontend npx tsc --noEmit clean, npx vitest run → 783 passed; lint 0 errors. Against a real local Supabase stack built the way an existing deployment upgrades (main 1b58c7aa schema.sql + 20260831_01..05, applied twice for idempotency): all four stack suites 22/22, including the new per-branch access_role assertions; fresh-vs-upgraded drift fingerprints identical (3,812 lines, no drift). Each fix verified red-before/green-after by stashing its code change.

Evidence caveat: the Live demo GIFs for flows 08/09 were filmed at 71da5a13, before 02d48724 and this round - the flows' server-side behaviour is unchanged, but the sidebar affordances they show predate the role-gating fixes.


Third fix round - 2026-08-26 (high-tier findings)

Rebased onto #268's high-tier tip plus two commits. New tip: d5f86663.

  • 6af8c258 - chat creation adopts the tenant lookup's new honesty. resolveContentOrgId is result-shaped since #267's round; this branch's three chat-creation sites now refuse the create rather than filing a project chat as personal when the project read fails.
  • d5f86663 - every refusal reaches the person who was refused. Two halves: (1) sidebar rename gets the same contract delete already had - the context restores AND rethrows, and the row surfaces "Chat not renamed" instead of the title silently snapping back; (2) the global chat page consumes the standing GET /chat/:id already serves - getChat folds is_owner/access_role into the row, and the page derives canSend from it, so a project viewer landing on a grant-reachable chat gets the disabled "Viewing only" composer instead of a live one whose sends 403. Base cases at d835a516: rename a colleague's chat as a viewer from the sidebar → title changes and silently reverts; open a colleague's project chat from the global sidebar as a viewer → writable composer, send → 403.

Regression tests: rename-rethrow (provider-level) and rename-surfacing (row-level) cases; a new page.roles.test.tsx pinning the composer read-only for access_role: "viewer" and live for member; all verified red at the pre-fix tip. Full stack tip: backend 1,084 passed, frontend 790 passed, both tsc clean.


Migration renumber - 2026-08-27 (decided deploy order)

Rebased onto the renumbered stack, plus commit 8ac1bbcf: this PR's two migrations are now 20260831_04_chat_permissions.sql and 20260831_05_chats_overview_rpc.sql, following the org files to slots that sort past #294's 20260829_01 - the maintainer's decided order is queues first, this stack after. All cross-references (migration headers, schema.sql pointers, code comments naming _04 as the origin of chats.shared_with, stack tests) moved in the same commit. Verified: main schema → #294's migration → 20260831_01..05 ×2 applies clean on a real Postgres; stack suites 22/22 on the combined database; drift fingerprints identical. Earlier 20260828_* mentions in this body refer to the renamed files. New tip: 8ac1bbcf.


Rebase onto September main - 2026-09-01

Rebased with the stack onto main at fdd4ed19. New tip: 59a93268 (25 commits over #268).

  • Chat list meets main's bulk-selection rework (ae81170f): main gave the project chat table the shared selection machinery (select-all, bounded-concurrency bulk delete with per-id outcomes) while this branch was re-gating the same surface on the served role. The merge keeps both: rename and delete gate on roleFrom/content.edit/container.delete, the bulk path role-filters to deletable rows before main's helper runs, failures land in the action notice with failed rows kept selected, and a single-row delete awaits the server before touching the list - a refused delete never drops the row.
  • Migrations move to 20260902_04/_05 (f353f898), following the org files past main's 20260901_01..03 (the deployment-watermark rule; fourth slot move for the stack, references and compose db-init mounts swept in the same commit). Function bodies remain byte-identical with schema.sql, checked mechanically.
  • The departing-email scrub keeps both halves of the account-deletion Promise.all it merged into: chat share lists are scrubbed AND main's export artifacts still die loudly.

Testing performed (this round): backend npx tsc --noEmit clean, npx vitest run1,361 passed | 40 skipped; frontend tsc clean, lint 0 errors, 931 passed. Stack-gated suites and the fresh-vs-upgraded drift check re-run against a real Postgres on the decided upgrade path - see the stack-verification note in #267's rebase section.

Our analysis

Extend role-based access control to chats — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-363.md from inside the repo you want the changes in.

⬇ Download capture-pull-363.md