Files
storage/docs/architecture/acl.md
glm-5.1 6aa2fcc6ff Architect storage around SQLite+Honker: remove PG, add multi-tenant identity, scoping
Reorient @alkdev/storage around a single SQLite database host with Honker
for pub/sub, event streams, and task queues. PostgreSQL is removed as a
target (ADR-038), eliminating dual schema maintenance and infrastructure
complexity. Honker provides DB + pubsub + queues in one .db file (ADR-039).

Add system/tenant DB model (ADR-040): identity tables in system.db, all
graph data in tenant-{orgId}.db files. Identity tables move from the hub
into storage (ADR-041). Scoping columns (ownerId, projectId) added to
graphs table (ADR-042). Graph types get scope (system/tenant/user) to
protect infrastructure schemas (ADR-043).

Define Drizzle-Honker session adapter (ADR-044): ~100-line adapter enabling
Drizzle typed queries and Honker pubsub/queue on a single connection with
transactional consistency.

Resolve OQ-03, OQ-04, OQ-19, OQ-21, OQ-22, OQ-23, OQ-24. Add new
open questions OQ-26 through OQ-29 for Honker integration specifics.

New docs: honker-integration.md (adapter, event patterns, migration).
Scrub all PG/jsonb/libsql references from existing spec docs.
2026-05-31 15:41:41 +00:00

620 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-05-31
---
# Access Control Graph
How access control is modeled as a metagraph, how it integrates with
`@alkdev/operations`' `Identity` and `AccessControl`, and how the
principal-agent framework maps to graph relationships.
## Purpose
Access control in the alk.dev platform needs to serve two distinct but
overlapping audiences:
1. **Setup-time** — A hub/spoke administrator defines which operations exist,
what their access requirements are, and which principal-agent relationships
are valid. This is the *schema* of authorization.
2. **Runtime** — When a session calls an operation, the system resolves whether
that specific call is permitted given the caller's identity, their scope
within the org, and any delegation chain involved. This is the *evaluation*
of authorization.
Both can be expressed as graphs. The setup-time definitions (operations, org
structures, trust relationships) become graph types — schema-level declarations
of what kinds of nodes and edges are valid. The runtime instances (who has
delegated to whom, which org has which scope over which resource) become graph
instances — concrete data that the authorization evaluator traverses.
This document defines:
- The **AclGraph Module** — a TypeBox `Type.Module` that defines the node and
edge types for access control
- The **ACL graph instance model** — how principal-agent relationships,
org-scoped permissions, and operation-level access control are represented as
graph instances
- The **relationship between setup-time and runtime** — how `OperationSpec`
definitions seed ACL graph types, and how `Identity` is evaluated at runtime
- The **principal-agent hierarchy** — how liability flows upward and permissions
flow downward through delegation edges
- The **scoping model** — how org membership and resource access are graph edges
## Design Decisions
| ADR | Decision | Status |
|-----|----------|--------|
| [034](decisions/034-acl-as-metagraph.md) | ACL is a metagraph, not domain-specific tables | Accepted |
| [035](decisions/035-actors-become-acl-nodes.md) | Actors become node types in ACL and org graphs, standalone table removed | Accepted |
| [036](decisions/036-principal-agent-as-delegation-edges.md) | Principal-agent relationships are `delegates` edges with scope narrowing | Accepted |
| [037](decisions/037-setup-vs-runtime-separation.md) | Setup-time definitions seed graph types; runtime instances are separate graphs | Accepted |
**Sub-decisions** (documented inline, not separate ADRs):
- **`identityType` discriminator over separate node types**: A single
`PrincipalNode` with an `identityType` field is used instead of separate
`AccountNode`, `OrgNode`, `ServiceNode`, `RoleNode` types. This avoids
node type proliferation and keeps the AclGraph Module compact. The type
discriminator drives evaluation branching rather than requiring separate
Module entries for each identity type.
- **AclGraph Config: directed, no multi, no self-loops**: Delegation and
scoping are inherently directional. Multi-edges between the same pair
(delegating different scope sets) carry no additional information — a
single edge with `narrowedScopes` attributes is sufficient. Self-loops
would represent self-delegation, which is semantically meaningless.
## Two Kinds of Authorization Scopes
A distinction that makes ACL tractable:
| Aspect | Setup-Time (Schema) | Runtime (Data) |
|--------|---------------------|----------------|
| What it is | Operation definitions and their `AccessControl` requirements | Who can do what, where, under whose authority |
| Source | `OperationSpec.accessControl` registered in the operations registry | ACL graph instances, org memberships, delegation chains |
| Stored as | Operation rows in the hub's `operations` table | Nodes and edges in ACL graph instances |
| Mutability | Changes when operations are registered/updated | Changes when org membership, delegation, or scoping changes |
| Evaluation | None — it's declarative | Traversed at call time to resolve permissions |
**Setup-time** answers: "What operations *could* require?" — it defines the
`AccessControl` schema per operation.
**Runtime** answers: "Does *this specific caller* have permission for *this
specific call*?" — it traverses the ACL graph instance.
The ACL graph type captures **runtime** authorization data. Setup-time
definitions live in `@alkdev/operations` and the hub's `operations` table. They
are related but distinct: setup-time defines the *rules*, runtime instances
store the *facts* about who follows those rules.
## The AclGraph Module
### Graph Type Configuration
The ACL graph type is **directed**, does not allow multi-edges, and does not
allow self-loops. Delegation and scoping are inherently directional (principal
→ agent, org → member). Self-delegation and multi-edges between the same pair
don't carry additional information — a single `delegates` edge with narrowed
scope attributes is sufficient.
```ts
Config: Type.Composite([
Metagraph.Import("Config"),
Type.Object({
type: Type.Literal("directed"),
multi: Type.Literal(false),
allowSelfLoops: Type.Literal(false),
}),
]),
```
### Node Types
| Node Type | Purpose | Key Attributes |
|-----------|---------|----------------|
| `PrincipalNode` | An identity that can delegate authority | `identityId`, `identityType`, `scopes`, `resources` |
| `ResourceNode` | A resource that can be scoped | `resourceType`, `resourceId` |
#### `PrincipalNode`
Represents an entity that holds authority and can delegate it. This is the
node-level equivalent of `@alkdev/operations`' `Identity` interface, extended
with the `identityType` concept needed for the principal-agent framework.
```ts
PrincipalNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
identityId: Type.String({ minLength: 1, maxLength: 255 }),
identityType: Type.Union([
Type.Literal("account"),
Type.Literal("service"),
Type.Literal("org"),
Type.Literal("role"),
]),
scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
}),
]),
```
- `identityId` references the account, service account, org, or role by its
canonical identifier. This is **not** a foreign key — it's a logical reference
to an entity in another system (the hub's `accounts` or `organizations` table,
or a role name in the `roles` table). The ACL graph stores the shape; the hub
stores the authoritative identity records. (See ADR-034.)
- `identityType` determines what kind of entity this principal represents. This
matters for evaluation: an `org` principal aggregates its members' scopes, a
`role` principal is a named set of scopes, an `account` principal is a human
or service identity.
- `scopes` and `resources` map directly from `@alkdev/operations`' `Identity`
interface. They represent the **base authority** of this principal before
any delegation narrowing.
- `OrgNode` and `AccountNode` are **not separate node types**. The
`identityType` discriminator on `PrincipalNode` avoids proliferating node
types for entities that are structurally identical (they all hold scopes
and resources). When evaluation needs entity-specific behavior (e.g., org
account aggregation), the `identityType` drives branching, not the node
type.
#### `ResourceNode`
Represents a resource that permission scopes can reference. Resources are the
targets of `AccessControl.resourceType` and `AccessControl.resourceAction`
projects, spokes, workspaces, or any entity that an operation scopes to.
```ts
ResourceNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
resourceType: Type.String({ minLength: 1, maxLength: 255 }),
resourceId: Type.String({ minLength: 1, maxLength: 255 }),
}),
]),
```
- `resourceType` maps to `AccessControl.resourceType` (e.g., `"project"`,
`"spoke"`, `"workspace"`).
- `resourceId` is the canonical identifier for the specific resource instance.
Together, `resourceType:resourceId` forms the key for
`Identity.resources` lookups.
### Edge Types
| Edge Type | Source → Target | Purpose |
|-----------|-----------------|---------|
| `DelegatesEdge` | PrincipalNode → PrincipalNode | Principal delegates (a subset of) authority to agent |
| `ScopesEdge` | PrincipalNode → ResourceNode | Principal has specific access to a resource |
| `BelongsToEdge` | PrincipalNode → PrincipalNode (identityType org) | Account/service is a member of an org |
#### `DelegatesEdge`
The core of the principal-agent framework. When principal P delegates to agent
A, a `DelegatesEdge` is created from P → A with `narrowedScopes` attributes
that represent the subset of P's authority transferred to A.
```ts
DelegatesEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("delegates"),
narrowedScopes: Type.Array(Type.String()),
narrowedResources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
}),
]),
DelegatesEdgeConstraints: Type.Object({
edgeType: Type.Literal("delegates"),
allowedSourceTypes: Type.Array(Type.Literal("Principal")),
allowedTargetTypes: Type.Array(Type.Literal("Principal")),
}),
```
- `narrowedScopes` is the **subset** of the principal's scopes that are
delegated. It MUST be a subset — an agent cannot receive more authority than
the principal holds. Enforcement happens at edge creation time: the evaluator
validates that `narrowedScopes` ⊆ principal's effective scopes.
- `narrowedResources` is an optional narrowing of resource-scoped permissions.
If absent, the agent inherits all of the principal's resource scoping. If
present, it must be a subset of the principal's effective resources.
- The **no-escalation invariant**: a delegation chain must never grant an agent
higher privilege than any ancestor. This is enforced by the evaluator at
edge creation time and at call-time evaluation. See Principal-Agent Framework
below.
#### `ScopesEdge`
Links a principal to a resource they have specific access to. This is the
graph-level representation of `Identity.resources`.
```ts
ScopesEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("scopes"),
actions: Type.Array(Type.String()),
}),
]),
ScopesEdgeConstraints: Type.Object({
edgeType: Type.Literal("scopes"),
allowedSourceTypes: Type.Array(Type.Literal("Principal")),
allowedTargetTypes: Type.Array(Type.Literal("Resource")),
}),
```
- `actions` maps to `AccessControl.resourceAction` values. For example,
`["read", "write"]` on a `ScopesEdge` from an account principal to a project
resource means that account has read+write access to that project.
- This edge type directly mirrors `Identity.resources` where the key format
`"type:id"` maps to `resourceType:resourceId` on the target `ResourceNode`,
and the value array maps to `actions`.
#### `BelongsToEdge`
Declares that an account (or service) is a member of an organization. This is
the graph-level representation of the hub's `organization_members` table.
```ts
BelongsToEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("belongs_to"),
membershipLevel: Type.Union([
Type.Literal("owner"),
Type.Literal("admin"),
Type.Literal("member"),
]),
}),
]),
BelongsToEdgeConstraints: Type.Object({
edgeType: Type.Literal("belongs_to"),
allowedSourceTypes: Type.Array(Type.Literal("Principal")),
allowedTargetTypes: Type.Array(Type.Literal("Principal")),
sourceTypeConstraint: Type.Optional(Type.Literal("account")),
targetTypeConstraint: Type.Optional(Type.Literal("org")),
}),
```
- `membershipLevel` maps directly from the hub's `organization_members.membershipLevel`.
- The target must be a PrincipalNode with `identityType: "org"`.
- The source is typically a `PrincipalNode` with `identityType: "account"` or
`"service"`.
- `sourceTypeConstraint` and `targetTypeConstraint` are **optional metadata**
on the edge constraints. They are NOT enforced by the metagraph schema
(which validates attribute schemas, not `identityType` values). They serve
as documentation for evaluation logic and can be validated at the
application layer. This is consistent with ADR-020 (no nodeTypeId on
nodes): the metagraph validates structural constraints (which node types
can be source/target), not domain semantics (which identityType values are
valid for a given edge type).
### Complete AclGraph Module
```ts
export const AclGraph = Type.Module({
Config: /* directed, no multi, no self-loops */,
PrincipalNode: /* identityId, identityType, scopes, resources */,
ResourceNode: /* resourceType, resourceId */,
DelegatesEdge: /* delegates with narrowedScopes, narrowedResources */,
DelegatesEdgeConstraints: /* Principal → Principal */,
ScopesEdge: /* scopes with actions */,
ScopesEdgeConstraints: /* Principal → Resource */,
BelongsToEdge: /* belongs_to with membershipLevel */,
BelongsToEdgeConstraints: /* account/service → org */,
MembershipLevel: Type.Union([
Type.Literal("owner"),
Type.Literal("admin"),
Type.Literal("member"),
]),
IdentityType: Type.Union([
Type.Literal("account"),
Type.Literal("service"),
Type.Literal("org"),
Type.Literal("role"),
]),
});
```
## Principal-Agent Framework
### Core Principle
The principal-agent framework comes from legal theory: a **principal** delegates
authority to an **agent**, and the principal bears responsibility for the
agent's actions within the scope of delegation. Two rules follow:
1. **Liability flows upward**: If an agent makes a mistake, the principal who
delegated to them is responsible. This chain continues upward through
delegation levels.
2. **Permissions flow downward**: An agent's effective permissions are always
a subset of their principal's permissions. Delegation can only narrow scope,
never expand it.
### As a Directed Graph
In the ACL graph, delegation is a `DelegatesEdge` from principal → agent:
```
User (principal)
│ delegates [{ scopes: ["dev:*"], narrowedResources: { "project:alpha": ["read", "write"] } }]
Coordinator (agent of User)
│ delegates [{ scopes: ["dev.fs.read", "dev.fs.write"], narrowedResources: { "project:alpha": ["read"] } }]
Implementer (agent of Coordinator)
```
Each `DelegatesEdge` carries `narrowedScopes` — what the principal is passing
down. The no-escalation invariant means:
- If User has `scopes: ["admin", "dev:*"]`, the Coordinator can only receive a
subset: `narrowedScopes: ["dev:*"]`
- If the Coordinator has effective `scopes: ["dev:*"]`, the Implementer can
only receive `narrowedScopes: ["dev.fs.read", "dev.fs.write"]`
- At no point can the Implementer have higher privilege than User
### Evaluation Algorithm
Given a call with `Identity { id, scopes, resources }` and an operation's
`AccessControl { requiredScopes, requiredScopesAny, resourceType, resourceAction }`:
1. **Check scopes (AND)**: Every scope in `requiredScopes` must be in the
identity's effective scopes.
2. **Check scopes (OR)**: At least one scope in `requiredScopesAny` must be in
the effective scopes.
3. **Check resource**: If `resourceType` and `resourceAction` are set, the
identity's `resources["resourceType:resourceId"]` must include
`resourceAction`.
The **effective scopes** for an identity are computed by traversing the ACL
graph:
```
effectiveScopes(identity) =
identity.scopes
∩ (delegation chain intersection)
(org membership scopes)
```
For a chain of `DelegatesEdge`s from root principal to this identity, each
edge narrows the scope. The effective scopes are the intersection of all scope
sets along the chain from root to leaf.
For `resources`, the same narrowing applies: if a `DelegatesEdge` has
`narrowedResources`, those narrow the resource scoping. If absent, the chain
is unchanged (agent inherits principal's resource scoping).
**Scope string semantics** follow `@alkdev/operations`' keypal convention:
colon-separated hierarchical segments where `*` is a wildcard matching any
suffix. `"dev:*"` matches `"dev.read"`, `"dev.write"`, `"dev.fs.read"`, etc.
`"dev:read"` matches only `"dev:read"`, not `"dev:write"`. The subset check
`narrowedScopes ⊆ effectiveScopes` uses this wildcard matching, not simple
string equality. This is consistent with `@alkdev/operations`' `AccessControl`
enforcement (see `checkAccess` in `access.ts`).
**Graph traversal optimization**: The evaluator can cache effective scopes per
identity per graph. When a `DelegatesEdge` or `ScopesEdge` is created or
removed, only identities in the affected subtree need recalculation.
### Multi-Parent Aggregation
A `PrincipalNode` can have multiple incoming edges. The aggregation rules
differ by edge type:
**Multiple `DelegatesEdge` incoming edges** (multiple principals delegating
to the same agent): The agent's effective scopes are the **union** of
delegation chains. Each chain is independently intersected (narrowed), then
the results are unioned. This allows an agent to receive authority from
multiple independent principals without either principal's chain restricting
the other.
```
effectiveScopes(agent) = (chain_i: root_i.scopes ∩ ... ∩ edge_n.narrowedScopes)
```
This preserves the no-escalation invariant per chain: no single chain can
exceed its root principal's authority. The union across chains means the agent
accumulates authority from multiple independent delegations, each bounded by
its own chain.
**`DelegatesEdge` + `BelongsToEdge` incoming edges**: These are additive.
`DelegatesEdge` provides delegation-chained scopes; `BelongsToEdge` provides
org-level scopes. The identity's effective scopes are the **union** of all
delegation chain results plus org-derived scopes.
```
effectiveScopes(identity) =
( delegation_chains) ( org_memberships)
```
Each component is independently bounded: delegation chains by their root
principal's authority, org memberships by the `membershipLevel`-derived
scope mapping.
### Transitivity
Delegation edges are **transitive**: if A delegates to B, and B delegates to C,
then C's effective authority is:
```
effectiveScopes(C) = A.scopes ∩ B.narrowedScopes ∩ C.narrowedScopes
```
This is equivalent to: C's effective authority is whatever A gave B, further
narrowed by what B gave C. The graph traversal is a depth-first walk up the
delegation chain, intersecting at each step.
### Cycle Detection
The ACL graph must be a **directed acyclic graph (DAG)**. Cycles in delegation
create paradoxes (A delegates to B who delegates back to A) and make scope
computation non-terminating.
**Detection algorithm**: At edge creation time, perform a DFS from the target
node upward through all `DelegatesEdge`s. If the source node is reachable, the
edge would create a cycle and must be rejected.
**`allowSelfLoops: false`** in the AclGraph Config prevents self-delegation
edges (A → A), but does NOT prevent longer cycles (A → B → A). Cycle
detection is performed at the application layer during edge insertion. The
`BelongsToEdge` type cannot create cycles because its target must have
`identityType: "org"` and orgs cannot delegate (they are not agents), so only
`DelegatesEdge` cycles need to be checked.
The chain always terminates** because the ACL graph is a DAG enforced at
edge creation time.
### BelongsTo vs Delegates
`BelongsToEdge` and `DelegatesEdge` serve different purposes:
- `BelongsToEdge` declares **organizational membership**: "Account A is a
member of Org O with level 'admin'." It enables org-level scoping: when an
operation requires `resourceType: "project"` access, and the project belongs
to Org O, account A's membership in O can be checked.
- `DelegatesEdge` declares **authority transfer**: "Principal P delegates scopes
S to Agent A." It creates the principal-agent relationship for a specific
task or session.
A `BelongsToEdge` does NOT carry scope narrowing — org membership is not
delegation. The org's effective scopes for a member are determined by the
member's `membershipLevel`, which maps to org-level permissions (owner → full
control, admin → manage projects and members, member → access org resources).
Org-level permissions can also be modeled explicitly with `ScopesEdge`:
an org principal can have a `ScopesEdge` to a resource representing the org
itself, with `actions: ["admin"]` for owners, `["manage"]` for admins.
## Scoping Model
With the system/tenant DB model (ADR-040), ACL scoping is simplified:
- **One ACL graph instance per tenant DB** — The tenant DB is inherently
org-scoped. OQ-22 is resolved: each org gets its own ACL graph instance.
- **No cross-org scoping within a tenant** — The entire tenant DB is one org.
The ACL graph does not need `orgId` columns or cross-org filtering.
- **Cross-org delegation** requires the hub to mediate between tenant DBs
(OQ-28, open).
### How They Connect
```
OperationSpec.accessControl ACL Graph Instance
┌─────────────────────┐ ┌─────────────────────────┐
│ requiredScopes: │ │ PrincipalNode │
│ ["dev:read"] │◄───────────│ identityId: "user-1" │
│ resourceType: │ evaluate │ scopes: ["dev:*"] │
│ "project" │ │ │
│ resourceAction: │ │ ScopesEdge │
│ "write" │ │ → ResourceNode │
│ │ │ (project:alpha) │
└─────────────────────┘ │ actions: ["read","write"]│
└─────────────────────────┘
```
The **evaluator** bridges the two:
1. Receives `AccessControl` from the operation definition
2. Looks up the caller's `Identity` (which may reference an ACL graph)
3. Traverses the ACL graph to compute effective scopes and resources
4. Applies `enforceAccess(accessControl, effectiveIdentity)`
### Org-Scoped Access
Within a tenant DB, ACL evaluation is straightforward — the entire DB is one
org. The PrincipalNode's `identityId` logically references `accounts.id` in the
system DB (ADR-041). When evaluating ACL, the hub reads the account's org
membership from the system DB's `organization_members` table (authoritative per
ADR-045) and the ACL graph's `BelongsToEdge` (derived) in the tenant DB.
```
Account PrincipalNode ──belongs_to──→ Org PrincipalNode
scopes (actions: ["manage", "read"])
ResourceNode (project:alpha)
```
The evaluation rule: if Account belongs to Org with `membershipLevel: "admin"`,
and Org has `ScopesEdge` to Resource with `actions: ["manage", "read"]`, then
Account has the intersection of `[Org-scopes for membershipLevel admin]` and
`[actions on ScopesEdge]` for that resource.
This intersection ensures that org-level permissions don't automatically grant
admin access just because the org admin has `actions: ["manage"]` — the account's
own base scopes limit what they can exercise from org membership.
## What This Replaces
### The Disconnected `actors` Table
The `actors` table in `src/sqlite/tables/actors.ts` is replaced by
`PrincipalNode` in the ACL graph. The standalone table has been removed
(ADR-035, ADR-038).
### Hub's `organization_members` as Authoritative Source
The hub's (now storage's) `organization_members` table is the authoritative
source for who belongs to which org (ADR-045). When org membership changes,
the consumer writes both:
1. The `organization_members` row (fast lookup, FK constraints)
2. The `BelongsToEdge` in the ACL graph instance (graph traversal, evaluation)
This dual-write is necessary because the SQL table provides O(1) membership
lookups and cascade behavior, while the ACL graph needs the edge for
traversal-based evaluation.
### Hub's Permission Resolution
The hub's current permission resolution model (role requests ∩ account scopes ∩
spoke trust level) is preserved. The ACL graph adds a **fourth input**:
delegation chain intersection. The full resolution becomes:
```
Effective permissions = ACL Effective ∩ Role.requests ∩ Account.allowed ∩ SpokeType.capable
```
The "ACL Effective" is the delegation-traversal result from the ACL graph.
This ensures that even if a role requests `admin` access, and an account has an
API key with `admin` scope, if the ACL delegation chain only grants `dev:read`,
the effective scope is `dev:read`.
## Open Questions
- **OQ-20**: Should `DelegatesEdge` support temporary delegation with
expiration? (Referenced in [open-questions.md](open-questions.md))
- **OQ-25**: What are the scope string semantics for subset validation?
(Referenced in [open-questions.md](open-questions.md))
- **OQ-28**: How does cross-tenant delegation work with separate DBs?
(Referenced in [open-questions.md](open-questions.md))
## Resolved Questions
- **OQ-21** (ACL evaluator location): Storage provides traversal primitives; hub composes with operations. Simplified by single-host model — no cross-DB joins needed within a tenant DB.
- **OQ-22** (ACL graph instance lifecycle): One per tenant DB. ADR-040.
- **OQ-23** (BelongsToEdge derivation): Derived from `organization_members`. ADR-045.
- **OQ-24** (identityId reference): Logical reference to `accounts.id` in system DB. ADR-041.
## References
- Metagraph Module system: [metagraph-module.md](metagraph-module.md)
- SQLite host and actors table: [sqlite-host.md](sqlite-host.md)
- Operations `Identity` and `AccessControl`: `/workspace/@alkdev/operations/src/types.ts`
- Hub identity tables: `/workspace/@alkdev/hub/docs/architecture/storage/identity.md`
- Hub agent-roles model: `/workspace/@alkdev/hub/docs/architecture/agent-roles.md`
- Hub roles table: `/workspace/@alkdev/hub/docs/architecture/storage/roles.md`
- Encrypted data and SecretGraph pattern: [encrypted-data.md](encrypted-data.md)