Files
hub/docs/architecture/storage/table-reference.md
glm-5.1 93e2286343 Align storage & architecture specs with published npm libraries
Systematically compared @alkdev/taskgraph, @alkdev/operations, and
@alkdev/flowgraph against storage/arch specs and fixed all mismatches.

Key changes:

Tasks (storage/tasks.md + ADR-011):
- Rename TaskFrontmatter → TaskInput to match library export
- Fix dependsOn (was depends_on) in field mappings — library uses
  camelCase; parseFrontmatter normalizes YAML snake_case on input
- Document DependencyEdge shape {from, to, qualityRetention?} and
  DB↔library field mapping
- Document graph node vs DB column distinction (TaskGraphNodeAttrs
  is a subset of TaskInput)
- Fix default risk fallback from low → medium (matches resolveDefaults)
- Fix cross-project guard column references (dependentTaskId, not taskId)
- Clarify @alkdev/taskgraph TS is source of truth; frontmatter is for
  LLM output parsing and legacy imports, not Rust CLI
- Add complete library exports reference

Operations (storage/spokes.md + operations.md):
- Add version, title, _meta columns to operations table (required by
  OperationSpec, were missing)
- Fix type casing: query/mutation/subscription (lowercase, matching
  OperationType runtime values)
- Make outputSchema and accessControl NOT NULL (matching library)
- Document ErrorDefinition shape {code, description, schema, httpStatus?}
- Document _meta vs commonCols.metadata distinction
- Add registerAll, get, getHandler, getByName, list, subscribe methods
- Fix buildCallHandler signature ({ registry, callMap })
- Fix OperationType values (lowercase)

Call graph (storage/call-graph.md + call-graph.md):
- Change operationId to NOT NULL with RESTRICT FK (was nullable/SET NULL)
  — matches flowgraph's required CallNodeAttrs.operationId
- Document sentinel __removed__ operation strategy for deletions
- Document ISO 8601 string ↔ timestamptz conversion requirement
- Rewrite CallEventMap to match actual library: flat dot-notation keys,
  timestamp on all events, nested error structure, optional output on
  completed event
- Remove call.running event (doesn't exist in library) — hub calls
  updateStatus(running) directly on dispatch
- Fix buildCallHandler({ registry, callMap }) signature
- Fix PendingRequestMap constructor (positional EventTarget)
- Add updateCall/removeCall/graph methods to API summary
- Document abort cascade as hub logic, not flowgraph logic
- Add open questions for operation deletion and reactive vs call graph
  semantics

Table reference (storage/table-reference.md):
- Update call_graph_nodes.operationId cascade to RESTRICT
- Update operations.type comment to lowercase
- Update status enum reference
2026-05-25 11:46:42 +00:00

267 lines
21 KiB
Markdown

---
status: draft
last_updated: 2026-05-25
---
# 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<Record<string, unknown>>().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 | RESTRICT | Call records must reference a valid operation. If an operation is being removed, the hub must reassign call records first (e.g., to a sentinel `__removed__` operation) or deny the removal. |
| 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 (lowercase: query/mutation/subscription) |
| 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. `type` column uses lowercase: `query`, `mutation`, `subscription` |
| `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)
```