Skip to content

feat(support): conversations + conversation_messages tables with workspace-membership RLS#28

Merged
hcho22 merged 3 commits into
mainfrom
fm/us066-conv-k4
Jun 24, 2026
Merged

feat(support): conversations + conversation_messages tables with workspace-membership RLS#28
hcho22 merged 3 commits into
mainfrom
fm/us066-conv-k4

Conversation

@hcho22

@hcho22 hcho22 commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Intent

The developer's goal was to implement story US-066: a pure database-migration change adding a new conversations and conversation_messages table pair governed by workspace-membership row-level security, as the foundation for the support-conversation surface (Epic E). They imposed a hard design constraint that this must be a brand-new table pair, not an extension of the existing owner-only threads/messages tables, which had to stay completely untouched (no kind discriminator and no policy edits to the leak-proof predicate). They specified exact columns for both tables, RLS enabled on both with conversations policies using the ADR-0002 membership EXISTS clause (with role appearing in no predicate) and child messages delegating to the parent conversation's workspace membership, two specific indexes, and an automated cross-workspace zero-leak test, all while keeping scope strictly to US-066 and not starting US-067+. After the work was committed, the developer ran the no-mistakes validation pipeline and, when it flagged foreign-key delete-behavior questions, decided that bot_user_id and claimed_by should use ON DELETE SET NULL while workspace_id stays with no cascade (matching the documents.workspace_id precedent), instructing the agent to apply that and continue. Following repeated validation-infrastructure crashes attributed to resource exhaustion rather than a code defect, the developer's final stated intent was to retry the no-mistakes validation run on the verified-green branch at commit 2ddad52 now that machine load had been reduced.

What Changed

  • Add migration 20260623120000_init_conversations.sql creating a new conversations + conversation_messages table pair (deliberately separate from the owner-only threads/messages pair, no kind discriminator). Both tables enable RLS with workspace-membership policies built on the ADR-0002 EXISTS clause against workspace_membership (presence only — role appears in no predicate), child messages delegating to the parent conversation's workspace; FKs bot_user_id and claimed_by use ON DELETE SET NULL while workspace_id stays NO ACTION, plus a (workspace_id, status) agent-queue index and a (conversation_id, created_at) transcript index.
  • Add backend/test_us066_conversations_rls.py, a cross-workspace zero-leak suite asserting members see only their own workspace's conversations and child messages through real PostgREST + RLS.
  • Document the two parallel trust models (owner-only vs workspace-membership) in AGENTS.md and CLAUDE.md, and sync the Phase-2 PRD status to mark the US-066 migration as landed.

Risk Assessment

✅ Low: A well-bounded, additive, thoroughly-documented migration whose RLS faithfully mirrors an existing leak-proof precedent and is pinned by an exact cross-workspace zero-leak test; the only findings are a data-integrity nit and a rare-path performance note.

Testing

Validated against the running local Supabase (DB :54322) that already has the target migration 2ddad52 applied. Baseline: confirmed the four conversation FK constraints and the documents.workspace_id precedent via pg_constraint introspection. Ran the project's own cross-workspace RLS test (9 exact assertions pass through the real PostgREST + RLS path, with a W2-member positive control proving the zeros are genuine). Wrote and ran a transaction-rolled-back FK demo showing bot_user_id/claimed_by SET NULL on user deletion (conversation preserved) and a blocked workspace delete (no cascade). Captured a psql schema snapshot confirming FK actions, RLS-enabled state, both indexes, role-free conversations policies, and untouched owner-only threads policies. No UI surface exists (pure DB migration), so evidence is CLI transcripts and a persisted-schema snapshot rather than screenshots. All checks pass; working tree clean and no DB residue.

Evidence: Cross-workspace zero-leak RLS test transcript (9 assertions)

conversations: 8 policy predicates, none reference role conversation_messages: every policy delegates to parent conversation RLS enabled on both conversations and conversation_messages both required indexes present threads policies remain owner-only (no workspace_membership branch) U1 list /conversations -> {C1} only (1 row) U1 direct read of C2 by id -> 0 rows (RLS-hidden) U1 sees C1's message, 0 of C2's (child delegation holds) positive control: U2 (W2 member) reads C2 -> the zeros are real OK: US-066 passed — 9 exact assertions; conversations + conversation_messages enforce the ADR-0002 membership boundary with zero cross-workspace leak and no role predicate

  conversations: 8 policy predicates, none reference role
  conversation_messages: every policy delegates to parent conversation
  RLS enabled on both conversations and conversation_messages
  both required indexes present
  threads policies remain owner-only (no workspace_membership branch)
  U1 list /conversations -> {C1} only (1 row)
  U1 direct read of C2 by id -> 0 rows (RLS-hidden)
  U1 sees C1's message, 0 of C2's (child delegation holds)
  positive control: U2 (W2 member) reads C2 -> the zeros are real
OK: US-066 passed — 9 exact assertions; conversations + conversation_messages enforce the ADR-0002 membership boundary with zero cross-workspace leak and no role predicate
Evidence: FK ON DELETE behavior demo output (second-commit decision)

STEP 1 delete bot user -> conversation SURVIVES, bot_user_id -> NULL ✓ STEP 2 delete claimed_by agent -> conversation SURVIVES, claimed_by -> NULL ✓ STEP 3 delete owning workspace -> BLOCKED by FK (no cascade) ✓ ForeignKeyViolationError: update or delete on table "workspaces" violates foreign key constraint "conversations_workspace_id_fkey" on table "conversations" OK: FK ON DELETE behavior matches commit 2ddad52 — bot_user_id & claimed_by SET NULL (conversation preserved), workspace_id no-cascade (delete restricted, mirrors documents.workspace_id).

SETUP    conversation created with both FK refs populated:
           bot_user_id = 445baf9e-ef6c-45de-aff1-9f814c7cd08d
           claimed_by  = 262d2bfe-fff1-4414-a0e9-5695713c6830

STEP 1   delete bot user -> conversation SURVIVES, bot_user_id -> NULL ✓
           bot_user_id = None, claimed_by = 262d2bfe-fff1-4414-a0e9-5695713c6830

STEP 2   delete claimed_by agent -> conversation SURVIVES, claimed_by -> NULL ✓
           bot_user_id = None, claimed_by = None

STEP 3   delete owning workspace -> BLOCKED by FK (no cascade) ✓
           ForeignKeyViolationError: update or delete on table "workspaces" violates foreign key constraint "conversations_workspace_id_fkey" on table "conversations"

OK: FK ON DELETE behavior matches commit 2ddad52 — bot_user_id & claimed_by SET NULL (conversation preserved), workspace_id no-cascade (delete restricted, mirrors documents.workspace_id).
Evidence: Persisted-schema snapshot (FK actions, RLS, indexes, role-free + threads-untouched policies)

FK ON DELETE: bot_user_id=SET NULL, claimed_by=SET NULL, workspace_id=NO ACTION, conversation_id=CASCADE | documents.workspace_id=NO ACTION (precedent) | RLS enabled on both tables | both required indexes present | conversations policies: references_role=f for all | threads policies: has_ws_membership_branch=f for all

=== FK ON DELETE behavior (a=NO ACTION, c=CASCADE, n=SET NULL) ===
                  conname                   | on_delete |          tbl          |       col       |      ref      
--------------------------------------------+-----------+-----------------------+-----------------+---------------
 conversation_messages_conversation_id_fkey | CASCADE   | conversation_messages | conversation_id | conversations
 conversations_bot_user_id_fkey             | SET NULL  | conversations         | bot_user_id     | users
 conversations_claimed_by_fkey              | SET NULL  | conversations         | claimed_by      | users
 conversations_workspace_id_fkey            | NO ACTION | conversations         | workspace_id    | workspaces
(4 rows)


=== documents.workspace_id precedent (must also be NO ACTION) ===
           conname           | on_delete 
-----------------------------+-----------
 documents_workspace_id_fkey | NO ACTION
(1 row)


=== RLS enabled? ===
        relname        | rls_enabled 
-----------------------+-------------
 conversations         | t
 conversation_messages | t
(2 rows)


=== Required indexes present? ===
                      indexname                       
------------------------------------------------------
 conversation_messages_conversation_id_created_at_idx
 conversations_workspace_id_status_idx
(2 rows)


=== conversations policies: none may reference wm.role ===
           polname           | cmd | has_using | has_check | references_role 
-----------------------------+-----+-----------+-----------+-----------------
 conversations_delete_member | d   | t         | f         | f
 conversations_insert_member | a   | f         | t         | 
 conversations_select_member | r   | t         | f         | f
 conversations_update_member | w   | t         | t         | f
(4 rows)


=== threads policies remain owner-only (no workspace_membership branch) ===
      polname       | has_ws_membership_branch 
--------------------+--------------------------
 threads_delete_own | f
 threads_insert_own | 
 threads_select_own | f
 threads_update_own | f
(4 rows)
Evidence: FK delete-behavior demo script
"""US-066 second-commit evidence: FK ON DELETE behavior for conversations.

Commit 2ddad52 set bot_user_id and claimed_by to ON DELETE SET NULL while
leaving workspace_id with no cascade (matching the documents.workspace_id
precedent). This script demonstrates that behavior end-to-end against the live
local Supabase DB:

  1. Delete the bot user  -> conversation survives, bot_user_id becomes NULL.
  2. Delete the claimed_by agent -> conversation survives, claimed_by becomes NULL.
  3. Try to delete the owning workspace while a conversation references it
     -> blocked by FK (NO ACTION), exactly like documents.workspace_id.

Runs in its own transaction and rolls everything back at the end, so it leaves
no residue in the shared local DB.
"""

from __future__ import annotations

import asyncio
import os
import uuid

import asyncpg

DB_URL = os.environ["DATABASE_URL"]
INSTANCE = "00000000-0000-0000-0000-000000000000"


async def main() -> None:
    ws = str(uuid.uuid4())
    bot = str(uuid.uuid4())
    agent = str(uuid.uuid4())
    conv = str(uuid.uuid4())

    conn = await asyncpg.connect(DB_URL)
    tx = conn.transaction()
    await tx.start()
    try:
        await conn.execute(
            """
            insert into auth.users
              (id, instance_id, email, encrypted_password, aud, role,
               raw_app_meta_data, raw_user_meta_data,
               created_at, updated_at, email_confirmed_at)
            values
              ($1, $3, 'bot@demo.local',   '', 'authenticated', 'authenticated',
               '{}'::jsonb, '{}'::jsonb, now(), now(), now()),
              ($2, $3, 'agent@demo.local', '', 'authenticated', 'authenticated',
               '{}'::jsonb, '{}'::jsonb, now(), now(), now())
            """,
            bot, agent, INSTANCE,
        )
        await conn.execute(
            "insert into public.workspaces (id, name) values ($1, 'FK-DEMO-WS')", ws
        )
        await conn.execute(
            """
            insert into public.conversations
              (id, workspace_id, bot_user_id, claimed_by, status)
            values ($1, $2, $3, $4, 'escalated')
            """,
            conv, ws, bot, agent,
        )

        before = await conn.fetchrow(
            "select bot_user_id, claimed_by from public.conversations where id=$1", conv
        )
        print("SETUP    conversation created with both FK refs populated:")
        print(f"           bot_user_id = {before['bot_user_id']}")
        print(f"           claimed_by  = {before['claimed_by']}")
        assert before["bot_user_id"] is not None and before["claimed_by"] is not None

        # 1. Delete the bot user -> SET NULL, conversation survives.
        await conn.execute("delete from auth.users where id=$1", bot)
        row = await conn.fetchrow(
            "select bot_user_id, claimed_by from public.conversations where id=$1", conv
        )
        assert row is not None, "conversation was deleted when bot user was removed!"
        assert row["bot_user_id"] is None, f"bot_user_id not nulled: {row['bot_user_id']}"
        print("\nSTEP 1   delete bot user -> conversation SURVIVES, bot_user_id -> NULL ✓")
        print(f"           bot_user_id = {row['bot_user_id']}, claimed_by = {row['claimed_by']}")

        # 2. Delete the claimed_by agent -> SET NULL, conversation survives.
        await conn.execute("delete from auth.users where id=$1", agent)
        row = await conn.fetchrow(
            "select bot_user_id, claimed_by from public.conversations where id=$1", conv
        )
        assert row is not None, "conversation was deleted when agent was removed!"
        assert row["claimed_by"] is None, f"claimed_by not nulled: {row['claimed_by']}"
        print("\nSTEP 2   delete claimed_by agent -> conversation SURVIVES, claimed_by -> NULL ✓")
        print(f"           bot_user_id = {row['bot_user_id']}, claimed_by = {row['claimed_by']}")

        # 3. workspace_id has NO cascade -> deleting the workspace is blocked.
        blocked = False
        try:
            await conn.execute("delete from public.workspaces where id=$1", ws)
        except asyncpg.ForeignKeyViolationError as e:
            blocked = True
            print("\nSTEP 3   delete owning workspace -> BLOCKED by FK (no cascade) ✓")
            print(f"           {type(e).__name__}: {str(e).splitlines()[0]}")
        assert blocked, "workspace delete was NOT blocked -- workspace_id cascade leaked in!"

        print(
            "\nOK: FK ON DELETE behavior matches commit 2ddad52 — bot_user_id & "
            "claimed_by SET NULL (conversation preserved), workspace_id no-cascade "
            "(delete restricted, mirrors documents.workspace_id)."
        )
    finally:
        await tx.rollback()  # leave the shared local DB untouched
        await conn.close()


if __name__ == "__main__":
    asyncio.run(main())

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 issues (1 warning, 1 info)
  • ⚠️ supabase/migrations/20260623120000_init_conversations.sql:34 - status (line 34) and channel (line 36) are free-text with no CHECK constraint, unlike role (line 21 of the file / the message role column) and the prior workspace_membership.role which both pin closed enums via CHECK. Because the agent-queue index is (workspace_id, status) and the queue list filters on status, an application bug writing a typo'd or mis-cased value (e.g. 'Active', 'resolve' vs 'resolved') would silently drop a conversation out of every status-filtered query with no DB-level guard. Consider check (status in ('active','escalated','resolved',...)) and a channel CHECK, or confirm the value set is intentionally left open for the foundation migration.
  • ℹ️ supabase/migrations/20260623120000_init_conversations.sql:32 - bot_user_id (line 32) and claimed_by (line 37) use ON DELETE SET NULL but neither column is indexed. Deleting a row from auth.users forces Postgres to sequentially scan conversations to find and null-out matching rows. This is a rare admin/lifecycle operation on a table that is unlikely to be large, so the cost is minor - noting it to complete the ON-DELETE-behavior reasoning from commit 2ddad52, not as a merge blocker.
✅ **Test** - passed

✅ No issues found.

  • DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres python -m backend.test_us066_conversations_rls — 9 exact cross-workspace zero-leak assertions through real PostgREST + RLS (W1 member sees only C1, direct read of C2 returns 0 rows, child-message delegation, W2-member positive control)
  • python fk_delete_behavior_demo.py — behavioral proof that deleting bot user / claimed_by agent SET NULL the FK columns while preserving the conversation, and deleting the owning workspace is blocked (NO ACTION), all rolled back to leave the shared DB untouched
  • psql schema snapshot — FK ON DELETE actions (bot_user_id/claimed_by=SET NULL, workspace_id=NO ACTION matching documents.workspace_id, conversation_id=CASCADE), RLS enabled on both tables, both required indexes present, no conversations policy references wm.role, threads policies remain owner-only with no workspace_membership branch
⚠️ **Document** - 1 info
  • ℹ️ supabase/migrations/20260623120000_init_conversations.sql:1 - The new migration header and US-066's acceptance criteria cite "ADR-0004 + ADR-0008" as the design record (echoed in AGENTS.md's "PRD Risk Module 4 (US-016 + US-017): metadata extraction & filtered retrieval #3 / ADR-0004" reference), but no docs/adr/0004-.md or docs/adr/0008-.md files exist - only 0001, 0002, 0007 are present. This is a pre-existing pattern (ADR-0003/0005/0006 are likewise referenced without files), but this change adds new dangling references. Authoring the ADRs requires the design owner's rationale, which I cannot invent in a docs-sync pass.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

hcho22 added 3 commits June 23, 2026 15:18
…RLS (US-066)

Add a NEW table pair for the support-conversation surface (Epic E), deliberately
separate from threads/messages. conversations carries the ADR-0002
workspace-membership boundary (EXISTS against workspace_membership on
workspace_id + auth.uid(), membership presence only - role appears in no
predicate); conversation_messages delegates to its parent conversation's
membership, mirroring how messages delegates to threads.

The leak-proof owner-only threads/messages predicate is left completely
untouched - no kind discriminator, no policy edits - keeping the two trust
models in separate tables (PRD Risk #3 / ADR-0004).

- columns per the US-066 AC (incl. claimed_by/claimed_at handoff fields)
- RLS enabled on both; SELECT/INSERT/UPDATE/DELETE all membership-gated
- indexes: conversations(workspace_id, status),
  conversation_messages(conversation_id, created_at asc)
- backend/test_us066_conversations_rls.py: cross-workspace zero-leak RLS test
  with same-workspace positive control; asserts no conversations policy
  references wm.role and that threads stays owner-only
- AGENTS.md: record the two-trust-model invariant
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agentic-rag Ready Ready Preview, Comment Jun 24, 2026 12:00am

@github-actions

Copy link
Copy Markdown
Contributor

Retrieval eval — PR vs main

n = 50 questions × 3 modes (vector, keyword, hybrid) on a 14-chunk corpus. PR ran in 94.82s; main in 75.82s.

Headline (each cell: PR value, Δ vs main)

Mode recall@5 MRR nDCG@5
vector 0.860 (±0.000) 0.772 (±0.000) 0.779 (±0.000)
keyword 0.110 (±0.000) 0.120 (±0.000) 0.112 (±0.000)
hybrid 0.860 (±0.000) 0.759 (±0.000) 0.769 (±0.000)

Per-category recall@5

Mode single_chunk multi_hop adversarial paraphrase
vector 0.900 (±0.000) 0.933 (±0.000) 0.600 (±0.000) 1.000 (±0.000)
keyword 0.250 (±0.000) 0.033 (±0.000) 0.000 (±0.000) 0.000 (±0.000)
hybrid 0.900 (±0.000) 0.933 (±0.000) 0.600 (±0.000) 1.000 (±0.000)

Comment is updated in place on each push by .github/workflows/retrieval-eval.yml (US-035). Comment-only — never blocks the build.

@hcho22 hcho22 merged commit 738db51 into main Jun 24, 2026
3 checks passed
@hcho22 hcho22 deleted the fm/us066-conv-k4 branch June 24, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant