Data Model
Database tables, relationships, RBAC system, RLS patterns, and common gotchas for the Sprinter Platform Postgres schema.
Data Model
The platform runs on Supabase Postgres with Row Level Security (RLS) enforced on all tables. Every table is scoped by tenant_id for multi-tenant isolation. This document covers the table inventory, relationships, access patterns, and common pitfalls.
Core tables
entities
The central table. All structured data -- regardless of domain -- lives here.
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key (default gen_random_uuid()) |
tenant_id | uuid | FK tenants, RLS scoping |
entity_type_id | uuid | FK entity_types |
entity_type_slug | text | Denormalized, auto-synced via sync_entity_type_slug trigger |
slug | text | URL-safe identifier |
title | text | Display name |
content | jsonb | Typed fields defined by entity_type.json_schema |
metadata | jsonb | System metadata (e.g., lockedFields, scoring data) |
tags | text[] | GIN-indexed tag array |
owner_id | uuid | FK auth.users |
parent_id | uuid | FK entities (nullable, for hierarchical entities) |
created_at | timestamptz | Auto-set |
Use entity_type_slug for filtering queries -- it avoids a join to resolve slug to UUID:
-- Correct: direct slug filter
SELECT * FROM entities WHERE tenant_id = $1 AND entity_type_slug = 'opportunity';
-- Avoid: slug -> UUID lookup
SELECT * FROM entities WHERE entity_type_id = (
SELECT id FROM entity_types WHERE slug = 'opportunity'
);Task work-model queries also rely on task-scoped expression indexes on entities.content rather than legacy public.tasks.metadata reads. Migration 20260423000000_task_entity_work_model_indexes.sql backfills missing content.delegation_state to human_only for entity_type_slug='task', then maintains these hot-path indexes:
(tenant_id, (content ->> 'delegation_state')) WHERE entity_type_slug = 'task'(tenant_id, (content ->> 'work_category')) WHERE entity_type_slug = 'task' AND content ? 'work_category'(tenant_id, (content ->> 'measurement_cadence')) WHERE entity_type_slug = 'task' AND content ? 'measurement_cadence'(tenant_id, ((content ->> 'readiness_score')::numeric)) WHERE entity_type_slug = 'task' AND content ? 'readiness_score'
These keep Command Center filters and delegation-queue loads on the entity-backed Task model.
entity_types
Schema definitions for entity types. Each row defines a data type with its JSON schema, field configuration, and UI settings.
| Column | Type | Notes |
|---|---|---|
id | uuid | No default -- must generate via crypto.randomUUID() |
slug | text | URL-safe identifier (e.g., "opportunity", "company") |
name | text | Display name |
json_schema | jsonb | JSON Schema defining the entity's fields |
config | jsonb | EntityTypeConfig: scoring, UI, AI enrichment, field configs |
tenant_id | uuid | Nullable -- NULL means global (visible to all tenants) |
visibility | text | Access control |
version_number | integer | Schema version tracking |
entity_relations
Graph edges connecting entities. Directional but often queried in both directions.
| Column | Type | Notes |
|---|---|---|
from_entity_id | uuid | FK entities |
to_entity_id | uuid | FK entities |
relationship_type | text | E.g., "relates-to", "parent-of" |
tenant_id | uuid | FK tenants |
metadata | jsonb | First-class relation fields stamp { field, rank, source } here — see below |
Composite indexes exist on (from_entity_id) and (to_entity_id) for efficient graph traversal. Two partial indexes on (tenant_id, from_entity_id, ((metadata->>'field'))) and (tenant_id, to_entity_id, ((metadata->>'field'))) support the first-class relation field queries (filter by field, sort by rank).
Relation field metadata shape:
{
"field": "target_markets",
"rank": 0,
"source": "manual" | "ai" | "extraction" | "response"
}When a relation is created via a first-class relation field declared on FieldConfig.relation, metadata.field records which field owns the row so the data table can filter "product ideas where target_markets = Enterprise SaaS". Rankable fields additionally stamp metadata.rank with the 0-indexed position so users can drag-drop to reorder and the order round-trips through the picker. Legacy relations without metadata.field are still visible in "any connection" queries and match on relationship_type as a fallback.
activities
Every entity write (create, update, delete) generates an activity record. This powers the activity feed and provides a full audit trail of data changes.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
entity_id | uuid | FK entities |
actor_id | uuid | FK auth.users |
action | text | E.g., "created", "updated", "deleted" |
title | text | Human-readable description |
metadata | jsonb | Additional context (changed fields, old values) |
Auth tables
profiles
User profile data. Created automatically on signup via a Supabase Auth trigger.
| Column | Type | Notes |
|---|---|---|
id | uuid | FK auth.users (primary key) |
email | text | User's email |
display_name | text | Display name |
active_tenant_id | uuid | Last selected workspace |
user_tenants
Tenant membership. This is the membership table -- NOT tenant_members.
| Column | Type | Notes |
|---|---|---|
user_id | uuid | FK auth.users |
tenant_id | uuid | FK tenants |
role_id | uuid | FK roles |
Unique constraint on (user_id, tenant_id). When a row is inserted, a trigger runs rebuild_user_permissions() to materialize the user's effective permissions.
roles
RBAC role definitions. Six global roles ship with the platform:
| Slug | UUID | Notes |
|---|---|---|
system_admin | 00000000-0000-0000-0000-000000000010 | Owner -- all permissions |
tenant_admin | 00000000-0000-0000-0000-000000000011 | Admin -- team + own level |
editor | 00000000-0000-0000-0000-000000000012 | Editor -- own CRUD + team read |
member | 00000000-0000-0000-0000-000000000001 | Member -- own CRUD + team read |
viewer | 00000000-0000-0000-0000-000000000013 | Viewer -- read-only |
guest | 00000000-0000-0000-0000-000000000014 | Default signup role -- read-only |
Global roles have tenant_id IS NULL and is_global_role = true. Tenant-specific custom roles can also be created.
role_permissions
Maps roles to permissions from the app_permission enum (63 values). Format: {resource}.{level}.{action}.
user_permissions
Materialized view of effective permissions per user per tenant. Built from role_permissions joined through user_tenants. Rebuilt automatically when role_permissions changes (via trigger tg_sync_permissions_from_role_permissions).
The authorize(permission) SQL function checks this table for the current user. RLS policies call authorize() to gate access.
Agent tables
agents
AI agent definitions. Can be code-defined (via registerAgent()) or DB-managed (created in Admin > Agents).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
slug | text | Unique identifier |
name | text | Display name |
description | text | What the agent does |
icon | text | Icon identifier |
system_prompt | text | Agent's system prompt |
model | text | LLM model identifier |
config | jsonb | AgentConfig: { toolGroups?, customTools?, skills?, heartbeat? } |
is_system | boolean | Whether it's a system agent |
enabled | boolean | Whether it's active |
created_by | uuid | FK auth.users (nullable) |
role_id | uuid | FK roles -- determines permissions for autonomous execution |
reports_to | uuid | FK agents (nullable, for org chart) |
role | text | Org chart role title |
title | text | Org chart display title |
agent_connections
External agent connections (OpenClaw, A2A protocol, MCP).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
name | text | Connection name |
type | text | openclaw, a2a, or mcp |
url | text | Connection endpoint |
credentials | jsonb | Encrypted credentials |
status | text | Connection status |
config | jsonb | Connection-specific configuration |
created_by | uuid | FK auth.users |
agent_heartbeat_runs
Logs of autonomous agent executions triggered by cron schedules.
| Column | Type | Notes |
|---|---|---|
agent_id | uuid | FK agents |
tenant_id | uuid | FK tenants |
status | text | running, completed, failed |
prompt | text | The prompt used for this run |
response | text | Agent's response |
agent_config_versions
Version history for agent configuration and prompt changes. Supports rollback.
| Column | Type | Notes |
|---|---|---|
agent_id | uuid | FK agents |
version_number | integer | Sequential version |
config | jsonb | Snapshot of agent config |
system_prompt | text | Snapshot of system prompt |
Chat tables
chats
Chat session metadata. Each chat belongs to a user and optionally scopes to an entity.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
user_id | uuid | FK auth.users |
title | text | Chat title (auto-generated or user-set) |
entity_id | uuid | FK entities (nullable -- entity-scoped chats) |
entity_type_slug | text | For entity-scoped context |
agent_id | uuid | FK agents (nullable) |
messages
Chat messages with AI SDK v6 support. Use this table, NOT chat_messages (which is retired to _legacy_chat_messages).
| Column | Type | Notes |
|---|---|---|
chat_id | uuid | FK chats |
role | text | user, assistant, system |
content | text | Plain text content |
parts | jsonb | AI SDK v6 parts array (tool calls, tool results, rich content) |
sender_type | text | Maps to role for backward compatibility |
sender_id | uuid | FK auth.users |
completion_id | text | AI completion tracking |
metadata | jsonb | Arbitrary metadata |
Tool tables
tool_runs
Execution log for all tool runs (human via form, agent via chat, external via API).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
tool_slug | text | Tool identifier |
user_id | uuid | FK auth.users |
input | jsonb | Tool input parameters |
output | jsonb | Tool output |
status | text | success, error |
duration_ms | integer | Execution time |
source | text | web, agent, or api |
chat_id | uuid | FK chats (nullable -- if run from chat) |
session_id | uuid | FK tool_sessions (nullable -- if part of collaborative session) |
tool_sessions
Collaborative tool sessions where multiple users submit inputs and view aggregated results.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
tool_slug | text | Tool identifier |
title | text | Session title |
entity_ids | uuid[] | Associated entities |
config | jsonb | Session configuration |
status | text | active, completed, cancelled |
share_token | text | Unique shareable link token |
created_by | uuid | FK auth.users |
Work execution tables
The legacy workflow and extraction runtime tables were removed in the unified
sessions migration. Current work execution uses visible actions, one runtime
sessions table, append-only session_events, and submitted
entity_responses.
actions
Visible units of work created by people, agents, system sync, or automation.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
slug | text | Unique within (tenant_id, workspace_id) |
name | text | Human-readable title |
description | text | Optional long-form description |
instructions | text | Prompt or playbook the executor receives |
entity_id | uuid | Optional target entity |
entity_type_id | uuid | Optional target entity type |
trigger_type | text | manual, cron, entity_created, entity_updated, field_changed, webhook |
trigger_config | jsonb | Trigger-specific parameters (cron expression, field filters, …) |
output_type | text | field, fields, entity, entities, relation-entity, document, status, none |
output_config | jsonb | Output contract (target field, schema, …) |
agent_slug | text | Optional agent assignee (resolved from agents.slug) |
assigned_to | uuid | Optional user assignee (FK auth.users) |
parent_id | uuid | Optional FK to parent action for nested DAGs |
depends_on | text[] | Slugs of upstream actions in the DAG |
status | text | draft, active, paused, disabled, completed |
priority | text | Optional priority bucket |
due_at | timestamptz | Optional deadline |
last_run_at | timestamptz | Most recent run timestamp |
last_run_status | text | Result of the last run |
run_count | int | Total runs to date |
is_system | bool | Bundle / built-in actions are flagged so they survive cleanup |
bundle_version, installed_by_bundle, installed_by_workspace_id | — | Bundle provenance (cleared on workspace override-clone) |
metadata | jsonb | Caller-defined extras |
sessions and session_events
Runtime execution state and transcript. Sessions hold the current status and ownership for a run; session events hold the ordered activity stream, tool calls, messages, elicitation events, errors, and lifecycle transitions.
| Table | Purpose |
|---|---|
sessions | One row per agent run, action run, response draft, tool run, or automation execution |
session_events | Append-only event log for transcript, observability, replay, and realtime UI updates |
entity_responses
Canonical submitted values. Manual edits, agent-generated values, extraction
outputs, and scored responses all converge here before promotion into
entities.content.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
entity_id | uuid | Target entity |
target_type | text | Field, criteria set, or structured response target |
values | jsonb | Submitted values keyed by field or dimension |
source | text | Manual, agent, API, extraction, or feedback |
status | text | Submitted, promoted, rejected, or superseded |
session_id | uuid | Runtime session that produced the response |
promoted_fields | text[] | Values promoted into entities.content |
Document tables
documents
File metadata for uploaded documents.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | FK tenants |
entity_id | uuid | FK entities (nullable -- can be unattached) |
title | text | Document title |
file_name | text | Original filename |
file_path | text | Storage path |
file_size | integer | Size in bytes |
mime_type | text | MIME type |
status | text | Processing status |
metadata | jsonb | Extracted metadata |
uploaded_by | uuid | FK auth.users |
document_chunks
Chunked document content for hybrid search and RAG.
| Column | Type | Notes |
|---|---|---|
document_id | uuid | FK documents |
chunk_index | integer | Sequential chunk position |
content | text | Chunk text content |
metadata | jsonb | Chunk metadata |
embedding | vector | Vector embedding (optional, requires vector extension) |
Additional tables
| Table | Purpose |
|---|---|
tenants | Workspace definitions (slug, name, tenant_type, logo_url) |
views | Configurable block layouts. Key columns: blocks (jsonb flat map Record<string,BlockConfig>), block_order (text[] display order), data_sources (jsonb named entity queries), layout, scope, page_type, publish_token, publish_status, publish_config |
nav_configs | Sidebar navigation configuration (tenant-level + user-level overrides) |
comments | Threaded comments on entities (one level of nesting via parent_id) |
user_favorites | Bookmarked entities (unique on user_id + entity_id) |
user_recent_views | Recently viewed entities with timestamps |
skills | Reusable agent instruction modules (slug, name, instructions, category) |
agent_skills | Agent-to-skill junction table |
cost_events | AI usage cost tracking (model, tokens, cost_cents) |
webhook_endpoints | Outgoing webhook configuration (url, secret, events, enabled) |
api_keys | Programmatic API access (key_hash, key_prefix, scopes) |
notifications | User notifications (type, title, body, read, link) |
chat_feedback | Message-level feedback (rating: up/down) |
shared_context | Tenant-level corrections and learnings (key-value store) |
analytics_events | Event tracking for analytics |
audit_logs | Change tracking (table_name, operation, old_data, new_data, changed_by) |
Key gotchas
These are the most common sources of bugs when working with the database:
Naming
| Correct | Incorrect | Notes |
|---|---|---|
user_tenants | tenant_members | The membership table |
messages | chat_messages | Chat messages table (legacy retired to _legacy_chat_messages) |
Schema quirks
entity_types.idhas no default. You must generate the UUID yourself:id: crypto.randomUUID(). Inserting without anidwill fail.entity_type_slugis denormalized. Thesync_entity_type_slugtrigger keepsentities.entity_type_slugin sync withentity_types.slug. Always use it for filtering instead of joining.- Default tenant UUID:
00000000-0000-0000-0000-000000000000(slug:"default"). UseDEFAULT_TENANT_IDfromfeatures/tenant/constants.ts-- never hardcode the UUID. - Default signup role:
guest(UUID:00000000-0000-0000-0000-000000000014), NOTmember. New users are read-only until promoted.
Query patterns
Never use .single() on UPDATE or DELETE:
// WRONG -- will throw PGRST116 if RLS filters the row
const { data } = await supabase
.from("entities")
.update(changes)
.eq("id", id)
.select("*")
.single(); // <-- dangerous
// CORRECT -- use array pattern
const { data, error } = await supabase
.from("entities")
.update(changes)
.eq("id", id)
.select("*");
if (error) throw new Error(`Update failed: ${error.message}`);
if (!data || data.length === 0) throw new Error("Not found or no permission");
return data[0];| Method | 0 rows | 1 row | 2+ rows | When to use |
|---|---|---|---|---|
.single() | ERROR | object | ERROR | SELECT by primary key, INSERT |
.maybeSingle() | null | object | ERROR | SELECT that may not exist |
.select() (array) | [] | [obj] | [obj, ...] | All UPDATE/DELETE + returning |
RLS patterns
Tenant scoping
Every RLS policy checks tenant_id against the user's membership:
CREATE POLICY "tenant_isolation" ON entities
USING (
tenant_id IN (
SELECT tenant_id FROM user_tenants
WHERE user_id = (SELECT auth.uid())
)
);Auth.uid() caching
Always wrap auth.uid() in a SELECT subquery in RLS policies:
-- CORRECT: evaluated once, cached
WHERE user_id = (SELECT auth.uid())
-- INCORRECT: re-evaluated per row
WHERE user_id = auth.uid()Permission checks
The authorize(permission) function checks the materialized user_permissions table:
CREATE POLICY "entities_read" ON entities
FOR SELECT USING (
authorize('entities.team.read')
OR (owner_id = (SELECT auth.uid()) AND authorize('entities.own.read'))
);Supabase client selection
| Client | When to use | RLS |
|---|---|---|
createClient() (authenticated) | User-scoped reads/writes | Enforced |
createAdminClient() | Cross-user/system ops: tenant management, provisioning, background jobs, extraction | Bypassed |
Default to the authenticated client. Use the admin client only when you explicitly need to bypass RLS -- typically in background jobs (Inngest functions), user provisioning, or cross-tenant operations.
Vector search functions
Three SQL functions support similarity search:
hybrid_search_entities(query_text, query_embedding, ...)-- combined text + vector search on entitieshybrid_search_chunks(query_text, query_embedding, ...)-- combined search on document_chunksmatch_chunks(query_embedding, ...)-- pure vector search on document_chunks
Atomic merge functions
Two Postgres RPCs support atomic JSONB writes on entities:
merge_entity_content(entity_id, tenant_id, new_content)-- merges incoming keys intoentities.contentmerge_entity_metadata(entity_id, tenant_id, new_metadata)-- merges incoming keys intoentities.metadata
Both functions scope the update by entity ID and tenant ID, then perform a single database-side UPDATE with COALESCE(column, '{}'::jsonb) || new_value. They exist to eliminate the read-merge-write race in entity update, extraction recovery, and bulk update paths.
These require the vector extension and populated embeddings in the relevant tables.
Migration workflow
Migrations live in supabase/migrations/ with timestamp-based naming:
00000000000000_baseline.sql # Full schema snapshot
20260316010000_description.sql # Forward migrationsCommands
pnpm db:start # Start local Supabase
pnpm db:reset # Apply migrations + seed
pnpm db:types # Regenerate TypeScript types
pnpm db:push # Push migrations to remote
pnpm db:diff # Diff schema changesAfter any migration: run pnpm db:types to regenerate lib/supabase/database.types.ts, then verify pnpm build passes.
Audit logging
The audit_logs table tracks INSERT/UPDATE/DELETE operations with full before/after snapshots:
-- Enable audit tracking on a table
SELECT enable_audit_tracking('table_name');Currently tracked: entities, entity_types, entity_relations, tenants, profiles, user_tenants, roles, role_permissions, chats, agents, user_favorites, user_recent_views.
Each audit log entry includes table_name, operation, old_data (jsonb), new_data (jsonb), changed_by (auth.uid()), tenant_id, and timestamp.