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
600 lines
39 KiB
Markdown
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/` |