# ADR-137: Sidecar Auth Posture — Gate Before Dispatch, Fail Closed, Patch Callers First

## Status

Proposed, 2026-08-11.

<!-- The concrete instance (claude-proxy /vision) was applied to production on 2026-08-11
     under hkl's explicit "go on N1" and "apply the vision fix on the VPS too". What is
     NOT yet approved is the generalisation to all sidecars, which is what this ADR asks
     for. Acceptance requires hkl's review of that generalisation, not of the N1 change. -->

## Status History

```yaml
status_history:
  - date: 2026-08-11
    status: Proposed
    changed_by: claude (authored on hkl's explicit instruction to decide rather than ask)
    reason: >
      Closing the long-standing N1 gap on claude-proxy forced three choices that no
      existing ADR governed, and that will recur for every credential-fronting sidecar:
      where the auth check sits relative to route dispatch, whether a missing token fails
      open or closed, and in what order a gate and its callers are deployed. ADR-132
      covers gemini-proxy's gating as an instance; nothing states the posture.
    changed_via: manual (DL CLI session)
```

## Context

360LM runs three credential-fronting sidecars. Each wraps a paid-or-personal credential behind a
local HTTP endpoint, so an unauthenticated route is not a leak of data but a **leak of the
credential's capability**.

| Service | Bind | Role |
|---|---|---|
| `claude-proxy` :8788 | `root_default` only | Claude OAuth ($0/token) |
| `gemini-proxy` :8789 | `root_default` only | Gemini OAuth ($0/token) |
| `notebooklm-server` :8790 (DL) | 127.0.0.1 | NotebookLM session cookies |

On 2026-08-11 `claude-proxy` was found with `/vision` routed **above** its token check, and the
check itself written `if TOKEN and ...` — fail-open. Both were deliberate at the time and
documented in the module docstring: gating `/vision` would have required adding `PROXY_TOKEN` to
two callers' environments, "a compose edit, which is out of scope for this additive,
no-compose-touch chunk." **The scope boundary was reasonable; what was missing was a rule saying
the debt had to be repaid before exposure, not merely recorded.**

Three facts shaped the decision:

1. **The only thing preventing internet reachability was the absence of a Traefik label.** No
   published port, no label, `exposedbydefault=false`. One future label — added for an unrelated
   reason, by someone who never read the docstring — turns an internal convenience into a public
   credential proxy.
2. **A per-route check is a standing invitation to forget.** `/vision` was added five days after
   `/generate` and simply did not inherit the check, because nothing forced it to.
3. **Fail-open is invisible when it fires.** An empty or unset `PROXY_TOKEN` makes
   `if TOKEN and ...` serve every route to everyone, and the service starts, logs normally, and
   answers requests. There is no signal distinguishing "secured" from "wide open".

## Decision

**1. Authenticate before dispatch, never per route.** The token check runs as the first statement
of the request handler, above any path matching. New routes are therefore gated by default and
**forgetting is no longer possible** — the failure mode becomes "a new route is unexpectedly
gated", which surfaces immediately and loudly, rather than "a new route is unexpectedly open",
which surfaces never.

**2. Fail closed, at startup and at request time.** A credential-fronting service with no token
**refuses to start** (`raise SystemExit`), rather than starting and serving openly. `if TOKEN and ...`
is prohibited; the check is unconditional.

**3. Patch and restart callers before gating the server.** A caller sending `Authorization` to a
not-yet-gated server is harmless — the header is ignored. The reverse breaks every caller instantly.
Deployment order is therefore fixed: callers first, gate last. **Bind-mounted code counts as a
restart** — it is imported at process start, so editing the file is not deploying it.

**4. Verification must assert the payload, not the status code.** The acceptance test for a gate is
three inputs producing two distinct outcomes: no token → 401, wrong token → 401, correct token →
200 **with a body only the real work could produce**. A 200 proves the transport layer; it says
nothing about the function beneath it (see Consequences).

## Alternatives considered

- **Keep per-route checks, add one to `/vision`.** Rejected: fixes the instance, not the class. The
  next route added under time pressure repeats it exactly.
- **Rely on the network boundary alone** (`root_default`, no Traefik label). Rejected: it is one
  label away from failing, the label would be added by someone with no reason to know, and defence
  in depth costs one line here.
- **Gate at Traefik / a reverse proxy instead of in the app.** Rejected: the services are reachable
  container-to-container without traversing Traefik at all, so the gate would sit beside the door
  rather than in it.
- **Leave fail-open so a misconfigured deploy still serves.** Rejected outright: that trades a loud,
  immediate outage for a silent credential exposure of unknown duration.
- **Do nothing until the exposure decision (D2) is made.** Rejected: N1 is a precondition of that
  decision, not an output of it. Deciding exposure while the endpoint is open forecloses the safe
  answer.

## Consequences

**Positive.** New routes inherit auth without anyone remembering. A missing token becomes a
startup failure — the loudest, earliest, cheapest possible signal. The exposure decision (D2) can
now be taken on its merits rather than under a live gap. Applying it uncovered a **six-day-old
silent defect** that the old shape had concealed.

**Negative, and real.** Every caller now needs the token in its environment, which means editing
production compose and restarting production services — precisely the cost A0 deferred. Five
services cycled for N1, not the three first estimated, because bind-mounted code needs a process
restart to load. An operator adding a route now gets a 401 they did not expect; that is the
intended trade, but it will cost someone an afternoon at least once.

**The finding that justifies decision 4.** The N1 verification passed cleanly — 401 / 401 / 200 —
while `/vision` returned `ok:true` with the body *"No image file was actually provided."* The status
code was honest about the layer it measured. Beneath it, the vision path had not worked since it
shipped on 2026-08-05: the image was actually read on **roughly 1 call in 10**, `ocr-proxy` silently
fell through to **paid OpenRouter** for the rest, and one reply fabricated a "W-2 tax document" for
a stall-setup quotation. Six days, no alert, real money. A gate that passes its own test can sit
directly on top of a dead function. See toolchain lessons #70 and #71.

## Revisit if

1. **A sidecar acquires a genuinely public route** (a health check for an external monitor, a
   webhook receiver). Decision 1 assumes every POST route should be gated; a deliberate exception
   needs its own reasoning, not a quiet re-ordering of the handler.
2. **D2 resolves toward exposure** (tailnet bind or Traefik). This ADR is a precondition for that
   conversation and does not settle it; an exposed service may need per-route scopes rather than
   one shared bearer.
3. **A fourth sidecar appears that cannot fail closed** — e.g. one whose credential is optional and
   which must degrade rather than refuse.
4. **Token rotation becomes automated.** Decision 2 assumes a static `PROXY_TOKEN` from `.env`; a
   rotating secret changes what "no token at startup" means.
5. **A caller appears that cannot hold a token** (a browser, a third-party service, anything outside
   `root_default`). That breaks decision 3's ordering guarantee and needs a different mechanism.
6. **`notebooklm-server` is ever considered for non-loopback bind.** Its upstream explicitly states
   the browser profile is account-equivalent and must not become the auth story for a remote server.
   That constraint is stronger than this ADR and overrides it.

## Does NOT govern

- **Whether any sidecar should be exposed beyond `root_default`.** That is **D2 + N-Q1..N-Q4**, still
  owed by hkl, and remains the blocker on Track C M1/M2. **This ADR must not be cited as precedent
  for exposing anything.** It makes exposure *discussable*, not *approved*.
- **`gemini-proxy`'s gating**, which predates this and is covered by ADR-132.
- **What token value is used, how it is stored, or how it rotates.**
- **Prompt or payload correctness inside a route.** The N1b vision defect is recorded here as
  evidence for decision 4, but prompt design is not an architectural decision and needs no ADR.
- **DL-local sidecar scheduling/persistence** (Task Scheduler shape, crash recovery). Operational,
  not architectural.

## References

- `Documents\design-automation\N1_VISION_AUTH_PATCH.md` — full patch, blast radius, verification.
- `Documents\design-automation\CLAUDE_PROXY_EXPOSURE_PLAN.md` — N1/N3, and D2 which this does not settle.
- ADR-132 — gemini-proxy bearer gating (the instance this generalises from).
- `feedback_dl_toolchain.md` #70 (envelope vs payload), #71 (verify the verifier), #68 (blind checks).
- `/root/n1-backup-2026-08-11/` — pre-change state, N1 and N1b separately reversible.
