Skip to content

chat: a conversation is between PRINCIPALS ​

Raised 2026-09-05. Follows the architecture change protocol. Decision context: ADR-0032 · chat and platform identity.

1 · Existing architecture ​

public.conversations models one thread between a business and a consumer: workspace_id and consumer_user_id, both NOT NULL, unique together. The table comment states the reasoning and it is sound — "Not user-to-user: several staff may answer for one vendor, and a user-to-user model breaks the moment a merchant hires a second person."

⚠ That reasoning is not being reversed. It is being generalised. The insight it encodes — that a business is a tenant and not a person — is exactly what a principal-based model preserves. What it did not anticipate is that the other side may also be a tenant, or may be a second person.

2 · Actual data model and relationships ​

ObjectShapeVerdict
conversationsworkspace_id NOT NULL · consumer_user_id NOT NULL · unique(workspace_id, consumer_user_id) · blocked_by_consumer_at · blocked_by_merchant_atmust change
messagessender_kind NOT NULL CHECK IN (consumer, merchant, system) · sender_user_id NULL · messages_system_has_no_authorvocabulary widens
conversation_reportsreported_by CHECK IN (consumer, merchant)vocabulary widens
conversation_states(conversation_id, user_id)no change
conversation_labelsuser_idno change
message_states(message_id, user_id)no change
policiesfive encode the two-role vocabularymust change

⚠ The two-role assumption is a vocabulary, not a column pair. It runs through the participant columns, the blocking columns, messages.sender_kind and conversation_reports.reported_by. Anyone planning this change from the participant columns alone would under-scope it by four objects.

⚠ And three tables are already right. conversation_states, conversation_labels and message_states all key on user_id, which stays the correct grain: each staff member pins their own view of a workspace thread. They are untouched.

3 · Current implementation ​

Two read RPCs (get_my_conversations, get_conversation_messages), the @qrsetu/domain/chat modules, the stub-bound packages/data chat seam, the consumer Chats list screen, and pgTAP coverage.

⚠ There is no write path at all. Zero Edge Functions touch conversations or messages (QRS-1015), which is what makes this change cheap: the code that would have encoded the old shape has not been written yet.

4 · Impact analysis ​

Measured, not inferred: every chat table on Dev holds zero rows — conversations, messages, conversation_states, conversation_labels, conversation_reports, message_states, all 0. Production has never run the consumer app. The realtime publication contains no tables, so nothing streams the current row shape either.

So the blast radius is five policies, two RPCs, two CHECK vocabularies, and the types that mirror them. No data moves.

5 · Gaps ​

Search space stated: all six chat migrations, plus pg_constraint and pg_policies on the live Dev database, grepped for request|pending|accepted|approve.

  1. No request state exists. conversation_states carries pin, favourite, archive, mute and manual-unread, and nothing about acceptance. The locked model needs one.
  2. Consumer↔consumer is unrepresentable (workspace_id is NOT NULL).
  3. Business↔business is unrepresentable (consumer_user_id is NOT NULL).
  4. handle_new_user assigns no slug — zero references. On Dev, 2 of 10 users hold an address, so eight accounts are unreachable by any capability.

6 · Alternatives evaluated ​

Nullable columns plus an XOR CHECK — the polymorphic-scope-column shape already measured as costly on feature_grants: every policy becomes a multi-branch predicate, and it does not extend to a third participant.

A separate direct_conversations table — forces two message tables or a polymorphic FK on messages, doubling every read path, policy and index. Rejected on operational simplicity.

Keep the two-role model, route consumer↔consumer through WhatsApp permanently (today's D-v launch decision). Fine for R1, not durable: business↔business still has no model, and the locked identity model requires a person to be reachable in-app by capability.

7 · Proposed change ​

public.conversation_participants — one row per principal in a thread, with an XOR over user_id/workspace_id. conversations loses its two participant columns and its two blocking columns; blocking becomes blocked_at on the participant row, which is the same fact generalised to N participants rather than a new concept.

The tap Message twice continues one thread invariant survives as a canonical participant-set key: a unique key over the sorted principal references, exact for two-party threads.

messages.sender_kind widens to (user, workspace, system); sender_user_id keeps recording who typed and a sender_participant_id records on whose behalf — which is precisely what lets several staff answer for one vendor without losing the author.

requested_at / accepted_at on the participant row carry the request gate: a first message to a USER participant is a request; a thread whose other participant is a WORKSPACE is auto-accepted, because a card exists to be messaged.

8 · Validation against all four customer types ​

Customer typeVerdictWhy
ConsumersafeGains consumer↔consumer, which does not exist today. Their existing thread with a shop is the same thread with the same grain.
Solo / SMB merchantsafeUnchanged in every respect they can observe. The workspace is still the participant, staff still answer as the workspace, and each staff member still keeps their own pins.
Car dealershipsafeThe multi-staff case the original comment was written to protect is preserved exactly — it is sender_user_id beside a workspace participant, which is the shape that already exists. Gains business↔business between a showroom and a supplier.
EnterprisesafeWorkspace-tree oversight is unaffected: participation is by workspace id, and my_workspace_ids() continues to answer for it. Nothing here reads ownership_model or the workspace path.

9 · Production-risk assessment ​

Low. Zero rows everywhere, no write path, no realtime consumer, and the change lands beforemanage-chat is written so no code is discarded. Rollback is reverting one migration; with no rows there is nothing to restore.

⚠ The risk that is not low is doing this later. After adoption, the participant columns would be referenced by every message, every policy, every client type and every printed card's conversation history — which is the owner's own reason for deciding now.

10 · Final recommendation ​

Implement, sequenced before manage-chat.

Not verified ​

❓ Whether Production holds chat rows. Prod is on a separate Supabase account unreachable from this session, and "Dev is the source of truth" governs — but the promotion checklist must confirm it rather than assume, and this proposal does not.

❓ The exact uniqueness mechanism for the participant set (a generated column versus a trigger- maintained key). Both work; the choice is an implementation detail measured against the real query plan, not an architectural one.

❓ Whether group conversations ever ship. The model permits them; nothing in this proposal builds them, and no design exists.