Files
storage/docs/architecture/sqlite-host.md
glm-5.1 ae242f33b9 Restructure identity tables: separate credential types, add peer_credentials, specify FK cascades and indexes
Identity tables were derived from hub's PostgreSQL schema but simplified
without documenting what was removed or why. This restructures them for the
current auth landscape (API key + wraith SSH/cert-authority):

- ADR-049: Separate api_keys and peer_credentials tables (different lookup
  patterns, columns, lifecycles), remove Gitea columns, map hub data→metadata
- ADR-050: Extract SHA-256 vs KDF decision from inline spec text
- Add peer_credentials table for SSH key and cert-authority auth
- Specify all FK cascade behaviors within system DB (RESTRICT, CASCADE, SET NULL)
- Complete index specifications for all identity tables
- Add scope boundary section (storage owns schemas, not auth/authorization)
- Update audit_logs with credentialId+credentialType polymorphic reference
- Add 3 new open questions (OQ-33/34/35) for credential type expansion
2026-06-02 12:33:20 +00:00

600 lines
39 KiB
Markdown

---
status: draft
last_updated: 2026-06-02
---
# SQLite Host
The SQLite database host for `@alkdev/storage`. Uses Drizzle ORM with Honker
for database operations, pub/sub, event streams, and task queues. TypeBox
schemas are generated from Drizzle table definitions via `src/sqlite/utils/`
(folded from `@alkdev/dbtype`/`@alkdev/drizzlebox`, ADR-046).
## Overview
The SQLite host provides:
1. **Metagraph tables** — 6 tables for graph types, node types, edge types,
graphs, nodes, and edges (ADR-002)
2. **Identity tables** — accounts, organizations, organization_members, api_keys,
audit_logs for multi-tenant authentication and authorization (ADR-041)
3. **Drizzle relations** for the relational query API
4. **TypeBox schemas** generated from Drizzle tables (select/insert
validation) via `src/sqlite/utils/` (folded from @alkdev/drizzlebox, ADR-046)
5. **Drizzle-Honker adapter** — thin session adapter for Honker integration
(ADR-044, POC validated)
6. **HonkerEventTarget** — pubsub `TypedEventTarget` on Honker primitives
(ADR-047, POC validated)
7. **Client factories**`createSystemDatabase(client)` and
`createTenantDatabase(client)` for the system/tenant DB model (ADR-040)
## Package Structure
```
src/sqlite/
├── tables/
│ ├── common.ts # commonCols
│ ├── identity/
│ │ ├── accounts.ts # accounts table + select/insert schemas
│ │ ├── organizations.ts # organizations table + select/insert schemas
│ │ ├── organization_members.ts # org membership + select/insert schemas
│ │ ├── api_keys.ts # API key credentials + select/insert schemas
│ │ ├── peer_credentials.ts # SSH key / cert-authority credentials + select/insert schemas
│ │ ├── audit_logs.ts # audit trail + select/insert schemas
│ │ └── index.ts # barrel re-export
│ ├── metagraph/
│ │ ├── graphTypes.ts # graph_types table + select/insert schemas
│ │ ├── nodeTypes.ts # node_types table + select/insert schemas
│ │ ├── edgeTypes.ts # edge_types table + select/insert schemas
│ │ ├── graphs.ts # graphs table + select/insert schemas
│ │ ├── nodes.ts # nodes table + select/insert schemas
│ │ ├── edges.ts # edges table + select/insert schemas
│ │ └── index.ts # barrel re-export
│ └── index.ts # barrel re-export
├── utils/ # folded from @alkdev/dbtype Phase 0 (ADR-046)
│ ├── schema.ts # createSelectSchema, createInsertSchema, createUpdateSchema
│ ├── column.ts # Column→TypeBox mappings (SQLite-only dispatch)
│ ├── types.ts # Public + internal TypeScript interfaces
│ ├── constants.ts # Integer range constants
│ └── utils.ts # isColumnType, isWithEnum, type helpers
├── relations.ts # Drizzle relational mappings
├── adapter.ts # Drizzle-Honker session adapter
├── event-target.ts # HonkerEventTarget (pubsub TypedEventTarget on Honker)
├── schema.ts # re-exports all tables + relations
└── client.ts # createSystemDatabase(), createTenantDatabase()
```
## Common Columns
All tables share these columns:
```ts
{
id: text("id").primaryKey(),
metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown>>().default({}),
createdAt: integer("created_at", { mode: "timestamp" })
.default(sql`(strftime('%s', 'now'))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.default(sql`(strftime('%s', 'now'))`)
.notNull(),
}
```
- `id` is a consumer-generated UUID text PK (no `$defaultFn`)
- `metadata` is an extension namespace following `_subsystem.key` convention
- `createdAt`/`updatedAt` are Unix epoch integers with timestamp mode
- No `$onUpdate` — consumers must set `updatedAt` explicitly
## Metagraph Tables
### `graph_types`
| Column | Type | Constraints | Notes |
| ----------- | ------------------- | ----------------------- | ------------------------------------------------------------ |
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| name | text | not null, **unique** | Graph type name (e.g., "call-graph", "acl") |
| description | text | default `""` | Human-readable description |
| config | text (JSON) | not null | `GraphConfig` — directed/undirected/mixed, multi, self-loops |
| version | integer | not null, default 1 | Breaking schema version (ADR-029) |
| scope | text | not null, default `"system"` | `system` / `tenant` / `user` (ADR-043) |
The `scope` column (ADR-043) controls who can create and modify graph type
definitions. System-scoped types (`acl`, `call-graph`) are seeded at setup time
and cannot be modified through the repository API.
### `node_types`
| Column | Type | Constraints | Notes |
| ----------- | ------------------- | -------------------------------------- | ---------------------------------------- |
| id | text | PK | |
| metadata | text (JSON) | default `{}` | |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| graphTypeId | text | not null, FK → graphTypes.id (cascade) | Parent graph type |
| name | text | not null | Node type name (e.g., "call", "account") |
| description | text | default `""` | |
| schema | text (JSON) | not null | TypeBox schema for node attributes |
**Unique constraint**: `(graphTypeId, name)` — node type names are unique within
a graph type.
### `edge_types`
| Column | Type | Constraints | Notes |
| ------------------ | ------------------- | -------------------------------------- | ---------------------------------------------- |
| id | text | PK | |
| metadata | text (JSON) | default `{}` | |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| graphTypeId | text | not null, FK → graphTypes.id (cascade) | Parent graph type |
| name | text | not null | Edge type name (e.g., "triggered", "can_read") |
| description | text | default `""` | |
| schema | text (JSON) | not null | TypeBox schema for edge attributes |
| allowedSourceTypes | text (JSON) | default `[]` | Node type names valid at source endpoint |
| allowedTargetTypes | text (JSON) | default `[]` | Node type names valid at target endpoint |
**Unique constraint**: `(graphTypeId, name)`.
**Empty array semantics**: `[]` means "no restriction" — any node type is valid.
### `graphs`
| Column | Type | Constraints | Notes |
| ----------- | ------------------- | --------------------------------------------- | ---------------------------------------------- |
| id | text | PK | |
| metadata | text (JSON) | default `{}` | |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| graphTypeId | text | FK → graphTypes.id (set null) | Set null on graph type deletion (orphan graph) |
| name | text | not null | Graph instance name |
| description | text | default `""` | |
| status | text | not null, enum: `active`, `archived`, `draft` | Default: `draft` |
| ownerId | text | nullable | Logical reference to accounts.id (ADR-042) |
| projectId | text | nullable | Logical reference to project identity (ADR-042) |
**Scoping columns** (ADR-042): `ownerId` and `projectId` are logical references
to entities in the system DB (accounts, projects). No FK constraint because the
referenced tables live in a different database file. The consumer enforces
referential integrity at the application layer.
No `orgId` column — the tenant DB file itself IS the org scope (ADR-040).
**Indexes**: `idx_graphs_owner_id` on `(ownerId)`, `idx_graphs_project_id` on
`(projectId)`, `idx_graphs_owner_id_project_id` on `(ownerId, projectId)`.
**On `graphTypeId` set null**: Orphan graphs cannot validate their node/edge
types against a missing type definition. The application should prevent graph
type deletion if active graphs reference it.
### `nodes`
| Column | Type | Constraints | Notes |
| ---------- | ------------------- | ---------------------------------- | --------------------------------------------- |
| id | text | PK | |
| metadata | text (JSON) | default `{}` | |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| graphId | text | not null, FK → graphs.id (cascade) | Parent graph |
| key | text | not null | Consumer-defined identity within the graph |
| attributes | text (JSON) | not null, default `{}` | Node attributes validated by node type schema |
**Unique constraint**: `(graphId, key)` — node keys are unique within a graph.
**No `nodeTypeId` column**: ADR-020.
### `edges`
| Column | Type | Constraints | Notes |
| ------------- | ------------------- | ---------------------------------- | ---------------------------------------------------- |
| id | text | PK | |
| metadata | text (JSON) | default `{}` | |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| graphId | text | not null, FK → graphs.id (cascade) | Parent graph |
| key | text | | Consumer-defined identity (null for anonymous edges) |
| sourceNodeKey | text | not null | Source node key within the graph |
| targetNodeKey | text | not null | Target node key within the graph |
| attributes | text (JSON) | not null, default `{}` | Edge attributes validated by edge type schema |
| undirected | integer (boolean) | default false | Treat as undirected regardless of graph type |
**Unique constraint**: `(graphId, key)`.
**Foreign keys**: `sourceNodeKey` and `targetNodeKey` reference
`(nodes.graphId, nodes.key)` with cascade delete (ADR-022).
## Identity Tables
Identity tables live in the **system DB** (ADR-040, ADR-041). They provide
multi-tenant authentication and authorization infrastructure. Storage owns the
table schemas and FK constraints; it does not own authentication logic,
authorization rules, key lifecycle, or credential verification — those are
consumer concerns.
The identity schemas are derived from the hub's PostgreSQL identity tables
(ADR-049). Gitea-specific columns are removed (git hosting integration is a
consumer concern, modeled in metagraph instances or consumer metadata). The
hub's `data` JSONB columns map to `commonCols.metadata` (same extension
namespace, `_subsystem.key` convention).
### Scope Boundary
Storage's identity tables provide **persistence and structural constraints**.
Consumer concerns NOT in storage's scope:
- Key generation, hashing, and verification (keypal, wraith handle this)
- Authentication protocol flow (hub/wraith handle this)
- Authorization and scope evaluation (ACL graph + operations enforce this)
- Account lifecycle policy (when to suspend, deactivate, transfer ownership)
- Key rotation and revocation orchestration
- Session and connection management
### `accounts`
| Column | Type | Constraints | Notes |
|-------------|---------------------|----------------------------------------|-------------------------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace (`_subsystem.key`). Replaces hub's `data` JSONB column (ADR-049). Account preferences, profile data. |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| email | text | not null, **unique** | Primary identifier. Service accounts may use deployment-configured reserved patterns. |
| displayName | text | | Display name |
| accessLevel | text | not null, default `user` | `admin`, `user`, `service` |
| status | text | not null, default `active` | `active`, `suspended`, `deactivated` |
**`accessLevel` semantics**: `admin` manages all resources across
organizations. `user` manages own resources and org-scoped resources. `service`
is an automated account (LLM workers, spoke credentials, CI tokens) — no git
hosting link required.
**`status` semantics**: `active` can authenticate. `suspended` is admin-locked
(security hold). `deactivated` is user-initiated shutdown. Suspended and
deactivated accounts retain owned resources (RESTRICT FK) but cannot
authenticate.
**Indexes**: `unq_accounts_email` UNIQUE on `(email)`,
`idx_accounts_access_level` on `(accessLevel)`,
`idx_accounts_status` on `(status)`.
No `giteaUsername` column — git hosting integration is a consumer concern
(ADR-049). When needed, store git associations in `metadata` or a metagraph
instance.
### `organizations`
| Column | Type | Constraints | Notes |
|--------|---------------------|----------------------------------------|-------------------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace. Replaces hub's `data` JSONB column (ADR-049). Org settings, billing data. |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| name | text | not null, **unique** | Organization name |
| slug | text | not null, **unique** | URL-friendly identifier |
| ownerId | text | not null, FK → accounts.id (**RESTRICT**) | Administrative/transferable owner. Cannot delete owner account while org exists. Transfer ownership first. |
**`ownerId` semantics**: The administrative owner of the organization. This
account MUST also have `membershipLevel: 'owner'` in `organization_members`
(enforced by consumer). To change the owner, the consumer calls a transfer
ownership operation that: (1) validates the new owner has `membershipLevel:
'owner'`, (2) updates `ownerId`, (3) optionally demotes the old owner's
membership level. RESTRICT cascade prevents deleting the owner account while
the org exists.
**Indexes**: `unq_organizations_name` UNIQUE on `(name)`,
`unq_organizations_slug` UNIQUE on `(slug)`,
`idx_organizations_owner_id` on `(ownerId)`.
**Dual ownership representation**: `organizations.ownerId` and
`organization_members.membershipLevel: 'owner'` both represent ownership. The
column exists for efficient lookup (a single indexed read for "who owns this
org?") and RESTRICT FK semantics (cannot delete the owner account while the
org exists). The membership row exists for relational queries ("list all
owners of this org"). The consumer-enforced invariant is: `ownerId` always
references an account that also has `membershipLevel: 'owner'` in
`organization_members`. The consumer must maintain this invariant on
membership changes and ownership transfers.
No `giteaOrgName` column — git hosting integration is a consumer concern
(ADR-049).
### `organization_members`
| Column | Type | Constraints | Notes |
|-----------------|---------------------|----------------------------------------|--------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| orgId | text | not null, FK → organizations.id (**CASCADE**) | Org deletion removes memberships |
| accountId | text | not null, FK → accounts.id (**CASCADE**) | Account deletion removes memberships |
| membershipLevel | text | not null | `owner`, `admin`, `member` |
**Unique constraint**: `(orgId, accountId)` — one membership per account per org.
**`membershipLevel` semantics**: `owner` has full control including member
management. `admin` can manage projects and members. `member` can access org
resources. Distinct from `organizations.ownerId``membershipLevel` is
runtime access control; `ownerId` is the administrative/transferable owner.
This table is the authoritative source for org membership (ADR-045). The ACL
graph's `BelongsToEdge` is derived from it — when membership changes, the
consumer writes the SQL row first, then creates or removes the ACL edge.
**Indexes**: `unq_org_members_org_account` UNIQUE on `(orgId, accountId)`,
`idx_org_members_account_id` on `(accountId)`,
`idx_org_members_org_id` on `(orgId)`.
### `api_keys`
API key credentials for bearer token authentication. The client sends a raw
key; the consumer hashes it and looks up by `keyHash`. Storage does not
perform hashing or verification — that is a consumer concern (keypal, hub).
| Column | Type | Constraints | Notes |
|------------|---------------------|----------------------------------------|------------------------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace. Scope data: `metadata.scopes` (`string[]`), `metadata.resources` (`Record<string, string[]>`), `metadata.tags` (`string[]`). Consumer provides the adapter (e.g., `HubKeyStorage` for keypal). Scopes remain in metadata rather than as native columns because scope schemas vary by consumer — keypal uses colon-separated hierarchies, other consumers may differ. |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| ownerId | text | not null, FK → accounts.id (**CASCADE**) | Account deletion removes API keys |
| keyHash | text | not null, **unique** | SHA-256 hash of raw key. Never stores raw key. |
| name | text | | Human-readable key label |
| enabled | integer | not null, default 1 | Immediate disable switch (1 = enabled, 0 = disabled) |
| expiresAt | integer (timestamp) | | When the key expires (null = never) |
| revokedAt | integer (timestamp) | | When the key was revoked (null = active). Permanent. |
| rotatedToId | text | | Self-reference to `api_keys.id` — the key that replaced this one (null if not rotated). |
| lastUsedAt | integer (timestamp) | | Last authentication time. Null if never used. |
**Key lifecycle states**: enabled+not expired = active. enabled+expired =
rejected. disabled = rejected regardless of expiration. revoked = permanently
disabled regardless of enabled/expiry.
**Rotation**: When a key is rotated, the consumer creates a new `api_keys` row
and sets the old key's `rotatedToId` to the new key's id. The old key's
`revokedAt` is set at the same time. This provides an audit trail of key
rotation without requiring a separate rotation history table.
**SHA-256 rationale**: API keys are high-entropy machine-generated strings
(128-bit+). Brute-force against SHA-256 is infeasible for such inputs. Slow
KDFs (bcrypt, Argon2) are unnecessary for machine keys — they add latency
without meaningful security improvement. (ADR-050)
**Indexes**: `unq_api_keys_key_hash` UNIQUE on `(keyHash)`,
`idx_api_keys_owner_id` on `(ownerId)`,
`idx_api_keys_enabled` on `(enabled)`,
`idx_api_keys_active` on `(ownerId)` WHERE `revokedAt IS NULL AND enabled = 1`
### `peer_credentials`
SSH key and certificate-authority credentials for wraith transport
authentication. The client presents an Ed25519 public key or OpenSSH
certificate; the consumer validates against the stored fingerprint. Storage
does not perform SSH authentication — that is a consumer concern (wraith,
hub).
| Column | Type | Constraints | Notes |
|-----------------|---------------------|----------------------------------------|------------------------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace. Cert data: `metadata.principals` (`string[]`), `metadata.restrictions` (`string[]`), `metadata.caFingerprint` (`string`, for cert-authority entries only). |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| ownerId | text | not null, FK → accounts.id (**CASCADE**) | Account deletion removes peer credentials |
| credentialType | text | not null | `ssh_key`, `cert_authority` |
| fingerprint | text | not null, **unique** | Ed25519 key fingerprint (SHA-256, OpenSSH format) |
| publicKeyData | text | not null | Full public key in OpenSSH format (`ssh-ed25519 AAAA...`) |
| name | text | | Human-readable label |
| enabled | integer | not null, default 1 | Immediate disable switch |
| expiresAt | integer (timestamp) | | When the credential expires (null = never). Certificates carry expiry; standalone keys typically don't. |
| revokedAt | integer (timestamp) | | When the credential was revoked (null = active). |
**`credentialType` semantics**: `ssh_key` is an individual public key. The
consumer verifies the key against known fingerprints. `cert_authority` is a
trusted CA public key. The consumer validates certificates signed by this CA
against the stored fingerprint. Both types share the same lookup pattern
(present fingerprint → find by fingerprint → check owner + enable + expiry +
revocation), which is why they share a table.
**Adding new credential types** (ADR-049): Credential types sharing the same
lookup column as `peer_credentials` (fingerprint-based) add a new
`credentialType` value to this table. Credential types requiring different
lookup columns warrant their own table. (OQ-33) Current types assume Ed25519
only; additional SSH key types may require `credentialType` expansion.
**Fingerprint format**: OpenSSH SHA-256 fingerprint (base64, no prefix). Used
for lookup during SSH authentication. The `publicKeyData` column stores the
full key for reconstruction/verification when needed.
**Indexes**: `unq_peer_credentials_fingerprint` UNIQUE on `(fingerprint)`,
`idx_peer_credentials_owner_id` on `(ownerId)`,
`idx_peer_credentials_credential_type` on `(credentialType)`,
`idx_peer_credentials_active` on `(ownerId)` WHERE `revokedAt IS NULL AND enabled = 1`
### `audit_logs`
Append-only audit trail for security-relevant events. The consumer (hub)
writes entries for key operations, authentication events, membership changes,
and other auditable actions. The consumer is responsible for reading and
displaying audit data.
| Column | Type | Constraints | Notes |
|----------------|---------------------|----------------------------------------|------------------------------------------------------|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default `{}` | Extension namespace. Session context: `metadata.sessionId` (when relevant). |
| createdAt | integer (timestamp) | not null, default `now` | |
| updatedAt | integer (timestamp) | not null, default `now` | |
| action | text | not null | `created`, `revoked`, `rotated`, `enabled`, `disabled`, `login`, `access_denied` |
| ownerId | text | not null, FK → accounts.id (**RESTRICT**) | The identity performing the action. RESTRICT prevents account deletion when audit entries exist — deactivate instead. |
| credentialId | text | | Logical reference to api_keys.id or peer_credentials.id (nullable — not all events are credential-related). |
| credentialType | text | | `api_key`, `peer_credential`, or null. Discriminator for `credentialId` — tells the consumer which table to look up. |
| orgId | text | FK → organizations.id (**SET NULL**) | Organization context. Null for personal actions. Set null on org deletion to preserve audit trail. |
| details | text (JSON) | | Action-specific context (IP, user agent, scope changes, etc.) |
**`action` enum is extensible**: The initial set covers API 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 by consumers as those features are implemented.
**`credentialId` + `credentialType` polymorphic reference**: Replaces the
previous `keyId` column (API key only). The pair allows audit entries to
reference either credential table. No FK constraint — the consumer resolves
the table based on `credentialType` (ADR-049).
**`orgId` FK with SET NULL**: Unlike `credentialId` (polymorphic, no single
target table), `orgId` always references `organizations.id` within the same
system.db. A real FK with `SET NULL` preserves the audit trail on org deletion
(nulling the org reference without deleting the audit entry) while enforcing
referential integrity at the database level rather than relying on consumer
discipline.
**Indexes**: `idx_audit_logs_owner_id` on `(ownerId)`,
`idx_audit_logs_credential_id` on `(credentialId)`,
`idx_audit_logs_action` on `(action)`,
`idx_audit_logs_created_at` on `(createdAt)`,
`idx_audit_logs_org_id` on `(orgId)`.
### FK Cascade Behavior (System DB)
All identity table FKs are intra-database (same system.db file). Real
constraints apply, not logical references.
| Relationship | onDelete | Rationale |
|-------------|----------|-----------|
| organizations.ownerId → accounts.id | RESTRICT | Cannot delete owner account while org exists. Transfer ownership first. |
| organization_members.orgId → organizations.id | CASCADE | Org deletion removes memberships |
| organization_members.accountId → accounts.id | CASCADE | Account deletion removes memberships |
| api_keys.ownerId → accounts.id | CASCADE | Account deletion removes API keys |
| peer_credentials.ownerId → accounts.id | CASCADE | Account deletion removes peer credentials |
| audit_logs.ownerId → accounts.id | RESTRICT | Audit integrity — deactivate accounts instead of deleting. Preserves accountability. |
| audit_logs.orgId → organizations.id | SET NULL | Org deletion preserves audit trail (org reference nulled, entry retained). |
Polymorphic references (no FK, consumer resolves):
`audit_logs.credentialId``api_keys.id` or `peer_credentials.id`
(disambiguated by `audit_logs.credentialType`).
Cross-DB logical references (no FK, different database file):
`graphs.ownerId``accounts.id`, `graphs.projectId` → project identity
(ADR-042). Consumer enforces referential integrity at application layer.
## Relations
### System DB Relations
- **accounts → organizations**: one-to-many (via `organizations.ownerId`)
- **accounts → organization_members**: one-to-many (via `organization_members.accountId`)
- **accounts → api_keys**: one-to-many (via `api_keys.ownerId`)
- **accounts → peer_credentials**: one-to-many (via `peer_credentials.ownerId`)
- **accounts → audit_logs**: one-to-many (via `audit_logs.ownerId`)
- **organizations → organization_members**: one-to-many (via `organization_members.orgId`)
### Tenant DB Relations
- **graphTypes → nodeTypes**: one-to-many
- **graphTypes → edgeTypes**: one-to-many
- **graphTypes → graphs**: one-to-many
- **graphs → nodes**: one-to-many
- **graphs → edges**: one-to-many
- **nodes → outgoing edges** (sourceNode): one-to-many
- **nodes → incoming edges** (targetNode): one-to-many
## Client Factories
### `createSystemDatabase(client)`
Creates a Drizzle database instance with the identity schema (accounts,
organizations, organization_members, api_keys, peer_credentials, audit_logs) attached.
```ts
import { createSystemDatabase } from "@alkdev/storage/sqlite";
import { open } from "@russellthehipp/honker-node";
const honkerClient = open("system.db");
const db = createSystemDatabase(honkerClient);
// Drizzle typed queries
const admins = db.select().from(accounts).where(eq(accounts.accessLevel, "admin"));
// Honker features on the same connection
db.$client.notify("account:created", { accountId: "user-1" });
```
### `createTenantDatabase(client)`
Creates a Drizzle database instance with the metagraph schema (graph_types,
node_types, edge_types, graphs, nodes, edges) attached.
```ts
import { createTenantDatabase } from "@alkdev/storage/sqlite";
import { open } from "@russellthehipp/honker-node";
const honkerClient = open("tenant-acme.db");
const db = createTenantDatabase(honkerClient);
// Drizzle typed queries
const activeGraphs = db.select().from(graphs).where(eq(graphs.status, "active"));
// Transactional: insert node + notify in one commit
db.transaction((tx) => {
tx.insert(nodes).values({ graphId, key: "call-1", attributes: {} }).run();
tx.$honkerTx.notify("nodes:created", { graphId, key: "call-1" });
});
```
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [038](decisions/038-sqlite-first-pg-removed.md) | SQLite-first, PG removed | Single database host |
| [039](decisions/039-honker-as-sqlite-extension.md) | Honker as SQLite extension | DB + pub/sub + queues in one file |
| [040](decisions/040-system-db-tenant-db.md) | System DB + tenant DB | Identity in system.db, graphs in tenant-{orgId}.db |
| [041](decisions/041-identity-tables-in-storage.md) | Identity tables in storage | accounts, organizations, api_keys, peer_credentials, audit_logs |
| [049](decisions/049-identity-schema-restructuring.md) | Identity schema restructuring | Separate credential tables, remove Gitea, data→metadata, FK cascades |
| [050](decisions/050-sha256-for-api-key-hashing.md) | SHA-256 for API keys | Fast hash for high-entropy machine keys, not slow KDF |
| [042](decisions/042-scoping-columns-on-graphs.md) | Scoping columns on graphs | `ownerId`, `projectId` on `graphs` table |
| [043](decisions/043-graph-type-scope.md) | Graph type scope | `system` / `tenant` / `user` scope on `graph_types` |
| [044](decisions/044-drizzle-honker-adapter.md) | Drizzle-Honker adapter | ~100-line session adapter, POC validated |
| [045](decisions/045-org-members-authoritative-belongsto-derived.md) | org_members authoritative | SQL table is source of truth; BelongsToEdge is derived |
| [046](decisions/046-fold-drizzlebox-as-utils.md) | Fold drizzlebox as utils | SQLite-only column mappings in src/sqlite/utils/ |
| [047](decisions/047-honker-event-target.md) | HonkerEventTarget | pubsub TypedEventTarget on Honker |
| [048](decisions/048-operation-specs-as-repo-surface.md) | OperationSpecs as repo surface | Table-defined operation contracts |
| [019](decisions/019-json-text-for-schema-columns.md) | JSON text for schema columns | SQLite uses `text` with JSON mode |
| [020](decisions/020-no-nodetypeid-on-nodes.md) | No nodeTypeId on nodes | Node type enforced at application layer |
| [022](decisions/022-composite-fks-for-node-references.md) | Composite FKs for node refs | Edges reference `(graphId, sourceNodeKey)` |
| [008](decisions/008-common-columns-pattern.md) | Common columns pattern | `id`, `metadata`, `createdAt`, `updatedAt` |
## Removed: `actors` Table
The `actors` table is removed per ADR-035. `ACTOR_TYPE` is replaced by the
`IdentityType` enum in the AclGraph Module. Identity data lives in the
`accounts` table (system DB) and `PrincipalNode` in ACL graph instances
(tenant DB).
## Removed: PostgreSQL Porting Notes
PostgreSQL is no longer a target (ADR-038). All porting notes from the previous
version of this document are obsolete. The single database host is SQLite via
Honker.
## Concurrency Model
Honker opens databases in WAL mode with a bounded reader pool and single writer
slot. This handles the expected concurrency for the hub use case:
- **Reader pool**: Up to `maxReaders` (default 4) concurrent read connections.
`db.$client.query()` uses the pool automatically.
- **Writer slot**: Single exclusive writer, acquired by `transaction()`. If the
slot is occupied, subsequent `transaction()` calls block until released.
- **Write timeout**: Honker sets `busy_timeout=5000` by default. Configurable at
`open()` time.
- **WAL mode**: Enables concurrent reads during writes. Required by Honker's
reader pool architecture.
For multi-process deployments, set WAL mode and ensure the busy timeout is
sufficient for expected lock contention.
## References
- Honker source: `/workspace/honker/`
- Honker Node binding: `/workspace/honker/packages/honker-node/`
- Hub identity tables (provenance): `/workspace/@alkdev/hub/docs/architecture/storage/identity.md`
- Operations AccessControl: `/workspace/@alkdev/operations/docs/architecture/api-surface.md`
- Source: `src/sqlite/`