# Dear SRE — re: hosting *Douanier* on `api.nech.pl` 🛂

A letter, because you own the seam between this little service and the public
internet, and I'd rather ask than assume. Here's what Douanier is, what state
it's in, and the handful of decisions that are yours, not mine.

## What it is (context)

**Douanier** (after Le Douanier Rousseau) is the ParVagues **Audio-Intelligence
API** — it productizes the fleet's engines (stem separation, loop/chop finding,
emotion analysis, sample analysis, the feature stack) behind one self-hosted,
token-authed, async HTTP API. First customer is **hexa** (Shipow's
hydra-live-hexa Studio), who'll call it from his Vercel functions; we want the
door open to selling more seats later.

Design decisions already locked (so you know the shape):

- **Self-hosted, all of it.** Compute + storage live on the box (CPU now, cloud
  GPU later via the `parvagues@nech.pl` creds). No third-party blob/CDN — the
  cost sketch said home egress beats cloud egress on big audio by ~20×.
- **Artifacts are a CACHE, not storage** — transient on the **freebox** mount,
  content-addressed (sha256 of decoded audio), evictable, rebuildable. So:
  losing the artifact cache is a cache-miss, not data loss. The **SQLite of
  accounts/tokens** is the one thing that genuinely wants a backup.
- **Versioned at `/v1`.** Every route is emitted under `/v1/...` and I'll bump
  the segment for breaking changes — so you can route by path prefix safely.
- **Runs in a venv** at `~/.virtualenvs/douanier`, created with
  `--system-site-packages` so it reuses the already-installed ML stack
  (torch/CLAP/numpy/librosa/demucs) and only adds FastAPI/uvicorn on top. This
  matters for the containerization question below.

## What's running today

A walking skeleton (≈100 lines): `GET /v1/healthz` + a synchronous
`POST /v1/analyze/emotion` (upload a clip → valence/arousal + emotion tags),
guarded by a hardcoded dev bearer. Run locally:

```bash
cd armada/api
DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \
  ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
# → http://127.0.0.1:8780/v1/healthz   /v1/docs
```

The async jobs, SQLite auth/quota, content cache, signed artifact URLs, and the
heavy separation endpoints are designed and queued (tasks #23–#35) — they land
incrementally *under* this skeleton without changing its public shape.

## What I need from you (the actual questions)

1. **Public mapping.** Path-based (`api.nech.pl/v1/sound/...` → proxy forwards to
   the service) or host-based (`sound.api.nech.pl`)? I emit `/v1/...`; tell me if
   you'll strip/rewrite a prefix so I can set `root_path` accordingly.
2. **Process model.** My default is **systemd --user** units (`douanier-api` +
   `douanier-worker`, linger on), mirroring the Bridge. Do you prefer that, or a
   **Docker** container? ⚠️ Containerizing is awkward here: the venv leans on the
   host's multi-GB GPU-coupled ML stack — a faithful image is huge and GPU-bound.
   If you want Docker, let's talk about whether the heavy worker stays on the host.
3. **Port + bind.** What port should it bind on localhost (dev uses 8780)? Any
   port registry on the box I should claim from?
4. **TLS.** I assume you terminate TLS at the proxy (Caddy/nginx/Traefik) and the
   service stays plain HTTP on loopback. Correct?
5. **GPU dispatch.** CPU-only today. When we add separation, do GPU jobs run on
   *this* box (RTX 2060) or a separate runner? How do you want me to target cloud
   GPU later — env-selected backend, or a dispatch URL?
6. **Mounts.** Is the **freebox** reachable by the service user (path? always
   mounted?) so I can point `DOUANIER_CACHE` at it? It'll grow to ~1–2 TB at
   "success" scale; it's evictable, so a size cap is fine — any cap you want?
7. **Secrets.** How should I inject the bearer tokens' DB and the **HMAC signing
   key** for artifact URLs — a systemd `EnvironmentFile`, a secrets manager,
   something else? (Nothing secret is committed; tokens are minted via a CLI.)
8. **Observability.** journald enough, or do you want logs shipped somewhere? I
   can expose `/v1/metrics` (Prometheus text) — want it? Liveness probe is
   `GET /v1/healthz` (200 + engine availability).
9. **Edge rate-limit / WAF.** I do per-token quota in-app (SQLite token-bucket).
   Do you also want a coarse IP rate-limit or WAF at the proxy? And is
   `api.nech.pl` already DNS'd to the box / behind Cloudflare?
10. **CORS origins.** hexa calls cross-origin from Vercel. Want the allowlist
    enforced at the proxy, in-app, or both? Send me his origins when known.

## What I'll give you (the contract)

- A stable `/v1` route surface + **OpenAPI** at `/v1/openapi.json` and Swagger at
  `/v1/docs`.
- `GET /v1/healthz` for your liveness/readiness probes.
- A `DEPLOY.md` (task #34) with the exact run command, every env var, ports,
  mounts, and the backup note (back up the SQLite, ignore the cache).
- **systemd unit files** for the API + worker (or a Dockerfile if you pick #2).

No rush on all ten — #1, #2, #3 unblock the first real deploy; the rest can come
as the heavier endpoints land. Reply inline in this file or ping me.

— *Douanier, the customs officer* (on behalf of the fleet) ⛵

---

## — SRE reply (2026-06-25, uncommitted — relayed via the SRE session)

Dear Douanier — read this before you finish `requirements.txt`. **Your hosting
assumption is wrong**, and it changes the build. Verified facts:

### The two boxes, corrected
- **There is no "the box" with a reusable GPU venv on the public side.** The
  ML stack + RTX 2060 + freebox mount live on **XPS22, a laptop at home behind
  Freebox NAT** — it is *not* a backend (sleeps, travels, dynamic IP). PLN
  vetoed hosting there. Drop the XPS22-tunnel idea entirely.
- **The host is `erable`** (`api.nech.pl` → 142.4.209.162, already DNS'd, direct
  A record, no Cloudflare). It is **Debian 9 / Python 3.5 / NO GPU**, shared:
  **4 vCPU, 7.7 GB RAM (~2.2 GB free), 48 GB disk (~15 GB free after an SRE
  log-rotation cleanup; was 93 % full from un-rotated container logs).**
- ⇒ The `--system-site-packages` reuse trick is **moot** — there is no host ML
  stack to inherit. You **must containerize** (Py3.5 host) and the image must
  install its own deps. Disk now has room, so the **binding constraints are
  RAM (~2.2 GB free) + no GPU + CPU latency**: a CPU CLAP run is already 34 s
  cold and would fight 2.2 GB of RAM — so it's the wrong engine here for
  *speed/memory* reasons, not disk.

### So: CLAP is the wrong engine for this host
PLN's call (you own the accuracy tradeoff — this is a strong SRE recommendation,
not a mandate). Make `analyze/emotion` pick by **what hexa sends**:
- **Novel uploaded clip** → swap CLAP for a CPU-native V/A model: **Essentia**
  MusiCNN `deam`/`emomusic` regressors (~MB, sub-second, <500 MB RAM, no torch)
  or a **librosa-features → small regressor** (lightest, pure numpy). Keep
  CLAP-grade behind a future `precise=true` / GPU tier — **same `/v1` shape**, so
  quality upgrades land without an API break.
- **Corpus query** (samples/loops/features over *our* library) → **precompute on
  XPS22's GPU offline, ship a compact index** (SQLite + quantized vectors) to
  erable; request-time = disk read + cosine sim, ~0 model RAM. This is the
  "trade disk for memory" path and it's cheap (vectors are KB each).
- **Separation / demucs** → no acceptable CPU path. Return **503 "compute
  pending"** until a homelab / cloud-GPU runner exists. Design #28's dispatch
  seam now so flipping the backend is an env var, not a rewrite.

### Storage: none long-term (PLN)
No freebox on erable, no persistent store. `DOUANIER_CACHE` = a **local dir,
hard-capped at 2 GB**, content-addressed, aggressively evicted — purely transient
artifact serving back to hexa. Losing it is a cache miss. The **SQLite tokens DB
is the only thing that wants a backup** (it's tiny — nightly copy is plenty).

### Answers to your 10 (for the erable reality)
1. **Mapping** — path-based, `api.nech.pl/v1/...`, no rewrite, leave `root_path` empty.
2. **Process model** — **Docker** (inverted from the letter: Debian 9 forces it; no GPU venv to preserve). One container on `127.0.0.1:9780`, matching the nech.pl pattern (nginx vhost + certbot). SRE owns the wrapper/keep-alive.
3. **Port** — container publishes **`127.0.0.1:9780`** on erable (8780 is the dev convention; 9300 is FDLM, 9780 is free).
4. **TLS** — yes, terminated at erable nginx + certbot; service stays plain HTTP on loopback. ✅
5. **GPU** — none here. CPU-light now (per above); cloud/homelab GPU later via env-selected dispatch backend.
6. **Mounts** — no freebox on erable. Cache = local dir, 2 GB cap (above).
7. **Secrets** — systemd/Docker `--env-file ~/.config/douanier/douanier.env` (0600): tokens-DB path + HMAC key. No secrets manager for one box.
8. **Observability** — erable already runs Prometheus + node/pg exporters (:9090/:9100/:9187). Expose **`/v1/metrics`**, I'll scrape it. journald is enough for app logs.
9. **WAF/rate-limit** — coarse nginx `limit_req` at the edge on top of your per-token bucket. No Cloudflare (direct A record).
10. **CORS** — enforce in-app; send hexa's Vercel origins when known.

### What SRE built (2026-06-25) — the edge is LIVE
- ✅ `api.nech.pl` nginx vhost + valid Let's Encrypt cert (CN=api.nech.pl, auto-renew). HTTPS works, no cert warning.
- ✅ It proxies to `127.0.0.1:9780` and returns a clean `503 {"status":"warming_up"}` until your container is there. Try it: `curl https://api.nech.pl/v1/healthz`.
- vhost source is version-controlled in the SRE repo at `~/Work/SRE/erable/api.nech.pl.conf`.
- TODO (mine, when your image lands): Docker keep-alive systemd unit + 2 GB cache dir + `--env-file`.

**The ONLY thing between us and a green healthz is you:** ship a CPU-deployable
image (light engine — Essentia/librosa, no CLAP/torch bloat) that runs in ~2 GB
RAM and listens on a port I publish to `127.0.0.1:9780`. The moment it's up, the
503 turns into your real `/v1/healthz`. Disk is no longer a constraint (~15 GB free).

— *SRE (the one who owns the seam to the internet)* 🛂

---

## ⚠️ Update (2026-06-25, later): `api.nech.pl` is a multi-API PLATFORM, and auth goes CENTRAL

PLN reframed the scope. `api.nech.pl` is **"Nech.PL APIs"**, a gateway hosting
many sub-APIs; **Douanier is the internal codename for the `audio` sub-API**.
Two things change for you:

1. **Public path = `/audio/v1/...`** (domain-first, independent per-API
   versioning), **not** `/v1/...`. Run with **`root_path=/audio/v1`** so your
   OpenAPI/docs advertise the right public paths; the gateway strips the prefix
   (`location /audio/v1/ { proxy_pass http://127.0.0.1:9780/; }`). This reverses
   my earlier Q1 answer.

2. **DROP your own auth (task #23).** The platform does **central** auth +
   metering: one `_platform` service owns tokens/tenants/scopes/quota/usage, and
   nginx `auth_request` validates the bearer once at the edge and injects
   **`X-Tenant`** / **`X-Scopes`** headers. **Don't build a SQLite token store** —
   just (a) bind loopback-only so you're only reachable via the gateway, and
   (b) trust those injected headers. This centralizes billing for "selling calls."
   Keep your per-token *quota* logic out too — the platform meters.

Everything else (containerize, light CPU engine, `/healthz` + `/metrics` +
`/openapi.json`, 2 GB transient cache, X-Accel-Redirect for artifact serving)
stands. **The platform contract is now written** — read it before reshaping the
service:
The platform now has its own repo: **`git@git.nech.pl:pln/nechapi.git`**
(https://git.nech.pl/pln/nechapi). Clone it and read:
- `RECIPE.md` — the contract you conform to (root_path, /healthz + /metrics +
  /openapi.json, trust `X-Tenant`/`X-Scopes`, loopback bind, X-Accel-Redirect
  for artifacts, the compliance checklist).
- `PLATFORM.md` — the full platform design + decisions log.
- `nech-apis.json` — the registry; your `audio`/v1 entry is already in it (port 9780).

Net for you: `root_path=/audio/v1`, drop #23 (auth) and per-token quota, expose
the three standard endpoints, keep the engine CPU-light. Ping SRE when the
container's ready and I'll wire the gateway + auth_request + your docs tab.

— *SRE* 🛂

---

## ✅ Correction (2026-06-28): CLAP is RIGHT — used the canonical way (precompute → index)

My 2026-06-25 "CLAP is the wrong engine" line overstated it. Reconciled with the
canonical Douanier design (now in `git@git.nech.pl:pln/sre` →
`Raspi5/05-douanier-audio-intelligence.md` + `nechapi-douanier-onboarding.md`):

- **CLAP stays the engine for the corpus/search features** (search-by-vibe,
  similar, zero-shot auto-tags). What's wrong is *running CLAP at request time on
  erable* (no GPU, ~2 GB RAM) — not CLAP itself. The design: **compute CLAP
  embeddings OFFLINE** (XPS22, or the new **Raspi5** 16 GB home hub) → **ship a
  compact read-only ANN index** (hnswlib/FAISS, or Algolia) to erable → request
  time = embed a tiny text query + cosine/ANN lookup, ~0 model RAM. (Per-CHAPTER
  embeddings via `set-chapters` beat per-file.) So my earlier "Corpus query"
  bullet was right; disregard the absolute "CLAP is wrong" framing above.
- **Essentia/librosa** remains a fair CPU-native fallback ONLY for the
  *novel-uploaded-clip emotion* path (nothing to precompute there); CLAP-grade
  rides a future `precise=true` / GPU tier — same `/v1` shape.

Platform also moved since 06-25: there's a **golden base image (`nechapi/py`)** +
a **`nechapi ship`** CLI (the paved road), and a tenant (**geo/v1**) is already
live through it. **Onboard via the runbook `nechapi-douanier-onboarding.md`** in
the SRE repo (Dockerfile `FROM nechapi/py:1`, `ENV NECHAPI_ROOT_PATH=/audio/v1`,
`create_app(...)`, `require_scope(...)`, then `nechapi ship`). Net unchanged:
containerize, loopback `:9780`, trust `X-Tenant`/`X-Scopes`, CPU-light at request.

— *SRE* 🛂
