# Collector socket front door — request

**Goal:** the requests board port 127.0.0.1:31338 never refuses connections during a
collector restart — systemd holds the socket, a node relay forwards to the collector and
retries the backend until it binds.

**Context:** spec `docs/specs/2026-08-16-collector-socket-frontdoor-design.md` (read it —
it records the verified platform facts: node v24 serves over a systemd-inherited fd, bun
does not, `systemd-socket-proxyd`/socat absent). Collector today binds `config.port`
(31338 on this box, from `~/.config/overdeck/config.toml`; schema default 4980) in
`collector/src/server.ts` (`Bun.serve`, ~line 607); unit source
`packaging/overdeck-collector.service`, installed by `packaging/install.sh` — which
installs but does NOT enable/start units; activation is a separate explicit step and this
plan keeps that split. Clients (od-requests CLI, web app, hooks, PermissionQueue
self-URL) all target `config.port` and must not change. Reviewed by sol/low 2026-08-16;
this revision incorporates all 18 findings.

**Files:**
- Create `collector/frontdoor/relay.mjs` — socket-activated TCP relay, plain node, zero deps.
- Create `packaging/overdeck-collector-front.socket` — `ListenStream=127.0.0.1:31338`,
  `Backlog=1024`, `WantedBy=sockets.target`.
- Create `packaging/overdeck-collector-front.service` — `ExecStart=__NODE__ <deploy>/collector/frontdoor/relay.mjs 127.0.0.1:31341`
  (`__NODE__` is substituted at install time, see migrate script); `Requires=`+`After=`
  its socket unit only; NO dependency on `overdeck-collector.service`;
  `Restart=on-failure`, `RestartSec=1`, `StartLimitIntervalSec=60`, `StartLimitBurst=30`.
- Create `packaging/frontdoor-migrate.sh` — the one-shot, ordered, rollback-capable
  handoff that activates the front door on a box (contract below). `install.sh` only
  copies/installs files, exactly as it does for the collector unit today.
- Modify `packaging/install.sh` — install the two front units and relay refresh: when an
  already-ENABLED front service's `relay.mjs` content or unit file changed since the
  running copy, `systemctl --user try-restart overdeck-collector-front.service` (never
  the socket — it must stay up). No enable/start of anything new (unchanged installer
  semantics).
- Modify `collector/src/config.ts` — add optional `bind_port` to `ConfigSchema`
  (positive int; absent ⇒ resolves to `port`).
- Modify `collector/src/server.ts` + `collector/src/index.ts` — `startServer` binds
  `bind_port` on the SAME host `resolveBindHost` returns today; every other use of
  `config.port` unchanged.
- Create relay + config tests under `collector/test/` (see Acceptance).

**Contract — relay (`node relay.mjs <backendHost:backendPort>`):**
- Activation validation: require `LISTEN_PID == process.pid` and `LISTEN_FDS == 1`
  exactly; zero, multiple, or mismatched fds ⇒ exit(1) with a one-line named error.
- Per accepted client: exactly ONE backend attempt in flight at any time. Connect
  backend; on ECONNREFUSED/ETIMEDOUT/EHOSTUNREACH schedule retry in 500ms until a
  per-connection deadline of 120s (env `OVERDECK_FRONTDOOR_DEADLINE_MS`, test-only).
  On deadline: destroy the client socket within one retry interval of the deadline.
- Cleanup invariants: client 'close' during the retry phase cancels the pending timer and
  destroys any in-flight backend attempt; every failed backend socket is destroyed and
  its listeners removed; a retry timer and a concurrent successful connect never race
  into two backend sockets.
- Piping (half-close-correct): create sockets with `allowHalfOpen: true`; forward EOF as
  `end()` on the opposite side, never `destroy()`; destroy the pair only on 'error' or
  when both directions have ended; let queued writes flush (default pipe backpressure).
- Logging edge-triggered only: one line when the backend first refuses, one on recovery.
  Never one line per retry.

**Contract — `frontdoor-migrate.sh` (idempotent; safe to re-run):**
- Preconditions (each check failing ⇒ print the named reason, change nothing, exit 0 with
  front units left disabled — this is the "any other install unaffected" guarantee):
  effective config `port == 31338`; `resolveBindHost` result is loopback (no
  `tailnetBind`); a node ≥ v20 binary resolvable (prefer the v24 at
  `~/.claude/bin/node`, else `command -v node`) — its absolute path is substituted for
  `__NODE__` into the installed front service unit (or a drop-in).
- Config edit: insert `bind_port = 31341` at the TOP of `~/.config/overdeck/config.toml`
  (root table — prepending is the only append-position that is always root-scoped), via
  temp file + atomic rename, only when the key is absent anywhere in the file. Then
  VERIFY: parse the resulting file with the collector's own config loader (bun one-liner)
  and assert effective `bind_port == 31341` and `port == 31338`; verification failure ⇒
  restore the preserved original file and abort.
- Handoff order (31338 is owned by the running collector until it stops, so the socket
  cannot start first): daemon-reload → stop `overdeck-collector.service` → enable+start
  the front SOCKET immediately (31338 now held by systemd) → enable the front service
  (activated by the socket) → start `overdeck-collector.service` (binds 31341) → verify:
  collector listening on 31341, and one authenticated request through 31338 succeeds.
- Rollback on any post-stop failure: stop+disable both front units, remove the
  `bind_port` line (restore preserved config), start `overdeck-collector.service`,
  exit non-zero with the failed step named.

**Behavior:**
- SSE/long-poll streams pass through untouched (raw TCP pipe, no HTTP parsing).
- Backend down < deadline: request completes after the wait; bytes the client sent while
  the backend was down are delivered once the backend connects (client socket is not
  read into the backend until the backend socket exists — rely on kernel socket
  buffering + pausing, never buffer unbounded in JS).
- Backend down ≥ deadline: client connection destroyed within deadline + one retry
  interval.
- Relay crash: socket unit keeps 31338; systemd respawns the relay within the pinned
  rate limits.
- IPv4-only by design: every in-repo client targets `127.0.0.1` literally (config-built
  URLs), never `localhost`. State this in the unit file comment.

**Out of scope:**
- `packaging/deploy-local.sh` (slice 0, landed separately — do not touch).
- Any `bun build` bundling of the collector (slice 2).
- The deploy `restart:` service list, web/controller/botmaster units, client retry logic
  in od-requests.
- Running `frontdoor-migrate.sh` on the live box (done after review, as the landing's
  installed proof — the script must be ready and tested, not executed by you).

**Acceptance:**
- Run: `cd collector && bun test` — existing suite stays green; config test asserts
  `bind_port` defaults to `port` and an explicit value wins.
- Relay tests (spawn via `systemd-socket-activate -l 127.0.0.1:<ephemeral>`, deadline
  overridden to 3000ms):
  1. Delayed backend with payload integrity: client connects and SENDS the full request
     body BEFORE the backend starts; backend starting ~1s later receives exactly the
     bytes sent (assert body equality), responds, client receives the response. PASS.
  2. Deadline bounded both sides: with no backend ever binding, the client connection
     dies no earlier than 3000ms and no later than 3000ms + 1000ms. PASS.
  3. Client abandons during retry: client disconnects at ~1s; assert the relay exits the
     retry loop (no backend connection ever made once the backend later binds — backend
     accept count stays 0) and process fd count returns to baseline. PASS.
  4. Half-close: client half-closes (FIN) after sending; response bytes still arrive in
     full before the pair closes. PASS.
  5. Relay restart under held socket: kill the relay process while the socket activator
     stays up; a new connection afterward still succeeds (fresh relay spawn). PASS
     (under `systemd-socket-activate` this means a second activation run; assert the
     socket path still accepts).
- Migrate script tests (bash, no live systemd mutation — dry-run seam or fixture HOME):
  precondition gates each bail cleanly (port≠31338, tailnetBind set, no node); TOML edit
  is root-scoped when the file ends inside a `[table]`; edit+verify round-trips through
  the real config loader; re-run with `bind_port` present is a no-op.
- Live proof after the owner-run migration on this box (document in the PR/landing
  message, not executed by the implementer):
  `systemctl --user restart overdeck-collector.service; curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://127.0.0.1:31338/health`
  prints `401 <seconds>` — an HTTP status after a wait proves the TCP+HTTP path end to
  end without a token (versus today's immediate connection refused); AND one od-requests
  invocation during a real restart returns the board with the CLI's retry path unused;
  AND an open web SSE stream reconnects normally after the restart.
