# Sidecar topology — the target shape

**Status:** agreed design, 2026-07-30. Owner's design; this file is the record.

**Progress.** Points 5 and 6 are partly real already, ahead of the rest: **pty** and **email** now serve
their own HTTP/WS listeners and the platform is a byte relay and a proxy onto them, with no command
vocabulary left between them. Every HTTP sidecar shares one `createSidecarProxy` factory. What has NOT
happened is the part this document is actually about — fixed ports, the platform reading a table instead
of being told at runtime, and `.env` feature toggles. Ports are still ephemeral and still announced.

Not to be confused with `sidecar-audit-2026-07.md`, which is the *audit* of the
current state (what's misplaced, and where). This is where it's going.

## The premise that makes it simple

**The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand —
friends and family included. Authentication *inside* that boundary is solving a problem we don't have, so
this design has no token work in it at all. Sidecars trust their caller exactly as they do today; the trust
boundary just moves from loopback to the tailnet.

Until that lands, things stay exposed as they are now. The security model is deliberately interim.

## The design

1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay *peers* of
   `officer` — never children. This is not a style preference: officer used to spawn the agent itself,
   which made it a grandchild, and PM2's tree-kill took the owner's chat session down on every restart.
   That was the worst thing about working on the platform, and it is fixed. Don't reintroduce it.
2. **Fixed port per sidecar**, declared once in that table. No random ports.
3. **The platform reads the table** instead of being told at runtime.
4. **`.env` enables and disables** features, i.e. sidecars. Absent sidecar = feature 503s cleanly, which is
   already how music, slskd and vault behave, so "disabled" is a tested state rather than a new one.
5. **NPM maps `/api/<feature>` straight to the sidecar** (`/api/music` → the music sidecar). Added by hand
   for now; automating it via the NPM API is the last step, not the first — no point automating a config
   that hasn't settled.
6. **The platform keeps auth, and the frontend's own concerns** — dashboards, persisted layouts, routing.
   It comes off the data path entirely.

## What this deletes

The point of the exercise, and the reason it's worth doing:

- `sidecar/connect.ts` — the dial-out-and-register loop, plus its per-sidecar reconnect backoff copies
- every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`,
  `pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started`
- most of `sidecar-registry.ts` — discovery, the pending-command map, capability lookup
- officer's proxying for anything that isn't auth or layout state

## Migration order

The original plan was music-first. In practice **pty** and **email** went first, because both had platform
code that had to move regardless, and both are now the worked examples of the end state: the sidecar owns
its transport, the platform authenticates and forwards.

What remains is the topology work itself — fixed ports, the shared table, `.env` toggles — which touches
every sidecar at once rather than one at a time. Vault last: off-limits by standing instruction, and the
least urgent anyway.

## Open questions

- **Ecosystem file, or a shared table?** The platform parsing `ecosystem.config.cjs` couples the app to
  PM2 being the thing that started it, which matters for containerising this later and for `bun dev`.
  The alternative is one plain TypeScript table (name, script, port, capability, enabled) that
  `ecosystem.config.cjs` generates its `apps:` array from and the platform imports directly — same single
  source of truth, no supervisor coupling. **Recommended, not yet decided.**
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the capabilities/items
  store, the chat session list, the file browser. Each needs a named home or officer quietly stays fat.
- **Does the registration socket survive?** Not needed for discovery once ports are static. Possibly worth
  keeping for liveness — or replace it with a health probe on the known port.
- **Capabilities.** Today a sidecar announces `capabilities: ['music']` and officer looks up by capability,
  not by name — which is what let the agent's PM2 name change from `officer-claude` to `officer-agent`
  without touching a caller. In a static table it collapses to a column. Keep it; it's cheap.
- **The non-owner account class may become dead weight.** `NON_OWNER_PATHS`, the music-only account
  scoping and the super-admin backstop exist because music is public. If friends and family join the
  tailnet instead, that whole class — and a chunk of `origin-validation.ts` — can go.

## Considered and dropped

Recorded so they aren't re-litigated:

- **Platform spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the
  platform only reads the topology.
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
  the original points 6–8. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its
  own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down
  at login, or has to be told about a logout.
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
  JWKS. Correct for a public deployment, unnecessary for a private tailnet. If the perimeter ever opens
  up again, this is the design to come back to.
- **Origin headers as the security boundary.** They aren't. `Origin: officer://<hex>` is client-chosen —
  trivially forged outside a browser, and the token is extractable from any shipped app binary. Origin
  scoping stays useful as defence in depth; it was never authentication.
