ArombaDocsChangelogSecurityStatus

Docs

Aromba — Architecture

Draft. This is a starting sketch, not the final design. The real architecture will be worked out in Claude Code with scalability as a hard requirement: many servers per user, many users per relay, skills and dashboard added without engine rewrites. Treat every choice below as a proposal to challenge, except the security boundaries in 05-security.md, which stay.

Three parts. Two of them are ours to run; one lives on the user’s server.

Split principle (fixed, even in the draft). The daemon must stay light (00-overview.md principle 8), so not everything can live in it. The line: anything that touches files or secrets runs on the user’s server — chores, log filtering, notebook, honeypots, the assistant’s tool calls. Anything heavy that works only on data the server already sends may run on the relay, opt-in: scene and map rendering, long-range history aggregation, share images, the hub. Voice transcription runs on the server when it can, otherwise through the AI provider by the user’s choice. When in doubt, the server keeps it.

 user's server                     aro.mba (ours)                 Telegram
 ┌───────────────────┐   HTTPS/WS  ┌───────────────┐   Bot API   ┌────────┐
 │ aromba daemon     │ ──────────► │ relay         │ ──────────► │ @Aromba│
 │  scheduler        │ ◄────────── │  pairing      │ ◄────────── │  Bot   │
 │  chores           │             │  message queue│             └────────┘
 │  watchers         │             │  (phase 4:    │
 │  assistant        │ ── HTTPS ─► │   dashboard)  │
 │  journal          │  AI provider└───────────────┘
 └───────────────────┘

The daemon does all the work. The relay only moves messages between the daemon and Telegram and, later, stores what the dashboard shows. The AI provider is called directly from the daemon; the relay never sees prompts or keys.

1. Daemon (aromba)

Two static Go binaries from one module: aromba (the Worker and the CLI) and aromba-warden (the Warden, 05-security.md section 2). Reasons for Go: single-file install with curl | sh, no runtime to install on a bare VPS, easy cross-compile for amd64/arm64 and later macOS, good process control and timeouts. The Warden links no SQLite, no WebSocket and no AI client, so it stays small and reviewable.

Packages, one job each:

BinaryPackageJob
Workerconfigconfig.toml, secrets.toml, relay.toml, network.toml; mtime-poll reload
WorkerstateSQLite at /var/lib/aromba/state.db: chore states, approvals, alerts, cache, health ring
WorkerjournalAppend-only log; builds receipts and aromba log
WorkerrelayOutbound WebSocket to aro.mba; reconnects; local queue; delivers messages and taps to bot
Workerbot, voiceCommands, buttons, settings; every outbound line formatted in one place with the style presets
Workerscan, health, inventoryFirst scan and /scan; /proc once a minute; server card and weekly inventory
Workerengine, scheduler, runnerChore loading and rules; quiet window, ticks, events; builds the run request the Warden executes
WorkerquarantineIndex, restore, 30-day purge
WorkerskillsDiscovers /opt/aromba/skills/*, validates manifests, loads chores, watchers, tools and prompt text
WorkerassistantPrompt assembly, tool loop, provider adapters, cache, notebook, budget
Workerupdate, cliSigned updates with rollback; `aromba status
WardenserverThe two sockets; peer uid checked on every connection
Wardenpolicy, redactAllow/ask/deny lists, path rules, approvals; redaction of every result
Wardencage, execbubblewrap from touches and fences; execution, timeouts, damage cap
Wardenintegrity, ladderSignature and policy checks; the eight signals and four steps
Wardenfence, honeypot, snapshotAuto-fence, decoys, snapshots for undo
Sharedproto, chore, sign, redact, versionWire formats, header parser, signature verification, the redaction corpus

Chores are bash and run as subprocesses inside the Warden’s cage; the daemon never embeds shell logic. Assistant tools are Go functions that call into the Warden’s policy first.

State: the Worker’s SQLite holds everything the Worker may change. The Warden keeps its own small state as root-owned files under /etc/aromba/warden/ — no SQLite in the Warden. Journal is a plain append-only file, rotated monthly.

2. Relay (relay.aro.mba)

Small service, Go, on a Vultr VPS in Frankfurt, Postgres behind it. Jobs:

  1. Registration and pairing: a daemon generates its own server_id and relay_token at first start and posts them with its boot_id to POST /v1/servers — open, rate-limited per IP — and gets a 6-char pairing token back. /start <token> from Telegram binds the chat: server_id ↔ chat_id ↔ relay_token. Unpaired servers are erased after 24 hours. A registration that repeats a known server_id with a different boot_id never overwrites the record; it goes to the duplicate check (14-failure.md).
  2. Messages: receives Telegram updates via webhook, forwards to the right server over its WebSocket; receives messages from servers and sends them to Telegram. In-transit only, deleted on delivery, 24 h max if a server is offline.
  3. Presence: knows if a server is online; answers the user if a message arrives while it isn’t.
  4. Phase 4: stores receipts and health numbers for the dashboard, per user.
  5. Phase 2, what we run for the fleet (05-security.md §11, 11-money.md §5–§6): the Warden’s own channel — its ladder alerts to the chat, and the signed freeze signal it fetches by long-poll, checked against the freeze public key before the relay serves it; opt-in incident reports and nightly totals, under a random install id and never beside a server; the staged rollout’s answer to “has this release reached me yet”, halted by a rise in reports and ours to let go on; the founder seat page’s sign-in and its payment watcher, which reads one chain through a JSON-RPC endpoint and holds an xpub, never a key that can spend. On a self-hosted relay none of this is configured: no admin token means no freeze and no stages (every release may go), no xpub means the founders paths do not exist.

The relay never runs commands, never sees AI traffic. It is the only part that holds the Telegram bot token. A compromised relay can read and inject chat messages, which is why red approvals show the exact command and why the terminal stays the root of trust.

Stack is deliberately boring: Go + Postgres + Caddy for TLS — one proxy, on the relay’s own machine: the relay’s limits by address trust the address that one proxy reports and no other, so a CDN in front of it would need the relay changed first. No queue service, no Kubernetes. One VPS handles thousands of idle daemons; each holds one WebSocket and sends a few KB a day.

3. Website and dashboard (aro.mba)

Static site (landing, docs, changelog, the founder seat page), served as files from our own server behind its reverse proxy. Dashboard in phase 4: auth through the bot itself (a code on the page, confirmed in the chat), reads from the relay’s Postgres through a small API. Read-only in its first release. Details in 08-dashboard.md.

4. On the user’s server

/usr/local/bin/aromba             link to /opt/aromba/current/aromba (same for aromba-warden)
/opt/aromba/releases/<version>/   one folder per installed release: both binaries and its manifest
/opt/aromba/current               link to the release that runs; swapped atomically by the Warden
/opt/aromba/previous              link to the one before it, kept for a rollback
/opt/aromba/release.json          which is which, and whether current has passed its probation
/opt/aromba/rollback.json         left by a rollback; the Worker reports it once
/etc/aromba/                      root-owned, mode 0755 (D62a): a compromised Worker cannot rename anything at this level, including the Warden's own folder below
/etc/aromba/worker/config.toml    settings (bot edits this)
/etc/aromba/worker/secrets.toml   AI key — mode 600, wiped by the Warden at the ladder's Cut
/etc/aromba/worker/relay.toml     the relay identity: server id and token — mode 600, so the chat outlives that wipe
/etc/aromba/worker/network.toml   allowed outbound hosts
/etc/aromba/worker/intake.toml    the products on this machine that hand Aromba work, and their keys — mode 600, hidden from every cage (15-instruments.md)
/etc/aromba/warden/policy.toml    fixed command policy, root-owned, signed with the release
/etc/systemd/system/aromba.service          Worker, user aromba
/etc/systemd/system/aromba-warden.service   Warden, root
/run/aromba/warden.sock           Worker → Warden requests; answers uid aromba only
/run/aromba/control.sock          terminal → Warden: panic, resume; answers root only
/run/aromba/worker/worker.sock    terminal → daemon: scan, pair, live status; root or aromba
/opt/aromba/chores/core/          shipped chores
/opt/aromba/chores/community/     added with `aromba chore add`
/opt/aromba/chores/mine/          user's and assistant's, never overwritten
/opt/aromba/skills/<name>/        installed skills
/var/lib/aromba/                  root-owned, mode 0755 (D62a), same reasoning as /etc/aromba
/var/lib/aromba/worker/state.db   SQLite
/var/lib/aromba/worker/instruments/<slug>/  what was built for a product: instrument.json and template.html (15-instruments.md)
/var/lib/aromba/journal/          append-only log, the Warden's, root-owned
/var/lib/aromba/worker/updates/<version>/  a downloaded release waiting for the Warden's verdict
/var/lib/aromba/quarantine/<date>/
/var/lib/aromba/notebook.md       assistant memory
/var/lib/aromba/models/           whisper model if voice is on

5. Daemon lifecycle

start → verify binary signature → load config → load chores and skills
      → connect relay (retry forever, backoff to 5 min)
      → if not paired: request token, print link, wait
      → run first scan once, send report
      → loop: tick scheduler · handle events · handle bot messages · run assistant turns
panic → stop scheduler, kill runners, wipe key, relay stays up in restricted mode
resume (terminal only) → reload config, back to loop

Offline behavior: chores keep running from local schedule; messages and receipts queue locally and flush when the relay is back. A server that was off during the quiet window runs its night chores on the next quiet window, not on boot.

6. Message flow, one round trip

  1. Watcher writes alert=disk 91% → engine emits disk-85 event and an alert message.
  2. bot formats it, relay sends it: {server_id, text, buttons:[{id:"look"},{id:"ignore"}]}.
  3. Relay posts to Telegram with inline keyboard, callback data = server_id:msg_id:button_id.
  4. User taps. Telegram → relay webhook → relay forwards {msg_id, button_id, from_chat} to the server.
  5. bot checks from_chat == paired chat, policy checks approval validity, then acts.
  6. Result is journaled, the message is edited via the same path.

Every message carries a nonce so replays are dropped.

7. Skills

A skill is a folder:

skills/<name>/
  skill.toml       name, version, description, kind (chores|capability|integration|knowledge),
                   weight (disk_mb, ram_mb), min aromba version, needs,
                   permissions: paths, root commands, network hosts
  chores/          same chore format
  watchers/        same, verb: watch
  tools/           optional assistant tools: a script plus a JSON schema, caged like a chore
  prompt.md        optional text added to the assistant's context while enabled
  receipt.md       optional template for its own receipt section

aromba skill add <name|url> installs from the catalog at aro.mba/skills or a git URL; the bot and dashboard do the same with a tap. The install shows weight and every permission; the user confirms each; nothing in a skill can widen its permissions later. Official skills are signed with our release key; community skills run red until reviewed and promoted. The base install carries only the system skill; the first scan suggests the rest (Docker found → docker skill, Postgres → postgres, and so on). Heavy skills (voice) show their weight against the user’s skills budget.

8. Repository layout

aromba/
  cmd/aromba/          main
  internal/            packages from section 1
  chores/core/         shipped chores
  skills/system        the only built-in skill
  skills/catalog/      official skills, published to aro.mba/skills
  relay/               relay service
  web/                 aro.mba site (phase 1: landing + install script only)
  docs/                these documents
  scripts/install.sh   what `curl | sh` fetches
  .github/workflows/   test, build, sign, release
  SECURITY.md, CLAUDE.md, README.md

One repo for daemon, relay and site in phase 1–3; split later if contributors need it.

9. Testing

10. Decisions log

DecisionWhyRevisit when
Go static binaryone-command install on bare VPSnever
Chores in bashanyone can read and write one; assistant can tooWindows support
Relay instead of bot token on each serverone leaked server can’t impersonate the botnever
WebSocket from server, no inbound portsworks behind any firewallnever
SQLite on serverzero setup, one filenever
Relay in Go + Postgres on one VPSboring, cheap> 20k daemons
No web UI before phase 4Telegram is enough to prove valuephase 4
Base install minimal, everything else a skillstays under the weight budget; users add only what their server needsnever
Warden as root, Worker with no sudoredaction and the cage both need one privileged placenever
Two binariesthe Warden stays tiny and reviewablenever
Two sockets, peer uid checked by the kernelterminal-only commands are enforced, not promisednever
Pure-Go SQLite, no cgostatic binary, plain cross-compileif size becomes a problem
Three signing keysa leaked release key can neither freeze nor rotatenever
Warden long-polls the relay for the freeze signala compromised Worker can’t block a freeze; one idle HTTPS request, not a second WebSocketnever
Registration is open, rate-limited, erased after 24 ha daemon must exist before it can be pairedabuse