Skip to content

17 — Architecture Decisions

The Aetherium Engine is the product of a handful of decisions that everything else rests on. This section records them: what was chosen, what was rejected, and — the part an evaluator actually needs — what each choice costs.

These are not ceremonial records written at decision time. Some were captured when they were made, in code comments and design notes; others are reconstructed from the implementation, and where that's the case the entry says so plainly. None were kept as a formal, dated ADR log, so what follows documents them after the fact, in the present tense, without inventing dates or authorship that was never written down. The decisions are in force; this is the reasoning behind the Engine as it stands.

Six decisions shape the Engine more than any others, and most sharply distinguish it from CrewAI, LangGraph, and AutoGen. If you're evaluating the architecture, this is where you decide whether its values match yours — because each of these bought something real and gave up something real. Every entry below follows the same shape: the context that forced a choice, the decision, the alternatives that were rejected, and the consequences, including the honest downsides. An ADR with no stated cost isn't an ADR. Read them in any order; they run roughly from the choices that most set the Engine apart to the ones a .NET developer will find most familiar.

ADR 1 — In-process-only MCP

Context. Agents need tools, and the Engine exposes them through the Model Context Protocol (MCP). MCP is a client/server protocol, so a host can also consume external MCP servers — stdio subprocesses or remote endpoints — and several frameworks treat that as the primary extension path.

Decision. Every MCP server class runs in-process, hosted by the Engine. Consuming external MCP servers is intentionally unsupported. An "MCP server class" is exactly that — a C# class the Engine hosts, not a separate process it talks to.

Alternatives considered. MCP client mode — connecting out to third-party stdio or remote MCP servers — was rejected on five recorded grounds: external servers bypass the Engine's security gates, run outside the per-agent container sandbox, reduce the audit trail to opaque input/output pairs, aren't needed for current use cases, and surrender nothing the in-process schema discovery already provides. Hosting the in-process servers container-side rather than in the Engine process was rejected too: most depend on host services — the database, the knowledge store, the search backend, the real-time hub — and the bridging adds complexity for no isolation gain, since the agent's container is already the execution sandbox.

Consequences. The payoff is uniform enforcement. The security gates — egress and DLP checks, the approval flow, the audit log — apply to every tool, because every tool runs in-process. A tool reached over an external MCP connection would sidestep all of them, and execution stays inside the agent's own container either way. The Engine still gets the ergonomic win an external protocol promises — tools are declared as annotated C# methods and their schemas are generated by reflection — without taking on the client-mode surface area to get it. The cost is the closed door: no configuration points the Engine at a third-party MCP endpoint or spawns one as a subprocess. A new integration is either a server class you write or the inbound webhook API — never a drop-in plug-in. Both paths keep the integration inside the Engine's trust boundary.

In practice: §07 — MCP Architecture for the design, §12 — Extending the Engine for writing a class, §13 — Integrations for the two shapes a real integration takes.

ADR 2 — Push-dispatched supervisor

Context. Multi-agent orchestration needs a coordination model, and there are two basic shapes. In a push model, a coordinator decides who does what and assigns the work. In a pull model, autonomous workers self-select tasks from a shared queue. The choice determines where control — and judgment — lives.

Decision. The Engine is push-dispatched. The supervisor's LLM makes every delegation decision through tool calls — delegate, spawn_subagents, and peer send_message_to — and workers execute the tasks they're assigned. Workers never self-select from a shared queue.

A distinction does the heavy lifting here, because it's the part that's easy to misread. Peer messaging (send_message_to) lets one worker drop a message straight into another's mailbox without round-tripping through the supervisor — collapsing what would otherwise be a four-hop relay (worker to supervisor, supervisor to the second worker, and back) down to two. That is a transport layer, and it's additive. The decision layer — who works on what — stays with the supervisor's LLM. Mailboxes move messages; they do not move the authority to delegate.

graph TB
    User([User request])
    subgraph decision["Decision layer — the supervisor's LLM decides"]
        Sup[Supervisor]
    end
    subgraph exec["Execution layer — workers run assigned tasks"]
        W1[Worker A]
        W2[Worker B]
    end
    User --> Sup
    Sup -->|"delegate (push)"| W1
    Sup -->|"delegate (push)"| W2
    W1 -->|reply| Sup
    W2 -->|reply| Sup
    W1 -.->|"send_message_to (transport only)"| W2
    Sup -->|synthesized response| User

Alternatives considered. Fully pull-based, autonomous workers drawing from a shared queue was rejected — and the reasoning is recorded, including a design note that reversed an earlier conclusion to the opposite effect. Pull-based workers fit the project-scoped, multi-tenant model awkwardly; they would outlive the project containers that bound their lifecycle; and removing the supervisor's judgment removes the value proposition, collapsing the system into a message router. The approval flow compounds it: the runtime's approval fallback treats supervisor delegation as a signal, and pull mode severs that chain.

Consequences. You get predictable control flow with a clear decision point. In Hierarchical topology with a plan, the supervisor builds the plan, delegates each step in order, reflects, re-plans if a step fails, and synthesizes the result — one place to reason about what happens next. That same single point of control is also where a circuit breaker trips: when a step keeps failing, the run stops cleanly instead of looping. The cost is that the supervisor is the serializing coordinator on that path: a sequential control point that work routes through. Peer messaging is the escape valve that keeps every exchange from doing so, but on the plan path the supervisor is, by design, in the middle.

In practice: §04 — Core Concepts for the topologies, §06 — Key Services for the orchestration loop, §07 — MCP Architecture for peer messaging.

ADR 3 — Network isolation as the trust boundary

Context. The Engine is local-first and single-host by default, and its agents run shells and write files. Something has to be the trust boundary for the orchestrator API that launches those agents.

Decision. The orchestrator API trusts its network. It carries no built-in authentication; the boundary is the operator's to build — a reverse proxy with mTLS, an API gateway, a bearer-token check at the edge, or simply a private network. This is deliberate, not an oversight: there is no authentication code anywhere in the solution. Identity is pushed to the edge rather than baked into the API.

Alternatives considered. Built-in authentication on the orchestrator API — bearer tokens, mTLS, a gateway integration — was deferred. In honesty, the trade-off was never weighed in a recorded design note; the only recorded position is that an open API is adequate on a trusted local network and that authentication is needed before any external exposure. The "network isolation is the trust boundary" framing here is reconstructed from that position and the implementation, not lifted from a prior decision record.

Consequences. The build endpoints — POST /api/orchestrator/run and /api/orchestrator/run-from-template — trust whatever reaches them. Because every agent can run shells and write files inside its container, an unauthenticated build endpoint reachable from the open internet is an arbitrary-code-execution surface. So the boundary is not optional: keep the API on a private network or strictly behind an authenticating proxy, and never expose it directly to the public internet. Outbound authenticity is handled — webhooks are signed — but inbound authenticity is entirely your deployment's responsibility. A quieter consequence follows from the same choice: with no authentication, the API has no notion of a per-user identity, so there is no built-in role-based access control or per-user audit at that layer — everything that reaches it is equally trusted, and accountability lives in the boundary you place in front of it. This is the Engine's most consequential open trade-off, and a known hardening candidate.

In practice: §13 — Integrations, §14 — Deployment, and §15 — Operations all carry the same warning; §18 — Roadmap is where the auth boundary is on the table.

ADR 4 — Three-layer permission model

Context. Agents call tools that read files, run shells, and reach the network. That needs governance — and a single yes/no check is too coarse to express "this agent may use these tools, but that one is destructive and needs approval."

Decision. Permissions are enforced in three layers. The connection layer is each node's mcp_servers allowlist — which server classes the agent connects to at all. The capability layer narrows that to specific methods: the router filters the tool catalog to the allowed tools and strips the rest from the schema before the LLM sees it. The runtime layer gates execution by security tier — Standard, Elevated, or Restricted — with ToolApprovalService intercepting calls that need approval. Around those sit the per-run tool_defaults argument layer and the egress/DLP and audit hooks that wrap every execution.

Alternatives considered. A single flat permission check — one gate returning yes or no per tool call — was rejected as too coarse. The recorded rationale is defense in depth: each layer catches what the others cannot.

Consequences. The capability layer is the quietly decisive one. Because a disallowed tool never appears in the schema, the model cannot hallucinate a call to it — the layer prevents whole classes of misbehavior instead of catching them after the fact. Combined with per-team egress allowlists, you get defense in depth with per-team scoping, and the hooks that wrap execution extend it at both ends — a pre-execution check can veto a call before it runs, which is how egress and DLP block credential leakage on outbound HTTP, and a post-execution hook records every call to an audit trail you can replay. The cost is wiring: registering a new tool is a code change in four places — the method, the dispatch arm, the server-group map, and the prompt docs — plus a fifth if you set its security tier. Miss that fifth edit and the tool defaults to Standard: no approval, basic audit. For a destructive tool, that is the edit that bites.

In practice: §07 — MCP Architecture for the layers, §12 — Extending the Engine for the wiring cost, §04 — Core Concepts for the plain-language model.

ADR 5 — PostgreSQL-only agent memory

Context. Agents need state that survives across turns: working notes within a session, and knowledge that accumulates over time. The fashionable default for agent memory is a vector store.

Decision. Agent memory is a two-tier model in PostgreSQL — the AgentMemory table, with no vector index. Working memory is the per-session scratchpad, overwritten each session; long-term memory is append-only — the knowledge an agent accumulates across runs. The vector store, ChromaDB, is the knowledge base and RAG layer — not agent memory. The two are deliberately separate subsystems. Why relational rather than vector isn't recorded anywhere; that part is reconstructed from the implementation.

Alternatives considered. Using a vector store for memory — the same embeddings machinery the knowledge base runs on — or an external memory service, were not adopted. Memory and knowledge stay split: one relational, one vector.

Consequences. Memory is rows in the PostgreSQL the Engine already runs and backs up, so there is no separate datastore to operate for it. Lookup is relational, ordered by relevance score plus recency and trimmed to a token budget when the orchestrator injects memory into the system context at turn start, and it layers cleanly into project-shared and agent-specific scopes. The trade-off is that memory recall is not semantic: there is no embedding-similarity search over an agent's memory the way there is over the knowledge base. Score-plus-recency is the retrieval model; semantic retrieval is the knowledge base's job, not memory's.

In practice: §04 — Core Concepts for the knowledge-versus-memory split, §06 — Key Services for the service, §15 — Operations for backups.

ADR 6 — Blazor WebAssembly for the UI (superseded)

Superseded on 2026-08-30

The UI is now a React single-page client served from the host's own wwwroot/app. The Blazor client was retired after the replacement reached parity. This entry is kept because the consequences below explain shapes still visible in the codebase — the Aetherium.Shared split, and publish-not-build. What replaced it, and why, is summarized at the end of this entry.

Context. The Engine needs a browser UI, and its backend is .NET end to end.

Decision. The UI is a Blazor WebAssembly client — the .Client project — running in Interactive Auto render mode. In that mode the first load renders on the server over a SignalR circuit, so the UI is responsive immediately, then hands off to WebAssembly in the browser once the client bundle has downloaded — a fast first paint followed by a thick client. The server project's own component surface is essentially scaffolding: the host page, layout, error and not-found pages, and the SignalR circuit. The pages users actually work in live in the client project. The rationale for Blazor over the alternatives isn't recorded, so this entry is reconstructed from the implementation.

Alternatives considered. Blazor Server, Blazor Hybrid, and a separate JavaScript SPA were not adopted. None were weighed against the choice in a recorded note, so the rejection is stated as fact rather than as a reconstructed argument.

Consequences. You get one .NET codebase from client to server, with the types both sides share living in Aetherium.Shared; building the server pulls in the WASM client automatically. The sharp edge is registration: a client service used during server-side rendering must be registered in both Program.cs files — server and client — or SSR throws InvalidOperationException. That double-registration gotcha is the most common cross-tier mistake. WASM also carries its own payload and startup characteristics, and one deployment consequence follows directly: you publish rather than build, because a plain build omits the client's wwwroot/ and the Engine would serve the API but return 404 for the browser.

What replaced it. A React client built with Vite and TypeScript, compiled into the server's wwwroot/app and served by the same host at /app; / redirects there. Same origin, so it shares auth, REST, SignalR and the run event stream with no CORS and no second deployment.

The reason was not the framework. Every agent action reached the old client as one flattened string, and the UI chose between three visual treatments by substring-matching a free-text label — in three places, with two different orderings — so a file write, a shell command and a call the security gate blocked all rendered identically. Porting that model into another framework would have paid for a migration and kept the defect, so the typed contract landed first and the client second: tool results now carry a typed card, run events have a published vocabulary, and the client's TypeScript types are generated from the server's OpenAPI document rather than hand-written. The double-registration gotcha above is gone with the prerender that caused it; publish-not-build survives, for a different reason — the client's assets are compiled into the static-asset manifest at publish time.

In practice: §05 — Solution Layout for the project split, §10 — Development Workflow for the build order, §14 — Deployment for publish-not-build.

Decisions woven in, not given full entries

Six ADRs don't exhaust the choices that shaped the Engine — they're the load-bearing ones. A few others are premises or corollaries rather than stand-alone decisions. Local-first is the founding premise the whole platform serves, set out in §01 — What is the Engine rather than relitigated here. MCP-first — choosing MCP as the tool protocol at all — follows directly from in-process-only (ADR 1); the protocol and its in-process hosting are one decision told from two sides. The static-code tool tiers and the per-run tool_defaults argument layer round out the permission model and are covered where they're used, in §07 — MCP Architecture, §08 — Templates, and §09 — Configuration. The three-tier workspace storage — Production, Staging, and Temp, each with its own resource limits and retention — is likewise a structural decision in its own right, set out in §14 — Deployment. These are real decisions; they just don't each need a record of their own.

Where to go next