From d4b4d4b4adf96df061be1987c1de5709d2d029e0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:21:23 +0200 Subject: [PATCH 1/4] docs: describe the single Cloud path after relay retirement Signed-off-by: Fredrik Ahlgren --- docs/architecture.md | 120 ++++++++++++++++++------------------------- docs/roadmap.md | 1 + 2 files changed, 50 insertions(+), 71 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3c8b51d4a..8bc95da6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -369,76 +369,54 @@ from the same SQLite snapshot so older Core versions also read current settings. ## Remote access boundary -Remote access is the FTW app and nothing else. The box holds one outbound WSS -connection to `wss://relay.ftw.energy`, and beside it a second socket for a -watch's one-shot exchanges; there is no inbound listener, no NAT-traversal -layer, no cloud account and no browser-managed site directory. -See [ADR 0006](adr/0006-app-uplink.md) for why Home Link was removed rather -than kept alongside it. - -The path is five packages: - -- [`go/internal/apiauth`](../go/internal/apiauth) — who is asking. One value, - one context key and a scope set, importing nothing of the box so that every - source of callers can produce one; -- [`go/internal/appenroll`](../go/internal/appenroll) — the Noise static key, - the rotatable rendezvous secret, the single-use pairing code, the QR payload - and the list of app keys that have been let in. All of it is boot-time - material stored beside `nova.key`, not in SQLite; -- [`go/internal/appwire`](../go/internal/appwire) — frames, the Noise IK - responder and the transport; -- [`go/internal/appproto`](../go/internal/appproto) — the message layer; -- [`go/internal/appuplink`](../go/internal/appuplink) — the outbound - connection, the per-epoch rendezvous handle and session demultiplexing. - -The properties that matter: - -- the app's trust anchor arrives optically. The QR payload is a URL fragment, - which is never sent in an HTTP request, so a hostile or compelled - `app.ftw.energy` can deny service but cannot impersonate a box; -- the box is not a WebAuthn relying party and stores no user credential. It - authorises a first connection by the pairing code inside the Noise handshake - and later ones by the app's pinned static key; -- the relay forwards encrypted frames and holds no keys. The handle the box - joins under is derived per epoch from the rendezvous secret, so it rotates - hourly and leaves no stable household ID in the protocol. The relay still - sees source IP, timing and connection continuity, which can correlate a - household across handle rotations; -- the machine identity in [`go/internal/gatewayidentity`](../go/internal/gatewayidentity) - is separate and does not authenticate this connection. It resolves to a - hardware-protected P-256 key where the hardware exists and a bound software - key with the same public-key and signature wire contract otherwise, with a - deterministic adjective-color-animal display name derived from the stable - 18-hex gateway ID. The name is a label, never an authenticator; -- authority is unchanged by the transport. A command carries an expiry and - preconditions, and core revalidates against fresh state before acting. Core - remains authoritative for telemetry freshness, validation, clamps, planning - and dispatch. An unavailable relay leaves local control and local recovery - intact. - -Proposed, not shipped: [ADR 0008](adr/0008-ftw-cloud.md) adds FTW Cloud, a -second outbound connection to `cloud.ftw.energy`. A box links when it is first -paired. The link carries each phone's own session with the box as `tun`, -ciphertext the service cannot read or forge, so a phone reaches the box with -the relay unreachable; and, when an owner turns it on, a signed, readable copy -of what the owner enabled. The box admits a session through the tunnel by the -key its handshake proves, exactly as over the relay, and keeps one session per -device across both. The relay path above stays while every client moves. -[protocol-cloud.md](protocol-cloud.md) specifies its wire format. - -[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, -`/r///exchange`, under the uplink's handle, and the relay hands -it one Noise message per request behind an id of its own. The box holds those -sessions in a table of eight, apart from the phones' sessions, which hold one -slot per device, so a watch never pushes a phone out; each ends after four exchanges, a minute idle or three -minutes. Every frame on it is lane 0 apart from `hello_ok`, and every answer is -two frames, so a read, a command and a refusal have one length. A command -answers no sooner than 400 ms after it arrives, so an applied one and a refused -one take the same time unless the EV save runs longer. Reads go to -FTW Cloud in one request, which has not shipped. The watch is a device of its -own and never holds more than the phone that added it. +The web client and native iOS app use FTW Cloud. The box keeps one outbound +connection to `cloud.ftw.energy`, over QUIC or WSS. Browsers use WSS. Each +client's own Noise IK session still ends at the box and travels as `tun` inside +the Cloud connection. Core opens no socket to the retired Frankfurt relay and +advertises no old command exchange. Older box software needs an update; older +client pairings may need a new QR. There is no legacy bridge. + +The path uses these packages: + +- [`go/internal/apiauth`](../go/internal/apiauth) names the caller and scopes. +- [`go/internal/appenroll`](../go/internal/appenroll) holds the box's Noise key, + single-use pairing codes, QR payload and enrolled client keys beside + `nova.key`. +- [`go/protocol/appwire`](../go/protocol/appwire) defines frames and Noise IK; + [`go/internal/appproto`](../go/internal/appproto) handles client requests. +- [`go/internal/appuplink`](../go/internal/appuplink) holds the box's Noise + sessions and serves them over the Cloud tunnel. +- [`go/internal/cloudlink`](../go/internal/cloudlink) holds the one Cloud + connection, the signed device roster and the optional readable copy. + +The QR pins the box's Noise key. A forwarding service cannot impersonate the +box. This does not protect a web client from code served by a compromised web +origin. The box is not a WebAuthn relying party and holds no passkey: it admits +the first session by a code inside Noise and later sessions by the client's +static key. + +The service sees a stable home id, public keys, roster roles and scopes, pairing +code hashes, source addresses and traffic timing. It cannot read the inner +Noise session. A separate owner grant enables a signed, readable copy for +Cloud features; that grant is off by default. An authorized cloud agent has +its own Noise endpoint and the box checks its grant on every request. Neither +transport access nor a readable-copy grant grants an agent control. + +Lane 0 frames keep their fixed length. On the Cloud tunnel the box sends on +each reading and on a beat. Commands retain their expiry, preconditions and +Core checks. Loss of Cloud leaves local control, stored goals and local +recovery running. + +Browser offline notices travel in bounded, service-only roster rows. The box +encrypts the notice and signs a short-lived VAPID authorization; Cloud checks +the current device and epoch before sending after an outage. Anonymous daily +fleet reports use the separate `/v1/fleet` route, which keeps only daily totals +for 90 days. Escrow remains a separate service. See +[protocol-cloud.md](protocol-cloud.md) for the wire and privacy limits. + +The native Watch uses its phone for box commands. Its Cloud one-shot reads and +push registrations do not open a box command exchange. [ADR 0009](adr/0009-watch-exchange.md) +records the old relay design; Core no longer advertises or connects that path. ### Who is let in, and as what @@ -454,7 +432,7 @@ not change shape and the app's scanner needs to know nothing about sharing. A guest scans what an owner scans and learns what they are from `hello_ok`. What stops a code being replayed is unchanged and holds for both: it is spent at the first success, it expires, it survives five wrong guesses and is then burned, -and it rides encrypted inside Noise handshake message 1, so the relay carrying +and it rides encrypted inside Noise handshake message 1, so the service carrying it never sees it. Two rules protect a household from locking itself out, and both live in diff --git a/docs/roadmap.md b/docs/roadmap.md index 93d634253..c6e6f8788 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,6 +23,7 @@ of Core; new solver work belongs in Energyplan. | Area | What the baseline contains | What remains | |---|---|---| +| Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Publish the Core beta and iOS build, update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. Stop the Frankfurt relay with its disk and address kept for rollback. Keep escrow separate. | | Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. | | Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | | Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. | From fc369ff02bd9d9ff5a4bbf6a01fa9542553a7b8d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:22:04 +0200 Subject: [PATCH 2/4] docs: keep machine identity separate from the Cloud keys Signed-off-by: Fredrik Ahlgren --- docs/architecture.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 8bc95da6f..14642a1d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -395,6 +395,11 @@ origin. The box is not a WebAuthn relying party and holds no passkey: it admits the first session by a code inside Noise and later sessions by the client's static key. +The machine identity in [`go/internal/gatewayidentity`](../go/internal/gatewayidentity) +is separate from app enrolment and the Cloud link key. It uses a protected +P-256 key where the hardware supports one and a bound software key elsewhere. +The gateway's display name is a label, never an authenticator. + The service sees a stable home id, public keys, roster roles and scopes, pairing code hashes, source addresses and traffic timing. It cannot read the inner Noise session. A separate owner grant enables a signed, readable copy for From 064ec3514576be542042db0ee53f55c0c05edf75 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:34:52 +0200 Subject: [PATCH 3/4] docs: record the published clients and stopped relay Signed-off-by: Fredrik Ahlgren --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index c6e6f8788..775808e0c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,7 +23,7 @@ of Core; new solver work belongs in Energyplan. | Area | What the baseline contains | What remains | |---|---|---| -| Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Publish the Core beta and iOS build, update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. Stop the Frankfurt relay with its disk and address kept for rollback. Keep escrow separate. | +| Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. Core [v0.147.0-beta.1](https://github.com/srcfl/ftw/releases/tag/v0.147.0-beta.1) and the web client are published; iOS 0.4.8 build 13 is uploaded to App Store Connect. Cloud runs in Stockholm and the Frankfurt relay is stopped, with disk and address kept for rollback. Escrow remains separate. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. | | Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. | | Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | | Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. | From 63d55adb98e7296d8de4a48349a1ff9107ba9b09 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:36:05 +0200 Subject: [PATCH 4/4] docs: align Watch and fleet sections with the Cloud cutover Signed-off-by: Fredrik Ahlgren --- docs/architecture.md | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 14642a1d8..70bcd14b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -403,8 +403,8 @@ The gateway's display name is a label, never an authenticator. The service sees a stable home id, public keys, roster roles and scopes, pairing code hashes, source addresses and traffic timing. It cannot read the inner Noise session. A separate owner grant enables a signed, readable copy for -Cloud features; that grant is off by default. An authorized cloud agent has -its own Noise endpoint and the box checks its grant on every request. Neither +Cloud features; that grant is off by default. Core's agent checks require a +separate Noise session and a current owner grant on every request. Neither transport access nor a readable-copy grant grants an agent control. Lane 0 frames keep their fixed length. On the Cloud tunnel the box sends on @@ -460,7 +460,8 @@ session may ask for its code, and only with a passkey step-up on that request; the code binds to that phone and has a slot of its own, so it never cancels an invite. The watch holds the `watch` role's scopes that its phone holds at that moment, spends its phone's command ids, and is listed and removed with its -phone. It reaches the box through the exchange and never through the stream. +phone. The native Watch sends box commands through its phone. Cloud one-shot +reads remain read-only; the retired relay exchange is not a current route. The routes behind that list have two doors, and what each one PROVES decides what it opens: @@ -483,6 +484,11 @@ and the answer is 400. ### The code that can be read aloud +Core retains a LAN-issued spoken-code form for clients that already hold the +box's trust material. Current web and iOS pairing uses a QR over Cloud: the +service lists no spoken-code hash, so a new client key needs a new QR. The +encoding and Core checks for spoken codes are described below. + A phone with no printed QR and no other paired device still has to be able to get back into its home. The floor is somebody standing at the box, reading eight characters down a phone: `XXXX-XXXX`, forty bits from `crypto/rand`, in @@ -604,19 +610,20 @@ The honest limits, which belong here rather than in a comment nobody reads: ## Fleet ping -The box's other outbound path, and the only one that carries readable content: -once a day it posts its FTW version and channel, the driver types in use, a +The box's anonymous daily report is separate from the Cloud readable-copy +grant. Once a day it posts its FTW version and channel, the driver types in use, a battery-capacity bucket, its price zone and an install-age bucket. It answers how many reports arrive and what they describe, which helps size engineering bets. -It goes over HTTPS to a separate `/fleet` door on `relay.ftw.energy`. It never -enters the encrypted WebSocket path: that stays blind and stores no routed -traffic. The HTTP door validates the fixed payload, adds it to the UTC day's -counters and drops it. It keeps 90 days of totals, not raw reports. +It goes over HTTPS to `https://cloud.ftw.energy/v1/fleet`. It never enters the +Noise session. The HTTP handler validates the fixed payload, adds it to the UTC +day's counters and drops it. It keeps 90 calendar days of totals, not raw +reports. An operator reads totals only on the loopback metrics listener. An +explicitly saved former default resolves to Cloud; a private collector keeps +its chosen address. -The constraint is the one above: Sourceful must not be able to follow a -household. So the message carries no gateway ID, no key, no serial, no site +Fleet totals must not form a household record. The message carries no gateway ID, no key, no serial, no site name, no counter and no timestamp — nothing in it says which box sent it — values are bucketed rather than reported, the version travels only when it is a release tag so a developer's build reports as unknown, and the send time is @@ -663,12 +670,12 @@ left through it is writing an FTW-signed row into the box's own `state.db` by hand, which this design does not claim to stop. Three things this does not fix, and each is said on the Settings screen or the -relay's local stats response. The fields still describe a household, so the +Cloud service's loopback stats response. The fields still describe a household, so the payload remains a quasi-identifier: a beta box in a small price zone with a big battery may be the only one like it, and coarse buckets with a small field set are what keep that population large rather than a proof that it is. The endpoint -also sees the source IP while the request is open, though neither the relay nor -Caddy writes it. And with no id there is no way to dedupe, so the totals count +also sees the source IP while the request is open, though the Cloud service writes no +request log or source address. And with no id there is no way to dedupe, so the totals count reports, not unique boxes. A failed send is forgotten, never retried. Settings → FTW app → Fleet statistics renders the