Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 73 additions & 83 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,76 +369,59 @@ 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/<epoch>/<handle>/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 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
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
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
Comment thread
frahlg marked this conversation as resolved.
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.
Comment thread
frahlg marked this conversation as resolved.

### Who is let in, and as what

Expand All @@ -454,7 +437,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
Expand All @@ -477,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:
Expand All @@ -500,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
Expand Down Expand Up @@ -621,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
Expand Down Expand Up @@ -680,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
Expand Down
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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. |
Expand Down
Loading