Sprinter Docs

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.

ColumnTypeNotes
iduuidPrimary key (default gen_random_uuid())
tenant_iduuidFK tenants, RLS scoping
entity_type_iduuidFK entity_types
entity_type_slugtextDenormalized, auto-synced via sync_entity_type_slug trigger
slugtextURL-safe identifier
titletextDisplay name
contentjsonbTyped fields defined by entity_type.json_schema
metadatajsonbSystem metadata (e.g., lockedFields, scoring data)
tagstext[]GIN-indexed tag array
owner_iduuidFK auth.users
parent_iduuidFK entities (nullable, for hierarchical entities)
created_attimestamptzAuto-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.

ColumnTypeNotes
iduuidNo default -- must generate via crypto.randomUUID()
slugtextURL-safe identifier (e.g., "opportunity", "company")
nametextDisplay name
json_schemajsonbJSON Schema defining the entity's fields
configjsonbEntityTypeConfig: scoring, UI, AI enrichment, field configs
tenant_iduuidNullable -- NULL means global (visible to all tenants)
visibilitytextAccess control
version_numberintegerSchema version tracking

entity_relations

Graph edges connecting entities. Directional but often queried in both directions.

ColumnTypeNotes
from_entity_iduuidFK entities
to_entity_iduuidFK entities
relationship_typetextE.g., "relates-to", "parent-of"
tenant_iduuidFK tenants
metadatajsonbFirst-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.

ColumnTypeNotes
tenant_iduuidFK tenants
entity_iduuidFK entities
actor_iduuidFK auth.users
actiontextE.g., "created", "updated", "deleted"
titletextHuman-readable description
metadatajsonbAdditional context (changed fields, old values)

Auth tables

profiles

User profile data. Created automatically on signup via a Supabase Auth trigger.

ColumnTypeNotes
iduuidFK auth.users (primary key)
emailtextUser's email
display_nametextDisplay name
active_tenant_iduuidLast selected workspace

user_tenants

Tenant membership. This is the membership table -- NOT tenant_members.

ColumnTypeNotes
user_iduuidFK auth.users
tenant_iduuidFK tenants
role_iduuidFK 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:

SlugUUIDNotes
system_admin00000000-0000-0000-0000-000000000010Owner -- all permissions
tenant_admin00000000-0000-0000-0000-000000000011Admin -- team + own level
editor00000000-0000-0000-0000-000000000012Editor -- own CRUD + team read
member00000000-0000-0000-0000-000000000001Member -- own CRUD + team read
viewer00000000-0000-0000-0000-000000000013Viewer -- read-only
guest00000000-0000-0000-0000-000000000014Default 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).

ColumnTypeNotes
tenant_iduuidFK tenants
slugtextUnique identifier
nametextDisplay name
descriptiontextWhat the agent does
icontextIcon identifier
system_prompttextAgent's system prompt
modeltextLLM model identifier
configjsonbAgentConfig: { toolGroups?, customTools?, skills?, heartbeat? }
is_systembooleanWhether it's a system agent
enabledbooleanWhether it's active
created_byuuidFK auth.users (nullable)
role_iduuidFK roles -- determines permissions for autonomous execution
reports_touuidFK agents (nullable, for org chart)
roletextOrg chart role title
titletextOrg chart display title

agent_connections

External agent connections (OpenClaw, A2A protocol, MCP).

ColumnTypeNotes
tenant_iduuidFK tenants
nametextConnection name
typetextopenclaw, a2a, or mcp
urltextConnection endpoint
credentialsjsonbEncrypted credentials
statustextConnection status
configjsonbConnection-specific configuration
created_byuuidFK auth.users

agent_heartbeat_runs

Logs of autonomous agent executions triggered by cron schedules.

ColumnTypeNotes
agent_iduuidFK agents
tenant_iduuidFK tenants
statustextrunning, completed, failed
prompttextThe prompt used for this run
responsetextAgent's response

agent_config_versions

Version history for agent configuration and prompt changes. Supports rollback.

ColumnTypeNotes
agent_iduuidFK agents
version_numberintegerSequential version
configjsonbSnapshot of agent config
system_prompttextSnapshot of system prompt

Chat tables

chats

Chat session metadata. Each chat belongs to a user and optionally scopes to an entity.

ColumnTypeNotes
tenant_iduuidFK tenants
user_iduuidFK auth.users
titletextChat title (auto-generated or user-set)
entity_iduuidFK entities (nullable -- entity-scoped chats)
entity_type_slugtextFor entity-scoped context
agent_iduuidFK agents (nullable)

messages

Chat messages with AI SDK v6 support. Use this table, NOT chat_messages (which is retired to _legacy_chat_messages).

ColumnTypeNotes
chat_iduuidFK chats
roletextuser, assistant, system
contenttextPlain text content
partsjsonbAI SDK v6 parts array (tool calls, tool results, rich content)
sender_typetextMaps to role for backward compatibility
sender_iduuidFK auth.users
completion_idtextAI completion tracking
metadatajsonbArbitrary metadata

Tool tables

tool_runs

Execution log for all tool runs (human via form, agent via chat, external via API).

ColumnTypeNotes
tenant_iduuidFK tenants
tool_slugtextTool identifier
user_iduuidFK auth.users
inputjsonbTool input parameters
outputjsonbTool output
statustextsuccess, error
duration_msintegerExecution time
sourcetextweb, agent, or api
chat_iduuidFK chats (nullable -- if run from chat)
session_iduuidFK tool_sessions (nullable -- if part of collaborative session)

tool_sessions

Collaborative tool sessions where multiple users submit inputs and view aggregated results.

ColumnTypeNotes
tenant_iduuidFK tenants
tool_slugtextTool identifier
titletextSession title
entity_idsuuid[]Associated entities
configjsonbSession configuration
statustextactive, completed, cancelled
share_tokentextUnique shareable link token
created_byuuidFK 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.

ColumnTypeNotes
tenant_iduuidFK tenants
slugtextUnique within (tenant_id, workspace_id)
nametextHuman-readable title
descriptiontextOptional long-form description
instructionstextPrompt or playbook the executor receives
entity_iduuidOptional target entity
entity_type_iduuidOptional target entity type
trigger_typetextmanual, cron, entity_created, entity_updated, field_changed, webhook
trigger_configjsonbTrigger-specific parameters (cron expression, field filters, …)
output_typetextfield, fields, entity, entities, relation-entity, document, status, none
output_configjsonbOutput contract (target field, schema, …)
agent_slugtextOptional agent assignee (resolved from agents.slug)
assigned_touuidOptional user assignee (FK auth.users)
parent_iduuidOptional FK to parent action for nested DAGs
depends_ontext[]Slugs of upstream actions in the DAG
statustextdraft, active, paused, disabled, completed
prioritytextOptional priority bucket
due_attimestamptzOptional deadline
last_run_attimestamptzMost recent run timestamp
last_run_statustextResult of the last run
run_countintTotal runs to date
is_systemboolBundle / built-in actions are flagged so they survive cleanup
bundle_version, installed_by_bundle, installed_by_workspace_idBundle provenance (cleared on workspace override-clone)
metadatajsonbCaller-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.

TablePurpose
sessionsOne row per agent run, action run, response draft, tool run, or automation execution
session_eventsAppend-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.

ColumnTypeNotes
tenant_iduuidFK tenants
entity_iduuidTarget entity
target_typetextField, criteria set, or structured response target
valuesjsonbSubmitted values keyed by field or dimension
sourcetextManual, agent, API, extraction, or feedback
statustextSubmitted, promoted, rejected, or superseded
session_iduuidRuntime session that produced the response
promoted_fieldstext[]Values promoted into entities.content

Document tables

documents

File metadata for uploaded documents.

ColumnTypeNotes
tenant_iduuidFK tenants
entity_iduuidFK entities (nullable -- can be unattached)
titletextDocument title
file_nametextOriginal filename
file_pathtextStorage path
file_sizeintegerSize in bytes
mime_typetextMIME type
statustextProcessing status
metadatajsonbExtracted metadata
uploaded_byuuidFK auth.users

document_chunks

Chunked document content for hybrid search and RAG.

ColumnTypeNotes
document_iduuidFK documents
chunk_indexintegerSequential chunk position
contenttextChunk text content
metadatajsonbChunk metadata
embeddingvectorVector embedding (optional, requires vector extension)

Additional tables

TablePurpose
tenantsWorkspace definitions (slug, name, tenant_type, logo_url)
viewsConfigurable 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_configsSidebar navigation configuration (tenant-level + user-level overrides)
commentsThreaded comments on entities (one level of nesting via parent_id)
user_favoritesBookmarked entities (unique on user_id + entity_id)
user_recent_viewsRecently viewed entities with timestamps
skillsReusable agent instruction modules (slug, name, instructions, category)
agent_skillsAgent-to-skill junction table
cost_eventsAI usage cost tracking (model, tokens, cost_cents)
webhook_endpointsOutgoing webhook configuration (url, secret, events, enabled)
api_keysProgrammatic API access (key_hash, key_prefix, scopes)
notificationsUser notifications (type, title, body, read, link)
chat_feedbackMessage-level feedback (rating: up/down)
shared_contextTenant-level corrections and learnings (key-value store)
analytics_eventsEvent tracking for analytics
audit_logsChange 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

CorrectIncorrectNotes
user_tenantstenant_membersThe membership table
messageschat_messagesChat messages table (legacy retired to _legacy_chat_messages)

Schema quirks

  • entity_types.id has no default. You must generate the UUID yourself: id: crypto.randomUUID(). Inserting without an id will fail.
  • entity_type_slug is denormalized. The sync_entity_type_slug trigger keeps entities.entity_type_slug in sync with entity_types.slug. Always use it for filtering instead of joining.
  • Default tenant UUID: 00000000-0000-0000-0000-000000000000 (slug: "default"). Use DEFAULT_TENANT_ID from features/tenant/constants.ts -- never hardcode the UUID.
  • Default signup role: guest (UUID: 00000000-0000-0000-0000-000000000014), NOT member. 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];
Method0 rows1 row2+ rowsWhen to use
.single()ERRORobjectERRORSELECT by primary key, INSERT
.maybeSingle()nullobjectERRORSELECT 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

ClientWhen to useRLS
createClient() (authenticated)User-scoped reads/writesEnforced
createAdminClient()Cross-user/system ops: tenant management, provisioning, background jobs, extractionBypassed

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 entities
  • hybrid_search_chunks(query_text, query_embedding, ...) -- combined search on document_chunks
  • match_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 into entities.content
  • merge_entity_metadata(entity_id, tenant_id, new_metadata) -- merges incoming keys into entities.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 migrations

Commands

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 changes

After 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.

On this page