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
21 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-25 |
Storage: Table Schemas
Canonical reference for all Drizzle table definitions, decomposed by domain. For overview, patterns, and setup, see ../README.md. For design decisions (ADRs), see ../../../decisions/. For the account-role-session model, see ../../agent-roles.md.
Table Index
| File | Tables | Domain |
|---|---|---|
| identity.md | accounts, organizations, organization_members, api_keys, audit_logs |
Auth, access, multi-tenancy |
| projects.md | projects, workspaces |
Project/workspace management |
| sessions.md | sessions, messages, parts |
Agent conversations, AI SDK |
| spokes.md | spokes, operations, operation_registrations |
Spoke registration, operations |
| services.md | clients, client_secrets |
External service connections |
| roles.md | roles |
Behavioral role definitions |
| coordination.md | mappings, detections |
Coordinator workflows |
| call-graph.md | call_graph_nodes, call_graph_edges |
Call observability |
| 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):
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 and ADR-012. |
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:
completedinmappings= the coordination workflow finished successfully.completedincall_graph_nodes= a single call resolved.completedintasks= an SDD task finished. These are independent — a mapping can becompletedwhile some of its call graph nodes arefailed.failedinmappings= the coordination workflow errored.failedincall_graph_nodes= a call threw an error.failedintasks= a task cannot proceed.abortedinmappings= coordinator cancelled the workflow.abortedincall_graph_nodes= a call was cancelled before completion.
Valid cross-table status combinations:
- Task
in-progress⟹ mappingactive(task is being worked on, mapping is live) - Task
completed⟹ mappingcompleted(task finished, mapping records success) - Task
failed⟹ mappingfailed(task errored, mapping records failure) - Task
blocked⟹ mappingactive(task is waiting on dependencies, mapping stays active) - Session
busywith 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:
// 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)