Architecture
aleph is one small core surrounded by decoupled processes that talk to it through a deliberately designed protocol. The core holds all of the meaning — the pipeline, the state machines, the stores, routing, identity, prompts. Every other moving part is either a satellite (a process that captures input and plays output for one device or channel) or the sidecar (a process that does nothing but run ML models). Neither of them owns any product logic; they are an ear, a mouth, and a calculator wired to a single brain.
The design goal is that adding a capability is adding a process, not editing the core. A new microphone, a pair of camera glasses, a chat transport, a second LLM backend — each is a self-contained program that speaks the protocol. The core does not learn that the program exists; it only ever sees the same typed messages and the same calc results it always has. This is the antidote to the failure mode the project is rewriting away from: orchestration split across two languages, where every feature has to be threaded through both. Here, every feature lives in the core, in one language, and the seams to the outside world are narrow, typed, and stable.
This document is normative — the source of truth for the shape of aleph. It defines the role split, the boundaries, and the invariants; the code conforms to it, not the other way around. An architecture-shaping change — moving a boundary, changing who owns a decision or a piece of state, adding a process role — starts as an edit to this document, in the same PR as the code, and the code change is derived from the doc's diff. When code and doc disagree, one of them is a bug: either the code drifted (fix the code) or the document is wrong (fix the document first, then the code). It is written in the present tense and overwritten in place — no supersession notes, no dated decision graveyard; the history lives in git and in the dated design docs under docs/design/, which record how individual changes got here and are allowed to rot. The file-by-file map — where the packages, protos, and CLI entry points physically live — is docs/file-map.md.
Prefer a picture? See the live architecture map
Open the architecture map → — a diagram scanned straight out of the code's //aleph:component markers when the site is built, so it always shows exactly what this deployment ships. Each box is a subsystem you can click into: the core pipeline, the provider catalog, the wire plane, and the memory stores. The prose below is the contract; the map is the current wiring. (§9 explains how it is extracted.)
1. Three kinds of process
Everything that runs is exactly one of three roles. Keeping them apart is the whole architecture:
┌───────────── Satellites ──────────────┐ ┌──── Core (Go) ────┐ ┌── Sidecar (Python) ───┐
│ one process per device / channel │ │ │ │ │
│ │ Alabama │ pipeline, FSM, │ calc │ loaded models only │
│ mic board · camera glasses · phone │◀───────▶│ stores, routing, │◀────▶│ (built from Prepare) │
│ · Telegram · Discord · desktop app │ wire │ identity, prompts│ RPCs │ │
│ │ │ │ │ no session, no user │
│ raw capture + raw speaker hint (opt) │ │ ALL the meaning │ │ no conversation │
└───────────────────────────────────────┘ └───────────────────┘ └───────────────────────┘| Role | Owns | Never sees |
|---|---|---|
| Satellite | local capture (mic / camera / chat glue), output playback, session initiation, an advertised capability surface | memory, the pipeline, any product decision |
| Core (Go) | the pipeline, every state machine, all stores (L1–L4), routing, identity resolution, prompts, thresholds | — (it sees everything, by design) |
| Sidecar (Python) | the loaded ML models (from Prepare) and their derived caches (e.g. KV-cache) | session, conversation, user, the notion of a "turn", the vector/FTS index (the core owns it) |
Why three and not one. A monolith forces every concern to live next to every other. Splitting by what a process is allowed to know gives each one a test surface it can be reasoned about alone: a satellite is judged only by "does it capture and play correctly and speak the protocol", the sidecar only by "does the model produce the right numbers", and the core — the only place with business logic — by everything else. The two outer roles are deliberately dumb, and that dumbness is what keeps them swappable.
2. The core knows only Message
The core is modality-blind. It does not have an audio path and a separate text path and a separate event path; it has one path, and the only thing flowing through it is a role-tagged Message.
audio ─▶ [Listen adapter] ─┐
text ─▶ [text adapter] ──┼─▶ Message(user) ─▶ [ CONTEXT → THINK → SPEAK ]
event ─▶ [event adapter] ──┘ │
▼
Message(assistant) ─▶ [output adapter]
├─ speak → synth → satellite
├─ text → chat satellite
└─ silent → capture storeAn adapter is a server-internal pipeline stage that turns one modality into a Message (or a Message back into a modality). It is not the external process — that distinction matters and §3 returns to it. A conversation is just an ordered sequence of Messages, each tagged with the user it came from. A user-message does not force an assistant-message, and an assistant-message can be proactive — the two are decoupled.
Typed at the edge, uniform in the core. The edges are richly typed: a message's content is a list of typed blocks (text, image, an audio reference), not an opaque blob, and each modality declares its own wire and tool surface. But the moment input crosses into the core it is a Message, and there is one code path from there. Tool-produced input (the result of a take_photo call) and pushed input (an inbound image) normalize into the same Message shape — one core path, two entry points. This is what lets a new modality be added at the edge without a new branch in the middle.
3. Satellites: one type, many capabilities
A satellite is a transport/capture endpoint — a process that owns a device or a channel. It captures raw input (a microphone stream, a camera frame, a chat message), plays output back, initiates a session, and advertises what it can do. That is the entire job description. Crucially, the catalogue of devices is wide and getting wider — an always-on mic board, a battery voice note, camera glasses, a phone assistant, a Telegram bot, a Discord voice channel — and they are all one type.
Why one type and not "Satellite vs Adapter vs Bridge". The temptation is to give the chat bot a different name from the microphone because they feel different. They are not, where it counts: every one of them dials the same wire, opens a session the same way, and advertises a capability set the same way. The variation between a mic and a camera and a chat API is a capability axis, not a type axis — it is data in the handshake ("I have a microphone and a speaker", "I have a camera", "I am text-only with a display"), not a separate class of program. Minting three process types that all map onto the same handshake-session-capability machinery is exactly the wrong move: it adds a layer without adding a distinction. There is one kind of thing that connects to the core, and it declares what it is.
Why a separate process and not a core module. The objection: a chat transport has to reach the conversation, so why not write it inside the core? Because reaching into the core is exactly what it must not do. A transport that lives in the core grows tendrils into core state — and then the core's codebase swells with every device's API and dependencies, one transport's hung library can take the whole assistant down, and that transport is locked to the core's language. The core can't host transports anyway — an ESP32 is C firmware, a phone is Kotlin, a board is on another machine; the satellite boundary exists regardless. So the real choice is one uniform boundary (everything is a satellite, even a local Telegram bot) versus two (some transports privileged in-core, the rest on the wire) — and one is cleaner. The process boundary is also what enforces "raw input + credential, no core decisions": a module can quietly couple to core internals, a process cannot. An earlier in-core Telegram integration is the cautionary tale — it started asserting the speaker's identity itself, the exact leak the boundary makes impossible, and it has since been removed in favour of the Telegram software satellite.
The wire carries raw input — never a core decision. A satellite hands the core unprocessed capture, and at most a raw, opaque-string hint at who is speaking (§4). It does not decide who the user is, which conversation this belongs to, whether the turn is over, or what the assistant should do. Those are core decisions, and a satellite that makes them has reached across the boundary.
Relaying the user's own boundary is not deciding it. The ban above is on inference: a satellite must never conclude from the audio that the speaker has finished. It may still carry a boundary the user states outright — a held push-to-talk control, whose press and release are as much raw input as the samples between them. A capture opened that way lives until the device closes it, and the core runs no end-of-speech inference on it; every other capture, the core delimits itself. The distinction is who judged: the user, or a machine guessing at them.
"Raw" means unprocessed of meaning, not of physics: echo cancellation is a satellite concern — each device cancels its own speaker echo locally, because only the device has the reference signal; the core and sidecar never do AEC (model-integration gotchas: docs/audio.md).
4. The device's identity, and a hint at who's speaking
A satellite has exactly one identity of its own: the device's. It cannot tell the core who the user is — at most it passes along a raw hint at who is speaking right now, and the core decides what that hint means. Those are two different things on two timescales: the device proves itself once, when it connects, and each input may carry a hint about its speaker.
The device's identity is the transport key. Every satellite — hardware or software — generates a static keypair on first start, and the public key is the device identity. It proves possession during the encrypted transport handshake; the core reads the authenticated pubkey off the transport and derives device_id = fingerprint(pubkey), which keys the registry, settings, and the device's tool namespace. Nothing about the device's identity rides as a wire field or a passed-through token — it is a property of the connection itself, established below the application protocol.
The device is never the speaker. A device identifies itself, not its user: one shared kitchen board hears many people, and even a phone can be handed around. There is no device class, no owner binding, and no "personal device" shortcut that attributes speech to a user because of which device it entered through — who is speaking is decided per input, from the input itself.
Who is speaking is only a hint. A satellite may attach one optional from string to an input — an opaque speaker hint (a Telegram account, a Discord user, whatever id the channel happens to have), passed through verbatim, never resolved. The core treats it as an opaque key: it matches the whole string against its registry and decides what it means, never parsing or interpreting the id itself. Voice carries no from at all — a voice speaker is resolved by voiceprint — so in practice from is the chat-account hint. The core resolves the string, or its absence, to a User against a registry that never leaves the server.
This is the load-bearing rule, and it is easy to get wrong. The cautionary tale is concrete: an earlier in-core Telegram integration built its own identity string (telegram:<id>) and sent it in a field the core treated as a resolved speaker — the transport both choosing the identity scheme and asserting the identity, so the string never matched a real User and resolution silently failed. The boundary is the fix, and Telegram now honours it as a software satellite: the transport passes the raw id as one opaque string, and the core decides whether telegram:42 is Anna, an anonymous chatter, or nobody.
Identity is not voice. A voiceprint is one way the core recognises a speaker, not a user and not a credential the satellite handles. The core attributes a Message to a User; how that user was recognised — chat account via from, or voiceprint from the audio — is a resolution detail the core owns. The sidecar is identity-blind: it can turn audio into a voiceprint vector (EmbedVoice), but the matching of that vector to a person is the core's job, against the core's registry.
5. The sidecar only computes
The sidecar is a stateless inference server. It exposes a small set of calc verbs — transcribe, generate, synthesize, embed, embed-voice, split-sentences — and a control plane that builds and swaps models (Prepare) and aborts in-flight work (Cancel). Its only authoritative state is the set of loaded models, built entirely from Prepare. It never inspects the data stream for meaning, never decides when speech has ended, never knows what a session or a conversation or a turn is.
One backend among several. The sidecar is not reached directly. Inference is consumed through a uniform Provider interface, and the sidecar is the local backend behind it — its Go-side driver, F1, holds the set of local sidecars, the GPU admission that lets a live turn beat background work, and cancel. API backends (Anthropic, an OpenAI-compatible endpoint) are peer providers the core calls directly, with no sidecar hop. A backend declares what it can do — a set of (verb, models), derived from what it has loaded — so the router sends a transcription only to a backend that offers it; not every backend implements everything. The calc RPCs are F1's wire to the local sidecar, not the Provider interface itself: Prepare/Cancel are local-only, because you cannot Prepare a remote API. F1's GPU admission is internal to that one backend — the router picks a backend; it never sees a GPU lease.
Control plane vs data plane. Everything that configures a model — which checkpoint, which device, context size — arrives once, on the control plane, and becomes loaded state. Everything that varies per request — the audio to transcribe, the sampling temperature, the TTS speed, a cancellation — rides on the data plane, on the call itself. The split is what keeps the sidecar a pure function of (loaded models, request): the same call with the same models always does the same thing.
Stateless means no authoritative state, not no caches. The sidecar may keep derived, volatile caches — the LLM KV-cache, a warm GPU context — because they are pure optimization: drop one and the next call recomputes the identical result, only slower (self-heal). Correctness stays a pure function of (loaded models, request); the cache changes latency, never output. Exploiting it is the core's job — it sends byte-stable prompt prefixes; the sidecar's one worker thread per model serializes calls, keeping the cache coherent.
Memory search draws the line where you might not expect: the index lives in Go, not the sidecar. The core owns the rows and the vector/FTS index in its own embedded store; the sidecar only turns text into a vector (embed). Search is then an in-process query in Go — embed the query once, compare against the stored vectors, filter by scope — so no rows or index ever cross the wire and the sidecar still persists nothing. "Logic and index in Go, the embedding model in Python" draws the line exactly there.
6. The protocol is the contract
There are two protocol surfaces, and they are the most important code in the system because they are the seams that make everything else independent:
- Alabama — the satellite ↔ core wire. A satellite connects, says hello with its capability set, then streams raw input and receives output. Device identity is the transport key, not a wire field; speaker identity rides per-input as an optional opaque
fromstring; capabilities ride as a typed declaration. - The calc RPCs — the core ↔ sidecar surface, the wire of the local inference backend (§5). Typed verbs in, typed results out, plus the
Prepare/Cancelcontrol plane. The polymorphicProviderseam sits above this; the calc RPC is one backend's transport, not the interface every backend satisfies.
A protocol designed this well is not plumbing — it is the architecture made explicit. Because the boundary is narrow and typed, a satellite can be rewritten in any language, the sidecar can be moved to another machine, and a new modality can be added, all without the core changing. The cost of a well-designed seam is paid once; the cost of a leaky one is paid on every feature forever. When in doubt, spend the effort on the protocol.
7. Continuity and privacy belong to the core
Two concepts that look like one are kept apart:
- A Thread (shown in the UI as a "Conversation") is durable and owned by the core. It is a continuity anchor — the ongoing conversation — not keyed by who is in it. When a second person joins, the thread does not fork; it keeps running, and who is present is a per-turn property, never the thread's identity. Threads decay when idle and age out into episodes.
- A Session is an ephemeral transport binding between a thread and a satellite. It can die and be re-routed; the core owns routing and survives a satellite dropping off. The sidecar never sees either one.
Thread coupling is a mode, not a voiceprint inference. By default threads are device-coupled: each satellite continues its own conversation — one household thread per shared room device, deterministic, no mystery non-recognition and no cross-room bleed. Follow-me is the opt-in user-coupled mode: an identified user's turns continue their personal thread across participating devices — walk-around continuity that knowingly accepts the voiceprint dependency. Auto-resolving threads by voiceprint everywhere is deliberately rejected: it trades a deterministic default for recognition failures and cross-context confusion.
Voiceprint is the only privacy boundary. Privacy is a core concern, never asserted by a satellite — and it is gated on the one signal the core actually owns: who is speaking, established by voiceprint. The invariant: a fact written by identified user U is recalled only into turns whose identified speaker is U; anonymous voices reach the shared, household-scoped pool. The device the words pass through plays no role — you can ask your assistant about your own facts from any room, and a housemate at the same microphone can never pull them, because their voice is not yours. There is deliberately no second privacy axis: no device class, no per-device owner, no "personal device" gate. One axis carries all the privacy the product needs.
Recall is then a matter of relevance, not a device gate: every surface personalises to whoever is speaking (best-effort identity), and getting that wrong is a relevance miss, not a leak — another user's private pool is never reachable without their voice.
Privacy gates recall, never delivery. A due reminder or announcement is spoken on its origin device (falling back to the configured announce device, then the sole connected satellite) — it is never withheld or silently dropped because of who might overhear it. Anyone in the room hears it; that is the accepted household model, and silent loss is the worse failure.
8. Tools and integrations
A tool is something the assistant can do — read a calendar, set a light, take a photo, call a user's home-grown API. Tools reach the model through one uniform surface, but they come from four places, split by who owns them and how deep they reach:
- First-party providers live in the core, behind a registry-driven abstraction. Adding one — Home Assistant, calendar, web search — is one provider class plus one registry entry, no edits to the pipeline or the LLM loop. They live in-core because they need what the core already has: the settings tree, OAuth tokens, a reconcile loop that re-derives their tools when settings change. The registry is what keeps this from becoming bloat — a provider is an isolated module behind a fixed interface, not a tendril into core internals.
- The sidecar contributes a tool when a loaded model is the action — a call the core routes over the calc RPCs to the local backend (F1). This is not a separate kind the model or the wire ever sees: it federates as an ordinary server tool, and "sidecar tool" is only the core's internal routing target. It is a source by ownership, invisible as a category.
- Third-party and user-supplied tools come over MCP — the standard protocol for attaching your own integrations. This is the tool plane's equivalent of a satellite: a decoupled process, any language, fault-isolated, that the core talks to over a wire. We do not invent a plugin protocol; MCP is the extension boundary. A user adds a tool by running an MCP server, never by recompiling the core. The core may reach that server directly or supervise it as a software-satellite over Alabama — either way the server's tools federate identically.
- Device tools come over Alabama — capabilities bound to a satellite's connection (the camera on the glasses, the LEDs on a board). They live for the session and are advertised in the device's handshake.
All of them federate into one tool registry the model sees as a flat list of typed descriptors. The LLM — and the core's tool loop — never knows whether a tool is an in-core provider, the sidecar, an MCP server, or a device; it sees a name, a schema, and a way to call it. This is the same move as Message for input and the calc RPCs for inference: typed at the edge, uniform in the core.
The satellite is the trust boundary. Trust is a server-only concern with no wire representation: the Alabama wire carries no provenance, so a device can only name a tool, never assert its own source or trust. Every satellite-advertised tool — native to the device or MCP-bridged behind it — is therefore stamped peer-trusted at registration, by definition rather than by default: when the operator pairs a device they trust it transitively, and the peer vouches for everything it passes through. Third-party trust exists only for MCP servers the core connects to directly — the one class of tool whose trust the core derives itself rather than inheriting from a peer.
The dividing line, when you're unsure where a new tool belongs: first-party and needs the settings tree → an in-core provider; a thin wrapper over a loaded model → routed to the sidecar; third-party, user-supplied, heavy, or worth sandboxing → MCP; bound to a physical device → a satellite tool.
9. Inside the core: transformers, deciders, stores
The core itself splits into three kinds of component, and the split is the organizing principle for everything inside it:
- Transformers turn one thing into another and decide nothing: the input adapters (
Listen, text, event), the Think stage, the output adapters, the typed edges between them. A transformer isf(input) → output, no arbitration. - Deciders choose among options under a policy. This is the core's actual mind: every decision whose outcome crosses a component boundary and shapes the turn. There are six.
- Stores hold durable state: the L1–L4 memory layers and the registries (user, device, thread binding, provider capability, tool).
The pipeline (Context → Think → Speak) is a chain of transformers; the deciders sit at the branch points and feed the transformers their choices.
Internal arbitration is not a core decider. The test question for "is this a decider": does its outcome cross a component boundary? A choice that only one component's internals need is encapsulated, not a peer. Three standing examples: the speaker-decision (voiceprint → speaker id) lives inside Identity Resolution; end-of-speech (the endpoint state machine over raw turn scores, for the captures whose boundary has to be inferred at all — §3) lives inside the Listen transformer; GPU admission (a live turn beats background work) lives inside the F1 backend. This rule is what keeps the decider family small and honest.
The six deciders
All of them live in Go; the sidecar participates in none of them.
- Identity Resolution — opaque credential →
User, against the server-only registry. Voice is resolved by voiceprint — the single identity source on audio, on every device; chat input resolves its verbatimfromhint. Confidence is action-banded, scaled to leak risk: a medium-confidence wake voiceprint may drive the greeting (name/persona only — cannot leak), but personal-pool recall, thread attachment, and security actions (unlocking a door, disarming the alarm) require the high band — the full end-of-utterance voiceprint or a verified chat-account link. Below the band → anonymous. - Attachment — binds a
Message(user)to a durable thread, message-driven, never at session open (a connection has no stable speaker). Chooses which thread continues under the coupling mode (§7): device-coupled by default, follow-me for identified users who opted in. Participants never fork a thread. - Context Assembly — builds the prompt context: which facts and episodes are injected and in what order, speaker-scoped per the privacy invariant (§7), under a token budget — while keeping the system-prompt prefix byte-stable across
Generatecalls (the prompt-cache invariant, §5). - Tool-Arming — which tools from the federated registry (§8) are visible to the LLM this turn, as typed descriptors. Armed schemas stay byte-stable for the same cache reason.
- Provider/Model Router — chooses
(backend, model)per inference verb against the live capability registry (§5): backends advertise derived(verb, models)sets, the router matches per verb, and no match fails loudly — never a silent fallback. Routing policy (task → backend maps, cascades, cost) is deliberately thin until real need arrives; the per-verb match shape is fixed. - Output Routing — binds a
Message(assistant)to the right live session(s): reply to the originating session, proactive/scheduled output to the origin device with the fallback chain of §7, hold briefly when no session is live.
Two "routers", different axes — the Provider/Model Router maps inference → backend, Output Routing maps assistant message → session. Never conflate them.
A seventh decider, Think-Dispatch — swapping the Think stage's implementation per turn (a deterministic NLU handler vs the LLM) — is shaped but deferred: if a fast path lands it is a Think-implementation swap behind a dispatch, never a bypass around identity, attachment, or routing.
The wiring, extracted from the code
This document is authoritative; the extracted counterpart is a build artifact, never committed. Every component declares what it is with an //aleph:component id=… kind=… system=… label="…" directive on its type, and packages/aleph/server/internal/topology scans the markers into diagrams when the docs site is built. Each component names a system — core, provider, wire, or memory — so the graph partitions into one diagram per subsystem plus a stitched overview: the architecture map links through to the core pipeline, the provider catalog, the wire plane, and the memory stores. The links are relative, so they always show the wiring of exactly this deployment. The scanner's tests fail on a marker nobody consults (a forgotten cable), an edge to a component that lost its marker, a system with no components, and any decider wired into the composition root without a marker. The core, wire, and memory systems are exhaustive; the provider catalog is curated — a representative handful of integration seams stand in for the ~dozen first-party providers, so the diagram stays legible. When that extracted picture disagrees with this section, one of them is a bug (see the preamble).
10. Adding a satellite
The whole point of the shape is that a new device is a new program, not a core change. The contract:
- Speak Alabama. Connect to the core's listener, say hello, stay connected for the life of the device's session.
- Declare capabilities in the handshake. What you can capture and play — mic, speaker, display, camera, text — as a typed declaration. The core wires your input to the right adapter and your output to the right modality from this alone.
- Pass raw input; let the core resolve identity. Stream unprocessed capture. Your device identity is already proven by the transport key — you add nothing for it. Optionally tag an input with a raw, opaque
fromstring when you know the external account speaking; never resolve it yourself. Let the core resolve identity, pick the conversation, and decide the turn. - Play what the core sends back. Render speech, text, or an image; respect the capability set you advertised.
That is the entire surface. The core's pipeline, identity registry, routing, and memory all work unchanged the moment the new process connects.
What a satellite must never do: resolve identity itself, decide which conversation an input belongs to, infer end-of-speech from the capture (relaying a boundary the user states with a held control is not inferring it — §3), or bake any product semantics into the wire. What the sidecar must never do: hold session, conversation, or user state, or make a routing or end-of-speech decision. What the core must never do: learn the specifics of a single transport — if a code path in the core knows it is talking to Telegram specifically, a boundary has leaked and the fix is to push that knowledge back out to the satellite or up into a capability.
11. Self-provisioning, startup, and configuration
The binary self-provisions and self-heals — UX is non-negotiable. The audience is people who want to self-host an Alexa without becoming sysadmins: you start the binary and it works. There is exactly one manual step ever — launching aleph — and everything else is the program's job to figure out at runtime. Any runtime state the program depends on — the sidecar venv, the host-specific torch/onnxruntime/xllamacpp wheels, downloaded ML models, the staged sidecar source — is detected, validated, and repaired by the server itself on launch, with no user intervention. A half-finished model download, a venv out of sync with the lockfile, a source tree older than the binary — all self-heal. A fix that requires the user to rm -rf a folder, re-run a download, or sync a venv is not a fix — push the detection-and-repair into the server.
Concretely: provisioning/repair lives in internal/sidecar/envprep/ (venv, torch swap, accelerator-wheel swap, import verification + nukeAndResync) and in the sidecar's models.py resolvers (model downloads, cache validation); the aleph launcher (nix/packages/aleph.nix) rsyncs the staged source on every start so a rebuilt binary refreshes a stale state dir. When you add anything the sidecar loads at runtime, make its resolver idempotent and self-correcting: re-verify the artifact is complete before use, re-fetch/rebuild on any gap — never assume a cache that exists is a cache that is correct. This is the counterpart to the no-silent-fallback rule: failures surface loudly in logs, but the recovery is automatic, never a user chore.
Startup order: WebUI first, Alabama second, sidecar async — never blocks either. The WebUI HTTP listener binds and serves immediately; the Alabama (satellite transport) listener binds and announces ready; sidecar envprep + spawn runs in a goroutine, fully off the startup path. The sidecar is a background ML worker: satellites must connect, settings must load, and the management UI must render before the sidecar venv is even touched — a long envprep (uv sync, pip repair, model download) must never delay WebUI or Alabama. Concretely in cmd/aleph/main.go: startSidecar() is only ever called as go startSidecar(); there is no synchronous call.
Configuration rides the settings proto — never env vars. Every tunable (memory knobs included) flows through the proto-defined settings tree that server, sidecar, and webui consume; there are no ALEPH_* env-var side channels. Adding a field: docs/adding-settings.md.