diff --git a/docs/adr/0006-app-uplink.md b/docs/adr/0006-app-uplink.md
index 9954f933..555f4adb 100644
--- a/docs/adr/0006-app-uplink.md
+++ b/docs/adr/0006-app-uplink.md
@@ -8,7 +8,9 @@
- Preserves: ADR 0003's retirement of the earlier remote-access implementation.
Nothing from it returns
- Revisited by: [ADR 0008](0008-ftw-cloud.md), proposed, which adds a second,
- read-only path for homes that turn FTW Cloud on
+ read-only path for homes that turn FTW Cloud on.
+ [ADR 0010](0010-cloud-entry.md), proposed, folds that path and this relay
+ into one outbound connection.
## Context
diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md
index 8ff1cfa1..dd59d626 100644
--- a/docs/adr/0008-ftw-cloud.md
+++ b/docs/adr/0008-ftw-cloud.md
@@ -6,6 +6,9 @@
the recommendation, taken as the working choice until he answers it.
- Revisits: [ADR 0006](0006-app-uplink.md), which made the relay the only
remote path. That still holds for homes that do not turn FTW Cloud on.
+- Revisited by: [ADR 0010](0010-cloud-entry.md), proposed, which carries
+ command frames on this connection. The cloud forwards them and still
+ cannot read them.
- Turns away from: [#1323](https://github.com/srcfl/ftw/issues/1323), which
asks that no feature need a Sourceful service.
- Wire format: the normative [FTW Cloud protocol](../protocol-cloud.md).
diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md
index 13acbadb..9bef288d 100644
--- a/docs/adr/0009-watch-exchange.md
+++ b/docs/adr/0009-watch-exchange.md
@@ -22,6 +22,9 @@
- Builds on: [ADR 0006](0006-app-uplink.md), the relay as the only path that
carries commands, and [ADR 0008](0008-ftw-cloud.md), FTW Cloud, which still
never controls.
+- Revisited by: [ADR 0010](0010-cloud-entry.md), proposed. The watch's
+ exchange moves onto the cloud uplink before the relay is retired. The
+ one-shot read is already at FTW Cloud.
## Context
diff --git a/docs/adr/0010-cloud-entry.md b/docs/adr/0010-cloud-entry.md
new file mode 100644
index 00000000..76c7b97c
--- /dev/null
+++ b/docs/adr/0010-cloud-entry.md
@@ -0,0 +1,332 @@
+# ADR 0010: FTW Cloud is the single remote entry
+
+- Status: proposed. Nothing here has shipped.
+- Date: 2026-10-15
+- Decided by: Fredrik on 15 October 2026. The target in [Decision](#decision)
+ is his. Questions [(a)](#a-where-the-relay-code-goes),
+ [(b)](#b-how-the-cloud-admits-a-command-session),
+ [(c)](#c-what-stands-in-for-a-passkey-on-an-agent-command)
+ and the [webapp questions](#webapp) are open.
+- Revisits: [ADR 0008](0008-ftw-cloud.md), which keeps commands off FTW Cloud,
+ and [ADR 0006](0006-app-uplink.md), which makes the relay the only remote
+ command path. [ADR 0009](0009-watch-exchange.md) rides that relay and moves
+ with it.
+- Pair, when this is accepted: `srcfl/ftw-cloud`, `srcfl/ftw-app` and
+ `srcfl/ftw-webapp`. The wire change is a paired pull request, as ADR 0008
+ requires.
+
+## Context
+
+The box dials two hosts.
+
+- **Stockholm, for the readable copy.** `wss://cloud.ftw.energy/v1/box`,
+ `ftwcloudd` on one `t4g.small` in eu-north-1. Core's uplink is
+ [`go/internal/cloudlink`](../../go/internal/cloudlink) (#1595). The grant
+ defaults off. The service reads what the owner enabled and never dispatches.
+ Its routes are `/v1/box`, `/v1/app`, `/v1/app/once`, `/v1/now`, `/source`
+ and `/healthz`. It has no accounts, no OAuth and no WebAuthn. Process logs
+ stay on the host. Statistics (ADR 0008, C8) are not built.
+- **Frankfurt, for commands.** `wss://relay.ftw.energy`, the Node relay in
+ [`srcfl/ftw-webapp` `relay/`](https://github.com/srcfl/ftw-webapp/tree/main/relay),
+ on its own EC2 in eu-central-1 (`i-0e8fec73834292798`). It forwards opaque
+ frames, holds no keys, and keeps no routed traffic. The box's endpoint is
+ `appuplink.Endpoint`. A watch's exchange is a second socket to the same host.
+
+The iPhone app is [`srcfl/ftw-app`](https://github.com/srcfl/ftw-app), a
+separate codebase from the webapp. Its passkey lives in `Passkey.kt`: the
+challenge is local random bytes, the relying-party id is `app.ftw.energy`,
+and the box is not a relying party ([ADR 0006](0006-app-uplink.md)). The
+webapp is [`srcfl/ftw-webapp`](https://github.com/srcfl/ftw-webapp). Its
+pages are Cloudflare static assets (`wrangler.jsonc`); the Noise session
+goes to the relay. Escrow is a third host, `escrow.ftw.energy`, also
+eu-central-1, and deliberately not the relay's machine.
+
+Roles on the box are `owner`, `viewer` and `watch`
+([`contract/registry.yaml`](../../contract/registry.yaml)). The box signs
+the device list. The cloud learns devices only from that list.
+
+## Decision
+
+**The box keeps one outbound connection, to FTW Cloud in Stockholm. That
+connection carries the readable copy and the command frames. The iOS app,
+the webapp, the box and the MCP connector all use this entry. The Frankfurt
+relay is retired after they have moved.**
+
+1. **Two clients, one door.** `srcfl/ftw-app` and `srcfl/ftw-webapp` stay
+ separate products. The webapp stays up, and it is not the center. Both
+ use `cloud.ftw.energy`. An owner approves an agent in the iOS app,
+ because that is where the passkey runs.
+
+2. **One socket from the box.** The #1595 uplink carries the signed
+ readable classes and the command frames. The box stops dialing
+ `relay.ftw.energy`. The connection stays outbound. Nothing listens for
+ the cloud to dial in.
+
+3. **A command frame is Noise between the client and the box.** The cloud
+ forwards those bytes unchanged and has no key that opens an iOS or
+ webapp session. This revises ADR 0008: the service carries commands, it
+ cannot read them, and it does not choose an action. Readable classes
+ stay signed and readable. A forwarded frame is not a new cleartext
+ instruction. `challenge`, `hello_ok`, `watch`, `resync` and `purged`
+ stay the only downlink messages that mean something in the clear.
+
+4. **This entry is the door for reads, writes, remote commands, logs,
+ statistics and the MCP connector.**
+
+5. **The box issues an agent.** The owner approves it in the iOS app with
+ the passkey. The box adds a device-list row with role `agent` and issues
+ a delegated session key: scoped, expiring, and revocable from the app.
+ The cloud checks that key on every MCP call. The box checks the command
+ again. The passkey stays in the app.
+
+6. **The first agent reads live data and sends control.** Statistics and
+ logs come later, on this same entry.
+
+7. **One MCP server, many homes.** A client such as Grok Bot, ChatGPT or
+ Claude uses OAuth 2.1. The access token maps to one home's delegated
+ key. A token for one home is useless against another.
+
+8. **GitHub is a separate App.** It can open an issue or a pull request
+ after redaction. It cannot merge.
+
+```mermaid
+flowchart LR
+ ios["iOS app
srcfl/ftw-app"]
+ web["webapp
srcfl/ftw-webapp"]
+ mcp["MCP clients"]
+ cloud["FTW Cloud
cloud.ftw.energy
eu-north-1"]
+ box["Home box"]
+ gh["GitHub App
issues and PRs"]
+ ios --> cloud
+ web --> cloud
+ mcp --> cloud
+ box -->|"one outbound WSS"| cloud
+ cloud -.-> gh
+```
+
+Frankfurt (`relay.ftw.energy`) is absent on purpose. Escrow
+(`escrow.ftw.energy`) stays its own host unless the [webapp
+questions](#webapp) say otherwise.
+
+## Data flow
+
+```mermaid
+sequenceDiagram
+ participant Box
+ participant Cloud as FTW Cloud
+ participant Client as iOS app, webapp, or agent
+ Box->>Cloud: one uplink
+ Note over Box,Cloud: signed live, plan, prices, health
+ Client->>Cloud: read
+ Cloud-->>Client: the box's bytes
+ Client->>Cloud: Noise command frame
+ Note over Cloud: forward unchanged
+ Cloud->>Box: same bytes
+ Box->>Cloud: Noise reply
+ Cloud->>Client: same bytes
+```
+
+The box is still the record and the authority. A command carries an expiry
+and preconditions. Core revalidates it against fresh state. Planner output
+still never reaches hardware. Stale site-meter data still stops dispatch.
+The cloud's routing check is not that decision.
+
+An agent command is Noise between the connector and the box. The connector
+holds that agent's delegated key, so it can read what it sends. It holds no
+key for a phone or a browser. Those frames stay opaque in the forwarder.
+
+The uplink today closes on a lane 0 frame, and the relay's command lane is
+lane 0, padded. The fold needs a carrier for the opaque bytes that does not
+turn them into a readable class. That carrier is part of [(b)](#b-how-the-cloud-admits-a-command-session).
+
+## Auth flow
+
+```mermaid
+sequenceDiagram
+ participant Owner as Owner, iOS app
+ participant Box
+ participant Client as MCP client
+ participant Cloud as FTW Cloud
+ Owner->>Owner: passkey, RP app.ftw.energy
+ Owner->>Box: approve an agent
+ Box->>Box: role agent, delegated key
+ Box->>Cloud: signed device list
+ Client->>Cloud: OAuth 2.1, PKCE
+ Cloud->>Owner: consent in the iOS app
+ Cloud->>Client: token for this home only
+ Client->>Cloud: MCP call
+ Cloud->>Cloud: token, scope, expiry, roster
+ Cloud->>Box: Noise as that agent
+ Box->>Box: scope, expiry, fresh state
+```
+
+There is still no cloud account, password or user directory. The OAuth
+subject is the agent device id the box assigned. The passkey cannot run
+inside an MCP client, so the consent step hands off to the iOS app. How
+that handoff works is open under [(c)](#c-what-stands-in-for-a-passkey-on-an-agent-command).
+
+Revoking the row in the app removes it from the signed device list. The
+next call fails, at the cloud and at the box. A key past its expiry fails
+at the box even if the cloud is wrong.
+
+Working scope set, until (c) picks it: `ftw.live.read`, `ftw.plan.read`,
+`ftw.health.read`, `ftw.dispatch.write`. Not on the first cut:
+`ftw.mode.write`, `ftw.members.write`, `ftw.assets.read`, and
+`api.passthrough`. Control stays on `cmd`.
+
+## Logs and statistics
+
+**Logs.** New scope `ftw.logs.read`, opt-in, redacted, short retention. The
+agent reads its own home through this entry: a redacted cloud excerpt and,
+when the owner allows it, a redacted box excerpt the box sends up. Names,
+addresses, serials, tokens and the rendezvous secret stay out. The length
+of retention is open. Today the cloud's lines stay on the host and the
+box's lines stay on the box. ADR 0008's rule stands beside this: no staff
+read path.
+
+**Statistics.** The plug-in is a later MCP read of this home's own history,
+sent by the box under a scope the grant and the agent role both carry
+(`ftw.history.read`, and the in-memory tiles ADR 0008 already describes).
+The cross-home store (C8, `statsId`, consent, off by default) is not an
+agent API and is not mixed into a home's token.
+
+## GitHub
+
+A GitHub App, separate from the MCP OAuth client. It may open issues and
+pull requests. It has no merge permission and it never merges by itself.
+The body is redacted first: no household identifiers, no raw logs. The
+first post is a high-risk action under [(c)](#c-what-stands-in-for-a-passkey-on-an-agent-command).
+
+## Open questions
+
+### (a) Where the relay code goes
+
+The forwarder has to sit on the process that holds the box's one socket. A
+second public host would mean a second dial, or a hop. `ftwcloudd` ends TLS
+itself; a TLS proxy in front breaks the exporter binding, so the relay's
+Caddy does not move in front of the uplink.
+
+- **Port to Go in `srcfl/ftw-cloud`.** One process, one host, one
+ certificate. `relay.ftw.energy` is a certificate name and a DNS alias
+ until old clients are gone, then the name and `i-0e8fec73834292798` go.
+ The relay's dead-man switch and `POST /fleet` have to be placed in that
+ cutover. Cloud already sends `box.unreachable`.
+- **Keep the Node relay as a loopback container on the Stockholm host.**
+ Two codebases, one host. `ftwcloudd` still owns 443 and hands command
+ bytes to the container. The box does not dial the container.
+
+The blindness tests in `relay/` have to keep failing when a watt, an op
+name or a device name appears in a log or in memory.
+
+### (b) How the cloud admits a command session
+
+The property is decided: opaque Noise, forwarded unchanged, no key for an
+iOS or webapp session, nothing routed written to disk. The mechanism is
+open.
+
+- Admit a client only when the signed device list contains its key. The
+ relay's capability was knowledge of the hourly handle. The cloud already
+ has a stable home id, so the handle does not have to be the capability.
+- Tell two sessions apart without reading them. The relay broadcasts
+ because it cannot. An outer session id, still inside the home, is the
+ alternative. A watch must not take a phone's slot (ADR 0009: four
+ streams, eight exchange sessions).
+- Rate-limit a client so it cannot fill the one socket. The numbers are
+ open.
+- Carry the bytes in a form the uplink will accept. Lane 0 is refused
+ today.
+
+Terminating Noise for an iOS or webapp session at the cloud would let the
+cloud read the command. That shape is out.
+
+One socket means the service that can read live data also sees command
+sizes and timing for that home. ADR 0008 already says an operator who runs
+both paths can correlate them. Here the correlation is the connection
+itself. The privacy notice has to say so.
+
+### (c) What stands in for a passkey on an agent command
+
+The passkey still gates creating, widening and revoking the agent. An MCP
+client cannot show that prompt. Working substitute:
+
+- an allowlist of `cmd` ops per scope, taken from the registry
+- a rate cap and a magnitude cap on what an op may ask
+- a push to the iOS app, approved there, for a high-risk action: site
+ mode, planner preferences, a durable goal, a GitHub post, and anything
+ outside the allowlist
+
+Open: which ops are high-risk, the numbers, and whether the OAuth handoff
+is a device code or a universal link into the app.
+
+### Webapp
+
+The webapp uses this same Stockholm path. It is not wrapped into the iOS
+app, and it is not switched off.
+
+- **What moves.** `relay/` is the Frankfurt EC2 and goes with it. `escrow/`
+ is another eu-central-1 instance, `escrow.ftw.energy`, on purpose not
+ that machine; retiring the relay does not move the sealed recovery
+ store. The pages are Cloudflare static assets, not an EC2. Open: escrow
+ stays, or is re-hosted, or is proxied via Stockholm. Open: the static
+ host stays on Cloudflare.
+- **The browser session.** The page opens a WebSocket to
+ `cloud.ftw.energy` and runs the Noise session it runs to the relay
+ today. Open: the webapp also opens the iPhone's readable-copy session
+ (`/v1/app`), or it keeps its cache and uses only the command tunnel.
+- **Origin and passkey.** The handshake is cross-origin and must allow the
+ webapp's origin only. The relying-party id is `app.ftw.energy`. A page
+ on `cloud.ftw.energy` cannot use that id, so those passkeys would stop
+ verifying. Widening the id to `ftw.energy` would let any origin under
+ the domain derive the PRF key, which the webapp architecture refuses.
+ Working choice: pages stay on `app.ftw.energy`; the cloud socket allows
+ that origin.
+- **Order and DNS.** The working sequence is the [phase table](#phases).
+ `relay.ftw.energy` aliases to Stockholm only while an old client still
+ dials it, and only with a certificate that names it. Escrow's name does
+ not follow that alias. Frankfurt is switched off last.
+
+## Phases
+
+| Step | Content | Proof |
+|---|---|---|
+| 0 | This ADR | Accepted |
+| 1 | Opaque command frames on the uplink; Frankfurt still serves old boxes | A box with the relay firewalled accepts a command |
+| 2 | The iOS app sends commands to Stockholm | A phone, and the box is not dialing Frankfurt |
+| 3 | The webapp sends commands to Stockholm | A browser session from the allowed origin |
+| 4 | Role `agent`, delegated key, approve and revoke in the iOS app | Revoke fails the next call |
+| 5 | MCP server: OAuth 2.1, live read and control, one home per token | A second home's token is refused |
+| 6 | Alias `relay.ftw.energy`, move the watch exchange, then switch Frankfurt off | No production box dials eu-central-1 |
+| 7 | Opt-in logs, `ftw.logs.read` | A redacted excerpt expires |
+| 8 | This home's statistics on the MCP read | The aggregate store has no agent API |
+| 9 | GitHub App | A redacted issue, and no way to merge |
+
+Steps 1 and 3 wait on (a), (b) and the webapp questions. Steps 7–9 stand
+on their own after 5.
+
+## What stays
+
+- Local control, the LAN page, planning and safety need no Sourceful
+ service. After Frankfurt is gone, a home with the grant off has no
+ remote command path. That is a further step away from
+ [#1323](https://github.com/srcfl/ftw/issues/1323) than ADR 0008, which
+ still had the webapp on the relay.
+- The passkey stays in the client. The box stores no WebAuthn credential.
+- `ftwcloudd` still imports only `go/protocol` from this repository. A
+ connector in that repo speaks the app protocol as a client and does not
+ link control code.
+- Escrow's claim is unchanged unless the webapp question moves the host.
+- Lane 0's padding rule is unchanged for as long as a relay path exists.
+ The cloud connection stays unpadded, as ADR 0008 decided, so command
+ sizes are visible on it.
+
+## Consequences
+
+- ADR 0008's "not a control path" becomes "forwards ciphertext, never
+ reads it, never dispatches". Homes that leave the grant off keep a
+ readable copy of nothing, and, once the alias is removed, no remote
+ commands either.
+- Whoever runs the host can read memory, as ADR 0008 already says, and
+ can see command timing beside the readable copy.
+- A wire change is paired pull requests in this repository and
+ `srcfl/ftw-cloud`, plus the iOS app and the webapp on the steps that
+ move them.
diff --git a/docs/architecture.md b/docs/architecture.md
index ca68dc41..2cd851fb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -414,6 +414,13 @@ second outbound connection for homes whose owner turns it on. It carries to
never carries control. Commands and long history stay on the relay path above.
[protocol-cloud.md](protocol-cloud.md) specifies its wire format.
+[ADR 0010](adr/0010-cloud-entry.md), also proposed, makes that Stockholm
+connection the only remote one. The box sends the readable copy and the
+command frames on the same outbound socket, and the Frankfurt relay is
+retired after the move. The iOS app, the webapp and an MCP connector use
+that entry. Command frames stay end-to-end Noise. The cloud forwards them
+and cannot read them.
+
[ADR 0009](adr/0009-watch-exchange.md) lets an Apple Watch work without the
phone. watchOS allows no WebSocket, so each step is one HTTPS request. Commands
go to the box through the relay's exchange: the box keeps a second socket,