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

CrateRoleDepends on
ares-serverBinary 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-typesShared types and errors (AppError, TenantContext).cordis
ares-vectorEmbedded vector store with HNSW. Standalone; no workspace dependencies.none
ares-ragRetrieval-augmented generation pipeline and embeddings.ares-types, cordis
ares-storePersistence. PostgreSQL through sqlx, Turso/libSQL behind a feature. Embeds migrations.ares-types, cordis; optional sqlx, libsql, ares-vector
ares-toolsTool trait, static and runtime tool registry, calculator.ares-types, cordis; optional ares-store, ares-mcp
ares-llmProvider clients, factory, pool, circuit breaker, Llm service.ares-types, ares-tools, cordis; optional ares-store
ares-mcpModel Context Protocol glue: client, auth, registry, server.ares-types, cordis; optional ares-store
ares-agentAgents: registry, router, orchestrator, Execute service, tenant scoping.ares-types, ares-llm, ares-tools, cordis; optional ares-store
ares-httpAxum 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:

CrateData that flows through it
ares-serverBoot 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.
cordisService handles and dependency epochs. Every get::<T>() in the server resolves here; versions bump when a store entry changes.
ares-typesStructs every crate shares: requests, responses, errors, TenantContext. Pure data; no I/O.
ares-vectorRaw f32 vectors plus HNSW graph nodes in process memory; queries enter as vectors and leave as neighbor ids and distances.
ares-ragText in, embeddings out: chunks go to an embedder, vectors to a store, and query results back through scoring and reranking.
ares-storeRows over sqlx: tenants, API keys, usage, agent versions, runs. Migrations flow outward from the embedded migrator at boot.
ares-toolsJSON tool calls in, JSON results out. The registry maps names to Tool impls; per-tenant allowlists gate resolution.
ares-llmChat completions to provider HTTP APIs; token counts and latency back. The circuit breaker wraps each provider client.
ares-mcpMCP protocol frames both ways: external servers become agent tools; the built-in MCP server exposes ARES agents outward.
ares-agentThe run pipeline: admitted request, tool-call rounds against Tools/Llm, assembled final response, run records toward storage.
ares-httpHTTP 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:

  1. Load .env and start tracing (src/main.rs:528-532).
  2. Create the root Cordis Context and the ReflectService (src/main.rs:534-554). The service registers notifiers for the Tools and Llm types and fires an initial notify, so dependents reconcile from the first moment.
  3. Register loader factories through register_loader_factories (src/main.rs:328, called at src/main.rs:557-558). Explicit chains run without the inventory feature; inventory collection runs with it.
  4. Boot the entries program (src/main.rs:560-568): parse config/cordis-entries.toml, compose includes, instantiate entries in file order. The Overlay entry runs early and fills empty entry configs from ares.toml. A boot failure logs Cordis Loader: boot failed and exits with code 1.
  5. Guard configuration presence (src/main.rs:570-585). A missing config prints an ares-server init hint and exits with code 1.
  6. 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.
  7. Preload runtime providers (src/main.rs:630-632) and snapshot current agent definitions into the version history (src/main.rs:673-707).
  8. 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. TenantRealms keeps one child context per tenant id, each backed by one fiber. Data-bearing services such as Tools resolve inside the realm. Tenant delete calls dispose, which undoes that realm's provides in last-in-first-out order.
  • Providers equal LLM clients. The Llm service wraps the provider registry. Its circuit breaker feeds Service::check. When the breaker opens, dependent fibers deactivate through guarded withdrawal.
  • Fibers own lifecycle. Each provided service has a fiber. Fiber::refresh recomputes 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 is root.extend().isolate_type(tools_tid, tenant_id).
  • Only data-bearing services isolate. The realm isolates on the Tools TypeId, so each tenant sees its own tool state and allowlists. Execute stays shared on purpose: it is a stateless engine, and its tenancy comes from the context handed to it (realms.rs:29-34). No TenantContext intercept 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.admit blocks 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 ReflectService notifiers for Tools and Llm and fires an initial notify, so dependent fibers recompute their dependency epoch right away (src/main.rs:534-554).
  • Editing config/cordis-entries.toml triggers parse, compose, diff-apply, and classify in one pass (cordis::reload_entries_from_disk, called from src/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.