From 5c96d96042aec9d95c0ff45a9ad24947e643fcf5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:52:46 +0000 Subject: [PATCH] docs(adr): propose FTW Cloud as the single remote entry ADR 0010 records one outbound box connection to Stockholm for the readable copy and Noise command frames, with the Frankfurt relay retired after the iOS app, webapp and MCP connector have moved. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- docs/adr/0006-app-uplink.md | 4 +- docs/adr/0008-ftw-cloud.md | 3 + docs/adr/0009-watch-exchange.md | 3 + docs/adr/0010-cloud-entry.md | 332 ++++++++++++++++++++++++++++++++ docs/architecture.md | 7 + 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0010-cloud-entry.md 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,