Skip to main content

Identity & activity: how the tables actually connect

Traced from supabase/migrations/** and schema_dump.sql, not from memory. Last verified: 2026-07-18 The headline is not the shape of the graph. It’s that three different tables answer “who did this,” and the bridge between them is reportedly holding one row.

The identity core

Four tables, two separate uuid spaces. Everything downstream inherits this split.
Read it this way:
  • auth.users and user_profiles are effectively one thing — same uuid, shared primary key. user_profiles.id has a FK to auth.users(id) ON DELETE CASCADE, and a signup trigger populates it.
  • people is a genuinely different id space. It has exactly one foreign key (company_id → companies) and no FK to auth at all.
  • The only enforced route between the spaces is users_auth. Every project-scoped RLS policy resolves identity through it, documented verbatim in the migrations as: auth.uid() → users_auth.auth_user_id → person_id.
  • There is a second, unenforced path: people.auth_user_id. It’s nullable with no FK, so it can hold a stale or nonexistent auth id indefinitely.

Where every activity table hangs

The tables aren’t usefully grouped by what they do — they’re grouped by which identity they key to, because that determines whether you can join them.

Lane A — → auth.users(id), enforced FK (~25 tables)

commitment_audit_log · permission_audit_log (changed_by) · email_events · ai_feedback_events · ai_retrieval_feedback · ai_learning_promotions · ai_task_feedback · drawing_change_history · user_table_views · ai_skill_usage_events · team_chat_messages · team_chat_channels · task_comments · psr_comments · dev_annotations · dev_panel_comments · design_violations · agent_learnings

Lane B — → user_profiles(id), enforced FK (same uuid values as Lane A)

app_error_events · app_error_groups · conversations · requests · training_docs · training_doc_steps · training_doc_assets · training_doc_relations
All four training tables live here.

Lane C — → people(id), a different uuid space

user_email_notifications · user_schedule_notifications · project_directory_memberships · project_role_members · user_directory_permissions · distribution_group_members · permission_audit_log (person_id) · project_companies.primary_contact_id · subcontractor_sov_submissions Reachable from a logged-in user only via users_auth.

Lane D — bare uuid, no foreign key at all (~10 tables)

db_audit_log · projects_audit · budget_line_history · submittal_history · ai_tool_write_audits · acumatica_outbound_audit_logs · app_request_log · chat_history · chat_sessions · document_user_access Values look like auth ids by convention. The database enforces nothing, so these can hold orphans forever.

Lane E — no identity column at all

documents_access_audit · erp_sync_log · acumatica_sync_runs_log · bot_debug_log

Lane F — AI database (fqcvmfqldlewvbsuxdvz), separate project

outlook_email_skip_audit · source_sync_runs · source_processing_jobs · ingestion_jobs · ingestion_dead_letter · pipeline_model_usage · document_chunk_retrieval_telemetry · packet_refresh_jobs · rag_pipeline_state · source_sync_health_snapshots · system_alerts Why this grouping matters: Lanes A and B hold the same uuid values, so a query can bridge them — but they aren’t schema-compatible, so there’s no single join you can write across all audit tables. Lane C is a different value space entirely.

Training tables — exact FKs

training_doc_relations.created_by is the odd one out: it defaults to NO ACTION while its three siblings are SET NULL. Deleting a user profile will block if it authored a relation.

What’s broken

Ranked by blast radius.

01 — The bridge: users_auth reportedly holds ~1 row

Flagged in docs/architecture/tables.yaml as a critical bug: most signups aren’t producing the bridge row, despite ~7 writer paths. Impact: every people-keyed RLS policy evaluates false for nearly all users. Lane C and all project-directory permissions are effectively dead.

02 — The admin gate: user_profiles reportedly empty, 123+ code paths read it

current_is_app_admin() does a live SELECT is_admin FROM user_profiles WHERE id = auth.uid(). All four training tables FK to this table. Impact: if it’s genuinely empty, training-doc inserts with a non-null created_by fail the FK, and every training RLS policy denies. tables.yaml says outright not to treat is_admin here as authoritative until this is resolved.

03 — Two admin functions that disagree by construction

is_admin() was migrated to read a JWT claim set at token issuance (20260517010000_rewrite_is_admin_to_read_jwt.sql). current_is_app_admin() never was — it still does a live table read. Impact: the two gates diverge for the lifetime of a JWT after any admin-flag change. user_profiles’ own policies use is_admin(); training, RAG, and budget tables use current_is_app_admin().

04 — Two competing auth→person resolvers

current_person_id() was redefined incompatibly between 20260409150000_commitment_sov_rls.sql (reads people.auth_user_id) and 20260427130000_secure_rag_documents_rls.sql (prefers users_auth, falls back to people.auth_user_id). Whichever migration ran last wins globally.

05 — A foreign key with mismatched types

timeline_events.actor_id is declared text REFERENCES auth.users(id) (20260401000001_change_workflow_tables.sql:145), but auth.users.id is uuid. Postgres has no cross-type equality operator for that constraint. Impact: either the constraint failed at migration time and the column is FK-less, or the live column isn’t actually text. Verify against the live DB before trusting this edge.

06 — An ACL table with zero referential integrity

document_user_access and document_group_access have no foreign keys at all — only composite primary keys. user_id is a dangling uuid; document_id is text while documents.id is uuid. Impact: orphaned access grants cannot be detected by the database.

What this means for the training layer


Confidence

Structure is solid. Every table, column, and foreign key above was read from migration files and schema_dump.sql. Those are authoritative. Row counts are not.users_auth has ~1 row” and “user_profiles is empty” come from hand-written notes in docs/architecture/tables.yaml, which may be stale. Findings 01 and 02 are the two that would change what you build. Both need one live count(*) before anyone commits to a plan.
  • Training Docs Architecture — what’s built in the training system
  • docs/architecture/tables.yaml — source of truth for table metadata
  • docs/architecture/TABLE-LIST.md — generated live table list