Architecture
ARES is a Cargo workspace. One root package, ares-server, holds the binary and the library facade. Ten crates under crates/ hold the capabilities. The kernel crate is published as ares-cordis; the workspace imports it as cordis.
Crate map
| Crate | Role | Depends on |
|---|---|---|
ares-server | Binary and library facade. Boots the server, registers factories, binds HTTP. | all capability crates, cordis |
cordis (ares-cordis) | Kernel. Typed Context, plugins, loader, fibers, events, intercepts. | none |
ares-types | Shared types and errors (AppError, TenantContext). | cordis |
ares-vector | Embedded vector store with HNSW. Standalone; no workspace dependencies. | none |
ares-rag | Retrieval-augmented generation pipeline and embeddings. | ares-types, cordis |
ares-store | Persistence. PostgreSQL through sqlx, Turso/libSQL behind a feature. Embeds migrations. | ares-types, cordis; optional sqlx, libsql, ares-vector |
ares-tools | Tool trait, static and runtime tool registry, calculator. | ares-types, cordis; optional ares-store, ares-mcp |
ares-llm | Provider clients, factory, pool, circuit breaker, Llm service. | ares-types, ares-tools, cordis; optional ares-store |
ares-mcp | Model Context Protocol glue: client, auth, registry, server. | ares-types, cordis; optional ares-store |
ares-agent | Agents: registry, router, orchestrator, Execute service, tenant scoping. | ares-types, ares-llm, ares-tools, cordis; optional ares-store |
ares-http | Axum routes, middleware, auth, overlay config, admin handlers. | ares-agent, ares-tools, ares-llm, ares-store, ares-rag, ares-types, cordis; optional ares-mcp, ares-vector |
The same crates with one-line data-flow notes each:
| Crate | Data that flows through it |
|---|---|
ares-server | Boot order and wiring only: it pushes factories into the context and hands the context to the HTTP listener; no domain data crosses it at request time. |
cordis | Service handles and dependency epochs. Every get::<T>() in the server resolves here; versions bump when a store entry changes. |
ares-types | Structs every crate shares: requests, responses, errors, TenantContext. Pure data; no I/O. |
ares-vector | Raw f32 vectors plus HNSW graph nodes in process memory; queries enter as vectors and leave as neighbor ids and distances. |
ares-rag | Text in, embeddings out: chunks go to an embedder, vectors to a store, and query results back through scoring and reranking. |
ares-store | Rows over sqlx: tenants, API keys, usage, agent versions, runs. Migrations flow outward from the embedded migrator at boot. |
ares-tools | JSON tool calls in, JSON results out. The registry maps names to Tool impls; per-tenant allowlists gate resolution. |
ares-llm | Chat completions to provider HTTP APIs; token counts and latency back. The circuit breaker wraps each provider client. |
ares-mcp | MCP protocol frames both ways: external servers become agent tools; the built-in MCP server exposes ARES agents outward. |
ares-agent | The run pipeline: admitted request, tool-call rounds against Tools/Llm, assembled final response, run records toward storage. |
ares-http | HTTP in, responses out. Middleware attaches tenant identity and usage; handlers open realms and call Execute. |
Feature flags forward down the chain: postgres enables sqlx in store, agent, mcp, tools, http; openai, azure, and bedrock add providers in ares-llm.
Process lifecycle
Startup follows one ordered pass in run_server (src/main.rs:524). Each step gates the next:
- Load
.envand start tracing (src/main.rs:528-532). - Create the root Cordis
Contextand theReflectService(src/main.rs:534-554). The service registers notifiers for theToolsandLlmtypes and fires an initialnotify, so dependents reconcile from the first moment. - Register loader factories through
register_loader_factories(src/main.rs:328, called atsrc/main.rs:557-558). Explicit chains run without theinventoryfeature; inventory collection runs with it. - Boot the entries program (
src/main.rs:560-568): parseconfig/cordis-entries.toml, compose includes, instantiate entries in file order. TheOverlayentry runs early and fills empty entry configs fromares.toml. A boot failure logsCordis Loader: boot failedand exits with code 1. - Guard configuration presence (
src/main.rs:570-585). A missing config prints anares-server inithint and exits with code 1. - Start the entries watcher (
src/main.rs:592-625). File events re-compose the program and apply diffs through the loader journal. When the watcher cannot start, a 30-second modified-time poll replaces it. - Preload runtime providers (
src/main.rs:630-632) and snapshot current agent definitions into the version history (src/main.rs:673-707). - Build CORS and rate-limit layers (
src/main.rs:895-944), bind the TCP listener (src/main.rs:949-950), and serve the Axum router with graceful shutdown on Ctrl+C and SIGTERM (src/main.rs:957-964).
Step 8 is the first step that touches the network for serving. A failure in any earlier step exits before any port opens.
The rate-limit layer
When rate_limit_per_second > 0, startup wraps the router in tower_governor (src/main.rs:900-913). The limiter is a GCRA bucket per client IP. It admits up to rate_limit_burst requests immediately and then admits one more request every \(1/\text{rate_limit_per_second}\) seconds. A background task prunes idle per-IP buckets every 60 seconds (src/main.rs:917-930). Responses carry x-ratelimit-* headers. Setting rate_limit_per_second = 0 removes the layer entirely and logs a warning.
Request path
request / job
-> TenantRealms.open then intercept (HTTP/MCP/JWT) or isolate only (background)
-> agent.admit (Execute, JWT chat, API-key middleware, MCP)
-> Execute::run
-> Tools / Llm / skills via EventsService waterfalls
-> response
flowchart LR
A[Request] --> B{Route class}
B -->|protected| C[JWT auth]
B -->|admin| D[Admin secret]
B -->|/v1| E[API key]
C --> F[TenantDb inject]
E --> F
F --> G[Usage tracking]
D --> H[Handler]
G --> H
H --> I[Realm open + intercept]
I --> J[agent.admit]
J --> K[Execute.run]
K --> L[Tools / Llm waterfalls]
L --> M[Response]
Middleware order in create_router (crates/ares-http/src/api/routes.rs) matches this flow. On protected routes the outermost layer validates the JWT, the next layer injects TenantDb into extensions, and the innermost layer records token usage after the handler returns. Admin routes sit behind admin_middleware, which checks the X-Admin-Secret header. The /v1 routes use API key authentication, plus context injection when the eruka-context feature is enabled.
Inside a handler, request_tenant_ctx opens the tenant realm and then applies the TenantContext intercept. Realms are cached child contexts; the same tenant gets the same realm. Background jobs call tenant_scope instead. They isolate without an intercept. The agent.admit event is the shared quota gate. A deny maps to HTTP 429 or an MCP tool error. After the gate, Execute::run drives tools, model calls, and skills through EventsService waterfalls.
Cordis concepts as server concerns
- Realms equal isolates.
TenantRealmskeeps one child context per tenant id, each backed by one fiber. Data-bearing services such asToolsresolve inside the realm. Tenant delete callsdispose, which undoes that realm's provides in last-in-first-out order. - Providers equal LLM clients. The
Llmservice wraps the provider registry. Its circuit breaker feedsService::check. When the breaker opens, dependent fibers deactivate through guarded withdrawal. - Fibers own lifecycle. Each provided service has a fiber.
Fiber::refreshrecomputes the dependency epoch. Losing a dependency moves a fiber to Pending, not Failed. Hot swap replaces a service in place; retire withdraws it under guard while consumers exist.
Peer-dependency versions
Provider versions are plain u64 values with a packed layout (crates/cordis/src/context.rs:227-247, constant VERSION_MAJOR_SCALE = 100_000). The major lives in the high digits and the minimum compatible floor in the remainder:
$$\text{major}(v) = \left\lfloor \frac{v}{100,000} \right\rfloor, \qquad \text{floor}(v) = v \bmod 100,000$$
An inject that requires \(M \cdot 100,000 + f\) is satisfied by provider version \(p\) exactly when the provider exists and is available, \(\text{major}(p) = M\), and \(p \geq M \cdot 100,000 + f\). Any mismatch leaves the inject unsatisfied and the dependent fiber Inactive; it never binds across a major boundary. Legacy provide() installs version 0, which satisfies only unconstrained injects.
Storage model
PostgreSQL is the default backend, through sqlx with rustls TLS. Turso/libSQL compiles behind the turso feature for edge deployment. Vector data lives in ares-vector by default, or in Qdrant, pgvector, ChromaDB, Pinecone, or LanceDB behind features. SQL migrations are embedded in the ares-store crate at compile time. The migrator ships inside the published package, so cargo install ares-server needs no external files.
Tenant realms
TenantRealms (crates/ares-store/src/realms.rs) is the cache that turns one row per tenant into one live child context per tenant.
- Open once, reuse forever.
open(root, tenant_id)returns the cached child, or creates it on first call with a double-checked write (realms.rs:35-47). The child isroot.extend().isolate_type(tools_tid, tenant_id). - Only data-bearing services isolate. The realm isolates on the
ToolsTypeId, so each tenant sees its own tool state and allowlists.Executestays shared on purpose: it is a stateless engine, and its tenancy comes from the context handed to it (realms.rs:29-34). NoTenantContextintercept lands inside the cached realm; handlers add it per request. - Disposal unwinds LIFO.
dispose(tenant_id)removes the child from the map and disposes its fiber (realms.rs:49-57). Fiber disposal undoes that realm's provides in last-in-first-out order, so teardown runs in exact reverse registration order. Tenant delete (crates/ares-http/src/api/handlers/admin/tenants.rs) calls this before dropping rows.
sequenceDiagram
participant R as Request
participant TR as TenantRealms
participant C as Cached child ctx
R->>TR: open(root, "acme")
TR->>C: first open: extend + isolate Tools
C-->>R: realm (same Arc for every later request)
R->>R: intercept TenantContext per request
Note over TR: delete_tenant -> dispose("acme")
TR->>C: fiber dispose, undos unwind LIFO
Failure philosophy
ARES fails closed at every trust boundary:
- Missing or invalid JWT, API key, or admin secret rejects the request before any handler runs.
- Quota denial through
agent.admitblocks execution. - An availability predicate rejection marks a service Failed. Failed is terminal.
- Guarded withdrawal deactivates dependents instead of binding to an incompatible provider.
- Missing tenant database access during tenant resolution fails closed.
- Loader composition stays fail-open for bad includes so one broken file cannot brick a running reload, but a failed boot pass still exits with code 1.
- The emergency stop switch halts all agent execution.
Retrieval scoring
Embedding similarity uses cosine similarity over dense vectors (crates/ares-rag/src/embeddings.rs:128-149):
$$\cos(a, b) = \frac{a \cdot b}{\lVert a \rVert,\lVert b \rVert}$$
The implementation clamps the result to \([-1, 1]\) and returns \(0\) for mismatched lengths or zero magnitude instead of dividing by zero. Search results report distance, not similarity; distance_to_similarity maps cosine distance back with \(1 - d\), L2 with \(1/(1+d)\), and inner product with \(-d\) (crates/ares-rag/src/search.rs:296-302). The hybrid reranker then min-max normalizes retrieval scores across the candidate set and blends: \(\text{final} = (1 - w),\text{norm}_{\text{retrieval}} + w,\text{rerank}\) (crates/ares-rag/src/reranker.rs:362-371).
Design rules
Three rules explain most ARES behavior at failure boundaries. Each rule names one concrete place you can observe it.
Fail closed
When identity or availability cannot be proven, reject; never guess. JWT scope resolution is the sharpest example: if claims name a tenant but no matching TenantContext exists, or the tenant store is absent, the request fails instead of falling back to an unscoped context (crates/ares-http/src/auth/middleware.rs:31-32). The same rule puts auth middleware outside every handler and makes quota denial through agent.admit block execution.
Guarded withdrawal
A provider with live consumers does not vanish underneath them. Retiring a service through POST /admin/cordis/services/{name}/retire answers 409 {"retired": false, "reason": "guarded", "consumers": N} while N active consumer fibers still rely on it; the removal happens only after the last consumer drops its reliance (crates/ares-http/src/api/handlers/admin/cordis.rs:71-80). Dependent fibers deactivate rather than bind to an incompatible replacement.
Eager reconciliation
State changes propagate immediately, not at next use. Two mechanisms carry this rule:
- Boot wires
ReflectServicenotifiers forToolsandLlmand fires an initial notify, so dependent fibers recompute their dependency epoch right away (src/main.rs:534-554). - Editing
config/cordis-entries.tomltriggers parse, compose, diff-apply, and classify in one pass (cordis::reload_entries_from_disk, called fromsrc/main.rs:488-493); consumers see the new provider set without a restart.
Together the three rules produce the observed lifecycle semantics: Pending means waiting, Failed means rejected permanently, and a guarded retire never leaves a consumer resolving nothing.