Setup repo: migrate architecture specs, code stubs, and tasks from alkhub_ts

Copy architecture docs, ADRs, storage domain specs, research, reviews,
and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for
standalone @alkdev/hub repo structure (src/ not packages/hub/).

Sanitize all sensitive information:
- Replace private IPs (10.0.0.1) with localhost defaults
- Remove internal server hostnames (dev1, ns528096)
- Replace /workspace/ private paths with npm package references
- Remove hardcoded credentials from examples
- Rewrite infrastructure.md without private network details

Add Deno project scaffolding: deno.json (pinned deps), .gitignore,
AGENTS.md, entry point. Migrate existing code stubs (crypto, config
types, logger) with updated import paths.
This commit is contained in:
2026-05-25 10:56:32 +00:00
parent 3e3f12d2d5
commit 2b63cda1c7
120 changed files with 11714 additions and 2 deletions

View File

@@ -0,0 +1,286 @@
---
status: draft
last_updated: 2026-04-19
---
# Storage: Drizzle + TypeBox + Postgres
## Overview
The storage layer uses Drizzle ORM for database operations, PostgreSQL as the persistence layer, and `@alkdev/drizzlebox` for automatic TypeBox schema generation from Drizzle table definitions. Drizzle table definitions are the single source of truth — `createSelectSchema` / `createInsertSchema` generate TypeBox schemas automatically.
**Location**: `src/storage/`
For table schemas, see [table-reference.md](./table-reference.md) (index, common columns, cascade behavior) and the per-domain schema files (identity.md, projects.md, sessions.md, etc.). For design decisions, see [../../decisions/](../../decisions/).
## Pattern: Drizzle-Typebox
Each table file follows this pattern:
```ts
import { pgTable, text, timestamp, jsonb, boolean, integer, index, unique } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox";
import { Type, type Static } from "@alkdev/typebox";
import { commonCols } from "./common.ts";
// 1. Table definition with Drizzle (source of truth)
export const sessions = pgTable("sessions", {
...commonCols,
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
title: text("title"),
status: text("status", { enum: ["idle", "busy", "retry", "archived"] })
.default("idle")
.notNull(),
data: jsonb("data").$type<SessionData>().default({}),
});
// 2. Select TypeBox schema (for API responses)
export const SelectSession = createSelectSchema(sessions, {
metadata: Type.Object({}, { additionalProperties: true }),
data: SessionDataSchema, // override JSON columns
});
export type SelectSession = Static<typeof SelectSession>;
// 3. Insert TypeBox schema (for API validation)
export const InsertSession = createInsertSchema(sessions, {
title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
status: Type.Optional(
Type.Union([
Type.Literal("idle"),
Type.Literal("busy"),
Type.Literal("retry"),
Type.Literal("archived"),
]),
),
});
export type InsertSession = Static<typeof InsertSession>;
```
## Common Columns
All tables share these columns:
```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 crypto.randomUUID() which generates UUIDv4 (random, non-sortable).
// For tables requiring chronological ordering by ID (e.g. parts, messages), use sortable IDs:
// - UUIDv7 (time-sortable) via a library like @std/ulid or uuidv7
// - Or add an explicit sequence/position column
// The parts table uses an explicit position-based ID scheme inherited from opencode's sortable
// timestamp-based IDs. See the parts table section in sessions.md for details.
//
// Note: updatedAt uses Drizzle's $onUpdate (application-level). Direct SQL updates bypass this
// and must manually SET updated_at = now(). For critical tables, consider adding a Postgres
// trigger as a safety net.
```
## JSONB Column Boundaries
All tables have `commonCols.metadata` (JSONB, default `{}`), and some tables have an additional domain-specific `data` or `config` column. The boundary between these columns matters for implementers:
- **`metadata`** (commonCols): Opaque key-value pairs for subsystem use, with a namespacing convention (`_subsystem.key`). Examples: `_keypal.scopes`, `_retention.expiresAt`, `_version`. If a subsystem needs to store data on a row, it uses `metadata` with its prefixed namespace. The `metadata` column is never queried in WHERE clauses or JOINs.
- **`data`** (domain-specific): Structured domain-specific data with known TypeScript types. Examples: session execution metadata (`model`, `tokens`, `cost`), message role-specific metadata, account preferences. Fields in `data` have defined shapes and may be validated against TypeBox schemas.
- **`config`** (clients): Validated connection configuration. Validated against the TypeBox schema for the client `type` on write. Secrets are NEVER in `config` — they go in `client_secrets`.
- **`identity`** / **`details`** (call graph, audit): Immutable context set at creation time. These record who/what/why and are never updated after creation.
**Rule of thumb**: If a field appears in WHERE clauses, JOIN conditions, or needs a constraint, it should be a proper column — not buried in JSONB.
## Package Structure
```
src/storage/
├── mod.ts # exports schema namespace + db client
├── client.ts # drizzle + postgres connection
├── schema.ts # barrel re-export of tables + relations
├── drizzle.config.ts # drizzle-kit migration config
├── tables/
│ ├── common.ts # shared columns (id, metadata, timestamps)
│ ├── accounts.ts # hub-local identity records
│ ├── roles.ts # behavioral role definitions (planned — see agent-roles.md)
│ ├── organizations.ts # top-level groupings
│ ├── organization_members.ts # account ↔ org membership
│ ├── projects.ts # projects (git repositories / work contexts)
│ ├── workspaces.ts # project workspaces (branches, directories)
│ ├── sessions.ts # agent conversation sessions
│ ├── messages.ts # session messages (metadata in data column)
│ ├── parts.ts # message parts (discriminated by type, content in data)
│ ├── spokes.ts # spoke registrations
│ ├── operations.ts # operation definitions (what an operation IS)
│ ├── operation_registrations.ts # provider registrations (who provides it now)
│ ├── api_keys.ts # API keys (keypal-managed, inbound auth)
│ ├── audit_logs.ts # keypal + hub audit trail
│ ├── clients.ts # external service registrations (outbound connections)
│ ├── client_secrets.ts # encrypted credentials for clients
│ ├── mappings.ts # worktree/spoke/coordinator mappings
│ ├── detections.ts # anomaly detection records
│ ├── call_graph_nodes.ts # call graph nodes
│ ├── call_graph_edges.ts # call graph edges
│ ├── tasks.ts # SDD task definitions
│ ├── task_dependencies.ts # task dependency edges
│ └── index.ts # barrel re-export
├── relations.ts # drizzle relational mappings
└── test/
└── helpers/
├── db.ts # test db setup
└── migrations.ts # migration runner for tests
```
## Database Connection
The hub reads database configuration from the encrypted config file (see [hub-config.md](../hub-config.md)). Connection parameters are NOT read from environment variables (see ADR-008, revised).
```ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema.ts";
// HubConfig.postgres is decrypted at startup by loadConfig()
function createPool(pgConfig: PostgresConfig) {
return new Pool({
host: pgConfig.host, // default: 127.0.0.1 (localhost)
port: pgConfig.port, // default: 5432
database: pgConfig.database, // default: alkdev
user: pgConfig.user,
password: pgConfig.password,
ssl: pgConfig.ssl,
max: pgConfig.maxConnections,
});
}
export const db = drizzle(pool, { schema });
```
See [infrastructure.md](../infrastructure.md) for network topology and connection details.
## Migration Strategy
```ts
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./migrations",
schema: "./schema.ts",
dialect: "postgresql",
dbCredentials: {
// Read from a local dev config file (gitignored).
// Generate via: alkhub-config decrypt --field postgres --config config.json
// Then assemble the URL from the decrypted fields.
// Do NOT use Deno.env.get() for database credentials.
// See hub-config.md §D7 for rationale.
url: loadDevDbUrl(),
},
});
```
Where `loadDevDbUrl()` reads from a developer-local config file (e.g., `.alkhub/dev-db.json`, gitignored):
```ts
import { readFileSync } from "node:fs";
function loadDevDbUrl(): string {
try {
const devConfig = JSON.parse(readFileSync(".alkhub/dev-db.json", "utf-8"));
return `postgresql://${devConfig.user}:${devConfig.password}@${devConfig.host}:${devConfig.port}/${devConfig.database}`;
} catch {
// Fallback for fresh dev setup — no secrets in env vars
return "postgresql://hub:***@localhost:5432/alkdev_dev";
}
}
```
Run: `drizzle-kit generate` to create migrations, `drizzle-kit migrate` to apply. At hub startup, migrations are applied programmatically (see [hub-startup.md](../hub-startup.md) Step 5).
**Important**: The hub's `drizzle.config.ts` does NOT use `Deno.env.get()` for database credentials. Instead, it reads from a local development config file (gitignored) or from a decrypted field produced by `alkhub-config decrypt`. See [hub-config.md](../hub-config.md) §D7 for the decision and the approved env vars list.
## Test Setup
```ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../../schema.ts";
export async function setupTestDb(testConfig: TestDbConfig) {
const pool = new Pool({
host: testConfig.host,
database: testConfig.database,
port: testConfig.port,
user: testConfig.user,
password: testConfig.password,
});
const db = drizzle(pool, { schema });
// Run migrations
return { pool, db };
}
```
Test database configuration is read from a test config file or test-specific Docker secrets, following the same pattern as production config (no env vars for credentials). The `ALKHUB_TEST_CONFIG_PATH` env var (non-sensitive) may point to the test config file location.
## Resolved Decisions
1. **~~Operation spec cleanup~~**: **Resolved** (D3). Operation definitions (`operations` table) persist independently of spoke connections. Operation registrations (`operation_registrations` table) are set to `status: 'inactive'` on disconnect and may be cascade-deleted if a spoke row is administratively removed. See D3 in storage-spec-phase1-resolutions.md.
2. **~~Workspaces vs. directories~~**: **Resolved**. `projects.directory` is the convenience shortcut for the default workspace; `workspaces.directory` is per-workspace. Both are needed.
3. **~~`accounts.role``accounts.accessLevel`~~**: **Resolved** by [ADR-012](../../decisions/ADR-012-agent-vs-role-vs-account.md). `accounts.role` renamed to `accounts.accessLevel` (values: admin/user/service). `organization_members.role` renamed to `organization_members.membershipLevel` (values: owner/admin/member). This disambiguates access levels from behavioral roles.
## Open Questions
1. **Message versioning**: Opencode has a `version` column on sessions for schema migration. Should we version the `data` column format on messages and parts for forward compatibility? The `commonCols.metadata` column could hold a `_version` field.
2. **Session message compaction**: Opencode has a `compaction` part type for context window management. The hub's storage should support this, but the compaction logic itself belongs in the session management layer, not in storage. Need to define what compaction means for hub-direct AI SDK sessions.
3. **Call graph retention policy**: Call graph data can grow fast. Need a retention policy — probably TTL-based cleanup of completed/failed calls older than N days, with aggregation for observability dashboards. See the payload truncation note in call-graph.md.
4. **Keypal adapter testing**: The `HubKeyStorage` adapter should have comprehensive tests. keypal's own test suite covers the core logic; our adapter tests cover the Drizzle integration.
5. **Cross-doc terminology migration**: The "spoke" naming ADR establishes the canonical terminology. Other architecture docs still contain "runner" / "runnerId" references. These should be updated in a separate pass.
6. **Anthropic conversation import**: Anthropic's web interface exports use a flat message model. A future import script should map these to our `messages` + `parts` tables. The Anthropic project model maps to our `projects` + `sessions` structure. Deferred — the export format is documented and available when needed.
7. **Gitea operations at startup**: The Gitea swagger spec is at `https://git.alk.dev/swagger.v1.json` (Swagger 2.0, 299 endpoints). Our `from_openapi.ts` supports this format. At hub startup, load the Gitea client config + secret from the DB, import the spec, and register ~300 Gitea operations.
8. **Client config schema evolution**: When a client type's TypeBox schema changes (e.g., adding a new field), existing DB rows with the old config shape may fail validation. Strategy: schemas should use `Type.Optional()` for new fields, and the resolution code should handle missing fields gracefully. If a breaking change is needed, bump a schema version in the `metadata` column. See [ADR-007](../../decisions/ADR-007-client-config-as-schema-validated-jsonb.md) for the validation pattern. Full contract pending `specify-client-config-validation` task.
9. **Task storage and sync**: The database is the source of truth for task data at runtime. Markdown files serve as the authoring surface for the Decomposer and taskgraph CLI — they are ingested into the DB via a sync operation (files → DB). When offline analysis is needed, tasks can be exported from DB back to files. See [tasks.md](./tasks.md) and [ADR-011](../../decisions/ADR-011-dual-task-representation.md).
10. **Task embeddings (deferred)**: Task descriptions could benefit from vector embeddings for similarity search ("find tasks like this one"). Deferred from initial implementation. The `metadata` JSONB column can hold an embedding reference later, or a separate `task_embeddings` table can be added when needed.
11. **Role definitions in database**: Role definitions (currently in `.opencode/agents/*.md`) should eventually become database records. A `roles` table would store role name, description, mode, permissions, tools, temperature, and model parameters. The transition follows the same pattern as taskgraph (file-based authoring, database as source of truth). See [agent-roles.md](../../agent-roles.md) for the full role model.
## References
- Crypto utility (AES-256-GCM + PBKDF2): `src/crypto.ts`
- Opencode message/part schema: opencode's session schema and message-v2 schema (npm package)
- Opencode SQLite schema: Verified against a local opencode database
- Keypal source and Drizzle adapter: keypal (npm package)
- AI SDK UIMessage format: AI SDK (npm package)
- MCP client config: `src/config/types.ts` (MCPServerConfig TypeBox schema)
- MCP client loader: `@alkdev/operations/from-mcp` (MCPClientLoader, createMCPClient, closeMCPClient)
- OpenAPI import: `@alkdev/operations/from-openapi` (HTTPServiceConfig, FromOpenAPI, supports Swagger 2.0 + OpenAPI 3.x)
- Gitea API spec: `https://git.alk.dev/swagger.v1.json` (Swagger 2.0, 299 endpoints)
- Anthropic exports: Anthropic export data (conversation format, docs.json)
- Agent sessions architecture: `docs/architecture/agent-sessions.md`
- Call protocol: `docs/architecture/call-graph.md`
- Coordination: `docs/architecture/coordination.md`
- Spoke design: `docs/architecture/spoke-runner.md`
- Task storage: [tasks.md](./tasks.md) — task tables, taskgraph integration, dual representation
- taskgraph CLI: @alkdev/taskgraph npm package — Rust CLI for task dependency management

View File

@@ -0,0 +1,116 @@
---
status: draft
last_updated: 2026-05-22
---
# Table Schemas: Call Graph
Call graph observability tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For call protocol architecture, see [../../call-graph.md](../../call-graph.md). For the flowgraph library that manages call/operation graphs in memory, see `@alkdev/flowgraph`.
### `call_graph_nodes`
Call graph entries for observability. Every operation invocation creates a node; parent-child relationships create edges. The `status` column matches `@alkdev/flowgraph/schema`'s `CallStatus` enum. See call-graph.md for the full call protocol spec.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| requestId | text NOT NULL UNIQUE | Protocol-level correlation key. Also serves as the flowgraph node key. |
| operationId | text | FK → operations.id — The operation definition that was called. Nullable — if an operation definition is removed, the call record survives but the operation reference is nulled. Uses the `operations` table (post-remap namespace+name), not the pre-remap identifier. |
| parentRequestId | text | Parent call's requestId (null = top-level call). Denormalized fast lookup — redundant with `triggered` edge in `call_graph_edges`. |
| identity | jsonb | Caller identity at time of call (`{ id, scopes, resources }`), matching `@alkdev/flowgraph/schema`'s `CallNodeAttrs.identity`. |
| callerAccountId | text | FK → accounts.id — The account that initiated this call. Nullable — system-initiated calls may not have an account. onDelete: SET NULL (calls survive account deletion for audit). This follows the D1 cascade policy — live session/call data uses nullable FK + SET NULL to preserve audit history. |
| status | text NOT NULL | Matches `@alkdev/flowgraph/schema`'s `CallStatus` enum: `pending`, `running`, `completed`, `failed`, `aborted`. State transitions are enforced by the flowgraph state machine — `pending → running → completed/failed` and `pending/running → aborted`. |
| input | jsonb | Call input (redacted before storage — see Payload Redaction). |
| output | jsonb | Call output (on success). **Contains `ResponseEnvelope.data` only** — the hub unwraps the envelope before storing in the call graph. Maps to `CallNodeAttrs.output` in flowgraph. |
| error | jsonb | `{ code, message, details? }` (on failure). Maps to `CallNodeAttrs.error` in flowgraph. |
| startedAt | timestamp with tz | When call was dispatched. Maps to `CallNodeAttrs.startedAt` in flowgraph. |
| completedAt | timestamp with tz | When call completed/failed/aborted. Maps to `CallNodeAttrs.completedAt` in flowgraph. |
**identity boundaries**: Caller identity at time of call (account, scopes, resources). This is immutable after creation. **metadata boundaries**: Retention metadata and other system fields. User-facing data goes in `input`/`output`.
**Indexes**: `idx_call_graph_nodes_request_id` UNIQUE on `(requestId)`, `idx_call_graph_nodes_operation_id` on `(operationId)`, `idx_call_graph_nodes_status` on `(status)`, `idx_call_graph_nodes_caller_account_id` on `(callerAccountId)`, `idx_call_graph_nodes_created_at` on `(createdAt)` — time-range queries, `idx_call_graph_nodes_operation_created` on `(operationId, createdAt)` — operation + time queries, `idx_call_graph_nodes_started_at` on `(startedAt)` — p99 latency analysis.
**Call graph payload size**: The `input` and `output` JSONB columns can grow arbitrarily large. For observability, the full payload is valuable but can bloat storage. Strategy: truncate payloads larger than 10KB to `{ _truncated: true, size: number, preview: string }` at the application layer. Full payloads can optionally be stored in object storage (S3/MinIO) with a reference URL in the `metadata` column. This keeps the call graph table lean while preserving the ability to inspect large payloads when needed.
**Mapping to `@alkdev/flowgraph`**: The `call_graph_nodes` columns map directly to `CallNodeAttrs` in `@alkdev/flowgraph/schema`. The in-memory flowgraph instance uses `requestId` as the node key. Storage reads populate a `FlowGraph.fromCallEvents()` call graph for observability queries, and storage writes persist each call protocol event incrementally.
### `call_graph_edges`
Edges in call graph (typed directed edges between calls). The `edgeType` column aligns with `@alkdev/flowgraph/schema`'s `EdgeType` enum for the edge types that flowgraph models (`triggered`, `depends_on`). The `requested_by` type is a storage-layer extension for identity tracing.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| sourceId | text NOT NULL | FK → call_graph_nodes.id (CASCADE) — deleting a source node removes its outgoing edges |
| targetId | text NOT NULL | FK → call_graph_nodes.id (CASCADE) — deleting a target node removes its incoming edges |
| edgeType | text NOT NULL | Edge type (see Edge Type Semantics below) |
**Indexes**: `idx_call_graph_edges_source_id` on `(sourceId)` — find calls originating from a node, `idx_call_graph_edges_target_id` on `(targetId)` — find calls targeting a node, `idx_call_graph_edges_source_id_type` on `(sourceId, edgeType)` — find outgoing calls of a specific type.
**Unique constraint**: `unq_call_graph_edges_source_target_type` UNIQUE on `(sourceId, targetId, edgeType)` — prevents duplicate edges from retries/reconnections.
### Edge Type Semantics
The `edgeType` column is an extensible text field. The initial set of edge types aligns with `@alkdev/flowgraph/schema`'s `EdgeType` enum for the first two, with a storage-layer extension for the third:
| Edge Type | Flowgraph `EdgeType` | Meaning |
|-----------|---------------------|---------|
| `triggered` | `EdgeType.triggered` | The source node caused the target node to execute. Represents the parent-child call hierarchy — when call A invokes call B (via `parentRequestId`), a `triggered` edge connects them. This is the most common edge type and corresponds to the call graph nesting described in the call protocol. Created automatically by `FlowGraph.addCall()` when `parentRequestId` is present. |
| `depends_on` | `EdgeType.depends_on` | The source node requires the result of the target node before it can complete. Represents a data dependency — call A cannot proceed until call B's output is available. Unlike `triggered`, the source does not cause the target to execute; it merely waits on it. Created by coordination logic via `FlowGraph.addDependency()`. |
| `requested_by` | Storage extension (no flowgraph `EdgeType`) | The target node was executed on behalf of the source node's identity. Represents the identity/authorization chain — call A's identity was delegated or propagated to call B. Used to trace which account's authority a call was performed under, distinct from the execution hierarchy (`triggered`). This is persisted in the database for observability but not modeled in the in-memory flowgraph graph. |
New edge types may be added as the call protocol evolves. Convention: use `snake_case` names, document each new type in this table, and ensure the type has a clear semantic distinction from existing types.
### Relationship: parentRequestId vs call_graph_edges
The `parentRequestId` column on `call_graph_nodes` and `triggered` edges in `call_graph_edges` both represent the parent-child call hierarchy, but serve different purposes:
- **`parentRequestId`** is a convenience shortcut on the node itself, set at call creation time from the call protocol's `parentRequestId` field. It enables fast point lookups ("who is this call's parent?") without a JOIN. Also used as the node key in the flowgraph instance.
- **`triggered` edges** represent the same relationship in the graph structure, enabling traversal queries ("find all children of this node"), path queries, and graph algorithm operations (topological sort, cycle detection).
- They are **intentionally redundant**: `parentRequestId` is denormalized for fast reads; edges are normalized for graph operations. Both should be kept consistent — when a node with a `parentRequestId` is stored, a `triggered` edge should also be created.
### Mapping to `@alkdev/flowgraph` In-Memory Model
The storage tables map to `@alkdev/flowgraph` types as follows:
| Storage Table/Column | Flowgraph Type | Notes |
|----------------------|---------------|-------|
| `call_graph_nodes` row | `CallNodeAttrs` (node in `FlowGraph`) | `requestId` is the node key in the flowgraph instance |
| `call_graph_nodes.status` | `CallStatus` enum | Same values: `pending`, `running`, `completed`, `failed`, `aborted` |
| `call_graph_nodes.identity` | `CallNodeAttrs.identity` | `{ id, scopes, resources }` |
| `call_graph_nodes.error` | `CallNodeAttrs.error` | `{ code, message, details? }` |
| `call_graph_edges` with `edgeType='triggered'` | `TriggeredEdgeAttrs` | Created by `FlowGraph.addCall()` when `parentRequestId` is present |
| `call_graph_edges` with `edgeType='depends_on'` | `DependencyEdgeAttrs` | Created by `FlowGraph.addDependency()` |
| `call_graph_edges` with `edgeType='requested_by'` | No flowgraph equivalent | Storage-layer only, not modeled in the in-memory graph |
**Reconstruction**: After a hub restart, the call graph is rebuilt from stored events or incremental rows using `FlowGraph.fromCallEvents()` or by iterating over `call_graph_nodes` + `call_graph_edges` rows and populating a `FlowGraph` instance via `addCall()` and `addDependency()`.
**Identifier mapping**: `call_graph_nodes` uses two identifiers — `id` (UUID, from `commonCols`, used as PK and FK target for edges) and `requestId` (text, UNIQUE, used as the flowgraph node key). When writing edges to `call_graph_edges`, the hub resolves `requestId``call_graph_nodes.id` for the FK references. When reconstructing from the database, the hub resolves `call_graph_nodes.id``requestId` for flowgraph node keys. This mapping is efficient because `call_graph_nodes.requestId` has a UNIQUE index.
**Serialization**: Flowgraph's `export()` produces graphology's native JSON format (`CallGraphSerialized`), which is suitable for snapshot/restore but not for incremental queries. The hub uses incremental storage for real-time observability and can optionally persist snapshots for fast recovery.
### Retention Policy
Call graph data is retained for 90 days by default (configurable via hub config). Completed/failed/aborted nodes and their edges older than the retention period are cleaned up by a background job. Pending/running nodes are never auto-deleted.
Aggregation for observability: Before deletion, summary statistics (call counts, average duration, error rates by operation) may be computed and stored in a separate aggregation table (deferred to Phase 2).
The `metadata` column on `call_graph_nodes` stores retention metadata: `{ _retentionExpiresAt: timestamp }` for tracking when a node becomes eligible for cleanup.
### Payload Redaction
Call graph `input` and `output` payloads may contain sensitive data (API keys, tokens, personal information). A redaction strategy is applied before storage.
**Redaction rules**: (1) Known sensitive field names (`apiKey`, `token`, `password`, `secret`, `authorization`, `key`) are replaced with `[REDACTED]`. (2) String values matching common secret patterns (Bearer tokens, base64-encoded secrets) are replaced with `[REDACTED]`. (3) Redaction is applied BEFORE the 10KB truncation — the truncated preview contains only redacted data.
**Redaction timing**: Applied at the application layer before DB write. Never store raw payloads and redact on read — redaction must be one-way.
**Configuration**: The list of redacted field names and patterns is configurable via hub config, with sensible defaults.
### Payload Truncation
**Truncation timing**: Payloads are truncated on DB write, not in-flight. In-flight calls hold full payloads in memory for processing. Only the persisted version is truncated.
**Truncation strategy**: Payloads larger than 10KB are truncated to `{ _truncated: true, size: number, preview: string }` where `preview` is the first 1024 bytes (not characters) of the JSON-serialized payload. The threshold is configurable via `HubConfig.callGraph.payloadTruncationThreshold` (defaults to 10240 bytes).
**Object storage reference**: For payloads exceeding the truncation threshold, the full payload MAY be stored in object storage (S3/MinIO) with a reference URL in the `metadata` column as `{ _storageRef: 's3://bucket/key' }`. This is Phase 2 and not yet implemented.

View File

@@ -0,0 +1,54 @@
---
status: draft
last_updated: 2026-04-19
---
# Table Schemas: Coordination
Mapping and detection tables for coordinator operations. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For coordination architecture, see [../../coordination.md](../../coordination.md).
### `mappings`
Worktree/session/spoke relationships. Links spawned sessions to their parent coordinator, the spoke they're running on, and the git branch. This is the coordination table that drives `coord.spawn`, `coord.status`, `coord.message`, and `coord.abort`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| sessionId | text NOT NULL | FK → sessions.id |
| spokeId | text | FK → spokes.id |
| workspaceId | text | FK → workspaces.id |
| parentSessionId | text | FK → sessions.id — Coordinator's session. onDelete: SET NULL — deleting the coordinator detaches the mapping but preserves it. |
| taskId | text | FK → tasks.id — The task this mapping is assigned to. Nullable — some mappings aren't task-scoped. |
| task | text | Denormalized task display name (slug or name) for quick reference without a JOIN. |
| status | text NOT NULL | Enum: `active`, `completed`, `aborted`, `failed`. Default: `active` |
**Indexes**: `idx_mappings_session_id` on `(sessionId)`, `idx_mappings_parent_session_id` on `(parentSessionId)`, `idx_mappings_spoke_id` on `(spokeId)`, `idx_mappings_task_id` on `(taskId)`, `idx_mappings_workspace_id` on `(workspaceId)` — workspace-scoped mapping queries.
`projectId` is derived from the session's project context, not stored directly. A mapping's project scope comes from its session. `workspaceId` is the workspace within that project.
**Status transitions**: `active``completed` (successful finish), `active``failed` (error), `active``aborted` (coordinator cancelled). No transition back to `active` from terminal states.
See coordination.md for the operations that create and query these mappings.
### `detections`
Anomaly detection records produced by the hub's monitoring heuristics. See coordination.md for the detection heuristics and `coord.detect` operation.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| sessionId | text NOT NULL | FK → sessions.id |
| anomalyType | text NOT NULL | `MODEL_DEGRADATION`, `HIGH_ERROR_COUNT`, `SESSION_STALL`. Extensible — new types can be added without schema migration. |
| severity | text NOT NULL | `high`, `medium`, `low` |
| details | jsonb | Detection-specific details (thresholds, counters, timestamps) |
| resolvedAt | timestamp with tz | When the detection was resolved/dismissed. Null if still active. |
| resolvedBy | text | FK → accounts.id — Who resolved it. onDelete: SET NULL |
| resolution | text | How it was resolved: `acknowledged`, `dismissed`, `escalated`, `fixed`. Null if still active. |
| dedupKey | text | Deterministic key for deduplication (e.g., hash of type+context). If a new detection has the same dedupKey as an active (unresolved) one, increment `occurrenceCount` instead of creating a new row. |
| occurrenceCount | integer NOT NULL DEFAULT 1 | Number of times this detection pattern has occurred. Incremented on dedup matches. |
**Indexes**: `idx_detections_session_id` on `(sessionId)` — find detections for a session, `idx_detections_type` on `(anomalyType)` — filter by detection type, `idx_detections_resolved_at` on `(resolvedAt)` — find active (unresolved) detections, `idx_detections_dedup_key` on `(dedupKey)` — dedup lookups.
**Deduplication**: When a new detection is created, compute a `dedupKey` from the detection type and relevant context. If an active (unresolved) detection with the same `dedupKey` exists, increment its `occurrenceCount` and update `details`/`updatedAt` instead of inserting a new row. This prevents persistent `MODEL_DEGRADATION` from creating a new row every check interval.
**Resolution**: A detection is active when `resolvedAt` is null. Setting `resolvedAt` (with `resolvedBy` and `resolution`) marks it as resolved. On session close (`sessions.status → archived`), consider auto-resolving active detections for that session.

View File

@@ -0,0 +1,156 @@
---
status: draft
last_updated: 2026-04-20
---
# Table Schemas: Identity & Auth
Account, organization, and authentication tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For the account-role-session model, see [../../agent-roles.md](../../agent-roles.md).
### `accounts`
Hub-local identity records. These are NOT Gitea users — they're identities in our system. They can be linked to Gitea accounts but aren't required to be. This table is the FK target for `api_keys.ownerId`, `audit_logs.ownerId`, `clients.ownerId`, `organizations.ownerId`, and `sessions.accountId`.
Accounts serve as the identity layer for both humans and LLMs. An LLM that creates sessions, makes commits, or owns API keys needs its own account (typically with `accessLevel: "service"`). See [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md) for the terminology rationale.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| email | text NOT NULL UNIQUE | Unique identifier. System/service accounts MAY use a deployment-configured reserved email pattern (e.g., `{model}@system.example.com`). The reserved pattern is a deployment concern — no specific domain is hardcoded. See D6 in storage-spec-phase1-resolutions.md. |
| displayName | text | Display name |
| accessLevel | text NOT NULL DEFAULT `user` | `admin`, `user`, `service` |
| status | text NOT NULL DEFAULT 'active' | Enum: active, suspended, deactivated. See D5 in storage-spec-phase1-resolutions.md. |
| giteaUsername | text | Link to Gitea account (nullable — service/LLM accounts may or may not have one) |
| data | jsonb | Account metadata (preferences, avatar URL, etc.) |
**data boundaries**: Account preferences and profile metadata. Authentication credentials never go here — API keys are in `api_keys`, secrets are in `client_secrets`.
**Indexes**: `unq_accounts_email` UNIQUE on `(email)`, `idx_accounts_gitea_username` on `(giteaUsername)`, `idx_accounts_display_name` on `(displayName)` — user search/autocomplete UIs.
**`accessLevel` semantics** (renamed from `role` to avoid confusion with behavioral roles — see [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md)):
- `admin`: Can manage all resources across organizations
- `user`: Can manage own resources and resources in organizations they belong to
- `service`: Automated accounts — LLM workers, spoke credentials, CI tokens. No Gitea link required.
**Account lifecycle**: Deactivated accounts cannot authenticate. Suspended accounts are admin-locked (e.g., security hold). Deactivated is user-initiated shutdown. Suspended/deactivated accounts can still own organizations (RESTRICT FK) and have audit entries (RESTRICT FK) but cannot authenticate.
**System account email convention**: Deployments may configure a reserved email domain or pattern for system-generated accounts (LLMs, bots, services). This prevents collision between human and system accounts and enables attribution in git commits and audit logs. The specific pattern is deployment-specific and should not be hardcoded in architecture documentation.
**LLM accounts**: An LLM worker account (e.g., with a deployment-configured system email) has `accessLevel: "service"`. It owns sessions, API keys, and audit trail entries. The LLM fills a **role** (defined in the `roles` table or `.opencode/agents/*.md`) for the duration of a session. The account provides identity and accountability; the role provides behavioral constraints and permissions.
**Authorization rules for `accessLevel`**: Only `admin` accounts can change another account's `accessLevel`. Accounts cannot self-promote. `service` accounts cannot change `accessLevel` at all. `user` accounts cannot change `accessLevel` of any account. The hub operations `hub.account.updateAccessLevel` and `hub.account.create` enforce these rules at the application layer. For the `admin`/`user`/`service` terminology distinction (renamed from `role`), see [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md).
### `organizations`
Top-level grouping for multi-tenancy. Organizations own projects, can scope clients, and group members. Minimal — just name + ownership. Gitea integration bridges via `giteaOrgName`.
**ownerId semantics**: This is the administrative/transferable owner of the organization. It MUST be an account that is also a member with `membershipLevel: 'owner'` (enforced by app logic). If the owner account needs to be changed, `org.transferOwnership` must be called first. RESTRICT FK prevents deleting the owner account.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL UNIQUE | Organization name |
| slug | text NOT NULL UNIQUE | URL-friendly identifier |
| giteaOrgName | text | Link to Gitea organization (nullable — some orgs are hub-only) |
| ownerId | text NOT NULL | FK → accounts.id — Administrative/transferable owner of the org. RESTRICT cascade prevents deleting the owner account while the org exists. |
| data | jsonb | Org metadata (billing, settings) |
**Indexes**: `unq_organizations_name` UNIQUE on `(name)`, `unq_organizations_slug` UNIQUE on `(slug)`, `idx_organizations_owner_id` on `(ownerId)`, `idx_organizations_gitea_org_name` on `(giteaOrgName)`.
### `organization_members`
Who belongs to which org. Simple membership + level.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| orgId | text NOT NULL | FK → organizations.id (cascade) |
| accountId | text NOT NULL | FK → accounts.id (cascade) |
| membershipLevel | text NOT NULL | `owner`, `admin`, `member` |
**Unique constraint**: `(org_id, account_id)` — one membership per account per org.
**Indexes**: `unq_org_members_org_account` UNIQUE on `(orgId, accountId)`, `idx_org_members_account_id` on `(accountId)`, `idx_org_members_org_id` on `(orgId)` — find members of an org.
**`membershipLevel` semantics** (renamed from `role` to avoid confusion with behavioral roles — see [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md)): `owner` has full control including billing and member management. `admin` can manage projects and members. `member` can access org resources.
**membershipLevel is runtime access control, separate from ownerId**: `membershipLevel: 'owner'` grants elevated permissions within the org. This is distinct from `organizations.ownerId`, which is the administrative/transferable owner. The invariant is: `organizations.ownerId` always references an account that also has `membershipLevel: 'owner'` in organization_members.
## Org Ownership Transfer
When an account that owns an organization needs to be removed, the organization's ownership must be transferred first (because `organizations.ownerId → accounts.id` has RESTRICT cascade).
The `org.transferOwnership` operation:
1. Validates that the new owner is an account with `membershipLevel: 'owner'` in the organization
2. Updates `organizations.ownerId` to the new owner
3. Optionally demotes the old owner's `membershipLevel` to 'admin' or 'member'
**Precondition**: `organizations.ownerId` must always reference a member with `membershipLevel: 'owner'`. Transfer must happen before account deactivation or deletion of the current owner.
**Error cases**: If the organization has no other members with `membershipLevel: 'owner'`, the transfer requires promoting a member first.
### `api_keys`
API keys for hub authentication. Uses keypal (v0.1.11) for key generation, hashing, verification, and scope management. The table follows our `commonCols` pattern but with proper columns for high-query fields instead of keypal's default JSONB-only approach.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| ownerId | text NOT NULL | FK → accounts.id — Key owner (maps to keypal's `ownerId`) |
| keyHash | text NOT NULL | SHA-256 hash of the raw key (never stores raw key) |
| name | text | Human-readable key label |
| description | text | Key purpose description |
| enabled | boolean NOT NULL DEFAULT true | Disable without revoking |
| expiresAt | timestamp with tz | When the key expires (null = never) |
| revokedAt | timestamp with tz | When the key was revoked (null = active) |
| rotatedToId | text | ID of the key this was rotated to |
| lastUsedAt | timestamp with tz | Last time the key was used to authenticate |
**Indexes**: `idx_api_keys_owner_id` on `(ownerId)`, `unq_api_keys_key_hash` UNIQUE on `(keyHash)`, `idx_api_keys_enabled` on `(enabled)` — filter enabled/disabled keys, `idx_api_keys_active` partial on `(ownerId)` WHERE `revoked_at IS NULL AND enabled = true` — efficiently find active keys. Note: `idx_api_keys_key_hash` is not listed separately because `unq_api_keys_key_hash` UNIQUE constraint auto-creates an index covering the same column.
**Keypal integration**: We implement keypal's `Storage` interface as a thin adapter (`HubKeyStorage`) that reads/writes this table. The `metadata` JSONB column (from `commonCols`) stores keypal's scope data:
- `metadata.scopes`: `string[]` — global permission scopes
- `metadata.resources`: `Record<string, string[]>` — resource-scoped permissions (key format: `"type:id"`)
- `metadata.tags`: `string[]` — filtering tags (lowercased)
This gives us proper SQL indexing on `owner_id`, `key_hash`, `enabled`, `expires_at`, `revoked_at` while keeping the flexible scope model in `metadata`.
**SHA-256 trade-off**: API keys are hashed with SHA-256, not a slow KDF (bcrypt, Argon2). This is acceptable because API keys are high-entropy machine-generated strings (128-bit+), making brute-force infeasible even with a fast hash. Human passwords require slow hashes; machine keys do not. This provides O(1) verification latency at high throughput. See ADR-010.
**Expiration and revocation behavior**:
- `expiresAt` is nullable — null means the key never expires. When present, the key is rejected after `expiresAt`. The `enabled` field is a separate kill switch (immediate disable regardless of expiration). A key can be: enabled+not expired (active), enabled+expired (rejected), disabled (rejected regardless of expiration).
- `revokedAt` is set when `keypal.revoke()` is called. Revoked keys are permanently disabled regardless of enabled/expiry status.
- **Error responses**: Expired, disabled, and revoked keys all return a generic authentication failure — not a specific reason — to avoid information disclosure to attackers.
**Key lifecycle**:
- **Create**: `keys.create({ ... })` → generates raw key, hashes it, stores hash in `key_hash`, returns `{ key, record }`
- **Verify**: `keys.verify(token)` → hashes the token, looks up by `key_hash`, checks `enabled` / `revoked_at` / `expiresAt`
- **Revoke**: `keys.revoke(id)` → sets `revoked_at` to now (soft delete)
- **Rotate**: `keys.rotate(id)` → creates new key, sets `rotated_to_id` on old key
- **Scope check**: `keys.hasScope(record, scope)` or `keys.checkResourceScope(record, type, id, scope)`
**Caching**: Use keypal's `RedisCache` with our existing Redis instance for key verification caching. Cache stores only the slim `CacheRecord` (id, expiresAt, revokedAt, enabled), not full metadata.
**`ownerId` semantics**: `api_keys.ownerId` is a FK to `accounts.id`. The account may be a user, admin, or service account. Service accounts (e.g., a spoke that needs its own API key) get an `accounts` row with `accessLevel: "service"`. This replaces the previous opaque string model with proper referential integrity.
### `audit_logs`
Audit trail for API key operations and security-relevant hub events.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| action | text NOT NULL | Action type: `created`, `revoked`, `rotated`, `enabled`, `disabled`, `login`, `access_denied` |
| keyId | text | FK → api_keys.id (nullable — not all audit events are key-related) |
| ownerId | text NOT NULL | FK → accounts.id — The identity that performed the action. RESTRICT cascade — accounts with audit entries cannot be hard-deleted; use account deactivation (status column) instead. |
| sessionId | text | FK → sessions.id — The session in which the action occurred. Nullable — not all actions happen in a session context. onDelete: SET NULL |
| orgId | text | FK → organizations.id — The organization context for the action. Nullable — personal actions aren't org-scoped. onDelete: SET NULL |
| details | jsonb | Action-specific context (IP, user agent, scope changes, etc.) |
**Indexes**: `idx_audit_logs_owner_id` on `(ownerId)`, `idx_audit_logs_key_id` on `(keyId)`, `idx_audit_logs_action` on `(action)`, `idx_audit_logs_created_at` on `(createdAt)`, `idx_audit_logs_session_id` on `(sessionId)`, `idx_audit_logs_org_id` on `(orgId)`.
Session and org context enable filtering audit logs by session (e.g., "what did this agent session do?") and organization (e.g., "show me all actions in this org").
**Keypal integration**: keypal's optional audit log methods (`saveLog`, `findLogs`, `countLogs`) are implemented on `HubKeyStorage` to write to this table. Hub-native audit events (login, access denied) also write here.
**`action` enum is extensible**: The initial set of action types (`created`, `revoked`, `rotated`, `enabled`, `disabled`, `login`, `access_denied`) covers keypal key operations and basic auth events. Additional actions for account, membership, and organization lifecycle events (e.g., `account_created`, `membership_added`, `org_created`) should be added as those features are implemented. New action types must be documented here and in table-reference.md.

View File

@@ -0,0 +1,41 @@
---
status: draft
last_updated: 2026-04-19
---
# Table Schemas: Projects & Workspaces
Project and workspace tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/).
### `projects`
Git repositories / work contexts. A project may have multiple workspaces (branches). Projects belong to organizations.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| orgId | text | FK → organizations.id (nullable — personal projects have no org) |
| name | text NOT NULL | Project name |
| directory | text | Local filesystem path (primary workspace) |
| repoUrl | text | Git remote URL |
| vcs | text | Version control system (default: `git`) |
| iconUrl | text | Project icon URL |
| iconColor | text | Project icon color (opencode compat) |
**Indexes**: `idx_projects_org_id` on `(orgId)` — find projects for an org.
### `workspaces`
Project workspaces — branches, directories, and execution contexts. A project can have multiple workspaces (e.g., main branch workspace, feature branch workspace). This maps to opencode's `workspace` concept and our coordination `mappings`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| projectId | text NOT NULL | FK → projects.id (cascade) |
| type | text NOT NULL | Workspace type: `local`, `remote`, `container` |
| branch | text | Git branch name |
| name | text | Human-readable workspace name |
| directory | text | Local filesystem path |
| extra | jsonb | Workspace-specific configuration |
**Indexes**: `idx_workspaces_project_id` on `(projectId)` — find workspaces for a project.

View File

@@ -0,0 +1,105 @@
---
status: draft
last_updated: 2026-04-20
---
# Table Schemas: Roles
Behavioral role definitions. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For the full account-role-session model, see [../../agent-roles.md](../../agent-roles.md). For the terminology decision, see [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md).
### `roles`
Behavioral role definitions that any account can fill during a session. Roles define what operations are available, what permissions are granted, and what scope constraints apply. Currently defined in `.opencode/agents/*.md` files; this table enables database storage and runtime permission resolution.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL UNIQUE | Role identifier (e.g., "architect", "implementation-specialist") |
| description | text | Human-readable description |
| mode | text NOT NULL | `primary` (user-facing) or `subagent` (spawned by coordinator) |
| temperature | real | Model sampling temperature (default: 0.2 for subagents, 0.3 for primary) |
| permissions | jsonb NOT NULL DEFAULT `[]` | Permission ruleset — array of `{ action, permission, pattern }` rules, evaluated first-match |
| tools | jsonb NOT NULL DEFAULT `{}` | Tool availability map — `{ toolName: boolean }` for enabled/disabled tools |
| prompt | text | System prompt template |
| parentId | text | FK → roles.id — Parent role for inheritance. onDelete: SET NULL — deleting a parent detaches children. |
| scopes | jsonb NOT NULL DEFAULT `[]` | API key scopes this role requires (string array, used during permission resolution) |
| data | jsonb | Additional role-specific configuration (model selection, max steps, etc.) |
**Indexes**: `unq_roles_name` UNIQUE on `(name)`, `idx_roles_parent_id` on `(parentId)`, `idx_roles_mode` on `(mode)`.
**`permissions` shape**: A `Permission.Ruleset` — an ordered array of rules evaluated first-match:
```ts
type PermissionRule = {
action: "allow" | "deny" | "ask"; // What to do when this rule matches
permission: string; // e.g., "edit", "read", "bash", "webSearch"
pattern: string; // Glob pattern for path-based matching (e.g., "src/**", "*")
};
type PermissionRuleset = PermissionRule[];
```
Example for implementation-specialist:
```json
[
{ "action": "allow", "permission": "read", "pattern": "**" },
{ "action": "allow", "permission": "write", "pattern": "src/**" },
{ "action": "allow", "permission": "edit", "pattern": "src/**" },
{ "action": "allow", "permission": "bash", "pattern": "deno *" },
{ "action": "deny", "permission": "bash", "pattern": "*" },
{ "action": "allow", "permission": "webSearch", "pattern": "*" }
]
```
**`tools` shape**: A simple boolean map for which tools are available to this role:
```json
{
"read": true,
"write": true,
"edit": true,
"glob": true,
"grep": true,
"bash": true,
"webSearch": true,
"webfetch": true
}
```
**Role inheritance**: When a role has a `parentId`, the child role inherits `permissions` and `tools` from the parent, with the child's values taking priority. Specifically:
- `permissions`: The parent's ruleset is prepended before the child's ruleset. First match wins, so child rules override parent rules for the same pattern.
- `tools`: Union of parent and child tool sets. If both define a tool, the child's value takes priority.
- `temperature`, `prompt`, `model`, `scopes`: Child values override parent values entirely (no merging).
- Max inheritance depth: 3 levels. Circular inheritance is prevented at role creation time.
**`data` shape**: Additional configuration that varies by role:
```ts
type RoleData = {
model?: { // Override model selection
providerID: string; // e.g., "anthropic", "openai"
modelID: string; // e.g., "claude-opus-4-5-20250101"
};
steps?: number; // Max agentic steps per turn
topP?: number; // Top-P sampling parameter
color?: string; // Display color for UI
hidden?: boolean; // Don't show in role selection UI
source?: "builtin" | "file" | "database"; // Where this role definition came from
filePath?: string; // Source file path (for file-based roles)
};
```
**OpenCode compatibility**: When importing from `.opencode/agents/*.md`, the YAML frontmatter maps to:
- `description` → from frontmatter `description`
- `mode` → from frontmatter `mode`
- `temperature` → from frontmatter `temperature`
- `tools` → from frontmatter `tools`
- `permissions` → converted from frontmatter `permission` (OpenCode uses `Permission.Ruleset` format)
- `prompt` → from markdown body content
- `data.model` → from frontmatter `model`
- `data.steps` → from frontmatter `steps`
- `data.source``"file"`
- `data.filePath` → path relative to project root
**Migration path**: Phase 1 uses `.opencode/agents/*.md` files. Phase 2 adds a `roles.sync` operation that reads files and upserts into this table. Phase 3 makes the database authoritative with files as a version-controlled editing surface.
**Sessions reference**: `sessions.roleName` is a free-form string that references `roles.name` by convention, but there is no FK constraint. Sessions may use role names not yet in the `roles` table (e.g., file-based roles not yet synced). A FK constraint may be added in Phase 3 when the database becomes authoritative.

View File

@@ -0,0 +1,108 @@
---
status: draft
last_updated: 2026-04-19
---
# Table Schemas: External Services
Client and credential tables for outbound service connections. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/).
### `clients`
External service registrations — "who we connect to." A client is any service the hub calls: LLM providers (Anthropic, OpenAI, OpenRouter), VCS (Gitea), compute (Vast.ai), MCP servers, JMAP, custom REST APIs. The `config` column holds the validated connection shape (URLs, headers, auth mechanism) **without credentials**. Credentials live in `client_secrets`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL UNIQUE | Identifier (`anthropic`, `gitea`, `openrouter`, `vast-ai`) |
| type | text NOT NULL | Client type: `llm-provider`, `vcs`, `compute`, `mcp-server`, `custom` |
| config | jsonb NOT NULL | Validated config instance — validated against the TypeBox schema for this `type` on write. **Validation timing**: Config is validated on write (API handler layer) using the TypeBox schema for the client `type`. On read, a startup validation pass logs warnings for rows that don't match the current schema — it does not block reads. |
| enabled | boolean NOT NULL DEFAULT true | Disable without deleting |
| ownerId | text NOT NULL | FK → accounts.id — who configured this client |
| orgId | text | FK → organizations.id (nullable — some clients are personal, not org-scoped) |
**config boundaries**: Connection configuration goes in `config` (URLs, headers, auth mechanism). This is validated against the TypeBox schema for the client `type`. Secrets are NEVER in `config` — they go in `client_secrets`.
**Indexes**: `unq_clients_name` UNIQUE on `(name)`, `idx_clients_type` on `(type)`, `idx_clients_owner_id` on `(ownerId)`, `idx_clients_org_id` on `(orgId)`.
**Config schema registry** (in code, not DB): Each client `type` maps to a TypeBox schema that validates `config` on write:
```ts
const clientConfigSchemas: Record<string, TSchema> = {
"llm-provider": LLMProviderConfig, // baseUrl, defaultModel, models[], auth mechanism
"vcs": VCSClientConfig, // baseUrl, specUrl, namespace, auth mechanism
"compute": ComputeConfig, // endpoint, region, auth mechanism
"mcp-server": MCPServerConfig, // command/url + args/headers (from hub config types)
"custom": HTTPServiceConfig, // baseUrl, headers, auth (from @alkdev/operations/from-openapi)
};
```
**Schema evolution contract**: New fields in client config schemas MUST be `Type.Optional()`. Breaking changes MUST use a new client `type` (e.g., `llm-provider-v2`). This ensures existing DB rows remain valid across deployments. Consider adding `configSchemaVersion` to `metadata` in a future phase if breaking changes become common. For now, optional fields handle forward compatibility.
**Validation chain**: API handler validates → Drizzle insert → DB stores. Direct SQL bypasses application validation — this is a known risk documented in README.md.
**Wiring config to secrets**: The config contains `secretKey` (or `envSecretKeys`) fields that point to named secrets in `client_secrets`. The config knows HOW to auth, the secrets table holds WHAT to auth with.
Example config for a Gitea client:
```json
{
"baseUrl": "https://git.alk.dev/api/v1",
"specUrl": "https://git.alk.dev/swagger.v1.json",
"namespace": "gitea",
"auth": { "type": "apiKey", "headerName": "Authorization", "prefix": "token ", "secretKey": "api_password" }
}
```
Example config for an MCP server:
```json
{
"command": "/usr/local/bin/mcp-server",
"args": ["--port", "3000"],
"envSecretKeys": { "OPENAI_API_KEY": "openai_key" }
}
```
**Runtime resolution**: On startup, load client → validate config → resolve secrets from `client_secrets` by `secretKey` wiring → merge config + decrypted secrets → create connection (MCP client, OpenAPI operations, etc.).
### `client_secrets`
Encrypted credential store — "how we authenticate to them." Each secret is an encrypted value (API key, password, OAuth token, SSH key) associated with a client. Stored as AES-256-GCM encrypted data via `src/crypto.ts`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| clientId | text NOT NULL | FK → clients.id (cascade) |
| key | text NOT NULL | Secret key name: `api_key`, `api_password`, `oauth_credentials`, `ssh_key`, etc. |
| value | jsonb NOT NULL | Encrypted payload — `EncryptedData { keyVersion, salt, iv, data }` from crypto.ts |
| keyVersion | integer NOT NULL DEFAULT 1 | Encryption key version for rotation |
| expiresAt | timestamp with tz | When the secret expires (e.g., OAuth token TTL). Null = no expiry. |
| lastUsedAt | timestamp with tz | When the secret was last used to authenticate |
**Unique constraint**: `(client_id, key)` — one named secret per client.
**Indexes**: `unq_client_secrets_client_key` UNIQUE on `(clientId, key)`, `idx_client_secrets_expires_at` on `(expiresAt)`.
**Encrypted data structure** (`EncryptedData` from crypto.ts):
```ts
interface EncryptedData {
keyVersion: number; // matches client_secrets.keyVersion
salt: string; // base64, 16 bytes (PBKDF2)
iv: string; // base64, 12 bytes (AES-GCM)
data: string; // base64, AES-256-GCM ciphertext
}
```
**Encryption flow**:
1. Raw secret (API key, password) → `crypto.encrypt(secret, dataEncryptionKey)``EncryptedData`
2. Store as JSONB in `value`
3. On use: `crypto.decrypt(value, dataEncryptionKey)` → raw secret
4. Data encryption keys from hub config (see [hub-config.md](../../hub-config.md) for the two-layer key model) — comma-separated list of `version:base64key` pairs (e.g., `v1:YmFzZTY0a2V5, v2:Zm9yYmFyYmF6`). Stored in the config file's `encryptionKeys` field (encrypted with the Docker-secret-provisioned master key). Generated once per version via `crypto.generateEncryptionKey()`. The first key in the list is the "current" key used for new encryptions. All keys in the list are available for decryption (allows key rotation). **No env vars for secrets** — see ADR-008 (revised).
**Secret format convention**: Most secrets are plain strings (API keys, passwords). Complex secrets (OAuth tokens) are JSON objects `JSON.stringify()`'d before encryption. The `key` name indicates the format: `api_key` = string, `oauth_credentials` = JSON.
**Key rotation protocol**:
- **On read**: Decrypt with the key version indicated by `client_secrets.keyVersion`. All key versions in the data encryption key ring (from hub config, see [hub-config.md](../../hub-config.md)) are available for decryption.
- **On write (new secret)**: Encrypt with the current key version (the first key in the encryption keys list from hub config).
- **Re-encryption**: Decrypt with old key version → encrypt with current key → UPDATE in a single DB transaction. If the process crashes between decrypt and UPDATE, the old version remains accessible (the row still references the old `keyVersion` and the old key is still in the key ring until fully rotated).
- **Background sweep**: A background job SHOULD periodically re-encrypt secrets using old key versions. Until re-encryption completes, secrets encrypted with old keys remain vulnerable if the old key is compromised. Key rotation for data encryption keys is independent of master key rotation — see [hub-config.md](../../hub-config.md) for the two-layer key model.
- **Error handling**: If a key version referenced by `client_secrets.keyVersion` is not found in the data encryption key ring, log an error and skip re-encryption. Alert the operator — this indicates a missing key that could cause data loss.

View File

@@ -0,0 +1,174 @@
---
status: draft
last_updated: 2026-04-20
---
# Table Schemas: Sessions, Messages & Parts
Agent conversation session tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For the session architecture, see [../../agent-sessions.md](../../agent-sessions.md).
### `sessions`
Agent conversation sessions. Every session — whether the LLM runs directly in the hub or in a remote opencode container — stores its data here. The hub is the source of truth; spokes are execution environments.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| accountId | text | FK → accounts.id — Nullable — orphaned sessions preserve conversation history for audit and debugging. See D1 in storage-spec-phase1-resolutions.md. |
| projectId | text NOT NULL | FK → projects.id (cascade) |
| workspaceId | text | FK → workspaces.id |
| parentId | text | FK → sessions.id — Parent session (coordinator relationship). onDelete: SET NULL — deleting a parent session detaches children but preserves them. |
| slug | text NOT NULL UNIQUE | URL-friendly session identifier (unique across all sessions). `slug` is generated from the session title using URL-friendly slugification (lowercase, hyphens for spaces, alphanumeric only). Uniqueness is enforced by the UNIQUE constraint. If a collision occurs, append a short random suffix. |
| title | text NOT NULL | Session title |
| status | text NOT NULL | Enum: `idle`, `busy`, `retry`, `archived`. Default: `idle` |
| version | text NOT NULL | Schema version of the session's `data` column. Default: `'1'`. Incremented when the data format changes (e.g., new optional fields added). New fields should be optional in the schema, so `version` advances for breaking changes only. The hub uses this for migration-aware reads: version 1 sessions get default values for new fields. This field exists for forward compatibility — it allows the hub to interpret session data correctly as the schema evolves. It is NOT a concurrency version (for optimistic locking, use `commonCols.updatedAt`). |
| provider | text | Execution path: `direct` (hub AI SDK) or `opencode` (spoke) |
| roleName | text | Which role this session fills (e.g., "architect", "implementation-specialist"). Formerly `agentName` in OpenCode. See [ADR-012](../../../decisions/ADR-012-agent-vs-role-vs-account.md) and [agent-roles.md](../../agent-roles.md). `roleName` is a free-form string (not a FK constraint). Known role names are defined in the `roles` table, but sessions may use ad-hoc role names. Application code should validate against known roles when available but tolerate unknown values. |
| data | jsonb | Role-specific metadata (model, tokens, cost, finish reason, etc.) |
**data boundaries**: Execution metadata goes in `data` (model, tokens, cost, finish reason, resolved permissions). Structured fields like `status`, `provider`, `roleName` are separate columns because they're queried, filtered, and constrained. If a field appears in WHERE clauses or JOINs, it should be a proper column, not buried in JSONB.
**Session `data` shapes**: The `data` JSONB column holds execution-path-specific metadata. For `direct` sessions: `{ model, tokens, cost, finish }`. For `opencode` sessions: additional fields from opencode's session model (summary stats, etc.). The `data` column also holds the resolved permissions for the session (`data.scope`), which is computed from the intersection of role permissions, account scopes, and spoke type trust level. See agent-sessions.md and [agent-roles.md](../../agent-roles.md) for the full models.
**Status lifecycle**:
- `idle`: Session exists, not currently executing
- `busy`: Session is actively processing (LLM call in progress)
- `retry`: Last execution failed, session pending retry
- `archived`: Session is read-only, no further interaction
**Indexes**: `unq_sessions_slug` UNIQUE on `(slug)`, `idx_sessions_project_id` on `(projectId)`, `idx_sessions_workspace_id` on `(workspaceId)`, `idx_sessions_status` on `(status)`, `idx_sessions_active` partial on `(id)` WHERE `status IN ('idle', 'busy', 'retry')` — efficiently find active (non-archived) sessions, `idx_sessions_account_id` on `(accountId)`, `idx_sessions_role_name` on `(roleName)`, `idx_sessions_parent_id` on `(parentId)` — find child sessions of coordinator.
### `messages`
Messages within sessions. Content is stored separately in the `parts` table. This follows the opencode pattern: message metadata in one row, parts in separate rows. This enables streaming individual part updates, querying parts independently, and SSE events for `message.part.updated`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| sessionId | text NOT NULL **IMMUTABLE** | FK → sessions.id (cascade) — Never updated after creation. |
| role | text NOT NULL | `user`, `assistant`, `system` |
| data | jsonb NOT NULL | Role-specific metadata |
**Message IDs use UUIDv4** (via `commonCols.id`). Ordering is handled by the composite index `idx_messages_session_id_created_at_id` on `(session_id, created_at, id)`. See ADR-003 for the rationale.
**Message `data` shapes** (discriminated by `role`):
`user` messages:
```ts
{
time: { created: number }, // epoch ms
format?: "text" | "json_schema", // input format hint
summary?: { title?: string, body?: string, diffs?: FileDiff[] },
agent?: string, // target agent name
model?: { providerID: string, modelID: string },
tools?: Record<string, boolean>, // enabled tools for this turn
}
```
`assistant` messages:
```ts
{
time: { created: number, completed?: number },
parentID?: string, // FK to the user message that triggered this turn
modelID: string,
providerID: string,
agent?: string,
path?: { cwd: string, root: string },
cost?: number,
tokens?: { input: number, output: number, reasoning?: number, cache?: { read: number, write: number } },
finish?: string, // "stop", "tool-calls", "length", etc.
error?: { code: string, message: string }, // typed error if the turn failed
}
```
`system` messages:
```ts
{
time: { created: number },
content: string, // system prompt text
}
```
**Compatibility with opencode**: The `data` blob is a superset of opencode's `InfoData`. When importing an opencode session, the opencode-specific fields (`parentID`, `path`, `modelID`, `providerID`, `cost`, `tokens`, `finish`) map directly. When importing from a hub-direct AI SDK session, the AI SDK `UIMessage` fields are projected into the same shape.
**Compatibility with AI SDK**: The AI SDK's `UIMessage` format (role + parts array) is assembled from these tables via a JOIN query. Storage is normalized; the API presents the denormalized view. No format conversion needed.
### `parts`
Message parts — the actual content of the conversation. Each part has a `type` discriminator and type-specific content in the `data` column. Parts are ordered by their `id` within a message, using sortable timestamp-based IDs (not `commonCols.id`).
**Important**: The `id` column for parts uses a sortable ID scheme (not UUIDv4 from `commonCols`). Opencode uses prefix-based sortable IDs like `prt_{timestamp_hex}{random}` that give chronological ordering. This enables `ORDER BY id ASC` within a message without needing a separate `position` column. The implementation should use a monotonic ID generator that produces lexicographically sortable IDs.
The `sessionId` column on parts is a deliberate denormalization of `message.sessionId` — it allows direct queries like "all parts for a session" without joining through messages. **`sessionId` on both `messages` and `parts` is IMMUTABLE after creation.** It must never be updated. This is enforced by application logic, not a DB trigger. When inserting a part, read the message's `sessionId` and set it on the part within the same transaction. Direct SQL must not update `sessionId` on existing rows.
| Column | Type | Notes |
|--------|------|-------|
| id | text PK NOT NULL | Sortable timestamp-based ID (not commonCols.id) |
| metadata | jsonb | defaults to `{}` |
| createdAt | timestamp with tz NOT NULL | defaults to `now()` |
| updatedAt | timestamp with tz NOT NULL | defaults to `now()`, `$onUpdate(() => new Date())` |
| messageId | text NOT NULL | FK → messages.id (cascade) |
| sessionId | text NOT NULL **IMMUTABLE** | FK → sessions.id (cascade, denormalized for direct queries) — Never updated after creation. |
| type | text NOT NULL | Part type discriminator (see below) |
| data | jsonb NOT NULL | Type-specific content |
**Parts are immutable after creation.** `updatedAt` is set on creation but parts should never be updated. The `$onUpdate` hook from `commonCols` is a no-op for parts because insert-only operations don't trigger it. If a part needs correction, insert a new part (e.g., a correction or amendment) rather than updating an existing one. The `id` column uses a sortable ID scheme (not UUIDv4 from `commonCols`) because chronological ordering within a message is required — see the sortable ID note above.
**Part types and their `data` shapes**:
The `type` field determines the shape of `data`. Our part types are a subset of opencode's `MessageV2.Part` discriminated union, expanded with AI SDK compatibility types. The types we include are:
| type | Description | data shape |
|------|-------------|------------|
| `text` | Main text content (user or assistant) | `{ text: string, synthetic?: boolean, ignored?: boolean, time?: { start: number, end: number }, metadata?: Record<string, unknown> }` |
| `reasoning` | Chain-of-thought / extended thinking | `{ text: string, metadata?: Record<string, unknown>, time: { start: number, end: number } }` |
| `tool` | Tool invocation with lifecycle state | `{ callID: string, tool: string, state: ToolState }` — see below |
| `step-start` | Beginning of an agentic step | `{ snapshot?: string }` — git tree hash |
| `step-finish` | End of an agentic step with cost accounting | `{ reason: string, snapshot?: string, cost?: number, tokens: { input: number, output: number, reasoning?: number, cache?: { read: number, write: number } } }` |
| `file` | File attachment | `{ mime: string, filename?: string, url: string, source?: FileSource }` |
| `patch` | Git patch applied during tool execution | `{ hash: string, files: string[] }` |
| `snapshot` | Git tree hash reference | `{ snapshot: string }` |
| `agent` | Sub-agent delegation (e.g., @reviewer) | `{ name: string, source?: { value: string, start: number, end: number } }` |
| `compaction` | Context window compaction marker | `{ auto: boolean, overflow?: boolean }` |
**Tool state discriminated union** (`ToolState`):
```ts
type ToolState =
| { status: "pending", input: Record<string, unknown>, raw: string }
| { status: "running", input: Record<string, unknown>, title?: string, metadata?: Record<string, unknown>, time: { start: number } }
| { status: "completed", input: Record<string, unknown>, output: string, title: string, metadata: Record<string, unknown>, time: { start: number, end: number, compacted?: boolean }, attachments?: FilePartData[] }
| { status: "error", input: Record<string, unknown>, error: string, metadata?: Record<string, unknown>, time: { start: number, end: number } }
```
**File source types**:
```ts
type FileSource =
| { type: "file", path: string, text: { value: string, start: number, end: number } }
| { type: "symbol", path: string, name: string, kind: number, range: LSPLikeRange, text: { value: string, start: number, end: number } }
| { type: "resource", clientName: string, uri: string, text: { value: string, start: number, end: number } }
type FilePartData = {
mime: string;
filename?: string;
url: string;
source?: FileSource;
};
```
**AI SDK `UIMessage` compatibility**: The API assembles `UIMessage` from `messages` + `parts` via JOIN. The mapping is:
- `text` (not ignored) → `{ type: "text", text }`
- `file` (non-text, non-directory) → `{ type: "file", url, mediaType, filename }`
- `reasoning``{ type: "reasoning", text }`
- `step-start``{ type: "step-start" }`
- `tool` (completed) → `{ type: "tool-{name}", state: "output-available", toolCallId, input, output }`
- `tool` (error) → `{ type: "tool-{name}", state: "output-error", toolCallId, input, errorText }`
AI SDK part types not mapped to the UIMessage view: `step-finish`, `patch`, `snapshot`, `compaction`, `agent`. These are either internal SDK events (`step-finish`, `compaction`), tool-execution metadata handled within the `tool` part's state lifecycle (`patch`, `snapshot`), or session-level delegation (`agent`, handled via `sessions.parentId`). They are stored in the `parts` table but excluded from the `UIMessage` assembly.
**Why separate `parts` table**: Streaming individual part updates, publishing `message.part.updated` SSE events, and querying parts independently (e.g., "find all tool calls in this session") all require parts to be their own rows, not embedded in a message JSON blob. This is the same pattern opencode uses and it works well at scale (100k+ parts across 24k+ messages in production).
**Parts are flat** — there is no `parentId` column on parts. Sub-agent delegation is handled at the session level (via `sessions.parentId`), not by nesting parts. If nesting becomes necessary in the future, it would require a schema change (adding `parentId` to parts).
**Indexes**: `part_session_idx` on `(session_id)`, `part_message_id_id_idx` on `(message_id, id)` for efficient message loading, and `idx_parts_session_id_type` on `(session_id, type)` for queries like "all tool-call parts in session X".

View File

@@ -0,0 +1,92 @@
---
status: draft
last_updated: 2026-04-19
---
# Table Schemas: Spokes & Operations
Spoke registration and operation specification tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For spoke architecture, see [../../spoke-runner.md](../../spoke-runner.md).
### `spokes`
Spoke registrations. When a spoke connects to the hub via WebSocket, it calls `hub.register` with its details and operation list. The hub creates a spoke record and registers the operations. When the spoke disconnects, the record is updated with `status: "disconnected"`.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL | Spoke display name |
| status | text NOT NULL | Enum: `connected`, `disconnected`. Default: `connected` |
| spokeType | text NOT NULL | Spoke type: `dev-env`, `client`, `compute` |
| projectId | text | FK → projects.id (nullable — some spokes aren't project-scoped) |
| lastHeartbeat | timestamp with tz | Last heartbeat timestamp |
| hostInfo | jsonb | Host metadata (`{ os, arch, nodeVersion, memory, cpu }`) |
| connectedAt | timestamp with tz | When the spoke connected |
| disconnectedAt | timestamp with tz | When the spoke disconnected (null if still connected) |
**Indexes**: `idx_spokes_project_id` on `(projectId)`, `idx_spokes_status` on `(status)`, `idx_spokes_name` on `(name)` — look up spoke by name, `idx_spokes_active` partial on `(id)` WHERE `status = 'connected'` — efficiently find connected spokes.
**No `reconnecting` status**: Spoke reconnection is handled at the WebSocket layer, not in the database. When a spoke disconnects, its status becomes `disconnected`. When it reconnects, it's a new connection — the spoke row is updated back to `connected` with a new `connectedAt`. Transient reconnection attempts don't need a database state; they're a transport concern.
If monitoring of reconnection attempts is needed, use the call graph (a `hub.register` call from the spoke) or observability events (WebSocket reconnection logs), not a database status.
**No `capabilities` column on spokes**: A spoke's capabilities are its registered operations. Query `operation_registrations` filtered by `providerId` and `status = 'active'` to find what a connected spoke can do. The `operations` table holds the definitions. See ADR-006 in decisions/.
**Relationship to operations and registrations**: When a spoke calls `hub.register` with an operations list, the hub creates or finds `operations` rows (definitions) for each operation, then creates `operation_registrations` rows linking the spoke to those definitions. When the spoke disconnects, registrations are set to `inactive` but definitions persist. See the `operations` and `operation_registrations` tables below.
**Input mapping from `hub.register`**: The `hub.register` operation (see spoke-runner.md) accepts `{ spokeId, operations[], spokeType, project, hardware }`. This maps to the `spokes` table columns as: `spokeId``id`, `spokeType``spokeType`, `project``projectId` (looked up by project identifier), `hardware``hostInfo`. The `name` field may be derived from the spoke's configuration or provided separately. Each operation in `operations[]` maps to an `operations` row (definition, created or found by namespace+name) and an `operation_registrations` row (provider binding, linking the spoke to the definition).
### `operations`
Operation definitions — what an operation IS. These persist independently of spoke connections. Multiple providers can register the same operation (by namespace+name); they share the definition.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| namespace | text NOT NULL | Post-remap identifier (e.g., `dev.{spokeId}.fs.read`) |
| name | text NOT NULL | Operation name within namespace (e.g., `fs.read`, `call`) |
| type | text NOT NULL | `QUERY`, `MUTATION`, `SUBSCRIPTION` |
| description | text | Human-readable description |
| inputSchema | jsonb NOT NULL | TypeBox schema for input |
| outputSchema | jsonb | TypeBox schema for output |
| errorSchemas | jsonb | Array of error type schemas |
| accessControl | jsonb | Access control definition |
| tags | jsonb | String array for search/filter |
**Unique constraint**: `CREATE UNIQUE INDEX unq_operations_namespace_name ON operations (namespace, name)` — operation definitions are unique by namespace+name, regardless of how many providers register them.
**Indexes**: `idx_operations_namespace` on `(namespace)`, `idx_operations_type` on `(type)`.
### `operation_registrations`
Provider registrations — which spoke/client PROVIDES an operation right now. Ephemeral data: these reflect the current runtime state of who can handle a call.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| operationId | text NOT NULL | FK → operations.id (CASCADE — deleting a definition removes all its registrations) |
| providerType | text NOT NULL | `spoke` or `client` — which provider type |
| providerId | text NOT NULL | FK → spokes.id when providerType is `spoke`; FK → clients.id when providerType is `client` |
| preRemapNamespace | text | The original namespace before remapping (e.g., `dev` for `dev.{spokeId}.fs.read`). Stored for traceability. |
| preRemapName | text | The original name before remapping |
| status | text NOT NULL | `active` or `inactive`. Default: `active`. Set to `inactive` on disconnect, re-activated on reconnect. |
| metadata | jsonb | Provider-specific metadata (version, health, latency hints) |
**Unique constraint**: `CREATE UNIQUE INDEX unq_operation_registrations_active ON operation_registrations (operationId, providerType, providerId) WHERE status = 'active'` — only one active registration per provider per operation.
**Indexes**: `idx_operation_registrations_operation_id` on `(operationId)`, `idx_operation_registrations_provider_id` on `(providerId)`, `idx_operation_registrations_status` on `(status)`.
**Spoke registration lifecycle**: When a spoke connects and registers:
1. Creates/updates the `spokes` row
2. For each operation the spoke provides:
- Creates or finds the `operations` row (by namespace+name). If this is a new spoke instance providing a known operation, the definition already exists.
- Creates an `operation_registrations` row linking the spoke to the operation definition, with `status: 'active'` and the pre-remap identifiers.
When a spoke disconnects:
1. Updates the `spokes` row to `status: "disconnected"`
2. Sets all the spoke's `operation_registrations` rows to `status: "inactive"`
3. Aborts in-flight calls via call protocol cascading
4. Operation definitions (in `operations`) are **never deleted on disconnect** — they persist for audit and potential reconnection.
When an admin deletes a spoke row (rare):
1. `operation_registrations` with that `providerId` are CASCADE deleted (ephemeral data, follows D1 cascade policy for ephemeral config)
2. If no other registrations exist for an operation, its definition may be cleaned up separately

View File

@@ -0,0 +1,267 @@
---
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<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 | 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)
```

View File

@@ -0,0 +1,445 @@
---
status: draft
last_updated: 2026-05-18
---
# Storage: Tasks & Task Dependencies
Tasks are the unit of work in the Spec-Driven Development (SDD) process. The **database is the source of truth** for task data at runtime. Markdown files serve as the **authoring surface** for the Decomposer role and the `taskgraph` CLI — they are ingested into the DB via a sync operation and can be exported back for offline analysis.
For the overall storage pattern, see [README.md](./README.md). For cross-cutting table reference (common columns, cascade behavior, index reference, status enums, relations), see [table-reference.md](./table-reference.md). For design decisions, see [../../decisions/](../../decisions/).
## Overview
### Why Database as Source of Truth
Taskgraph's file-based model works well for single-agent, single-worktree workflows. In the hub's multi-agent, multi-worktree environment, files create problems:
- **Parallel worktrees**: Agent A marks a task `in-progress` in their worktree's file. Agent B can't see this — the file lives in A's working directory. The coordinator can't get a consistent view.
- **Reliable coordination**: The coordinator needs to query "which tasks are pending?" and "what's blocking task X?" at runtime without scanning filesystems across worktrees.
- **Atomic status updates**: An agent calling `hub.task.updateStatus` gets an immediate, transactional state change visible to all other agents and the coordinator.
The database is the authoritative, queryable, concurrent-safe representation. Files are the authoring format.
### Relationship to taskgraph CLI
The `taskgraph` CLI operates on markdown files. Its value is in **offline analysis**`topo`, `cycles`, `parallel`, `critical`, `bottleneck`, `risk-path`, `decompose`. These commands depend on categorical fields (`scope`, `risk`, `impact`, `level`) being assessed.
The workflow is:
1. **Author** — Decomposer creates/edits markdown files using `taskgraph init` and direct editing
2. **Sync** — Files are ingested into the DB (files → DB)
3. **Execute** — Coordinator and agents query and mutate the DB via hub operations
4. **Analyze** — When needed, export from DB to files, run `taskgraph risk-path` etc.
The taskgraph CLI is not required at runtime. The hub uses **@alkdev/taskgraph** for runtime graph operations (topological sort, cycle detection, parallel groups, critical path, risk analysis) — see [Graphology Integration](#graphology-integration-runtime-graph-ops).
## Task Authority Model
| Aspect | Authority | Why |
|--------|-----------|-----|
| Task structure (all fields) | **DB** | Queryable, concurrent-safe, consistent |
| Task specification (body) | **DB** (`body` column) | Stored as markdown text; agents append notes during execution |
| Task authoring/creation | **Files** → sync → DB | Decomposer edits files; sync ingests them |
| Runtime status mutations | **DB** (hub operations) | `hub.task.*` operations — coordinator and agents call these |
| Offline graph analysis | **Files** (taskgraph CLI) | Export from DB when needed for `taskgraph risk-path` etc. |
See [Field Authority Split](#field-authority-split) for the explicit list of authored vs runtime-managed fields.
## Field Authority Split
Fields are split into two categories based on who writes them:
### Authored Fields (upserted by file sync)
These fields are written by the Decomposer/file sync. The `ON CONFLICT DO UPDATE SET` clause in the sync upsert includes **only** these columns:
| Field | DB Column |
|-------|-----------|
| id | `slug` |
| name | `name` |
| (project) | `projectId` |
| (directory path) | `path` |
| scope | `scope` |
| risk | `risk` |
| impact | `impact` |
| level | `level` |
| priority | `priority` |
| tags | `tags` |
| assignee | `assignee` |
| due | `dueAt` |
| (body) | `body` |
| created | `fileCreatedAt` |
| modified | `fileModifiedAt` |
| depends_on | `task_dependencies` table |
**Note**: `projectId` is set from the project context during sync (the task file's location within a project's `tasks/` directory determines the project), not from taskgraph frontmatter. `commonCols` fields (`id`, `metadata`, `createdAt`, `updatedAt`) are DB-generated and not part of the sync conflict domain.
### Runtime-Managed Fields (mutated via `hub.task.*` operations only)
These fields are never overwritten by sync. They are only mutated by hub operations (`hub.task.updateStatus`, `hub.task.addNote`, etc.):
| Field | DB Column | Set By |
|-------|-----------|--------|
| status | `status` | `hub.task.updateStatus` |
| (started timestamp) | `startedAt` | `hub.task.updateStatus` (on `in-progress`) |
| (completed timestamp) | `completedAt` | `hub.task.updateStatus` (on `completed`) |
> **Warning**: Sync must never write `status`, `startedAt`, or `completedAt` — these are owned by hub operations. The sync upsert uses `ON CONFLICT DO UPDATE SET` only for authored fields; runtime fields are excluded from the SET clause.
## Field Mapping: taskgraph Frontmatter → DB Columns
Every field in taskgraph's `TaskFrontmatter` struct maps to a dedicated DB column. No frontmatter fields are relegated to JSONB `metadata`.
| taskgraph Field | DB Column | Type | Notes |
|---|---|---|---|
| `id` | `slug` | text NOT NULL | Direct mapping. No transformation. `slug` is taskgraph-compatible, used in `depends_on` references. |
| `name` | `name` | text NOT NULL | Direct mapping |
| `status` | `status` | text NOT NULL, enum | Direct mapping: `pending`, `in-progress`, `completed`, `failed`, `blocked`. Default: `pending`. |
| `depends_on` | `task_dependencies` table | — | Each element creates a row: `depends_on[i]``dependsOnTaskId`, task → `dependentTaskId` |
| `scope` | `scope` | text, enum | `single`, `narrow`, `moderate`, `broad`, `system`. **Nullable** — NULL = not yet assessed. |
| `risk` | `risk` | text, enum | `trivial`, `low`, `medium`, `high`, `critical`. **Nullable** — NULL = not yet assessed. |
| `impact` | `impact` | text, enum | `isolated`, `component`, `phase`, `project`. **Nullable** — NULL = not yet assessed. |
| `level` | `level` | text, enum | `planning`, `decomposition`, `implementation`, `review`, `research`. **Nullable** — NULL = not yet assessed. |
| `priority` | `priority` | text, enum | `low`, `medium`, `high`, `critical`. Nullable. |
| `tags` | `tags` | text[] | String array. Default `{}`. |
| `assignee` | `assignee` | text | Assigned agent or person. Nullable. |
| `due` | `dueAt` | timestamp with tz | Renamed from `due` for DB convention. Nullable. |
| `created` | `fileCreatedAt` | timestamp with tz | Frontmatter `created` field. Separate from DB `createdAt` (row creation time). Nullable — frontmatter may not include it. |
| `modified` | `fileModifiedAt` | timestamp with tz | Frontmatter `modified` field. Separate from DB `updatedAt` (row update time). Nullable. |
| (body) | `body` | text | Markdown content after frontmatter. Nullable — empty body is valid. |
| (directory path) | `path` | text | Logical grouping prefix: `architecture`, `implementation/storage`. Nullable — tasks created via API with no file origin have no path. See [Path Semantics](#path-semantics). |
| (project) | `projectId` | text NOT NULL | FK → projects.id |
### Table Schemas
### `tasks`
SDD task definitions. The database is the source of truth for task data at runtime. Markdown files serve as the authoring surface for the Decomposer and taskgraph CLI — they are ingested into the DB via a sync operation. Every field in taskgraph's `TaskFrontmatter` struct maps to a dedicated DB column (no frontmatter fields in `metadata` JSONB).
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| projectId | text NOT NULL | FK → projects.id (cascade) — tasks belong to a project |
| slug | text NOT NULL | taskgraph `id` — kebab-case identifier used in `depends_on` references. Unique within a project. |
| name | text NOT NULL | Human-readable task name (from frontmatter `name`) |
| path | text | Logical grouping prefix derived from filesystem location (e.g., `architecture`, `implementation/storage`). Nullable — tasks created via API with no file origin have no path. Enables `WHERE path LIKE 'implementation/%'` for scoped queries. |
| status | text NOT NULL | Enum: `pending`, `in-progress`, `completed`, `failed`, `blocked`. Default: `pending`. Status transitions go through hub operations, not file edits. |
| scope | text | Categorical scope: `single`, `narrow`, `moderate`, `broad`, `system`. **Nullable** — NULL = not yet assessed. See [Why Categorical Fields Are Nullable](#why-categorical-fields-are-nullable-not-not-null-with-defaults). |
| risk | text | Categorical risk: `trivial`, `low`, `medium`, `high`, `critical`. **Nullable** — NULL = not yet assessed. |
| impact | text | Categorical impact: `isolated`, `component`, `phase`, `project`. **Nullable** — NULL = not yet assessed. |
| level | text | Task level: `planning`, `decomposition`, `implementation`, `review`, `research`. **Nullable** — NULL = not yet assessed. |
| priority | text | Priority: `low`, `medium`, `high`, `critical`. Nullable. |
| assignee | text | Assigned agent or person. Nullable. |
| dueAt | timestamp with tz | Due date (from frontmatter `due`). Nullable. |
| tags | text[] | Filtering tags. Default `{}`. GIN index for array-contains queries. |
| body | text | Markdown task specification (from file body after frontmatter). Nullable — empty body is valid. Agents may append notes during execution. |
| fileCreatedAt | timestamp with tz | Frontmatter `created` field — file creation time from the markdown. Separate from DB `createdAt` (row creation time). Nullable. |
| fileModifiedAt | timestamp with tz | Frontmatter `modified` field — file modification time from the markdown. Separate from DB `updatedAt` (row update time). Nullable. |
| startedAt | timestamp with tz | When status became `in-progress`. Set by hub operation, not by agent. |
| completedAt | timestamp with tz | When status became `completed`. Set by hub operation. |
**Unique constraint**: `unq_tasks_project_slug` UNIQUE on `(projectId, slug)` — task slugs are unique within a project.
**pgEnum Definitions**: The following enum columns use PostgreSQL `pgEnum` for type safety. Drizzle's `pgEnum` generates named PostgreSQL enums and provides TypeScript type inference. The enum values are aligned with taskgraph's categorical fields.
```ts
export const taskStatus = pgEnum("task_status", ["pending", "in-progress", "completed", "failed", "blocked"]);
export const taskScope = pgEnum("task_scope", ["single", "narrow", "moderate", "broad", "system"]);
export const taskRisk = pgEnum("task_risk", ["trivial", "low", "medium", "high", "critical"]);
export const taskImpact = pgEnum("task_impact", ["isolated", "component", "phase", "project"]);
export const taskLevel = pgEnum("task_level", ["planning", "decomposition", "implementation", "review", "research"]);
export const taskPriority = pgEnum("task_priority", ["low", "medium", "high", "critical"]);
```
The decomposer template should consume these same enum definitions to ensure DB-level constraints match the application-level typing.
**Indexes**: `idx_tasks_project_id` on `(projectId)`, `idx_tasks_project_status` on `(projectId, status)` — composite for "find all pending tasks in project X", `idx_tasks_status` on `(status)`, `idx_tasks_active` partial on `(projectId)` WHERE `status IN ('pending', 'in-progress', 'blocked')` — efficiently find active tasks, `idx_tasks_path` on `(path)` **with `text_pattern_ops`** — locale-independent LIKE pattern matching for path prefix queries (e.g., `WHERE path LIKE 'implementation/%'`), `idx_tasks_priority` on `(priority)`, `idx_tasks_assignee` on `(assignee)`, `idx_tasks_due_at` on `(dueAt)`, `idx_tasks_tags` GIN on `(tags)` — for array-contains queries (`tags @> '{security}'`).
**`slug` semantics**: From taskgraph frontmatter `id` field. Kebab-case identifiers like `auth-setup`, `storage-tasks-table`. Appears in `depends_on` arrays.
**`path` semantics**: Nullable — tasks created via API with no filesystem origin have no path. When set, captures the logical grouping derived from the `tasks/` directory structure. E.g., a file at `tasks/implementation/storage/tasks-table.md` gets `path: "implementation/storage"`. Enables `WHERE path LIKE 'implementation/%'` (scoped queries) without requiring a `parentId` FK. This replaces the previous `parentId` column — grouping is a path concern, not a tree relationship.
**No `parentId` column**: Grouping is handled by `path`, dependencies by `task_dependencies`. A "meta task" is just a regular task that depends on its sub-tasks — no special entity type needed.
**No `removedAt` column**: When a task file is removed, the sync operation DELETEs the DB row. Git history preserves the file-level history; the DB doesn't need to duplicate it with soft deletes. FK cascade handles cleanup.
**`metadata` JSONB**: Reserved for truly ad-hoc data not in the taskgraph schema. No taskgraph frontmatter fields are stored here — all have proper columns.
### `task_dependencies`
Dependency edges between tasks. Directed: a row means the dependent task depends on the prerequisite task (prerequisite must complete before dependent can start). Mirrors the taskgraph `depends_on` relationship.
| Column | Type | Notes |
|--------|------|-------|
| commonCols | — | id, metadata, createdAt, updatedAt |
| dependsOnTaskId | text NOT NULL | FK → tasks.id (cascade) — The prerequisite task (must complete first) |
| dependentTaskId | text NOT NULL | FK → tasks.id (cascade) — The dependent task (waits for prerequisite) |
**Unique constraint**: `unq_task_dependencies_depends_on_task` UNIQUE on `(dependsOnTaskId, dependentTaskId)` — no duplicate dependency edges.
**Indexes**: `idx_task_dependencies_depends_on_task_id` on `(dependsOnTaskId)` — "what depends on this task?", `idx_task_dependencies_dependent_task_id` on `(dependentTaskId)` — "what does this task depend on?".
**Direction**: `dependentTaskId` is the task that has the dependency. `dependsOnTaskId` is the prerequisite task. Together they form a directed edge: `dependentTaskId``dependsOnTaskId` meaning "task dependentTaskId depends on task dependsOnTaskId". In the graph, there's an edge from `dependsOnTaskId``dependentTaskId` (prerequisite → dependent). This gives correct topological order: prerequisites before dependents.
**Cross-project dependency guard**: `taskId` and `dependsOnTaskId` MUST reference tasks within the same project. The application layer enforces this constraint — creating a dependency between tasks in different projects is rejected with a validation error. This is not enforced at the DB level (FK constraints allow cross-project references), so the application must check project consistency before insert.
A future DB-level guard could use a trigger: `BEFORE INSERT ON task_dependencies` that checks `NEW.taskId` and `NEW.dependsOnTaskId` reference tasks in the same project. This is deferred to Phase 2 — the application-layer check is sufficient for now.
**Sync source**: Dependency edges are authored in task file frontmatter (`depends_on: [other-task]`) and synced to this table during the file → DB sync operation. The sync clears and re-inserts all edges for a task on each run — dependencies are fully replaced by the sync, not merged or modified at runtime.
## Why ALL Frontmatter Fields Get Proper Columns
ADR-001 establishes the pattern: "separate structured columns for high-query, high-filter fields." For tasks, **every** taskgraph frontmatter field is queryable and filterable in the coordinator's workflow:
- `priority` — "show me high-priority pending tasks" (coordinator prioritization)
- `assignee` — "which tasks are assigned to agent X?" (work assignment)
- `dueAt` — "which tasks are due this week?" (deadline tracking)
- `tags` — "filter by tag" (cross-cutting concerns)
Shoving these into `metadata` JSONB loses type safety, indexability, and SQL queryability — exactly the problems the database is meant to solve. The `metadata` JSONB column (from `commonCols`) is reserved for truly ad-hoc data that isn't in the taskgraph schema.
### Why Categorical Fields Are Nullable (Not NOT NULL with Defaults)
The previous design made `scope`, `risk`, `impact`, and `level` NOT NULL with defaults (`narrow`, `low`, `isolated`, `implementation`). This conflated two states:
- **Assessed as `low`** — the Decomposer evaluated this and determined the risk is low
- **Not assessed** — nobody filled this in
Hiding the distinction with defaults means the coordinator can't distinguish a deliberate assessment from a gap. NULL is the correct signal for "not yet assessed."
Taskgraph itself makes these fields `Option<TaskScope>`, `Option<TaskRisk>`, etc. — nullable. The DB should match the source model.
**Application-layer handling**: When `scope`, `risk`, `impact`, or `level` is NULL, the coordinator should:
- Warn that the task hasn't been assessed
- Exclude it from cost-benefit analysis (you can't compute risk-path without risk values)
- Suggest the Decomposer assess it
For @alkdev/taskgraph operations that need numeric weights, provide fallbacks at the application layer (e.g., treat NULL risk as `low` for topo sort, but warn).
## Path Semantics
The `path` column captures the logical grouping of tasks, derived from their location in the `tasks/` directory hierarchy:
```
tasks/
├── architecture/
│ ├── auth-design.md → path: "architecture"
│ └── storage-overview.md → path: "architecture"
├── research/
│ └── embedding-approach.md → path: "research"
└── implementation/
├── storage/
│ ├── tasks-table.md → path: "implementation/storage"
│ └── relations.md → path: "implementation/storage"
└── auth/
└── oauth-flow.md → path: "implementation/auth"
```
**`path` is nullable** because tasks created at runtime via hub operations (not synced from files) have no filesystem origin.
**`path` enables scoped queries**:
- `WHERE path = 'architecture'` — all architecture tasks
- `WHERE path LIKE 'implementation/%'` — all implementation tasks
- `WHERE path = 'implementation/storage'` — storage implementation tasks
This is a prefix-based grouping mechanism. It replaces `parentId` (which was not in the taskgraph model and conflated organizational grouping with dependency ordering).
**Locale sensitivity**: The `path` column uses `text` type with the database's default collation. LIKE pattern matching (`WHERE path LIKE 'implementation/%'`) is collation-sensitive. For case-sensitive matching (recommended for task paths which use lowercase), use `COLLATE "C"` or ensure the default collation is `C`/`POSIX`. Alternatively, use `text_pattern_ops` operator class for the index: `CREATE INDEX idx_tasks_path ON tasks (path text_pattern_ops)` which enables `LIKE` and `~` pattern matching regardless of collation.
## Grouping vs Dependencies
**There is no `parentId` column.** Task grouping and dependency ordering are separate concepts:
- **Grouping** — `path` column. "This task belongs to the `implementation/storage` group." Enables scoped queries. Derived from filesystem layout during sync.
- **Dependencies** — `task_dependencies` table. "This task cannot start until that task completes." Enables topological sort, cycle detection, critical path. Derived from `depends_on` frontmatter.
A "meta task" (e.g., "implement storage") is simply a task that `depends_on` all its sub-tasks. There is no special entity type — it's regular task + dependency edges. The coordinator picks up the meta task as an assignment, and the implementation specialist works through sub-tasks in dependency order.
**Why not `parentId`**: `parentId` was invented in a previous doc revision but has no basis in the taskgraph data model. It created confusion:
- Redundant with `task_dependencies` (a meta task's dependencies ARE its sub-tasks)
- Required a fragile "inference from directory structure" during sync
- Violated the invariant that the DB schema mirrors the taskgraph frontmatter model
## Relationship to Existing Tables
### `mappings` Table
The `mappings` table links sessions to coordinators, spokes, and worktrees. A `taskId` column references the task a mapping is assigned to:
```ts
taskId: text REFERENCES tasks(id) // FK to tasks
task: text // denormalized display name (e.g., task.slug or task.name)
```
This preserves the quick-reference pattern (coordinators can list mappings with task names without a JOIN) while maintaining referential integrity.
### `projects` Table
Tasks belong to a project via `tasks.projectId`. A project's tasks live in the project's `tasks/` directory. Cross-project task dependencies are not supported — tasks can only depend on other tasks within the same project. This is enforced at the application level (see task_dependencies cross-project guard).
### `sessions` Table
Sessions are linked to tasks indirectly through `mappings`. When the coordinator spawns a session for a meta task:
1. The task row already exists in `tasks` (synced from file or created via API)
2. Creates a `sessions` row for the implementation specialist
3. Creates a `mappings` row with `taskId` pointing to the meta task
## Task Status Lifecycle
```
pending → in-progress → completed
↘ failed → in-progress (retry)
↘ blocked → in-progress (unblocked)
```
| Status | Meaning |
|--------|---------|
| `pending` | Task exists, not yet started |
| `in-progress` | A session is actively working on this task |
| `completed` | Task finished successfully |
| `failed` | Task failed, may retry (Safe Exit protocol) |
| `blocked` | Task is blocked by an unmet dependency or external issue |
Status transitions go through **hub operations** (`hub.task.updateStatus`), not file edits. This ensures:
- All agents see consistent state immediately
- The coordinator can query "which tasks are pending?" reliably
- No merge conflicts from parallel file edits
Timestamp columns `startedAt` and `completedAt` track when a task entered `in-progress` and `completed` states respectively. These are set by the hub operation, not by the agent.
## Task Notes (Append-Only)
Agents may need to add notes to a task during execution (observations, partial progress, blockers encountered). For v1, this is handled by **appending markdown to the `body` column**:
```markdown
## Task Description (original)
Implement the tasks table with Drizzle-TypeBox pattern...
## Implementation Notes
- 2026-04-19: Started with table definition, commonCols pattern works
- 2026-04-19: Hit issue with text[] type for tags — need to check Drizzle support
```
The `hub.task.addNote` operation appends a timestamped note section to the end of `body`. This is simple, preserves the full context in one place, and requires no additional tables.
**Concurrency model for `hub.task.addNote`**: Notes are appended to the task `body` field using **DB-level concatenation**: `UPDATE tasks SET body = COALESCE(body, '') || $note WHERE id = $taskId`. This avoids read-modify-write cycles entirely — the append is atomic at the SQL level, eliminating race conditions between concurrent agents.
As a fallback for scenarios where DB-level concatenation isn't feasible, **optimistic locking via `updatedAt`** can be used: read the current `updatedAt`, append the note, and `UPDATE WHERE updatedAt = readValue`. If the row was updated between read and write, the UPDATE affects 0 rows and the operation must be retried. This is sufficient for the expected low-contention scenario (one agent at a time writing notes to a task).
For high-contention scenarios (multiple agents writing simultaneously), consider a separate `task_notes` table with `INSERT` operations instead of UPDATE appends.
If structured, multi-agent notes become necessary later, a dedicated `task_notes` table can be added. The `body` append pattern doesn't preclude this — it's additive.
## Why Categorical Estimates Matter
The `scope`, `risk`, `impact`, and `level` fields are not cosmetic metadata — they are what make taskgraph's analysis commands produce useful results. The cost-benefit framework (see taskgraph framework docs) demonstrates a structural property: **upstream failures multiply downstream damage**.
These fields power:
- **`taskgraph decompose`** — flags tasks where `risk > medium` or `scope > moderate`
- **`taskgraph risk-path`** — finds the highest cumulative risk path
- **`taskgraph critical`** — finds completion blockers
- **`taskgraph bottleneck`** — finds high-betweenness tasks
Without them, you just get topological sort — useful, but not structurally insightful. The DB columns for these fields are **nullable** (NULL = not assessed) rather than NOT NULL with defaults, because the distinction between "deliberately assessed as `low`" and "nobody filled this in" is itself valuable information for the coordinator.
## Graphology Integration (Runtime Graph Ops)
For runtime graph operations, the hub uses **`@alkdev/taskgraph`** — a TypeScript package that wraps graphology and provides a high-level `TaskGraph` class plus analysis functions. The CLI (`taskgraph`) is for offline authoring and analysis; the TS package is for runtime use.
The approach:
1. Load all `tasks` + `task_dependencies` rows for a project from the DB
2. Build a `TaskGraph` via `TaskGraph.fromRecords(tasks, edges)`
3. Run analysis functions as needed: `criticalPath()`, `parallelGroups()`, `bottlenecks()`, `riskPath()`, `shouldDecomposeTask()`, `workflowCost()`
This works because realistic task graphs are small — typically 1050 tasks, rarely exceeding 200 even on large projects. Building a graph from DB rows is instant at this scale (`TaskGraph.fromRecords` with 100 nodes reconstructs in <5ms).
`@alkdev/taskgraph` exports:
- **`TaskGraph`** — construction (fromTasks, fromRecords, fromJSON), mutation (addTask, removeTask, addDependency, updateTask), queries (hasCycles, findCycles, topologicalOrder, dependencies, dependents, getTask), validation (validateSchema, validateGraph), export
- **Analysis functions** — criticalPath, weightedCriticalPath, parallelGroups, bottlenecks, riskPath, riskDistribution, calculateTaskEv, workflowCost, shouldDecomposeTask
- **Schema types** — TaskScope, TaskRisk, TaskImpact, TaskLevel, TaskPriority, TaskStatus enums with TypeBox schemas
- **Frontmatter** — parseFrontmatter, serializeFrontmatter (YAML + markdown)
- **Error classes** — TaskgraphError, CircularDependencyError, TaskNotFoundError, etc.
**Why not taskgraph NAPI for v1**: The Rust CLI (`taskgraph`) is for offline authoring and analysis. The TypeScript package (`@alkdev/taskgraph`) handles all runtime graph operations. Graphology is a transitive dependency through `@alkdev/taskgraph` and handles < 200 nodes trivially. NAPI is unnecessary at realistic scales.
## Sync Flow
```
┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
│ Decomposer │ │ taskgraph CLI │ │ Hub DB │
│ creates .md │──────►│ validates │──────►│ tasks table │
│ files │ │ analyzes │ │ task_dependencies │
└──────────────┘ └───────────────┘ └──────────────────┘
┌────────┴─────────┐
│ Hub operations │
│ hub.task.* │
│ (status, notes) │
└────────────────────┘
```
### Sync: Files → DB
The sync operation runs as a **single database transaction**:
1. **Begin transaction**
2. Scan `tasks/` directory for markdown files
3. Parse frontmatter (YAML) + body (markdown) from each file. `@alkdev/taskgraph` provides `parseFrontmatter()` and `serializeFrontmatter()` for YAML+markdown parsing. `parseTaskFile()` and `parseTaskDirectory()` are Node.js only (use `node:fs/promises`); for Deno, use `parseFrontmatter()` with Deno file I/O.
4. Upsert into `tasks` table (matches by `(projectId, slug)`)
5. For each task, `DELETE FROM task_dependencies WHERE dependentTaskId = ?` then `INSERT` the current edges — dependency edges are fully replaced, not merged, because the files own the dependency declarations
6. **Commit transaction**
If any step fails, the entire sync rolls back — no partial updates.
**Concurrency**: Only one sync should run at a time. The Decomposer triggers sync after creating/updating task files. No concurrent sync mechanism is needed for v1.
**Deleted files**: When a task file is removed from `tasks/`, the sync operation **deletes** the corresponding DB row. Git history preserves the full file-level history — the DB doesn't need to duplicate it with soft deletes. FK cascade handles cleanup (`task_dependencies` rows, `mappings.taskId` SET NULL).
### DB → Files (Export)
When graph analysis is needed, export DB rows back to markdown files:
1. Query `tasks` + `task_dependencies` for a project
2. For each task, generate markdown with YAML frontmatter + body
3. Write to `tasks/` directory structure (using `path` to determine subdirectory)
4. Run `taskgraph validate`, `taskgraph risk-path`, etc.
This is a manual step — "I want to run analysis now" — not an automatic sync.
### Sync Error Handling
| Error | Behavior |
|-------|----------|
| Invalid YAML frontmatter | Skip file, log warning with file path and parse error. Continue with remaining files. |
| Missing required `id` or `name` field | Skip file, log warning. Task cannot be synced without these fields. |
| `depends_on` references non-existent slug within project | Insert the dependency edge anyway (dangling reference). The coordinator detects and warns about unresolvable dependencies. `taskgraph validate` should be run before sync to catch these. |
| Duplicate `id` (slug) in same project | Fail the sync with a clear error. Slug uniqueness is enforced by the DB constraint `unq_tasks_project_slug`. |
| File removed from filesystem | DELETE the DB row. FK cascade handles dependent rows. Git preserves history. |
**Validation ordering**: Run `taskgraph validate` before sync to catch structural errors (cycles, missing dependencies, duplicate IDs) at the CLI level. The DB sync then handles data-level integrity (unique constraints, FK checks).
## Open Questions
1. **Embeddings**: Task descriptions may benefit from vector embeddings for similarity search. Deferred — the `metadata` JSONB column can hold an embedding reference later, or a separate `task_embeddings` table can be added.
2. **Bulk status updates**: When the coordinator completes a meta task (all sub-tasks done), should it automatically mark the meta task `completed`? Likely yes — this is an application-level operation, not a DB concern.
3. **Cross-project dependencies**: Not supported. Tasks can only depend on other tasks within the same project. Application-layer validation rejects cross-project dependencies; a future DB-level trigger guard is deferred to Phase 2 (see task_dependencies cross-project guard).
4. **Task versioning**: When a task's body is modified (e.g., notes appended), should we keep previous versions? For v1, no — the current body is sufficient. If audit trail is needed, `updatedAt` timestamp + `metadata` revision count could suffice.
## References
- Cost-benefit framework: taskgraph framework docs — why categorical estimates are structurally required
- Workflow guide: taskgraph workflow docs — practical usage patterns
- Task file format: @alkdev/taskgraph README — field definitions
- TaskFrontmatter struct: @alkdev/taskgraph package source — canonical field types and defaults
- taskgraph architecture: taskgraph architecture docs
- Storage pattern: [README.md](./README.md)
- Table reference (cross-cutting): [table-reference.md](./table-reference.md)
- ADR-011: [../../decisions/ADR-011-dual-task-representation.md](../../decisions/ADR-011-dual-task-representation.md)
- @alkdev/taskgraph (runtime graph engine): `@alkdev/taskgraph` npm package