Architecture Overview
Alloy is a headless, API-first project management service built in Rust. It supports two database backends (PostgreSQL multi-tenant, SQLite single-tenant) and exposes its capabilities through an HTTP API, a CLI, a TUI, and an MCP server.
Crate Dependency Graph
┌────────────┐
│ alloy-core │ (domain models, service traits, repo traits)
└─────┬──────┘
│
┌─────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────────┐
│ alloy-api │ │ alloy-cli │ │ alloy-test-utils │
│ (Axum, │ │ (Clap CLI, │ │ (test builders, │
│ SQLx, │ │ depends on │ │ seeders, │
│ repos) │ │ alloy-api │ │ fake data) │
└──────┬─────┘ │ + core) │ └──────────────────┘
│ └──────────────┘
│
┌──────┴─────┐
│ alloy-mcp │ (MCP server — HTTP client to alloy-api)
└────────────┘
┌────────────┐
│ alloy-tui │ (Ratatui TUI — HTTP client to alloy-api)
└────────────┘
Key rules:
alloy-corehas zero framework dependencies — no Axum, no SQLx, no Tokio. It contains only domain models, enums, service traits, and repository trait definitions.alloy-apidepends onalloy-coreand provides concrete SQLx-backed repository implementations plus Axum HTTP handlers.alloy-clidepends on bothalloy-apiandalloy-core(it embeds the server).alloy-mcpandalloy-tuiare HTTP clients — they talk to a runningalloy-apiinstance over the network and do not link against it.alloy-test-utilsdepends onalloy-coreand provides shared test helpers.
Request Lifecycle
A typical authenticated API request flows through these layers:
HTTP Request
│
▼
┌──────────────────────┐
│ Axum Router │ Route matching
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Rate Limit Layer │ Token-bucket rate limiting (tower middleware)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Security Headers │ CORS, CSP, HSTS, X-Frame-Options
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Tenant Context │ Extracts org_id from token, sets task-local
│ Middleware │ for PostgreSQL RLS (see Tenant Isolation below)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Auth Extractor │ Validates JWT or API key from Authorization header
│ (AuthContext) │ Produces AuthContext: user_id, org_id, role, scopes
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Handler Function │ Business-level validation, converts AuthContext
│ (handlers/*.rs) │ to ActorContext, calls service methods
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Service Layer │ Domain logic: permission checks, audit logging,
│ (alloy-core │ workflow transitions, business rules
│ services/*.rs) │ Operates on trait-based repos (no DB knowledge)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Repository Impl │ SQLx queries against PostgreSQL or SQLite
│ (alloy-api │ PG repos use begin_tenant_tx() for RLS
│ repos/*.rs) │ SQLite repos query directly with org_id filter
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Database │ PostgreSQL 16 or SQLite (WAL mode)
└──────────────────────┘
Auth Flow Detail
- The
Authorization: Bearer <token>header is read by theAuthContextextractor (an AxumFromRequestPartsimplementation). - Tokens prefixed
alloy_live_oralloy_test_are validated as API keys — the key hash is looked up in the database, and the associated user, org, role, scopes, and project restrictions are loaded. - All other tokens are validated as JWTs — signature verification uses the
configured HMAC secret. JWT auth always grants
scopes: "*". - The resulting
AuthContextcarriesuser_id,org_id,email,role,scopes, andallowed_project_idsfor downstream use. - Handlers convert
AuthContextintoActorContext(a framework-free struct inalloy-core) before calling service methods.
SQLite vs PostgreSQL Paths
Alloy supports two database backends selected by the ALLOY_DATABASE_URL
environment variable:
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Use case | Multi-tenant production | Single-tenant / local dev |
| Connection | postgres://... | sqlite://path or sqlite::memory: |
| Tenant isolation | Row Level Security (RLS) | org_id column filters in queries |
| Migrations | migrations/postgres/ | migrations/sqlite/ |
| Migration runner | refinery embed_migrations! | refinery embed_migrations! |
| UUID storage | Native UUID type | TEXT |
| Timestamps | TIMESTAMPTZ | TEXT (ISO 8601) |
| Auto-increment | SERIAL / BIGSERIAL | INTEGER PRIMARY KEY |
| Journal mode | WAL by default | WAL enabled at connection |
| Pool type | sqlx::PgPool | sqlx::SqlitePool |
The Database enum in alloy-api/src/db.rs wraps both backends behind a
uniform interface. Migrations are compiled into the binary via
refinery::embed_migrations! and run automatically on startup when
ALLOY_AUTO_MIGRATE=true.
Repository traits are defined once in alloy-core/src/repos.rs. Each trait
has two implementations in alloy-api/src/repos/:
pg_<domain>.rs— PostgreSQL implementation usingsqlx::PgPoolsqlite_<domain>.rs— SQLite implementation usingsqlx::SqlitePool
Tenant Isolation Model
Alloy enforces strict data isolation between organisations (tenants).
PostgreSQL: Row Level Security
- Every table has an
org_idcolumn. - RLS policies restrict
SELECT,INSERT,UPDATE, andDELETEto rows whereorg_idmatches the session variableapp.tenant_id. - The tenant context middleware runs before every request. It extracts
org_idfrom the auth token and stores it in atokio::task_local. - PostgreSQL repository methods call
begin_tenant_tx()which starts a transaction and executesSET LOCAL app.tenant_id = '<org_id>'. This activates RLS filtering for the duration of the transaction. - RLS failures are silent — they return fewer rows, not errors. Tests must assert zero results across tenant boundaries, not expect errors.
Request with Org A token
│
▼
tenant_context_middleware ──► task_local CURRENT_TENANT = Org A
│
▼
PG repo: begin_tenant_tx()
│
▼
SET LOCAL app.tenant_id = 'org-a-uuid'
│
▼
SELECT * FROM tickets WHERE ...
(RLS automatically adds: AND org_id = 'org-a-uuid')
SQLite: Explicit Filtering
SQLite has no RLS. Instead:
- Every query includes
AND org_id = ?in itsWHEREclause. - The
org_idparameter comes from theActorContextpassed through the service layer. - This provides defence-in-depth alongside application-level permission checks in the service layer.
Application-Level Checks
Both backends also enforce:
- Ownership checks in service methods (e.g. only the author of a time entry can submit it).
- Role-based access via
ActorContext.role(owner, admin, member, viewer). - Scope restrictions via
ActorContext.scopes(API keys can be limited to read-only or specific operations). - Project restrictions via
ActorContext.allowed_project_ids(API keys can be scoped to specific projects).