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