--- status: draft last_updated: 2026-04-23 --- # Storage: Table Schemas Canonical reference for all Drizzle table definitions, decomposed by domain. For overview, patterns, and setup, see [../README.md](../README.md). For design decisions (ADRs), see [../../../decisions/](../../../decisions/). For the account-role-session model, see [../../agent-roles.md](../../agent-roles.md). ## Table Index | File | Tables | Domain | |------|--------|--------| | [identity.md](./identity.md) | `accounts`, `organizations`, `organization_members`, `api_keys`, `audit_logs` | Auth, access, multi-tenancy | | [projects.md](./projects.md) | `projects`, `workspaces` | Project/workspace management | | [sessions.md](./sessions.md) | `sessions`, `messages`, `parts` | Agent conversations, AI SDK | | [spokes.md](./spokes.md) | `spokes`, `operations`, `operation_registrations` | Spoke registration, operations | | [services.md](./services.md) | `clients`, `client_secrets` | External service connections | | [roles.md](./roles.md) | `roles` | Behavioral role definitions | | [coordination.md](./coordination.md) | `mappings`, `detections` | Coordinator workflows | | [call-graph.md](./call-graph.md) | `call_graph_nodes`, `call_graph_edges` | Call observability | | [tasks.md](./tasks.md) | `tasks`, `task_dependencies` | SDD task management | | *(planned — Phase 2)* | *(future `roles_audit`)* | Role change history (deferred) | ## Common Columns All tables share these columns (`commonCols`): ```ts import { text, timestamp, jsonb } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; export const commonCols = { id: text("id") .primaryKey() .$defaultFn(() => crypto.randomUUID()), metadata: jsonb("metadata").$type>().default({}), createdAt: timestamp("created_at", { withTimezone: true }) .default(sql`now()`) .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .default(sql`now()`) .notNull() .$onUpdate(() => new Date()), }; ``` **Note**: `commonCols.id` uses UUIDv4 (random, non-sortable). For tables requiring chronological ordering by ID, only `parts` uses sortable IDs (see ADR-003). Messages rely on the composite index `(session_id, created_at, id)` for ordering. **Note**: `updatedAt` uses Drizzle's `$onUpdate` (application-level). Direct SQL updates bypass this and must manually `SET updated_at = now()`. For critical tables, consider a Postgres trigger as a safety net. ## Foreign Key Cascade Behavior | Relationship | onDelete | Rationale | |-------------|----------|-----------| | organizations.ownerId → accounts.id | RESTRICT | Administrative owner — cannot be deleted while org exists. Transfer via org.transferOwnership before account deletion. | | organization_members.orgId → organizations.id | CASCADE | Deleting an org removes all memberships | | organization_members.accountId → accounts.id | CASCADE | Deleting an account removes all memberships | | projects.orgId → organizations.id | SET NULL | Org deletion detaches projects but preserves them | | workspaces.projectId → projects.id | CASCADE | Deleting a project removes all its workspaces | | sessions.projectId → projects.id | CASCADE | Deleting a project removes all its sessions | | sessions.workspaceId → workspaces.id | SET NULL | Workspace deletion detaches sessions but preserves them | | sessions.parentId → sessions.id | SET NULL | Parent deletion detaches children but preserves them | | messages.sessionId → sessions.id | CASCADE | Deleting a session removes all its messages | | parts.messageId → messages.id | CASCADE | Deleting a message removes all its parts | | parts.sessionId → sessions.id | CASCADE | Deleting a session removes all its parts | | operations.* → (no FK to spokes) | — | Operations have no direct spoke FK — definitions are provider-independent | | operation_registrations.operationId → operations.id | CASCADE | Definition deleted → all its registrations cascade | | operation_registrations.providerId → spokes.id (polymorphic) | Application-level | On spoke disconnect, registrations set to `status: 'inactive'`. On admin spoke row deletion, registrations CASCADE. See D1/D3 in storage-spec-phase1-resolutions.md. | | spokes.projectId → projects.id | SET NULL | Project deletion detaches spokes but preserves registration records | | api_keys.ownerId → accounts.id | CASCADE | Deleting an account removes its API keys | | audit_logs.keyId → api_keys.id | SET NULL | Key deletion preserves audit trail | | audit_logs.ownerId → accounts.id | RESTRICT | Audit trails must preserve accountability; RESTRICT prevents account deletion when audit entries exist. Accounts with audit entries are deactivated via status column instead of deleted. | | audit_logs.sessionId → sessions.id | SET NULL | Session deletion preserves audit trail | | audit_logs.orgId → organizations.id | SET NULL | Org deletion preserves audit trail | | clients.ownerId → accounts.id | RESTRICT | Can't delete an account that owns clients | | clients.orgId → organizations.id | SET NULL | Org deletion detaches clients but preserves them | | client_secrets.clientId → clients.id | CASCADE | Deleting a client removes all its secrets | | mappings.sessionId → sessions.id | CASCADE | Deleting a session removes its mapping | | mappings.spokeId → spokes.id | SET NULL | Spoke disconnect preserves mapping records | | mappings.parentSessionId → sessions.id | SET NULL | Coordinator deletion detaches but preserves mapping | | mappings.taskId → tasks.id | SET NULL | Task deletion detaches mapping but preserves it | | mappings.workspaceId → workspaces.id | SET NULL | Workspace deletion detaches mapping but preserves it | | detections.sessionId → sessions.id | CASCADE | Deleting a session removes its detections | | detections.resolvedBy → accounts.id | SET NULL | Resolving account deletion preserves detection record (nullable FK + SET NULL — detection retains context without the resolver reference) | | roles.parentId → roles.id | SET NULL | Deleting a parent role detaches children (they become standalone) | | sessions.accountId → accounts.id | SET NULL | Deleting an account preserves sessions but detaches them (audit trail maintained) | | tasks.projectId → projects.id | CASCADE | Deleting a project removes all its tasks | | task_dependencies.dependsOnTaskId → tasks.id | CASCADE | Prerequisite task deletion removes its outgoing dependency edges | | task_dependencies.dependentTaskId → tasks.id | CASCADE | Dependent task deletion removes its incoming dependency edges | | call_graph_edges.sourceId → call_graph_nodes.id | CASCADE | Deleting a node removes its outgoing edges | | call_graph_edges.targetId → call_graph_nodes.id | CASCADE | Deleting a target node removes its incoming edges | | call_graph_nodes.operationId → operations.id | SET NULL | Operation definition deletion preserves call records but detaches them (nullable FK — call data retains audit value even if the operation is removed) | | api_keys.rotatedToId → api_keys.id | SET NULL | Old key keeps its data; if new key is deleted, rotation link is broken but both keys remain | ## Index Reference | Table | Index | Type | Purpose | |-------|-------|------|---------| | accounts | `unq_accounts_email` | UNIQUE | Email is primary identifier | | accounts | `idx_accounts_gitea_username` | B-tree | Gitea bridge lookup | | accounts | `idx_accounts_display_name` | B-tree | User search/autocomplete UIs | | organizations | `unq_organizations_name` | UNIQUE | Name is unique | | organizations | `unq_organizations_slug` | UNIQUE | Slug is unique | | organizations | `idx_organizations_owner_id` | B-tree | Find orgs by owner | | organizations | `idx_organizations_gitea_org_name` | B-tree | Gitea bridge lookup | | organization_members | `unq_org_members_org_account` | UNIQUE (org_id, account_id) | One membership per account per org | | organization_members | `idx_org_members_account_id` | B-tree | Find orgs for an account | | organization_members | `idx_org_members_org_id` | B-tree | Find members of an org | | sessions | `idx_sessions_project_id` | B-tree | Load sessions for a project | | sessions | `idx_sessions_workspace_id` | B-tree | Filter sessions by workspace | | sessions | `idx_sessions_status` | B-tree | Filter by session status | | sessions | `idx_sessions_active` | Partial B-tree (WHERE status IN ('idle', 'busy', 'retry')) | Efficiently find active (non-archived) sessions | | sessions | `idx_sessions_account_id` | B-tree | Find sessions by account | | sessions | `idx_sessions_role_name` | B-tree | Find sessions by role | | sessions | `unq_sessions_slug` | UNIQUE | Slug is unique across all sessions | | sessions | `idx_sessions_parent_id` | B-tree | Find child sessions of coordinator | | projects | `idx_projects_org_id` | B-tree | Find projects for an org | | workspaces | `idx_workspaces_project_id` | B-tree | Find workspaces for a project | | messages | `idx_messages_session_id_created_at_id` | Composite | Paginated message loading per session (opencode pattern) | | parts | `part_session_idx` | B-tree | Direct part queries per session | | parts | `part_message_id_id_idx` | Composite (message_id, id) | Load parts for a message in order | | parts | `idx_parts_session_id_type` | Composite (session_id, type) | Find parts by type within a session (e.g., all tool-call parts) | | call_graph_nodes | `idx_call_graph_nodes_request_id` | UNIQUE | Unique call correlation | | call_graph_nodes | `idx_call_graph_nodes_operation_id` | B-tree | Filter by operation | | call_graph_nodes | `idx_call_graph_nodes_status` | B-tree | Filter by status | | call_graph_nodes | `idx_call_graph_nodes_caller_account_id` | B-tree | Find calls by caller account | | call_graph_nodes | `idx_call_graph_nodes_created_at` | B-tree | Time-range queries for call graph nodes | | call_graph_nodes | `idx_call_graph_nodes_operation_created` | Composite (operationId, createdAt) | Operation + time queries | | call_graph_nodes | `idx_call_graph_nodes_started_at` | B-tree | p99 latency analysis (startedAt separate from createdAt) | | call_graph_edges | `idx_call_graph_edges_source_id` | B-tree | Graph traversal — find calls originating from a node | | call_graph_edges | `idx_call_graph_edges_target_id` | B-tree | Graph traversal — find calls targeting a node | | call_graph_edges | `idx_call_graph_edges_source_id_type` | Composite (sourceId, edgeType) | Find outgoing calls of a specific type | | call_graph_edges | `unq_call_graph_edges_source_target_type` | UNIQUE (sourceId, targetId, edgeType) | Prevent duplicate edges from retries/reconnections | | operations | `unq_operations_namespace_name` | UNIQUE (namespace, name) | Operation definition uniqueness by namespace+name | | operations | `idx_operations_namespace` | B-tree | Filter by namespace | | operations | `idx_operations_type` | B-tree | Filter by operation type | | operation_registrations | `unq_operation_registrations_active` | UNIQUE partial (WHERE status = 'active') | One active registration per provider per operation | | operation_registrations | `idx_operation_registrations_operation_id` | B-tree | Find registrations for an operation | | operation_registrations | `idx_operation_registrations_provider_id` | B-tree | Find registrations for a provider | | operation_registrations | `idx_operation_registrations_status` | B-tree | Filter by registration status | | api_keys | `idx_api_keys_owner_id` | B-tree | List keys by owner | | api_keys | `unq_api_keys_key_hash` | UNIQUE | Prevent duplicate key hashes (also covers `idx_api_keys_key_hash` — UNIQUE constraint auto-creates an index) | | api_keys | `idx_api_keys_enabled` | B-tree | Filter enabled/disabled keys | | api_keys | `idx_api_keys_active` | Partial B-tree (WHERE revoked_at IS NULL AND enabled = true) | Efficiently find active (non-revoked, enabled) keys without scanning revoked/disabled rows | | audit_logs | `idx_audit_logs_owner_id` | B-tree | Audit trail by owner | | audit_logs | `idx_audit_logs_key_id` | B-tree | Audit trail by key | | audit_logs | `idx_audit_logs_action` | B-tree | Filter by action type | | audit_logs | `idx_audit_logs_created_at` | B-tree | Paginated audit log queries | | audit_logs | `idx_audit_logs_session_id` | B-tree | Filter audit logs by session | | audit_logs | `idx_audit_logs_org_id` | B-tree | Filter audit logs by organization | | clients | `unq_clients_name` | UNIQUE | Client name is unique | | clients | `idx_clients_type` | B-tree | Find clients by type | | clients | `idx_clients_owner_id` | B-tree | Find clients by owner | | clients | `idx_clients_org_id` | B-tree | Find clients by org | | client_secrets | `unq_client_secrets_client_key` | UNIQUE (client_id, key) | One named secret per client | | client_secrets | `idx_client_secrets_expires_at` | B-tree | Find expiring secrets | | mappings | `idx_mappings_session_id` | B-tree | Find mapping for a session | | mappings | `idx_mappings_parent_session_id` | B-tree | Find children of a coordinator | | mappings | `idx_mappings_spoke_id` | B-tree | Find mappings for a spoke | | mappings | `idx_mappings_task_id` | B-tree | Find mapping for a task | | mappings | `idx_mappings_workspace_id` | B-tree | Workspace-scoped mapping queries | | detections | `idx_detections_session_id` | B-tree | Find detections for a session | | detections | `idx_detections_anomaly_type` | B-tree | Filter by detection type | | detections | `idx_detections_resolved_at` | B-tree | Find active (unresolved) detections | | detections | `idx_detections_dedup_key` | B-tree | Dedup lookups | | spokes | `idx_spokes_project_id` | B-tree | Find spokes for a project | | spokes | `idx_spokes_status` | B-tree | Find connected spokes | | spokes | `idx_spokes_active` | Partial B-tree (WHERE status = 'connected') | Efficiently find connected spokes without scanning disconnected rows | | spokes | `idx_spokes_name` | B-tree | Look up spoke by name | | tasks | `unq_tasks_project_slug` | UNIQUE (projectId, slug) | Task slugs unique within a project | | tasks | `idx_tasks_project_id` | B-tree | Find tasks for a project | | tasks | `idx_tasks_project_status` | Composite (projectId, status) | Find pending/in-progress tasks for a project | | tasks | `idx_tasks_status` | B-tree | Filter by task status | | tasks | `idx_tasks_active` | Partial B-tree (WHERE status IN ('pending', 'in-progress', 'blocked')) | Efficiently find active tasks (pending, in-progress, blocked) | | tasks | `idx_tasks_path` | B-tree with text_pattern_ops | Scoped queries by path prefix (e.g., `LIKE 'implementation/%'`). Uses `text_pattern_ops` operator class for locale-independent LIKE pattern matching. | | tasks | `idx_tasks_priority` | B-tree | Filter by priority | | tasks | `idx_tasks_assignee` | B-tree | Find tasks assigned to an agent | | tasks | `idx_tasks_due_at` | B-tree | Deadline queries | | tasks | `idx_tasks_tags` | GIN | Array-contains queries on tags | | task_dependencies | `unq_task_dependencies_depends_on_task` | UNIQUE (dependsOnTaskId, dependentTaskId) | No duplicate dependency edges | | task_dependencies | `idx_task_dependencies_depends_on_task_id` | B-tree | What depends on this task? | | task_dependencies | `idx_task_dependencies_dependent_task_id` | B-tree | What does this task depend on? | | roles | `unq_roles_name` | UNIQUE | Role name is unique | | roles | `idx_roles_parent_id` | B-tree | Find roles that inherit from a parent | | roles | `idx_roles_mode` | B-tree | Filter by mode (primary/subagent) | ## Status Enum Reference Status enums across tables: | Table | Status Values | Meaning | |-------|---------------|---------| | `sessions` | `idle`, `busy`, `retry`, `archived` | Session lifecycle | | `sessions.roleName` | text | Which behavioral role (e.g., "architect", "implementation-specialist"). Free-form string, not a FK constraint. See [agent-roles.md](../../agent-roles.md) and [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md). | | `spokes` | `connected`, `disconnected` | WebSocket connection state | | `operations` | (no status column) | — Definitions are persistent | | `operation_registrations` | `active`, `inactive` | Provider registration lifecycle | | `mappings` | `active`, `completed`, `aborted`, `failed` | Coordination workflow state | | `call_graph_nodes` | `pending`, `running`, `completed`, `failed`, `aborted` | Call protocol lifecycle | | `tasks` | `pending`, `in-progress`, `completed`, `failed`, `blocked` | SDD task lifecycle (matches taskgraph; transitions via hub operations) | | `api_keys` | (not an enum) | `enabled` boolean + `revokedAt` timestamp + `expiresAt` timestamp | | `accounts` | `accessLevel` column | `admin`, `user`, `service` — access level (renamed from `role` to avoid confusion with behavioral roles; see ADR-012) | | `accounts` | `status` column | `active`, `suspended`, `deactivated` — Account lifecycle — active accounts can authenticate, suspended are admin-locked, deactivated are user-initiated shutdown | | `organization_members` | `membershipLevel` column | `owner`, `admin`, `member` — org membership level (renamed from `role`; see ADR-012) | | `clients` | `enabled` boolean | Enabled/disabled toggle, not a status enum | `mappings.active` and `call_graph_nodes.pending`/`running` are different concepts — "active" means the mapping's workflow is in progress (the coordinator is still working), while "pending"/"running" refer to the call protocol's execution state. ### Cross-Table Status Mapping Equivalent states across tables, grouped by semantic meaning: **Active/Enabled** across tables: `sessions.status = 'busy'`, `spokes.status = 'connected'`, `mappings.status = 'active'`, `accounts.status = 'active'`, `clients.enabled = true`, `api_keys.enabled = true` **Inactive/Disabled** across tables: `sessions.status = 'archived'`, `spokes.status = 'disconnected'`, `mappings.status = 'aborted'`, `accounts.status = 'suspended' OR 'deactivated'`, `clients.enabled = false`, `api_keys.enabled = false` **Terminal states**: `sessions.status = 'archived'` (completed conversation), `mappings.status = 'completed'` (successful finish), `call_graph_nodes.status = 'completed'`, `tasks.status = 'completed'` **Same-named statuses with different semantics**: - `completed` in `mappings` = the coordination workflow finished successfully. `completed` in `call_graph_nodes` = a single call resolved. `completed` in `tasks` = an SDD task finished. These are independent — a mapping can be `completed` while some of its call graph nodes are `failed`. - `failed` in `mappings` = the coordination workflow errored. `failed` in `call_graph_nodes` = a call threw an error. `failed` in `tasks` = a task cannot proceed. - `aborted` in `mappings` = coordinator cancelled the workflow. `aborted` in `call_graph_nodes` = a call was cancelled before completion. **Valid cross-table status combinations**: - Task `in-progress` ⟹ mapping `active` (task is being worked on, mapping is live) - Task `completed` ⟹ mapping `completed` (task finished, mapping records success) - Task `failed` ⟹ mapping `failed` (task errored, mapping records failure) - Task `blocked` ⟹ mapping `active` (task is waiting on dependencies, mapping stays active) - Session `busy` with no mapping ⟹ session is running outside coordination context Note: Different domains use different status semantics. A session being `busy` doesn't mean the spoke is `connected` — they're independent states from independent lifecycles. Don't overgeneralize. ## Relations Explicit `relations()` definitions with `one()` and `many()` for Drizzle's relational query API: ```ts // Key relations: // accounts → organizations (one-to-many via ownerId) // accounts → organization_members (one-to-many) // organizations → organization_members (one-to-many) // organizations → projects (one-to-many) // organizations → clients (one-to-many, nullable FK) // projects → workspaces (one-to-many) // projects → sessions (one-to-many) // workspaces → sessions (one-to-many) // sessions → messages (one-to-many, cascade) // messages → parts (one-to-many, cascade) // sessions → parts (one-to-many, for direct queries) // sessions → mappings (one-to-many) // sessions → detections (one-to-many) // spokes → operation_registrations (one-to-many, polymorphic FK via providerType/providerId) // operations → operation_registrations (one-to-many, cascade) // accounts → api_keys (one-to-many) // api_keys → audit_logs (one-to-many) // accounts → audit_logs (one-to-many) // sessions → audit_logs (one-to-many) // organizations → audit_logs (one-to-many) // accounts → clients (one-to-many) // clients → client_secrets (one-to-many, cascade) // call_graph_nodes → call_graph_edges (one-to-many, both directions) // projects → tasks (one-to-many, cascade) // tasks → task_dependencies (one-to-many, cascade — both directions: as prerequisite and as dependent) // tasks → mappings (one-to-many, via taskId) // call_graph_nodes → call_graph_edges (one-to-many, both directions) ```