From 444d1e1675da23e9d36c59da1ea377de5bc622dd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:37:02 +0200 Subject: [PATCH 1/3] docs: propose FTW Cloud (ADR 0008) and its protocol ADR 0008, status proposed, records Fredrik's decisions of 12 October 2026: a readable, read-only copy of what a home's owner turns on, served to the iPhone app, its widgets and APNs from ftwcloudd in srcfl/ftw-cloud on AWS eu-north-1. The box signs every message; the service never controls and never chooses actions; the relay, escrow and lane 0 do not change. It states the statistics rules now, because consent comes at first use, orders the work core first (uplink, cache, first screen), and records the turn away from #1323 and ADR 0006's single remote path. docs/protocol-cloud.md is the normative wire format: unpadded lane 1 frames, one signing rule, a handshake bound to the TLS session, a field table per class and scope, cadence, the closed set the service may send the box, roster, extension token, push, freshness with eight vectors described in text, and the new registry names. VISION.md and AGENTS.md gain the matching paragraph and invariants; architecture.md and ADR 0006 point to the ADR. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 + VISION.md | 13 + docs/adr/0006-app-uplink.md | 2 + docs/adr/0008-ftw-cloud.md | 288 ++++++++++++++++ docs/architecture.md | 6 + docs/protocol-cloud.md | 671 ++++++++++++++++++++++++++++++++++++ 6 files changed, 986 insertions(+) create mode 100644 docs/adr/0008-ftw-cloud.md create mode 100644 docs/protocol-cloud.md diff --git a/AGENTS.md b/AGENTS.md index 7f30fc2cf..cc1d4934b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,12 @@ Read [docs/architecture.md](docs/architecture.md) for the system map and - Every clamp protects a quantified hardware or control risk. - Persistent device state is keyed by stable hardware identity, not a YAML name. - Database queries stay in [`go/internal/state`](go/internal/state). +- Lane 0 frames have a fixed length and cadence; the padding is a privacy + control. The FTW Cloud uplink is not padded, so it sends lane 1 frames and + never lane 0. +- FTW Cloud may hold a readable copy of what the owner enabled. It never sends + control and never chooses actions. See + [ADR 0008](docs/adr/0008-ftw-cloud.md), proposed. ## Drivers diff --git a/VISION.md b/VISION.md index 4ebd1db61..8f2a6729e 100644 --- a/VISION.md +++ b/VISION.md @@ -235,6 +235,19 @@ authorized cloud agent is an endpoint that can read the data the user grants it; do not describe that access as blind. The box keeps operating when the agent, MCP service or network is unavailable. +FTW Cloud is such an endpoint. It is proposed in +[ADR 0008](docs/adr/0008-ftw-cloud.md) and has not shipped. The iPhone app is +the way into it, and it may become a paid service. When an owner turns it on, +the box sends Sourceful a signed, readable copy of what the owner enabled, so +the app starts fast and its alerts, widgets and Live Activities work while it +is closed. FTW Cloud never sends control and never chooses which actions a +user is offered; commands still go from the app to the box over the encrypted +session. Statistics need their own consent, off by default. This turns away +from an earlier aim that no feature should need a Sourceful service: the +iPhone app needs FTW Cloud, while the box does not. A household that wants +only local operation runs the box itself, updates included, and uses the +box's own page, the webapp over the blind relay and ntfy. + This document adds no new protocol operation, credential or access grant. New shared names belong in the contract registry and must ship with their validation and matching clients. diff --git a/docs/adr/0006-app-uplink.md b/docs/adr/0006-app-uplink.md index ff07470a4..9954f9338 100644 --- a/docs/adr/0006-app-uplink.md +++ b/docs/adr/0006-app-uplink.md @@ -7,6 +7,8 @@ script are deleted, not deprecated - 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 ## Context diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md new file mode 100644 index 000000000..f49ed127d --- /dev/null +++ b/docs/adr/0008-ftw-cloud.md @@ -0,0 +1,288 @@ +# ADR 0008: FTW Cloud keeps a readable copy the owner turns on + +- Status: proposed. Nothing described here has shipped. +- Date: 2026-10-12 +- Decided by: Fredrik on 12 October 2026, decisions 1–5 and 7. Decision 6 is + 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. +- 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). + +## Context + +The iPhone app needs what the relay path does not give: a first screen from a +cold start without a Noise handshake through the relay; alerts, widgets and +Live Activities that update while the app is closed; and loadpoint and +control state beyond the ten lane 0 fields. [VISION.md](../../VISION.md) asks +us to verify the relay's fit first. It does not fit: lane 0 is a fixed 512 B +frame, everything else is pulled one request at a time, the relay allows four +streams per box, and a closed app is reachable only through APNs, so some +service must hold what the push carries. A cloud paired with every box as a +Noise device would hold a key to every box. + +The earlier iOS plan stayed blind, with a sealed push gateway, more lane 0 +fields and a sealed widget mailbox. Fredrik also wants statistics across +homes, which blind storage cannot give. + +## Decision + +**FTW Cloud is a Go service, `ftwcloudd`. For each home whose owner turns it +on, it keeps a readable copy in memory of live flows, loadpoint and control +state, the plan, prices, health, events and 48 hours of 5-minute tiles. It +serves that copy to the home's phones and widgets, and it sends APNs. The box +opens the connection, picks and signs every message, and can revoke at any +time.** + +Fredrik decided the following on 12 October 2026. + +1. **Hosting and repositories.** AWS eu-north-1 (Stockholm), in an AWS + account of its own. `ftwcloudd` lives in a public repository, + `srcfl/ftw-cloud`, under AGPL-3.0-only. The wire format lives in + `go/protocol` here, under Apache-2.0 + ([#1588](https://github.com/srcfl/ftw/pull/1588)). *Why:* a service that + reads household data should show its code, and a separate module keeps + control code out of its build. +2. **Readable, not encrypted,** so Sourceful can compute statistics and + aggregates. In return the service must be very secure. It reads and never + controls, and never chooses which actions a user is offered. The box signs + every message. The relay and escrow stay blind and do not change. +3. **The iPhone app is the way into FTW Cloud,** which may become a paid + service. A household that wants only local operation runs the box itself, + updates included, with the box's own page, the webapp over the blind relay + and ntfy. This turns away from #1323; see below. +4. **Consent at first use,** in plain words. FTW Cloud is part of the app's + service, on contract (GDPR art. 6(1)(b)). Statistics have their own switch + in the same flow, off by default, on consent (art. 6(1)(a) and 7(4)). A + lawyer must confirm both. Because consent comes at first use, this ADR + sets the statistics rules now. +5. **No padding on cloud traffic.** It uses lane 1 frames, because lane 0 is + always padded. The service reads the content anyway, so padding would hide + only timing from the network, at about three times the data. +6. **Apple sees event codes.** *Assumed; Fredrik has not answered.* Alerts + carry the box-signed code and arguments in the APNs payload, because a + fetch would need a token and a round trip before the alert shows. +7. **Lane 0 and the relay do not change.** + +## What FTW Cloud is not + +- **Not the record.** The box is. Older history reaches phones over Noise. +- **Not a control path.** Commands go from the app to the box over Noise. The + service holds no credential the box accepts for a change of state. +- **Not a source of actions.** Buttons, Siri and App Intents come from app + code and from event kinds the phone has verified. +- **Not a clock, config source or update channel** for the box. +- **Not an account system.** A phone proves itself with the Noise key it + paired with the box. +- **Not needed to run the house.** + +## How it works + +``` +box ──wss, TLS 1.3, signed frames──▶ ftwcloudd ◀──wss, Noise IK──── iPhone app + │ │ ◀──https GET, token──────── widgets + │ └──APNs──▶ Apple ──▶ locked phones + └──Noise via the relay: commands, long history──▶ iPhone app, webapp +``` + +- The box dials a fixed address over TLS 1.3 and checks a service signature + bound to that TLS session before it sends data, so a proxy in the middle + reads nothing. `ftwcloudd` ends TLS itself for that reason. +- Each message belongs to one class and each class to one scope. The box + drops driver names, device capabilities and inventory and uses opaque ids. + The service forwards the box's bytes unchanged, by scope. +- The app runs Noise IK to a service key built into the app and gets the + current state in the handshake reply. Widgets read with a short-lived token. +- Alerts carry the box-signed event. The phone checks it, renders its own + text and picks buttons from the verified kind. +- The home box sends about 5 MB a day. +- If the service is down, the app falls back to the relay and iOS alerts + stop; web push and ntfy go on. + +The [protocol](../protocol-cloud.md) has the classes, field tables, cadence, +grant routes, limits and timers. + +## Control boundary + +**FTW Cloud reads and never controls. It never chooses the actions a user is +offered.** Code enforces this: + +- The service can import only `go/protocol` from this repository, so it + cannot link control code. In Core, an import test keeps control, API and + loadpoint writers out of the uplink package. +- The service may send the box five message types. Anything else closes the + socket, under a fuzz test. The box issues a grant only when an owner asks. +- A false message meant to make a user tap the wrong action meets three + limits: buttons come from verified kinds, state is signed, and a `cmd` keeps + the guards the box checks against fresh state. + +Control still needs a relay session, so the app shows the data path and the +control path as two states. + +## Access + +- **The grant lives on the box.** A missing grant reads as **off**, the + opposite of `app_link`. An owner grants over an app session; any door may + revoke. Every change notifies all owners. +- **Scopes.** Live, history, plan, health and the new `ftw.events.read`. + Inventory stays out. +- **Roster.** The box signs the list of paired devices, their keys and the + scopes their roles allow. The service learns devices only from it. +- **Widgets** get a 7-day read token for live data and prices. It is not a + device key and cannot command. +- **Revocation.** A device revoke reaches the service within a second. A grant + revoke deletes the home there, and the box keeps its key until it holds a + signed purge receipt. + +## Freshness + +Data read through FTW Cloud carries the new carrier state `cloud` and never +looks fresher than the box said it was. The phone trusts a send time only +through an anchor it can check, and shows unanchored data as "cloud, age +unverified", never live. A replay keeps its old send time and shows as stale. +The service cannot sign, so it cannot clear staleness. The +[protocol](../protocol-cloud.md#freshness) states the rules and eight test +vectors. + +## Statistics + +**The start flow has two switches.** The first turns FTW Cloud on; the iPhone +app needs it, and it lists what leaves the house. The second turns statistics +on; it is off by default, and the app works the same either way. A change to +purpose or fields needs a new policy version and fresh consent. + +- **Purpose.** To learn how homes with FTW use and shift energy, to improve + FTW's planning and defaults, and to publish figures about homes as a group. + Not for advertising, decisions about one home, or handing rows to anyone. +- **Fields.** Per closed hour, the site's energy totals in Wh, rounded to + 10 Wh, under the ledger's flow names; the price zone; which of battery, + solar and charger the site has; the policy version. Nothing finer than an + hour, and no names, addresses, IP addresses, serials, coordinates, VINs, + config or logs. +- **`statsId`.** 128 random bits drawn by the box at consent, derived from + nothing else. A new consent draws a new one. +- **Retention.** Rows go 13 months after the hour they describe. Withdrawal or + a purge deletes them within 24 hours. Published aggregates stay, and the + consent screen says so. +- **Cell rules.** At least 20 homes per figure, nothing finer than a price + zone or an hour, and no home above 30 % of a cell's sum. Code applies the + rules; no person reads raw rows. +- **Partners.** Homes under a partner's control stay out unless that + partner's contract allows them in. Nova keeps B2B partner telemetry. +- **Law.** The service links `statsId` to the home so a purge can reach it, + which makes the rows personal data (CJEU C-413/23 P). Serving the owner's phones + is necessary for the service asked for and meets the exemption in LEK + 9 kap. 28 §; statistics are not, so they need consent. A lawyer confirms. + +## Security and retention + +The relay protects data by being unable to read it. The service can read it, +so its protection comes from controls: grants only from an owner and off by +default; a preview of the exact bytes; signed messages over TLS bound to the +handshake; one code path to a home's data, with canary homes that alert if one +home's data reaches another; no staff read path, under a test; two-person +review, signed images and the running digest published at `/source`; host +access only through logged SSM; outbound traffic only to APNs and AWS; and an +erase test. The legal work is a privacy notice in Swedish and English, terms, +a DPIA, records of processing, a DPA with AWS, AWS and Apple as the only +subprocessors, a breach process, and export and erase. + +**The honest limit:** someone with root on the service host can read memory. +The privacy notice says so. + +| Store | Kept | +|---|---| +| Live state | Memory only; readings dropped an hour after the box goes quiet | +| Registry and APNs tokens | Until purge, or 90 days without a roster | +| Events | A one-hour ring in memory | +| Access logs | 14 days, with a keyed hash instead of the address | +| SSM sessions and deploy log | 24 months | +| Backups | None; boxes refill the service | +| Statistics | 13 months | + +## Operations and cost + +One `t4g.small` in eu-north-1, apart from the relay and escrow, with SQLite on +an encrypted volume and no standby; a tested drill rebuilds it. The budget is +at most 3,000 lines of Go in the service, about 1,700 in Core and 900 in +Swift. Estimated cost per month: $25–35 for 1,000 homes, $60–150 for 10,000, +and $600–1,600 for 100,000. A wire change needs paired pull requests here and +in `srcfl/ftw-cloud`. + +## Local-only operation and #1323 + +[#1323](https://github.com/srcfl/ftw/issues/1323) asks for a tested rule that +no feature a household needs may depend on a Sourceful service. **This ADR +turns away from that rule for the iPhone app, on purpose.** + +- The box stays complete on its own. Control, planning, safety, history, the + box's own page and ntfy alerts need no Sourceful service, and that should + become a tested rule. +- The iPhone app does not. Its fast start, alerts, widgets and Live + Activities need FTW Cloud, which may cost money. +- A household that wants no Sourceful service runs the box itself, updates + included, with the box's own page and ntfy. One that accepts the blind relay + adds the webapp and web push. +- The blind push gateway in the earlier iOS plan is shelved. An iOS alert + needs FTW Cloud. + +## What changes from ADR 0006 + +ADR 0006 said one remote path means one thing to threat-model, one pairing +story and one place freshness can go wrong. For homes that turn FTW Cloud on, +there are two paths and two places for freshness to go wrong; the carrier +state `cloud` and box signatures answer the second. Pairing stays one story, +because the service learns devices only from the box. + +ADR 0006 also removed Home Link partly because the relay learned the +household. FTW Cloud brings back a stable household id at a Sourceful +service, by the owner's choice, and whoever runs both relay and service can +link a relay handle to a home by IP and timing. The privacy notice says so. +Homes without FTW Cloud keep ADR 0006 as it is. + +## Milestones + +The core comes first: the uplink, the cache and the app's first screen. Each +later step stands on it and ships on its own. + +| Step | Content | Proof | +|---|---|---| +| C1 | This ADR, the protocol, `go/protocol` ([#1588](https://github.com/srcfl/ftw/pull/1588)), the registry names | Drift CI green; ADR accepted | +| **C2 Uplink** | Grant, `cloud.key`, preview; signed `live`, `lp`, `prices`, `hb`; roster; import ban | 24 hours of the home box against staging; preview equals received bytes; a dead service leaves control unchanged | +| **C3 Cache** | `ftwcloudd`: uplink, memory cache, roster, app session, deployed in eu-north-1 | A revoke stops the stream within a second; a rebuilt host refills | +| **C4 First screen** | The app's socket to the service, carrier `cloud`, freshness, relay fallback, the start flow | p95 cold start on mobile data, against the relay; a replay shows stale | +| C5 Alerts | Signed events, notification extension, `box.unreachable` | A forged alert shows a generic line | +| C6 Widgets | Extension token, tiles, widget push | A force-quit phone shows tomorrow's prices by 13:30 | +| C7 Live Activity | Signed state in the extension view | A forged state shows "Open FTW" | +| C8 Statistics | Ingest, backfill, cell rules | DPIA signed; withdrawal purges within 24 hours | + +The statistics switch ships with the start flow in C4. The box records consent +and `statsId` from then on, nothing is sent until C8, and the backfill covers +the gap. A public launch needs C2–C4 and the legal work. + +## Alternatives considered + +- **Stay blind:** no statistics, and every new surface needs a new sealed + path. +- **The service in this repository:** it would link control code into an + 18 MB binary, beside the box's release keys. +- **A proprietary service:** it cannot link AGPL code others wrote, and trust + would rest on promises. +- **Cloudflare Workers:** APNs only through undocumented behaviour, and EU-only + TLS needs an Enterprise plan. +- **API Gateway, IoT Core or Supabase Realtime:** per-message billing, about + $57,000 to $143,000 a month at 100,000 homes. + +## Risks + +| Risk | Cheapest way to reduce it | +|---|---| +| One bug exposes every home | One access path, canary homes, no readings on disk | +| An insider or the deploy path | Two-person review, a published digest, an SSM alert | +| Composing state costs too much CPU on a Pi | Bench in C2 | +| Signatures cost data on a 4G box, about 150 MB a month | Measure in C2 | +| The network sees burst timing; Apple sees event codes | The privacy notice; no names in event arguments | +| Claims or defaults drift back | Claim tests; a missing grant reads as off | +| A paid app meets App Store purchase rules | Decide the payment path before charging | +| AGPL code in the App Store | An App Store exception in `ftw-app` before outside code arrives | diff --git a/docs/architecture.md b/docs/architecture.md index c4c415343..752feb1fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,6 +356,12 @@ 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. +Proposed, not shipped: [ADR 0008](adr/0008-ftw-cloud.md) adds FTW Cloud, a +second outbound connection for homes whose owner turns it on. It carries a +signed, readable copy of what the owner enabled to `cloud.ftw.energy`, and it +never carries control. Commands and long history stay on the relay path. +[protocol-cloud.md](protocol-cloud.md) specifies its wire format. + The path is five packages: - [`go/internal/apiauth`](../go/internal/apiauth) — who is asking. One value, diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md new file mode 100644 index 000000000..62c8541a4 --- /dev/null +++ b/docs/protocol-cloud.md @@ -0,0 +1,671 @@ +# FTW Cloud protocol + +Proposed with [ADR 0008](adr/0008-ftw-cloud.md). Nothing here has shipped. +This document is normative: "must" is a requirement on the box, the service +or the phone. Byte-level test vectors land with the code, in this repository +and in `srcfl/ftw-cloud`. Until then the text below is the reference, and the +vectors described in [Freshness](#freshness) are the cases they must cover. + +The app protocol, which runs inside Noise between a phone and its box, is +specified in +[srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md). +FTW Cloud reuses its frame layout, CBOR rules and tile format, and changes +nothing in it. Lane 0 and the relay stay as they are. The Go types for both +live in `go/protocol`, under Apache-2.0 +([#1588](https://github.com/srcfl/ftw/pull/1588)). + +## Parties and connections + +- **Box:** FTW Core with an FTW Cloud grant. +- **Service:** `ftwcloudd`, at `cloud.ftw.energy`. +- **Phone:** the iPhone app, paired with the box over Noise as today. +- **Extension:** a widget, notification service extension or Live Activity + on that phone. + +| Connection | Opened by | Transport | What protects it | +|---|---|---|---| +| Uplink | Box | `wss://cloud.ftw.energy/v1/box` | TLS 1.3, a handshake bound to the TLS session, every box message signed | +| App session | Phone | `wss://cloud.ftw.energy/v1/app` | TLS, then Noise IK to the service's static key | +| Extension read | Extension | `GET https://cloud.ftw.energy/v1/now` | TLS and an extension token; the data inside is box-signed | +| Push | Service | APNs | Box-signed payloads where they carry data | + +**Uplink rules.** + +- The endpoint is a compile-time constant in Core, as the relay's is. +- TLS 1.3 is the minimum. The dialer uses no proxy, whatever the environment + says. +- Reconnects back off with full jitter from 0.5 s to 60 s. The attempt + counter resets after 60 s connected. +- The service sends a WebSocket ping every 15 s. The box's read deadline is + 45 s and its write deadline 10 s. +- WebSocket compression is off. +- A box keeps one uplink. A second one from the same home replaces the first. + +## Frames + +Each WebSocket binary message carries exactly one frame in the app +protocol's layout: + +``` +offset field type value on FTW Cloud +0 ver u8 1 +1 lane u8 1, always +2 flags u8 0 +3 rsvd u8 0 +4 len u16 BE payload bytes +6 payload u8[len] CBOR envelope +``` + +- **No padding.** The message is exactly `6 + len` bytes. Lane 0 never + appears on an FTW Cloud connection. +- A receiver closes the connection on a lane 0 frame, a nonzero flag, bytes + after `len`, or a text message. +- Inside the app session the same frames ride as Noise transport messages, + also without padding. + +Lane 0 is padded to a fixed size and cadence because the relay must learn +nothing from the stream. The service reads the content, so padding would hide +only timing from the network, at about three times the data. The network sees +when bursts happen, and the privacy notice says so (ADR 0008, decision 5). + +## Envelope and signature + +``` +Envelope = { t: text, b: bstr, sig: bstr } +``` + +- CBOR, core deterministic encoding, text keys. No `id`: nothing on this link + is a request with a reply id. +- `b` is the body, itself deterministic CBOR, carried as a byte string so that + the signature covers exact bytes and the service can forward them unchanged. +- `sig` is ES256 (ECDSA on P-256 with SHA-256), 64 bytes, `r ‖ s` + big-endian. +- **Signed input:** the bytes `ftw.cloud.v1\0` (12 ASCII bytes and a zero + byte), then the deterministic CBOR encoding of the array `[home, t, b]`, + where `home` is the text home id. +- The box signs with `cloud.key`. The service signs `hello_ok` and `purged` + with its service key. Every other envelope the service sends has an empty + `sig`. +- A receiver verifies over the bytes it received, never over a re-encoding. +- The box's public key reaches phones over Noise, beside the table of opaque + ids, in the session information every role receives. It never comes from + the service. +- Unknown keys in a body are ignored, as in the app protocol. An unknown `t` + from the box is dropped and counted by the service, so a newer box works + with an older service. An unknown `t` from the service closes the uplink. +- The service forwards a home's messages in the order it received them. + +## Keys and ids + +| Name | What it is | +|---|---| +| `cloud.key` | ES256 (P-256) key the box makes when an owner grants FTW Cloud. Never backed up | +| `pk` | Its public key as a 65-byte uncompressed SEC1 point | +| `home` | Lowercase base32, no padding, of the first 16 bytes of SHA-256(`ftw.cloud.home.v1` ‖ `pk`). 26 characters | +| Service key | P-256. Core carries up to two public halves, so the key can rotate across a release | +| Service Noise key | X25519 static key. The app carries up to two | +| `grantKey` | 32 random bytes in the grant record. A new grant draws a new one | +| Opaque source id | First 8 bytes of HMAC-SHA256(`grantKey`, `src\0` ‖ driver name), base64url without padding | +| Opaque loadpoint id | First 8 bytes of HMAC-SHA256(`grantKey`, `lp\0` ‖ loadpoint id), base64url without padding | + +A home id comes only from the key that signed `hello` on this connection, +never from a body field. + +## Common body fields + +Every body the box sends carries these four fields. The class tables below do +not repeat them. + +| Field | Type | Meaning | +|---|---|---| +| `seq` | uint | Starts at 1 and rises by 1 per message, per boot | +| `boot` | bstr, 16 B | Random, drawn at each Core start | +| `uptimeMs` | uint | Box monotonic time since start, at send | +| `wallMs` | uint | Box wall time at send. Present only while NTP has synced the box clock | + +## Handshake + +1. **Service → box: `challenge`** `{v: 1, nonce: bstr32}`, unsigned. +2. **Box → service: `hello`**, signed. The service computes `home` from `pk`, + checks the signature and that `nonce` is its own, or closes. + + | Field | Type | Meaning | + |---|---|---| + | `v` | uint | 1 | + | `pk` | bstr, 65 B | The box's public key | + | `nonce` | bstr, 32 B | Echo of the challenge | + | `boxNonce` | bstr, 32 B | Random, for the service to sign | + | `build` | text | Core version | + | `clock` | text | `ntp` or `none` | + | `caps` | [text] | Uplink features this box speaks | + | `rosterSeq` | uint | The box's current roster number | + | `grantId` | bstr, 16 B | The grant this connection serves | + +3. **Service → box: `hello_ok`**, signed by the service key. + + | Field | Type | Meaning | + |---|---|---| + | `boxNonce` | bstr, 32 B | Echo | + | `ekm` | bstr, 32 B | The TLS exporter value (RFC 8446 §7.5), label `EXPORTER-ftw-cloud-v1`, empty context | + | `have` | map text→text | Tile id to etag, for the tiles the service holds | + | `rosterSeq` | uint | The roster number the service holds | + + The box checks the signature against a service key it carries, that + `boxNonce` is its own, and that `ekm` equals its own exporter value for + this TLS session. A proxy that ends TLS in the middle has a different + exporter value. If any check fails, the box closes and backs off. **It + sends nothing but `hello` before this check passes.** This is why the + service must end TLS itself rather than behind a proxy. +4. **Box → service:** `roster`, when the service's number is lower; then one + full message of each class the grant allows; then the tiles missing from + `have`. +5. After that the box sends on change, by the cadence rules. + +## Classes + +The box sends these. Each class belongs to one scope, and the service +forwards a class only to devices whose effective scope includes it. These +tables are the allowlist: a field the box sends must have a row here, and a +test fails on any field without one. Driver names, device capabilities and +inventory have no rows. + +### `live`: `ftw.live.read` + +| Field | Type | Meaning | +|---|---|---| +| `fields` | map uint→int | Frozen field ids 1–6 and 10, in the registry's units | +| `fieldSources` | map uint→[text] | The sources each field's freshness follows | +| `sources` | map text→Source | The source table. Sent when `srcRev` changed since the box's previous `live`, and in every full message | +| `srcRev` | uint | Rises by 1, per boot, each time the source table changes | +| `dispatchBlockedBy` | [text] | Sources whose staleness stops dispatch | + +**Source** + +| Field | Type | Meaning | +|---|---|---| +| `kind` | text | As in the app protocol | +| `state` | text | `live`, `lagging`, `stale`, `down` or `never`, as the box grades it | +| `lastOkMs` | int | Box uptime at the last good reading | +| `staleAfterMs` | uint | How long a reading from this source stays current | + +### `lp`: `ftw.live.read` + +| Field | Type | Meaning | +|---|---|---| +| `controlRev` | uint | The box's control revision, as a `cmd` guard needs it | +| `loadpoints` | [Loadpoint] | One per loadpoint | +| `feedback` | [Feedback] | One per device function FTW commands | + +**Loadpoint** + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | Opaque loadpoint id | +| `plugged` | bool | A car is connected | +| `socPermille` | uint or null | Car charge level, null when unknown | +| `socSource` | text | `vehicle`, `inferred` or `assumed`; empty when unplugged | +| `socAgeS` | uint or null | Seconds since the level was last confirmed, at send | +| `targetPermille` | uint or null | The level to reach | +| `readyByMs` | int or null | Wall time the car should be ready | +| `recurring` | bool | The target comes from a weekly schedule | +| `powerW` | int | Charging power, positive while charging | +| `sessionWh` | uint | Energy delivered this session | +| `session` | text | Session id; changes at each plug-in | +| `manual` | Manual or null | Manual charging state, null when none | +| `commandedW` | int or null | Power last commanded | +| `commandedReason` | text | Stable code for that command | +| `surplusOnly` | bool | Charging from surplus solar only | +| `gridDeferred` | bool | Grid charging deferred by the plan | +| `planPending` | bool | A replan is due | +| `boost` | bool | Battery boost is on | +| `nextStartMs` | int or null | Wall time the next planned charge starts | +| `nextEndMs` | int or null | Wall time it ends | +| `nextWh` | uint or null | Energy it plans to deliver | + +**Manual** + +| Field | Type | Meaning | +|---|---|---| +| `state` | text | The manual state | +| `sinceAgeMs` | uint | Time in that state, at send | +| `requestedW` | int or null | Power the user asked for | +| `commandedW` | int or null | Power commanded for it | +| `reason` | text | Stable code | + +**Feedback** + +| Field | Type | Meaning | +|---|---|---| +| `kind` | text | The device function, such as battery or charger | +| `src` | text | Opaque source id | +| `status` | text | `following`, `waiting`, `limited`, `not_following`, `no_contact` or `not_controlled` | +| `reason` | text | Stable code | +| `severity` | text | As on the box | +| `evidence` | text | `none`, `accepted`, `measured` or `confirmed` | +| `fresh` | bool | The readings behind the status are fresh | +| `actualW` | int or null | Measured power | +| `ageMs` | uint or null | Age of that reading by box uptime, at send | + +The change test ignores `socAgeS`, `sinceAgeMs` and `ageMs`. The phone adds +the time since the message. + +### `prices`: `ftw.live.read` + +| Field | Type | Meaning | +|---|---|---| +| `zone` | text | Price zone | +| `currency` | text | ISO 4217 code | +| `t0Ms` | int | Wall time the first slot starts | +| `slotMs` | uint | Slot length. Where stored slots differ in length, the box repeats a longer slot's price across `slotMs` slots | +| `spotMinor` | [int or null] | Market price, minor units per kWh | +| `totalMinor` | [int or null] | What the household pays, tariff and VAT included | +| `stale` | bool | The slots do not cover the window the box holds prices for | + +`totalMinor` shows the household's tariff. The privacy notice says so. The box +sends `prices` only when a hash of `t0Ms`, `spotMinor` and `totalMinor` +changes. + +### `plan`: `ftw.plan.read` + +| Field | Type | Meaning | +|---|---|---| +| `decisionId` | text | The plan's id. The box sends `plan` only when it changes | +| `genMs` | int | Wall time the plan was made | +| `stale` | bool | The planner could not run | +| `fallback` | bool | Core's Go fallback made this plan, not the solver | +| `t0Ms` | int | Wall time the first slot starts | +| `slotMs` | uint | Slot length | +| `batteryW` | [int or null] | Battery power per slot, site convention | +| `gridW` | [int or null] | Grid power per slot | +| `socPermille` | [uint or null] | Planned battery level at slot end | +| `evW` | [int or null] | Planned charging power | +| `pvLimitW` | [int or null] | Planned solar limit, null where none | +| `priceMinor` | [int or null] | Import price, minor units per kWh | +| `reason` | [text] | Stable code per slot | + +The grid ceiling the plan defends is not sent. + +### `health`: `ftw.health.read` + +| Field | Type | Meaning | +|---|---|---| +| `ok` | uint | Drivers working | +| `degraded` | uint | Drivers working with faults | +| `faulted` | uint | Drivers failing | +| `offline` | uint | Drivers not answering | + +### `event`: `ftw.events.read` + +| Field | Type | Meaning | +|---|---|---| +| `eventId` | text | Unique per event | +| `kind` | text | A kind from `contract/push-catalogue.yaml` | +| `severity` | text | As on the box | +| `args` | map text→(text or int) | The catalogue's placeholders | +| `lp` | text | Opaque loadpoint id, when the event concerns one | + +No argument carries a driver or device name. Where the catalogue text names a +device, the box sends `src`, an opaque source id, and the phone fills in the +name from its table. The box sends an event after its own rules (enabled, +cooldown). Delivery is best effort. + +### `tiles`: `ftw.history.read` + +The body is the app protocol's `hist.chunk` body: `tileId`, `etag`, `res`, +`startMs`, `stepMs`, `series`, `data` and `partial`, with the same meaning, +tile ids, etags and bytes as the box serves over Noise. + +- `res` is `5m`. The service keeps 48 hours. +- `series` is drawn from `grid_w`, `pv_w`, `battery_w`, `load_w`, `ev_w` and + `battery_soc`. +- A closed tile goes whole. When a 5-minute bucket closes, the box appends to + the trailing tile: `partial` is true and `startMs` is the first new bucket. + +### `hb`: every device in the roster + +| Field | Type | Meaning | +|---|---|---| +| `clock` | text | `ntp` or `none` | +| `srcRev` | uint | The source table revision now in force | + +### Service-only classes + +The service never forwards these to a phone. + +**`roster`** + +| Field | Type | Meaning | +|---|---|---| +| `rosterSeq` | uint | Persisted on the box. Rises by 1 at each pair, role change, revoke or grant change | +| `ttlS` | uint | 86,400. Counted from when the service received the roster | +| `devices` | [Device] | Every device paired with the box | +| `grant` | Grant | What the owner granted | + +**Device** + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | The box's device id | +| `key` | bstr, 32 B | The device's X25519 Noise static key | +| `role` | text | `owner` or `viewer` | +| `scopes` | [text] | Scopes the box derives from the role. The service holds no role table | +| `epoch` | uint | The device's epoch on the box | + +**Grant** + +| Field | Type | Meaning | +|---|---|---| +| `id` | bstr, 16 B | Grant id | +| `scopes` | [text] | A subset of `cloud_scopes` | +| `purposes` | [text] | A subset of `cloud_purposes` | +| `controller` | text | The data controller, set by the pairing flow | + +**`stats`**, only while `grant.purposes` contains `stats`. + +| Field | Type | Meaning | +|---|---|---| +| `statsId` | bstr, 16 B | Random, drawn by the box at consent | +| `policy` | text | Version of the consent text the owner accepted | +| `zone` | text | Price zone | +| `has` | [text] | Which of `battery`, `pv` and `ev_charger` the site has | +| `hours` | [Hour] | At most 168 per message | + +**Hour.** `startMs`, then Wh totals for the site, rounded to 10 Wh, under the +energy ledger's flow names: `grid_import`, `grid_export`, `pv_generation`, +`battery_charge`, `battery_discharge`, `consumer_use`, `vehicle_charge` and +`vehicle_discharge`. A flow the site lacks is absent. + +**`stats.withdraw`** `{statsId}`. The service deletes that id's rows within +24 hours. + +**`bye`** `{why, purge}`. `why` is `revoked`, `shutdown` or `update`. +`purge: true` asks the service to delete the home. `why: "update"` holds +back `box.unreachable` until the box returns or an hour passes. + +## Cadence + +- The box keeps the latest value per class and sends a class only when its + content changed. The change test ignores `lastOkMs` in sources, as the app + protocol's source comparison does, and the age fields named under `lp`. +- Each class goes at most once per period: 5 s by default, 1 s while a + `watch` is in force. `lp` goes at most every 2 s until a Pi 4 bench shows + what composing it costs. +- `plan` goes only when `decisionId` changes, `prices` only when its hash + changes, `tiles` when a bucket closes, and `event` at once. +- Every 15 minutes, and after a `resync`, the box sends a full `live` with + `sources` and a full `lp`. +- **Heartbeat.** The box sends `hb` whenever neither `live` nor `hb` has left + for H, where H = clamp(the smallest `staleAfterMs` among sources ÷ 2, + 2 s, 15 s), and 15 s when there are no sources. +- A source whose `staleAfterMs` is under 4 s can show lagging between + heartbeats. The phone cannot see faster than the heartbeat, so that is + honest. + +## Service to box + +The service may send the box only these. Anything else, a frame over 4 KiB, +a text message or a lane 0 frame closes the uplink. A fuzz test covers it. + +| `t` | Body | Rule | +|---|---|---| +| `challenge` | `{v, nonce}` | First message. Unsigned | +| `hello_ok` | See Handshake | Signed by the service key | +| `watch` | `{hz: 1}` | Raises the period to 1 s for 60 s. The service resends it while a phone asks. Without it the box returns to 5 s | +| `resync` | `{what, tiles}` | `what` holds `full`, `tiles` or `roster`; `tiles` lists tile ids. The box honours at most one per 5 minutes and ignores the rest | +| `purged` | `{atMs, what}` | Signed by the service key. `what` is `home` or `stats`. Sent once the deletion is done | + +The box acts on none of these beyond what the table says. It takes no time, +config, update or instruction from the service. + +## Backpressure and limits + +- One writer per socket. While a write blocks, the newest value per class + replaces the one waiting. Events wait in a ring of 32; when it is full the + oldest drops and the box counts the drop. +- A write blocked for 10 s closes the socket. +- A reconnect sends full state and the missing tiles. It replays no old + `live`, `lp` or `event`. +- **Byte budget.** The box counts the bytes it sent over a rolling 24 hours + of uptime. Past 20 MB, four times the measured 5 MB, it sends only `hb`, + `event`, `roster`, `stats.withdraw` and `bye` until the count falls. `GET + /api/cloud` shows the count and whether the budget is reached. + +## Roster + +- The box sends a roster at connect when the service's number is lower, on + every change within one control tick, and every 6 hours to renew `ttlS`. +- The service accepts a roster whose `rosterSeq` is at least the one it + holds. An equal number renews `ttlS`. A lower one is ignored. +- When `ttlS` runs out with no newer roster, the service serves nothing for + the home and drops its sessions and tokens. +- **Effective scope** of a device is its `scopes` intersected with + `grant.scopes`. +- A device that leaves the roster, or whose `epoch` changes, loses its + sessions, tokens and APNs registrations within a second of the service + receiving the roster. +- The service learns devices only from the roster. It never adds one. + +## App session + +1. The phone opens `wss://cloud.ftw.energy/v1/app` and runs + `Noise_IK_25519_ChaChaPoly_SHA256` as initiator. The responder's static key + is a service Noise key built into the app. The prologue is + `ftw.cloud.v1:` followed by the home id. +2. The service accepts the handshake only if the phone's static key is in the + home's current roster and not suspended. +3. Handshake message 1 carries `now {hz}`. Message 2 carries, for each class + the device may read, the newest box envelope the service holds, the newest + `live` that carried `sources`, the newest `hb`, and the service's own + `{lastSeenMs, online}`. +4. The service then forwards each new box envelope the device may read, + unchanged, as unpadded lane 1 frames. + +**Phone to service.** The service acts on these and ignores other types. + +| `t` | Who | Effect | +|---|---|---| +| `watch` | Any device | Ask for 1 s updates for 60 s | +| `token.get` | Any device | Returns `token {token, expiresInS}` for this device | +| `push.register` | Any device | `{apnsToken, environment, kinds}`; `kinds` from `alert`, `widgets`, `liveactivity` | +| `push.unregister` | Any device | Removes this device's APNs token | +| `suspend` | Owner | `{deviceId}`. Suspends a device | +| `unsuspend` | Owner | `{deviceId}`. Lifts a suspension | + +A suspension can only narrow access, never beyond what the roster allows. It +holds until an owner lifts it or the roster drops the device, and it works +while the box is offline. Anything the service says itself, such as +`lastSeenMs`, carries no box signature, and the phone never shows it as a +reading. + +## Extension token + +- 32 random bytes, base64url. The service stores its SHA-256, never the token. +- Bound to one roster entry and valid for 7 days. The app renews it at each + foreground and background refresh. +- It dies when the roster drops the device, the device's epoch changes, an + owner suspends the device, or the grant ends. +- `GET /v1/now` with `Authorization: Bearer ` returns CBOR + `{envelopes, lastSeenMs}`: the newest `live`, the newest `live` with + `sources`, `prices` and `hb`, as the box signed them. +- A token never reads `lp`, `plan`, `event`, `tiles` or `health`. A test + proves it. +- An extension checks signatures with the box key, and ages data with the + anchor, that the app stored in its App Group. + +## Push + +| Type | Payload | Phone behaviour | +|---|---|---| +| `alert` | `aps` with the generic title "FTW" and `mutable-content: 1`, plus `home` and the `event` envelope in base64 | The extension checks the signature, drops an already seen `(boot, seq)`, renders text from the app's catalogue copy and picks the category, and so the buttons, from the verified `kind`. A failed check shows a generic line with no buttons | +| `widgets` | Content changed, no data | Widgets fetch with their token | +| `liveactivity` | `home` and the `lp` envelope in base64 | The view shows numbers only if the signature checks and the age is within the charger source's `staleAfterMs`; otherwise "Open FTW". `staleDate` follows the box age | +| `box.unreachable` | Generic, from the service | Sent after 600 s without the box's socket. No box signature, no arguments, no buttons | + +Each push goes only to devices whose effective scope covers what it carries: +`alert` needs `ftw.events.read`, and `widgets` and `liveactivity` need +`ftw.live.read`. `box.unreachable` goes to devices registered for `alert`. +The service sends `widgets` when tomorrow's prices arrive and when a car is +plugged in. It derives no category, collapse id or priority from an event's +kind. + +## Freshness + +Carrier state `cloud` labels data read through the service. Source states +stay the box's, and the rules below can only make them worse. + +**Accepting a message.** The phone accepts a box envelope only if its +signature verifies with the box key for that home. Within one boot it drops a +message whose `seq` is not above the highest it has accepted. A message from +another boot replaces the current boot only if it is anchored and its send +time is later than the newest message the phone holds. + +**Anchors.** The phone trusts a send time only by one of two rules. + +- **Rule N.** The message has `wallMs`. Send time = `wallMs`. If any message + of a boot arrives with `wallMs` more than 2 s ahead of the phone clock, the + phone stops using rule N for that boot. +- **Rule U.** During a Noise session with the box, the phone records phone + time minus box uptime for messages that carry the box's `boot` and + `uptimeMs`, and keeps the smallest offset per boot. Send time = offset + + `uptimeMs`. This needs the box's `boot` in the Noise session information. +- When both apply, the phone uses the earlier send time. When neither does, + the data is unanchored: it shows as "cloud, age unverified", at best + `stale`, and never `live`. + +**State.** Let T be the send time of the newest accepted, anchored `live` or +`hb`, and t = phone time − T, at least zero. + +- If the latest accepted `live` or `hb` names a `srcRev` newer than the + source table the phone holds, every source is `stale` until that table + arrives. +- Otherwise, for each source with box state B and `staleAfterMs` S, the time + grade is: no change while t ≤ S, `lagging` while S < t ≤ 2S, and `stale` + when t > 2S. +- The effective state is the worse of B and the time grade, in the order + `live`, `lagging`, `stale`, `down`, `never`. +- Fields follow their sources through `fieldSources`, as on Noise. + +The service cannot sign, so it cannot make a source look fresher. It can only +delay, drop or replay, and each of those ages the data. + +**Vectors.** Each case below becomes a byte vector, replayed in Go and Swift. + +1. **A quiet live source stays live.** One source, S = 10 s, state `live`, + `srcRev` 7, so H = 5 s. The box sends a full `live` at uptime 0, then only + `hb` naming `srcRev` 7 every 5 s for 120 s, under rule N with 100 ms in + transit. At every moment t ≤ 5.1 s, and the source shows `live`. +2. **A dead uplink ages on time.** As case 1, but the socket dies after the + `hb` at uptime 60 s. The source shows `live` up to t = 10 s, `lagging` from + just past 10 s, and `stale` from just past 20 s. +3. **A replay goes stale.** The service replays case 1's first `live` and + `hb` an hour later to a phone that has seen nothing from that boot. Their + send time is an hour old, so the source shows `stale`. A phone that holds a + higher `seq` from that boot drops them. +4. **A withheld table goes stale.** The box moves the source to `lagging` with + `srcRev` 8, and the service drops that `live`. The next `hb` names + `srcRev` 8. The phone holds revision 7, so the source shows `stale` until + revision 8 arrives. +5. **No anchor, never live.** The box reports `clock: "none"`, and the phone + has had no Noise session. The data shows as "cloud, age unverified" and + never `live`. +6. **A fast box clock loses rule N.** A message arrives with `wallMs` 3 s + ahead of the phone clock. Rule N stops for that boot, and without rule U + the data is unanchored, as in case 5. +7. **A changed byte is dropped.** The service flips one byte of `b`. The + signature fails, the phone drops the message, and the source ages from the + last good message, as in case 2. +8. **The box's state wins.** The box reports a source as `down`, and t = 1 s. + The source shows `down`. + +## Grant on the box + +The grant lives in the box's state store under `cloud/grant_v1`, not in the +config document, so a whole-document config write cannot reset it. It holds +the grant id, scopes, purposes, a fingerprint of `cloud.key`, `grantKey`, the +granting device, a hash of the policy text, the time or `clock-unknown`, the +data controller and, with statistics on, the `statsId`. + +- A missing record, or a fingerprint that does not match `cloud.key`, reads + as **off**, and the box says so. This is the opposite of `app_link` and + `fleet_ping`, where a missing section reads as on. +- `cloud.key` is never backed up. A restored box has FTW Cloud off until an + owner turns it on again, under a new home id. +- A grant works with the relay off. + +| Route | Who may call it | +|---|---| +| `GET /api/cloud` | Owner. State, scopes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached | +| `GET /api/cloud/preview` | Owner. The exact bytes that leave the house | +| `POST /api/cloud/grant` | Owner over an app session. On the LAN, only from loopback or with the house password | +| `POST /api/cloud/stats` | Same gate, as a separate action | +| `DELETE /api/cloud/grant`, `DELETE /api/cloud/stats` | Any door, because narrowing access is safe | + +Every change pushes `cloud.granted` or `cloud.revoked` to all owners and +writes an audit line on the box. The box cannot verify the app's step-up, so +the grant screen claims no more than an owner session. + +## Revocation and deletion + +- **Device revoke** on the box: a roster without the device. See Roster. +- **Grant revoke:** the box sends `bye {why: "revoked", purge: true}`. The + service deletes the home: registry row, tokens, APNs registrations, live + copy, events, tiles and statistics rows. It sends `purged {what: "home"}` + and closes. The box keeps `cloud.key` until it holds a valid `purged`, + retrying at each connect, then deletes the key and the grant and shows the + receipt time. +- **Statistics withdrawal:** `stats.withdraw`. The service deletes the rows + within 24 hours and sends `purged {what: "stats"}` when done, on the same or + a later connection. +- **Silence:** after an hour the service serves only `lastSeenMs` and no + readings. After 30 days it drops tokens and APNs registrations. After 90 + days without a valid roster it deletes the home as in a purge. +- **A new home** that sends no roster within 24 hours is deleted. + +## Service state + +Per home, the service keeps in memory the newest envelope of each class, the +newest `live` that carried `sources`, 48 hours of tiles and a one-hour ring of +events. It keeps no history of readings and writes none to disk. Its registry +holds the home id, the box key, the roster, the current `statsId`, APNs +tokens and token hashes. + +## Registry names + +The contract registry does not hold these names yet. A separate pair of pull +requests adds them in `srcfl/ftw-webapp` first, then copies them here and to +`srcfl/ftw-app`. + +| Name | Kind | Goes into | +|---|---|---| +| `ftw.events.read` | Scope: events behind notifications | `registry.yaml`, `scopes` | +| `cloud_scopes` | List: `ftw.live.read`, `ftw.history.read`, `ftw.plan.read`, `ftw.health.read`, `ftw.events.read` | `registry.yaml`, new block | +| `cloud_purposes` | List: `serve`, `stats` | `registry.yaml`, new block | +| `cloud.link` | Capability: the box can run the FTW Cloud uplink. Absent means the app offers no FTW Cloud | `registry.yaml`, `capabilities` | +| `cloud` | Carrier state, between `relay` and `cache` | `registry.yaml`, `carrier_states` | +| `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when the grant changes | `push-catalogue.yaml` | + +Class names, field names and the signing tags are not registry names. They +live here and in `go/protocol`, held by golden vectors in both repositories. + +## Numbers in one place + +| What | Value | +|---|---| +| Reconnect backoff | 0.5 s to 60 s, full jitter | +| Ping, read deadline, write deadline | 15 s, 45 s, 10 s | +| Period per class | 5 s; 1 s while watched; `lp` at least 2 s | +| `watch` lasts | 60 s | +| Heartbeat H | clamp(smallest `staleAfterMs` ÷ 2, 2 s, 15 s) | +| Full state | Every 15 minutes | +| `resync` honoured | At most one per 5 minutes | +| Event ring on the box | 32 | +| Byte budget | 20 MB per 24 hours of uptime | +| Largest frame from the service | 4 KiB | +| Roster `ttlS`, renewal | 24 hours, every 6 hours | +| Extension token | 7 days | +| `box.unreachable` after | 600 s | +| Readings served after silence | 1 hour | +| Tokens and APNs dropped after | 30 days | +| Home deleted after | 90 days without a roster; 24 hours for a new home without one | +| Statistics rows kept | 13 months from the hour they describe | +| Clock lead allowed under rule N | 2 s | From d4aeafa33deb8b966ff968116f99f0dc261fd83f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:44:09 +0200 Subject: [PATCH 2/3] docs(cloud): close freshness gaps found in review An independent review found ways stale data could still show as live. - hb now names the newest live and lp it covers (liveSeq, lpSeq) and carries each source's reading age, so the phone grades a reading by the box's own ladder instead of keeping it live for another S. - The box sends no hb while a changed live or lp waits, nor past the byte budget, so an hb never vouches for values the box has not sent. - The phone keys its table by boot, tracks seq per class, takes the earlier of wallMs and arrival under rule N, adds a drift margin and a 24 h lifetime under rule U, and shows old alerts without buttons. - hello now carries the box's TLS exporter value too, so a proxy that ends TLS cannot hold a socket for the home; the label starts with EXPERIMENTAL as RFC 5705 asks. - Restores, roster expiry, purge receipts, tiles for phones, Live Activity tokens and what Apple sees are now stated; timers agree between the ADR and the protocol. Eight new vectors cover the cases. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- VISION.md | 9 +- docs/adr/0008-ftw-cloud.md | 90 ++++----- docs/architecture.md | 12 +- docs/protocol-cloud.md | 378 +++++++++++++++++++++++-------------- 5 files changed, 296 insertions(+), 197 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc1d4934b..b94d6c7b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,8 +69,8 @@ Read [docs/architecture.md](docs/architecture.md) for the system map and - Persistent device state is keyed by stable hardware identity, not a YAML name. - Database queries stay in [`go/internal/state`](go/internal/state). - Lane 0 frames have a fixed length and cadence; the padding is a privacy - control. The FTW Cloud uplink is not padded, so it sends lane 1 frames and - never lane 0. + control. The proposed FTW Cloud uplink is not padded, so it sends lane 1 + frames without the bulk buckets and never lane 0. - FTW Cloud may hold a readable copy of what the owner enabled. It never sends control and never chooses actions. See [ADR 0008](docs/adr/0008-ftw-cloud.md), proposed. diff --git a/VISION.md b/VISION.md index 8f2a6729e..d7b0dfc71 100644 --- a/VISION.md +++ b/VISION.md @@ -243,10 +243,11 @@ the app starts fast and its alerts, widgets and Live Activities work while it is closed. FTW Cloud never sends control and never chooses which actions a user is offered; commands still go from the app to the box over the encrypted session. Statistics need their own consent, off by default. This turns away -from an earlier aim that no feature should need a Sourceful service: the -iPhone app needs FTW Cloud, while the box does not. A household that wants -only local operation runs the box itself, updates included, and uses the -box's own page, the webapp over the blind relay and ntfy. +from [#1323](https://github.com/srcfl/ftw/issues/1323), which asks that no +feature need a Sourceful service: the iPhone app needs FTW Cloud, while the +box does not. A household that does not want FTW Cloud runs the box itself, +updates included, and uses the box's own page, the webapp over the blind +relay and ntfy. This document adds no new protocol operation, credential or access grant. New shared names belong in the contract registry and must ship with their diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index f49ed127d..8f9a34c28 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -41,25 +41,28 @@ Fredrik decided the following on 12 October 2026. account of its own. `ftwcloudd` lives in a public repository, `srcfl/ftw-cloud`, under AGPL-3.0-only. The wire format lives in `go/protocol` here, under Apache-2.0 - ([#1588](https://github.com/srcfl/ftw/pull/1588)). *Why:* a service that - reads household data should show its code, and a separate module keeps - control code out of its build. -2. **Readable, not encrypted,** so Sourceful can compute statistics and + ([#1588](https://github.com/srcfl/ftw/pull/1588), where a lawyer still + confirms the relicensing). *Why:* a service that reads household data + should show its code, and a separate module keeps control code out of its + build. +2. **Readable by Sourceful, not end-to-end encrypted,** so it can compute + statistics and aggregates. In return the service must be very secure. It reads and never controls, and never chooses which actions a user is offered. The box signs every message. The relay and escrow stay blind and do not change. 3. **The iPhone app is the way into FTW Cloud,** which may become a paid - service. A household that wants only local operation runs the box itself, + service. A household that does not want FTW Cloud runs the box itself, updates included, with the box's own page, the webapp over the blind relay and ntfy. This turns away from #1323; see below. 4. **Consent at first use,** in plain words. FTW Cloud is part of the app's service, on contract (GDPR art. 6(1)(b)). Statistics have their own switch - in the same flow, off by default, on consent (art. 6(1)(a) and 7(4)). A - lawyer must confirm both. Because consent comes at first use, this ADR + in the same flow, off by default, on consent (art. 6(1)(a)) that is free + in the sense of art. 7(4). A lawyer must confirm both. Because consent comes at first use, this ADR sets the statistics rules now. -5. **No padding on cloud traffic.** It uses lane 1 frames, because lane 0 is - always padded. The service reads the content anyway, so padding would hide - only timing from the network, at about three times the data. +5. **No padding on cloud traffic.** It uses lane 1 frames without the bulk + buckets, because lane 0 is always padded. The service reads the content + anyway, so padding would hide only size and timing from the network, at + about three times the data. 6. **Apple sees event codes.** *Assumed; Fredrik has not answered.* Alerts carry the box-signed code and arguments in the APNs payload, because a fetch would need a token and a round trip before the alert shows. @@ -86,11 +89,12 @@ box ──wss, TLS 1.3, signed frames──▶ ftwcloudd ◀──wss, Noise IK └──Noise via the relay: commands, long history──▶ iPhone app, webapp ``` -- The box dials a fixed address over TLS 1.3 and checks a service signature - bound to that TLS session before it sends data, so a proxy in the middle - reads nothing. `ftwcloudd` ends TLS itself for that reason. +- The box dials a fixed address over TLS 1.3. Box and service each check + that the other signed this TLS session's exporter value before any data + moves, so a proxy that ends TLS in the middle reads no household data. No + such proxy may sit in front of `ftwcloudd`. - Each message belongs to one class and each class to one scope. The box - drops driver names, device capabilities and inventory and uses opaque ids. + drops driver names and device capabilities and uses opaque ids. The service forwards the box's bytes unchanged, by scope. - The app runs Noise IK to a service key built into the app and gets the current state in the handshake reply. Widgets read with a short-lived token. @@ -108,9 +112,10 @@ grant routes, limits and timers. **FTW Cloud reads and never controls. It never chooses the actions a user is offered.** Code enforces this: -- The service can import only `go/protocol` from this repository, so it - cannot link control code. In Core, an import test keeps control, API and - loadpoint writers out of the uplink package. +- The service can import only `go/protocol` from this repository, and #1588 + checks that `go/protocol` imports nothing from Core, so the service cannot + link control code. In Core, an import test keeps control, API and loadpoint + writers out of the uplink package. - The service may send the box five message types. Anything else closes the socket, under a fuzz test. The box issues a grant only when an owner asks. - A false message meant to make a user tap the wrong action meets three @@ -124,24 +129,26 @@ control path as two states. - **The grant lives on the box.** A missing grant reads as **off**, the opposite of `app_link`. An owner grants over an app session; any door may - revoke. Every change notifies all owners. + revoke. Every change notifies all owners. A viewer's app shows that the + home uses FTW Cloud, and viewers get no alerts. - **Scopes.** Live, history, plan, health and the new `ftw.events.read`. Inventory stays out. - **Roster.** The box signs the list of paired devices, their keys and the scopes their roles allow. The service learns devices only from it. - **Widgets** get a 7-day read token for live data and prices. It is not a device key and cannot command. -- **Revocation.** A device revoke reaches the service within a second. A grant +- **Revocation.** A device revoke reaches the service within a second while + the box is connected. A grant revoke deletes the home there, and the box keeps its key until it holds a signed purge receipt. ## Freshness Data read through FTW Cloud carries the new carrier state `cloud` and never -looks fresher than the box said it was. The phone trusts a send time only +looks fresher than the box would grade it. The phone trusts a send time only through an anchor it can check, and shows unanchored data as "cloud, age -unverified", never live. A replay keeps its old send time and shows as stale. -The service cannot sign, so it cannot clear staleness. The +unverified", never live. The service cannot sign, so it cannot clear +staleness. The [protocol](../protocol-cloud.md#freshness) states the rules and eight test vectors. @@ -178,15 +185,13 @@ purpose or fields needs a new policy version and fresh consent. ## Security and retention The relay protects data by being unable to read it. The service can read it, -so its protection comes from controls: grants only from an owner and off by -default; a preview of the exact bytes; signed messages over TLS bound to the -handshake; one code path to a home's data, with canary homes that alert if one -home's data reaches another; no staff read path, under a test; two-person -review, signed images and the running digest published at `/source`; host -access only through logged SSM; outbound traffic only to APNs and AWS; and an -erase test. The legal work is a privacy notice in Swedish and English, terms, -a DPIA, records of processing, a DPA with AWS, AWS and Apple as the only -subprocessors, a breach process, and export and erase. +so its protection comes from controls: off by default; a preview of the exact +bytes; one code path to a home's data, with canary homes that alert on a +leak; no staff read path, under a test; two-person review and the running +digest published at `/source`; host access only through logged SSM; outbound +traffic only to APNs and AWS; and an erase test. The legal work is a privacy +notice in Swedish and English, terms, a DPIA, records of processing, a DPA +with AWS, a breach process, and export and erase. **The honest limit:** someone with root on the service host can read memory. The privacy notice says so. @@ -194,7 +199,8 @@ The privacy notice says so. | Store | Kept | |---|---| | Live state | Memory only; readings dropped an hour after the box goes quiet | -| Registry and APNs tokens | Until purge, or 90 days without a roster | +| Registry | Until purge, or 90 days without a roster | +| APNs registrations | Until purge, or 30 days without the box | | Events | A one-hour ring in memory | | Access logs | 14 days, with a keyed hash instead of the address | | SSM sessions and deploy log | 24 months | @@ -229,11 +235,10 @@ turns away from that rule for the iPhone app, on purpose.** ## What changes from ADR 0006 -ADR 0006 said one remote path means one thing to threat-model, one pairing -story and one place freshness can go wrong. For homes that turn FTW Cloud on, -there are two paths and two places for freshness to go wrong; the carrier -state `cloud` and box signatures answer the second. Pairing stays one story, -because the service learns devices only from the box. +ADR 0006 said one remote path means one thing to threat-model and one place +freshness can go wrong. For homes that turn FTW Cloud on there are two of +each; the carrier state `cloud` and box signatures answer the second. Pairing +stays one story, because the service learns devices only from the box. ADR 0006 also removed Home Link partly because the relay learned the household. FTW Cloud brings back a stable household id at a Sourceful @@ -259,7 +264,8 @@ later step stands on it and ships on its own. The statistics switch ships with the start flow in C4. The box records consent and `statsId` from then on, nothing is sent until C8, and the backfill covers -the gap. A public launch needs C2–C4 and the legal work. +the gap. The lawyer also confirms that order: if the DPIA in C8 changes the +rules, the app asks again. A public launch needs C2–C4 and the legal work. ## Alternatives considered @@ -267,8 +273,7 @@ the gap. A public launch needs C2–C4 and the legal work. path. - **The service in this repository:** it would link control code into an 18 MB binary, beside the box's release keys. -- **A proprietary service:** it cannot link AGPL code others wrote, and trust - would rest on promises. +- **A proprietary service:** trust would rest on promises. - **Cloudflare Workers:** APNs only through undocumented behaviour, and EU-only TLS needs an Enterprise plan. - **API Gateway, IoT Core or Supabase Realtime:** per-message billing, about @@ -278,11 +283,12 @@ the gap. A public launch needs C2–C4 and the legal work. | Risk | Cheapest way to reduce it | |---|---| -| One bug exposes every home | One access path, canary homes, no readings on disk | +| One bug exposes every home | One access path, canary homes, no live readings on disk | | An insider or the deploy path | Two-person review, a published digest, an SSM alert | | Composing state costs too much CPU on a Pi | Bench in C2 | | Signatures cost data on a 4G box, about 150 MB a month | Measure in C2 | -| The network sees burst timing; Apple sees event codes | The privacy notice; no names in event arguments | +| The network sees message sizes and timing | The privacy notice | +| Apple sees the home id, event codes and Live Activity state | The privacy notice; no names in arguments; the M7 spike | | Claims or defaults drift back | Claim tests; a missing grant reads as off | | A paid app meets App Store purchase rules | Decide the payment path before charging | | AGPL code in the App Store | An App Store exception in `ftw-app` before outside code arrives | diff --git a/docs/architecture.md b/docs/architecture.md index 752feb1fb..ad9e8ba8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,12 +356,6 @@ 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. -Proposed, not shipped: [ADR 0008](adr/0008-ftw-cloud.md) adds FTW Cloud, a -second outbound connection for homes whose owner turns it on. It carries a -signed, readable copy of what the owner enabled to `cloud.ftw.energy`, and it -never carries control. Commands and long history stay on the relay path. -[protocol-cloud.md](protocol-cloud.md) specifies its wire format. - The path is five packages: - [`go/internal/apiauth`](../go/internal/apiauth) — who is asking. One value, @@ -402,6 +396,12 @@ The properties that matter: 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 for homes whose owner turns it on. It carries to +`cloud.ftw.energy` a signed, readable copy of what the owner enabled, and it +never carries control. Commands and long history stay on the relay path above. +[protocol-cloud.md](protocol-cloud.md) specifies its wire format. + ### Who is let in, and as what An enrolment carries a role from `contract/registry.yaml`: `owner`, which may diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 62c8541a4..d1a8a6290 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -9,9 +9,9 @@ vectors described in [Freshness](#freshness) are the cases they must cover. The app protocol, which runs inside Noise between a phone and its box, is specified in [srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md). -FTW Cloud reuses its frame layout, CBOR rules and tile format, and changes -nothing in it. Lane 0 and the relay stay as they are. The Go types for both -live in `go/protocol`, under Apache-2.0 +FTW Cloud reuses its frame layout, CBOR rules, field encoding and tile format, +and changes nothing in it. Lane 0 and the relay stay as they are. The Go types +for both live in `go/protocol`, under Apache-2.0 ([#1588](https://github.com/srcfl/ftw/pull/1588)). ## Parties and connections @@ -24,7 +24,7 @@ live in `go/protocol`, under Apache-2.0 | Connection | Opened by | Transport | What protects it | |---|---|---|---| -| Uplink | Box | `wss://cloud.ftw.energy/v1/box` | TLS 1.3, a handshake bound to the TLS session, every box message signed | +| Uplink | Box | `wss://cloud.ftw.energy/v1/box` | TLS 1.3, a handshake bound to the TLS session on both sides, every box message signed | | App session | Phone | `wss://cloud.ftw.energy/v1/app` | TLS, then Noise IK to the service's static key | | Extension read | Extension | `GET https://cloud.ftw.energy/v1/now` | TLS and an extension token; the data inside is box-signed | | Push | Service | APNs | Box-signed payloads where they carry data | @@ -34,6 +34,8 @@ live in `go/protocol`, under Apache-2.0 - The endpoint is a compile-time constant in Core, as the relay's is. - TLS 1.3 is the minimum. The dialer uses no proxy, whatever the environment says. +- No proxy may end TLS in front of the service, because that breaks the + handshake binding. A plain TCP load balancer is fine. - Reconnects back off with full jitter from 0.5 s to 60 s. The attempt counter resets after 60 s connected. - The service sends a WebSocket ping every 15 s. The box's read deadline is @@ -56,17 +58,20 @@ offset field type value on FTW Cloud 6 payload u8[len] CBOR envelope ``` -- **No padding.** The message is exactly `6 + len` bytes. Lane 0 never - appears on an FTW Cloud connection. +- **No padding.** In the app protocol, lane 1 pads to bulk buckets of 1, 4 or + 16 KiB. FTW Cloud uses lane 1 without the buckets: the message is exactly + `6 + len` bytes. Lane 0 never appears on an FTW Cloud connection. - A receiver closes the connection on a lane 0 frame, a nonzero flag, bytes after `len`, or a text message. - Inside the app session the same frames ride as Noise transport messages, also without padding. -Lane 0 is padded to a fixed size and cadence because the relay must learn -nothing from the stream. The service reads the content, so padding would hide -only timing from the network, at about three times the data. The network sees -when bursts happen, and the privacy notice says so (ADR 0008, decision 5). +The app protocol pads lane 0 to a fixed size and cadence because the relay +must learn nothing from the stream. The service reads the content, so padding +would hide only size and timing from the network, at about three times the +data. The network sees when messages go and how large they are, which shows +which class changed and when. The privacy notice says so (ADR 0008, +decision 5). ## Envelope and signature @@ -74,8 +79,7 @@ when bursts happen, and the privacy notice says so (ADR 0008, decision 5). Envelope = { t: text, b: bstr, sig: bstr } ``` -- CBOR, core deterministic encoding, text keys. No `id`: nothing on this link - is a request with a reply id. +- CBOR, core deterministic encoding, text keys. - `b` is the body, itself deterministic CBOR, carried as a byte string so that the signature covers exact bytes and the service can forward them unchanged. - `sig` is ES256 (ECDSA on P-256 with SHA-256), 64 bytes, `r ‖ s` @@ -84,22 +88,22 @@ Envelope = { t: text, b: bstr, sig: bstr } byte), then the deterministic CBOR encoding of the array `[home, t, b]`, where `home` is the text home id. - The box signs with `cloud.key`. The service signs `hello_ok` and `purged` - with its service key. Every other envelope the service sends has an empty - `sig`. + with its service key. Every other envelope the service sends, and every + envelope between phone and service, has an empty `sig`. - A receiver verifies over the bytes it received, never over a re-encoding. - The box's public key reaches phones over Noise, beside the table of opaque - ids, in the session information every role receives. It never comes from - the service. -- Unknown keys in a body are ignored, as in the app protocol. An unknown `t` - from the box is dropped and counted by the service, so a newer box works - with an older service. An unknown `t` from the service closes the uplink. + ids, the home id and the box's current `boot`, in the session information + every role receives. It never comes from the service. +- Receivers ignore unknown keys in a body, as in the app protocol. The service + drops and counts an unknown `t` from the box, so a newer box works with an + older service. An unknown `t` from the service closes the uplink. - The service forwards a home's messages in the order it received them. ## Keys and ids | Name | What it is | |---|---| -| `cloud.key` | ES256 (P-256) key the box makes when an owner grants FTW Cloud. Never backed up | +| `cloud.key` | ES256 (P-256) key the box makes when an owner grants FTW Cloud. The box never backs it up | | `pk` | Its public key as a 65-byte uncompressed SEC1 point | | `home` | Lowercase base32, no padding, of the first 16 bytes of SHA-256(`ftw.cloud.home.v1` ‖ `pk`). 26 characters | | Service key | P-256. Core carries up to two public halves, so the key can rotate across a release | @@ -123,17 +127,28 @@ not repeat them. | `uptimeMs` | uint | Box monotonic time since start, at send | | `wallMs` | uint | Box wall time at send. Present only while NTP has synced the box clock | +Other wall-time fields, such as `readyByMs` and `t0Ms`, come from the box's +clock. While the box reports `clock: "none"`, the phone shows them as +unverified. + ## Handshake +Both sides bind the handshake to the TLS session with its exporter value +(RFC 8446 §7.5), label `EXPERIMENTAL-ftw-cloud-v1`, empty context, 32 bytes, +called `ekm` below. A proxy that ends TLS in the middle holds two TLS sessions +with two different values, so neither side's check passes through it. + 1. **Service → box: `challenge`** `{v: 1, nonce: bstr32}`, unsigned. 2. **Box → service: `hello`**, signed. The service computes `home` from `pk`, - checks the signature and that `nonce` is its own, or closes. + checks the signature, that `nonce` is its own and that `ekm` equals its own + exporter value, or closes. | Field | Type | Meaning | |---|---|---| | `v` | uint | 1 | | `pk` | bstr, 65 B | The box's public key | | `nonce` | bstr, 32 B | Echo of the challenge | + | `ekm` | bstr, 32 B | The box's exporter value for this TLS session | | `boxNonce` | bstr, 32 B | Random, for the service to sign | | `build` | text | Core version | | `clock` | text | `ntp` or `none` | @@ -146,19 +161,19 @@ not repeat them. | Field | Type | Meaning | |---|---|---| | `boxNonce` | bstr, 32 B | Echo | - | `ekm` | bstr, 32 B | The TLS exporter value (RFC 8446 §7.5), label `EXPORTER-ftw-cloud-v1`, empty context | + | `ekm` | bstr, 32 B | The service's exporter value for this TLS session | | `have` | map text→text | Tile id to etag, for the tiles the service holds | | `rosterSeq` | uint | The roster number the service holds | The box checks the signature against a service key it carries, that - `boxNonce` is its own, and that `ekm` equals its own exporter value for - this TLS session. A proxy that ends TLS in the middle has a different - exporter value. If any check fails, the box closes and backs off. **It - sends nothing but `hello` before this check passes.** This is why the - service must end TLS itself rather than behind a proxy. -4. **Box → service:** `roster`, when the service's number is lower; then one - full message of each class the grant allows; then the tiles missing from - `have`. + `boxNonce` is its own and that `ekm` equals its own exporter value. If any + check fails, the box closes and backs off. **It sends nothing but `hello` + before this check passes.** A proxy that reads `hello` learns the box's + public key and grant id, and cannot use them. +4. **Box → service:** a `roster` when the service's number differs from its + own (see [Roster](#roster)); then one full message of each class the grant + allows; then the tiles missing from `have`. While a purge is pending, the + box sends only `bye` instead (see [Revocation](#revocation-and-deletion)). 5. After that the box sends on change, by the cadence rules. ## Classes @@ -166,17 +181,19 @@ not repeat them. The box sends these. Each class belongs to one scope, and the service forwards a class only to devices whose effective scope includes it. These tables are the allowlist: a field the box sends must have a row here, and a -test fails on any field without one. Driver names, device capabilities and -inventory have no rows. +test fails on any field without one. Driver names and device capabilities +have no rows. Source kinds and counts, health counts and the statistics `has` +list still show what kinds of equipment a site has. ### `live`: `ftw.live.read` | Field | Type | Meaning | |---|---|---| -| `fields` | map uint→int | Frozen field ids 1–6 and 10, in the registry's units | -| `fieldSources` | map uint→[text] | The sources each field's freshness follows | -| `sources` | map text→Source | The source table. Sent when `srcRev` changed since the box's previous `live`, and in every full message | +| `fields` | map text→int | Frozen field ids 1–6 and 10, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W | +| `fieldSources` | map text→[text] | The sources each field's freshness follows | +| `sources` | map text→Source | The source table. Sent whenever `srcRev` changes, and in every full message | | `srcRev` | uint | Rises by 1, per boot, each time the source table changes | +| `ages` | map text→uint | For each source in state `live` or `lagging`, the age of its newest reading at send, in ms of box uptime | | `dispatchBlockedBy` | [text] | Sources whose staleness stops dispatch | **Source** @@ -185,7 +202,7 @@ inventory have no rows. |---|---|---| | `kind` | text | As in the app protocol | | `state` | text | `live`, `lagging`, `stale`, `down` or `never`, as the box grades it | -| `lastOkMs` | int | Box uptime at the last good reading | +| `lastOkMs` | int | Box uptime at the last good reading, when the table was sent | | `staleAfterMs` | uint | How long a reading from this source stays current | ### `lp`: `ftw.live.read` @@ -201,6 +218,7 @@ inventory have no rows. | Field | Type | Meaning | |---|---|---| | `id` | text | Opaque loadpoint id | +| `src` | text | Opaque source id of the charger, whose freshness the loadpoint follows | | `plugged` | bool | A car is connected | | `socPermille` | uint or null | Car charge level, null when unknown | | `socSource` | text | `vehicle`, `inferred` or `assumed`; empty when unplugged | @@ -259,11 +277,10 @@ the time since the message. | `slotMs` | uint | Slot length. Where stored slots differ in length, the box repeats a longer slot's price across `slotMs` slots | | `spotMinor` | [int or null] | Market price, minor units per kWh | | `totalMinor` | [int or null] | What the household pays, tariff and VAT included | -| `stale` | bool | The slots do not cover the window the box holds prices for | -`totalMinor` shows the household's tariff. The privacy notice says so. The box -sends `prices` only when a hash of `t0Ms`, `spotMinor` and `totalMinor` -changes. +`totalMinor` shows the household's tariff, and the privacy notice says so. The +phone sees how far the slots reach. The box sends `prices` only when a hash of +`t0Ms`, `slotMs`, `spotMinor` and `totalMinor` changes. ### `plan`: `ftw.plan.read` @@ -301,25 +318,27 @@ The grid ceiling the plan defends is not sent. | `eventId` | text | Unique per event | | `kind` | text | A kind from `contract/push-catalogue.yaml` | | `severity` | text | As on the box | -| `args` | map text→(text or int) | The catalogue's placeholders | -| `lp` | text | Opaque loadpoint id, when the event concerns one | +| `args` | map text→(text or int) | The catalogue's placeholders, except names | +| `src` | text | Opaque source id, when the event concerns a device | +| `lp` | text | Opaque loadpoint id, when the event concerns a loadpoint | -No argument carries a driver or device name. Where the catalogue text names a -device, the box sends `src`, an opaque source id, and the phone fills in the -name from its table. The box sends an event after its own rules (enabled, -cooldown). Delivery is best effort. +No argument carries a driver or device name. Where the catalogue text has a +name placeholder, such as `{name}` in `driver.offline`, the box sends `src` +and the phone fills in the name from its table. The box sends an event only +when its own rules (enabled, cooldown) allow. Delivery is best effort. ### `tiles`: `ftw.history.read` The body is the app protocol's `hist.chunk` body: `tileId`, `etag`, `res`, -`startMs`, `stepMs`, `series`, `data` and `partial`, with the same meaning, -tile ids, etags and bytes as the box serves over Noise. +`startMs`, `stepMs`, `series`, `data` and `partial`. A closed tile has the +same tile id, etag and bytes as the box serves over Noise. - `res` is `5m`. The service keeps 48 hours. -- `series` is drawn from `grid_w`, `pv_w`, `battery_w`, `load_w`, `ev_w` and - `battery_soc`. -- A closed tile goes whole. When a 5-minute bucket closes, the box appends to - the trailing tile: `partial` is true and `startMs` is the first new bucket. +- `series` is drawn from `grid_w`, `pv_w`, `battery_w` and `load_w`, which the + box serves today, and `ev_w` and `battery_soc`, which C2 adds. +- When a 5-minute bucket closes, the box appends to the trailing tile. Only + then does `startMs` differ from the Noise meaning: it is the first new + bucket, and `partial` is true. ### `hb`: every device in the roster @@ -327,6 +346,9 @@ tile ids, etags and bytes as the box serves over Noise. |---|---|---| | `clock` | text | `ntp` or `none` | | `srcRev` | uint | The source table revision now in force | +| `liveSeq` | uint | `seq` of the newest `live` the box has sent | +| `lpSeq` | uint | `seq` of the newest `lp` the box has sent, 0 if none | +| `ages` | map text→uint | As in `live`, at the time of this `hb` | ### Service-only classes @@ -358,7 +380,7 @@ The service never forwards these to a phone. | `id` | bstr, 16 B | Grant id | | `scopes` | [text] | A subset of `cloud_scopes` | | `purposes` | [text] | A subset of `cloud_purposes` | -| `controller` | text | The data controller, set by the pairing flow | +| `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet | **`stats`**, only while `grant.purposes` contains `stats`. @@ -375,6 +397,9 @@ energy ledger's flow names: `grid_import`, `grid_export`, `pv_generation`, `battery_charge`, `battery_discharge`, `consumer_use`, `vehicle_charge` and `vehicle_discharge`. A flow the site lacks is absent. +The service refuses `stats` for a `statsId` it has deleted, so a box restored +from an older backup cannot turn a withdrawn consent back on. + **`stats.withdraw`** `{statsId}`. The service deletes that id's rows within 24 hours. @@ -385,21 +410,22 @@ back `box.unreachable` until the box returns or an hour passes. ## Cadence - The box keeps the latest value per class and sends a class only when its - content changed. The change test ignores `lastOkMs` in sources, as the app - protocol's source comparison does, and the age fields named under `lp`. + content changed. The change test ignores `lastOkMs`, as the app protocol's + source comparison does, and the age fields named under `live` and `lp`. - Each class goes at most once per period: 5 s by default, 1 s while a `watch` is in force. `lp` goes at most every 2 s until a Pi 4 bench shows what composing it costs. +- Two messages skip the period. A `live` whose source table changed goes at + once, with `sources`. An `hb` goes when it is due. - `plan` goes only when `decisionId` changes, `prices` only when its hash changes, `tiles` when a bucket closes, and `event` at once. - Every 15 minutes, and after a `resync`, the box sends a full `live` with `sources` and a full `lp`. - **Heartbeat.** The box sends `hb` whenever neither `live` nor `hb` has left for H, where H = clamp(the smallest `staleAfterMs` among sources ÷ 2, - 2 s, 15 s), and 15 s when there are no sources. -- A source whose `staleAfterMs` is under 4 s can show lagging between - heartbeats. The phone cannot see faster than the heartbeat, so that is - honest. + 2 s, 15 s), and 15 s when there are no sources. It sends no `hb` while a + changed `live` or `lp` waits for its period, so an `hb` never vouches for + values the box has not sent. ## Service to box @@ -412,7 +438,7 @@ a text message or a lane 0 frame closes the uplink. A fuzz test covers it. | `hello_ok` | See Handshake | Signed by the service key | | `watch` | `{hz: 1}` | Raises the period to 1 s for 60 s. The service resends it while a phone asks. Without it the box returns to 5 s | | `resync` | `{what, tiles}` | `what` holds `full`, `tiles` or `roster`; `tiles` lists tile ids. The box honours at most one per 5 minutes and ignores the rest | -| `purged` | `{atMs, what}` | Signed by the service key. `what` is `home` or `stats`. Sent once the deletion is done | +| `purged` | `{atMs, what, grantId, statsId}` | Signed by the service key. `what` is `home` or `stats`. Sent once every deletion it names is done | The box acts on none of these beyond what the table says. It takes no time, config, update or instruction from the service. @@ -426,23 +452,28 @@ config, update or instruction from the service. - A reconnect sends full state and the missing tiles. It replays no old `live`, `lp` or `event`. - **Byte budget.** The box counts the bytes it sent over a rolling 24 hours - of uptime. Past 20 MB, four times the measured 5 MB, it sends only `hb`, - `event`, `roster`, `stats.withdraw` and `bye` until the count falls. `GET - /api/cloud` shows the count and whether the budget is reached. + of uptime. Past 20 MB, four times the measured 5 MB, it sends only `event`, + `roster`, `stats.withdraw` and `bye` until the count falls. It sends no + `hb` then, so data on phones ages to stale rather than looking current. + `GET /api/cloud` shows the count and whether the budget is reached. ## Roster -- The box sends a roster at connect when the service's number is lower, on - every change within one control tick, and every 6 hours to renew `ttlS`. +- The box sends a roster at connect when the service's number differs from its + own, on every change within one control tick, and every 6 hours to renew + `ttlS`. +- If `hello_ok.rosterSeq` is above the box's own, as after a restore from an + older backup, the box moves its number past the service's and sends a new + roster, so revokes made since the backup still arrive. - The service accepts a roster whose `rosterSeq` is at least the one it holds. An equal number renews `ttlS`. A lower one is ignored. -- When `ttlS` runs out with no newer roster, the service serves nothing for - the home and drops its sessions and tokens. +- When `ttlS` runs out with no newer roster, the service closes the home's + sessions and refuses new sessions and token reads until a roster arrives. - **Effective scope** of a device is its `scopes` intersected with `grant.scopes`. - A device that leaves the roster, or whose `epoch` changes, loses its sessions, tokens and APNs registrations within a second of the service - receiving the roster. + receiving the roster, which needs the uplink to be up. - The service learns devices only from the roster. It never adds one. ## App session @@ -450,32 +481,37 @@ config, update or instruction from the service. 1. The phone opens `wss://cloud.ftw.energy/v1/app` and runs `Noise_IK_25519_ChaChaPoly_SHA256` as initiator. The responder's static key is a service Noise key built into the app. The prologue is - `ftw.cloud.v1:` followed by the home id. + `ftw.cloud.v1:` followed by the home id, which the phone learned from the + box over Noise. 2. The service accepts the handshake only if the phone's static key is in the home's current roster and not suspended. -3. Handshake message 1 carries `now {hz}`. Message 2 carries, for each class - the device may read, the newest box envelope the service holds, the newest - `live` that carried `sources`, the newest `hb`, and the service's own +3. Handshake message 1 carries `now {hz}`. Message 2 carries a CBOR array of + box envelopes in rising `seq`: for each class the device may read, the + newest one the service holds, the newest `live` that carried `sources`, + and the newest `hb`. After the array comes the service's own `{lastSeenMs, online}`. 4. The service then forwards each new box envelope the device may read, unchanged, as unpadded lane 1 frames. -**Phone to service.** The service acts on these and ignores other types. +**Phone to service.** These use the envelope with an empty `sig`. The +service acts on these and ignores other types; a reply has the type named in +the table. | `t` | Who | Effect | |---|---|---| | `watch` | Any device | Ask for 1 s updates for 60 s | -| `token.get` | Any device | Returns `token {token, expiresInS}` for this device | -| `push.register` | Any device | `{apnsToken, environment, kinds}`; `kinds` from `alert`, `widgets`, `liveactivity` | -| `push.unregister` | Any device | Removes this device's APNs token | +| `tiles.get` | Devices with `ftw.history.read` | `{have}`, tile id to etag. The service answers with the `tiles` envelopes the phone lacks | +| `token.get` | Any device | Answered with `token {token, expiresInS}`. A new token replaces the device's old one | +| `push.register` | Any device | `{apnsToken, environment, kind, activityId}`. One registration per APNs token; a Live Activity and its push-to-start each register their own | +| `push.unregister` | Any device | `{apnsToken}` | | `suspend` | Owner | `{deviceId}`. Suspends a device | | `unsuspend` | Owner | `{deviceId}`. Lifts a suspension | A suspension can only narrow access, never beyond what the roster allows. It holds until an owner lifts it or the roster drops the device, and it works -while the box is offline. Anything the service says itself, such as -`lastSeenMs`, carries no box signature, and the phone never shows it as a -reading. +while the box is offline, until the roster runs out. Anything the service +says itself, such as `lastSeenMs`, carries no box signature, and the phone +never shows it as a reading. ## Extension token @@ -486,7 +522,7 @@ reading. owner suspends the device, or the grant ends. - `GET /v1/now` with `Authorization: Bearer ` returns CBOR `{envelopes, lastSeenMs}`: the newest `live`, the newest `live` with - `sources`, `prices` and `hb`, as the box signed them. + `sources`, `prices` and `hb`, as the box signed them, in rising `seq`. - A token never reads `lp`, `plan`, `event`, `tiles` or `health`. A test proves it. - An extension checks signatures with the box key, and ages data with the @@ -496,86 +532,129 @@ reading. | Type | Payload | Phone behaviour | |---|---|---| -| `alert` | `aps` with the generic title "FTW" and `mutable-content: 1`, plus `home` and the `event` envelope in base64 | The extension checks the signature, drops an already seen `(boot, seq)`, renders text from the app's catalogue copy and picks the category, and so the buttons, from the verified `kind`. A failed check shows a generic line with no buttons | +| `alert` | `aps` with the generic title "FTW" and `mutable-content: 1`, plus `home` and the `event` envelope in base64 | The extension checks the signature and drops an already seen `(boot, seq)`. If the event is anchored and at most 10 minutes old, it renders text from the app's catalogue copy and picks the category, and so the buttons, from the verified `kind`. Otherwise it shows a generic line with no buttons | | `widgets` | Content changed, no data | Widgets fetch with their token | -| `liveactivity` | `home` and the `lp` envelope in base64 | The view shows numbers only if the signature checks and the age is within the charger source's `staleAfterMs`; otherwise "Open FTW". `staleDate` follows the box age | +| `liveactivity` | `home`, the `lp` envelope and the newest `hb` envelope, in base64 | The view shows numbers only if both signatures check, the `hb` covers that `lp`, and the charger source grades `live` by the freshness rules; otherwise "Open FTW". `staleDate` is set to when that grade would lapse | | `box.unreachable` | Generic, from the service | Sent after 600 s without the box's socket. No box signature, no arguments, no buttons | Each push goes only to devices whose effective scope covers what it carries: `alert` needs `ftw.events.read`, and `widgets` and `liveactivity` need -`ftw.live.read`. `box.unreachable` goes to devices registered for `alert`. -The service sends `widgets` when tomorrow's prices arrive and when a car is -plugged in. It derives no category, collapse id or priority from an event's -kind. +`ftw.live.read`. Viewers therefore get no alerts. `box.unreachable` goes to +devices registered for `alert`. The service sends `widgets` when tomorrow's +prices arrive and when a car is plugged in. It derives no category, collapse +id or priority from an event's kind. How often a Live Activity can refresh +within Apple's budget is the iOS plan's M7 spike. + +**What Apple sees.** Every alert payload carries the home id, the event kind, +its arguments and the box's `boot`, `seq` and send time. A Live Activity +payload carries the whole `lp` envelope: charge level, power, ready time and +energy delivered. ADR 0008 lists this as a risk. ## Freshness Carrier state `cloud` labels data read through the service. Source states -stay the box's, and the rules below can only make them worse. +come from the box, and the rules below can only make them worse. + +**Assumption.** The phone's own clock is close to true time. A phone clock +that runs behind lets a delaying service hide that much delay. **Accepting a message.** The phone accepts a box envelope only if its -signature verifies with the box key for that home. Within one boot it drops a -message whose `seq` is not above the highest it has accepted. A message from -another boot replaces the current boot only if it is anchored and its send -time is later than the newest message the phone holds. +signature verifies with the box key for that home. It keeps the highest `seq` +accepted per class and per boot, and drops a message of that class whose +`seq` is not higher. It processes the envelopes of a snapshot in rising +`seq`. A message from another boot replaces the current boot only if it is +anchored and its send time is later than the newest message the phone holds. +When the boot changes, the phone drops the source table and field values it +held and waits for that boot's full `live`. **Anchors.** The phone trusts a send time only by one of two rules. -- **Rule N.** The message has `wallMs`. Send time = `wallMs`. If any message - of a boot arrives with `wallMs` more than 2 s ahead of the phone clock, the - phone stops using rule N for that boot. +- **Rule N.** The message has `wallMs`. Send time = the earlier of `wallMs` + and the phone's time of arrival, so a fast box clock cannot make data look + fresher. If any message of a boot arrives with `wallMs` more than 2 s ahead + of the phone clock, the phone stops using rule N for that boot. - **Rule U.** During a Noise session with the box, the phone records phone time minus box uptime for messages that carry the box's `boot` and `uptimeMs`, and keeps the smallest offset per boot. Send time = offset + - `uptimeMs`. This needs the box's `boot` in the Noise session information. + `uptimeMs`. Box clocks drift, so the phone adds 0.01 % of the time since the + offset was last learned to every age, and drops the anchor 24 hours after + it was last learned. - When both apply, the phone uses the earlier send time. When neither does, the data is unanchored: it shows as "cloud, age unverified", at best `stale`, and never `live`. -**State.** Let T be the send time of the newest accepted, anchored `live` or -`hb`, and t = phone time − T, at least zero. - -- If the latest accepted `live` or `hb` names a `srcRev` newer than the - source table the phone holds, every source is `stale` until that table - arrives. -- Otherwise, for each source with box state B and `staleAfterMs` S, the time - grade is: no change while t ≤ S, `lagging` while S < t ≤ 2S, and `stale` - when t > 2S. -- The effective state is the worse of B and the time grade, in the order - `live`, `lagging`, `stale`, `down`, `never`. -- Fields follow their sources through `fieldSources`, as on Noise. +**State.** The phone holds, for the current boot, the source table with its +`srcRev`, the newest `live` and `lp` with their `seq`, and the newest anchored +`live` or `hb`, called M below. Let t = phone time − M's send time, at least +zero. + +1. If M names a `srcRev` newer than the table the phone holds, every source is + `stale` until that table arrives. +2. If M is an `hb` whose `liveSeq` is above the `seq` of the `live` the phone + holds, every field is `stale` until that `live` arrives. The same holds + for `lpSeq` and the loadpoint and feedback values in `lp`. +3. Otherwise, for each source with box state B, `staleAfterMs` S and age A in + M's `ages`, the reading's age now is A + t. Graded as the box grades it, + that is `live` up to S, `lagging` up to 2S, `stale` up to 6S and `down` + beyond. A source missing from `ages` keeps B. +4. The effective state is the worse of B and that grade, in the order `live`, + `lagging`, `stale`, `down`, `never`. +5. A field follows the worst of its sources in `fieldSources`. A field with no + source, such as the mode, is `live` while t ≤ 2H and `stale` after. A + loadpoint follows its charger source in `src`. The service cannot sign, so it cannot make a source look fresher. It can only -delay, drop or replay, and each of those ages the data. +delay, drop or replay, and each of those ages the data or marks it stale. **Vectors.** Each case below becomes a byte vector, replayed in Go and Swift. - -1. **A quiet live source stays live.** One source, S = 10 s, state `live`, - `srcRev` 7, so H = 5 s. The box sends a full `live` at uptime 0, then only - `hb` naming `srcRev` 7 every 5 s for 120 s, under rule N with 100 ms in - transit. At every moment t ≤ 5.1 s, and the source shows `live`. -2. **A dead uplink ages on time.** As case 1, but the socket dies after the - `hb` at uptime 60 s. The source shows `live` up to t = 10 s, `lagging` from - just past 10 s, and `stale` from just past 20 s. -3. **A replay goes stale.** The service replays case 1's first `live` and +In all of them the phone uses rule N with 100 ms in transit unless stated. + +1. **A quiet live source stays live.** One source, S = 10 s, read every + 4.5 s, so H = 5 s. The box sends a full `live` with `srcRev` 1, then only + `hb` every 5 s for 120 s, each with an age of at most 4.5 s. The age now + never passes 9.6 s, and the source shows `live` throughout. +2. **A dead uplink ages on time.** As case 1, but the socket dies after an + `hb` whose age for the source is 2 s. The source shows `live` until t = 8 s, + `lagging` until 18 s, `stale` until 58 s and `down` after. +3. **A replay ages out.** The service replays case 1's first `live` and `hb` an hour later to a phone that has seen nothing from that boot. Their - send time is an hour old, so the source shows `stale`. A phone that holds a - higher `seq` from that boot drops them. + send time is an hour old, so the source shows `down`. A phone that holds a + higher `seq` for those classes drops them. 4. **A withheld table goes stale.** The box moves the source to `lagging` with - `srcRev` 8, and the service drops that `live`. The next `hb` names - `srcRev` 8. The phone holds revision 7, so the source shows `stale` until - revision 8 arrives. + `srcRev` 2, and the service drops that `live`. The next `hb` names + `srcRev` 2. The phone holds revision 1, so the source shows `stale` until + revision 2 arrives. 5. **No anchor, never live.** The box reports `clock: "none"`, and the phone - has had no Noise session. The data shows as "cloud, age unverified" and - never `live`. + holds no rule U offset for that boot, or none at all. The data shows as + "cloud, age unverified" and never `live`. 6. **A fast box clock loses rule N.** A message arrives with `wallMs` 3 s ahead of the phone clock. Rule N stops for that boot, and without rule U the data is unanchored, as in case 5. -7. **A changed byte is dropped.** The service flips one byte of `b`. The - signature fails, the phone drops the message, and the source ages from the - last good message, as in case 2. -8. **The box's state wins.** The box reports a source as `down`, and t = 1 s. - The source shows `down`. +7. **A small clock lead gains nothing.** A message arrives with `wallMs` 1.5 s + ahead of the phone clock. Its send time is the arrival time, not `wallMs`. +8. **A changed byte is dropped.** The service flips one byte of `b`. The + signature fails and the phone drops the message. +9. **A dropped `live` goes stale.** The box sends `live` with `seq` 40, the + service drops it, and the next `hb` names `liveSeq` 40. The phone holds + `seq` 31, so every field shows `stale` until `seq` 40 arrives. +10. **A held-back `lp` goes stale.** As case 9, for `lp` and `lpSeq`. The car + view shows `stale` and no charge level as current. +11. **The byte budget runs out.** The box stops sending `live` and `hb`. The + data ages as in case 2. +12. **A reboot clears the old table.** The phone holds boot X's table with + `srcRev` 9. Boot Y's `hb` arrives, anchored and later, with `srcRev` 3, + and boot Y's full `live` is dropped. The phone shows every source `stale` + and no field as current until boot Y's full `live` arrives. +13. **Rule U drift counts against freshness.** The offset was learned 10 hours + ago. Every age gains 3.6 s, and at 24 hours the anchor lapses. +14. **A snapshot with mixed `seq`.** Message 2 holds `prices` with `seq` 5 and + `live` with `seq` 3000. The phone, which held `live` 2990 and no `prices`, + accepts both. +15. **An old alert shows the generic line.** The service replays a signed + event from 2 hours ago to a phone that never saw it. The extension shows + the generic line with no buttons. +16. **The box's state wins.** The box reports a source as `down`, and t = 1 s. + The source shows `down`. ## Grant on the box @@ -588,8 +667,10 @@ data controller and, with statistics on, the `statsId`. - A missing record, or a fingerprint that does not match `cloud.key`, reads as **off**, and the box says so. This is the opposite of `app_link` and `fleet_ping`, where a missing section reads as on. -- `cloud.key` is never backed up. A restored box has FTW Cloud off until an - owner turns it on again, under a new home id. +- The box never backs up `cloud.key`. A box restored onto new hardware has + FTW Cloud off until an owner turns it on again, under a new home id. A box + restored in place keeps its key; the roster and `stats` rules above stop an + older grant from undoing later changes. - A grant works with the relay off. | Route | Who may call it | @@ -609,25 +690,33 @@ the grant screen claims no more than an owner session. - **Device revoke** on the box: a roster without the device. See Roster. - **Grant revoke:** the box sends `bye {why: "revoked", purge: true}`. The service deletes the home: registry row, tokens, APNs registrations, live - copy, events, tiles and statistics rows. It sends `purged {what: "home"}` - and closes. The box keeps `cloud.key` until it holds a valid `purged`, - retrying at each connect, then deletes the key and the grant and shows the - receipt time. + copy, events, tiles and statistics rows. It keeps a tombstone of the home + id until it has delivered `purged {what: "home"}`, which it sends once all + of that is gone, statistics rows included. Until then the box keeps + `cloud.key`, sends only `hello` and `bye` on each connect, and shows the + revoke as pending. With a valid `purged` it deletes the key and the grant + and shows the receipt time. - **Statistics withdrawal:** `stats.withdraw`. The service deletes the rows within 24 hours and sends `purged {what: "stats"}` when done, on the same or a later connection. -- **Silence:** after an hour the service serves only `lastSeenMs` and no - readings. After 30 days it drops tokens and APNs registrations. After 90 - days without a valid roster it deletes the home as in a purge. +- **Silence:** after an hour without the box's socket, the service serves only + `lastSeenMs` and no readings. When the roster runs out, it closes sessions + and refuses reads. After 30 days it drops tokens and APNs registrations. + After 90 days without a valid roster it deletes the home as in a purge. - **A new home** that sends no roster within 24 hours is deleted. ## Service state Per home, the service keeps in memory the newest envelope of each class, the newest `live` that carried `sources`, 48 hours of tiles and a one-hour ring of -events. It keeps no history of readings and writes none to disk. Its registry -holds the home id, the box key, the roster, the current `statsId`, APNs -tokens and token hashes. +events. It writes no live readings to disk; the statistics files hold the +only readings it stores. Its registry holds the home id, the box key, the +roster, suspensions, the current and deleted `statsId`s, APNs registrations +and token hashes. + +A rebuilt host starts with an empty registry. Boxes send their rosters again +and phones register again at their next start. Suspensions are lost, so an +owner must suspend again. ## Registry names @@ -640,7 +729,7 @@ requests adds them in `srcfl/ftw-webapp` first, then copies them here and to | `ftw.events.read` | Scope: events behind notifications | `registry.yaml`, `scopes` | | `cloud_scopes` | List: `ftw.live.read`, `ftw.history.read`, `ftw.plan.read`, `ftw.health.read`, `ftw.events.read` | `registry.yaml`, new block | | `cloud_purposes` | List: `serve`, `stats` | `registry.yaml`, new block | -| `cloud.link` | Capability: the box can run the FTW Cloud uplink. Absent means the app offers no FTW Cloud | `registry.yaml`, `capabilities` | +| `cloud.link` | Capability: the box can run the FTW Cloud uplink. Absent means the app uses the relay only and offers no FTW Cloud | `registry.yaml`, `capabilities` | | `cloud` | Carrier state, between `relay` and `cache` | `registry.yaml`, `carrier_states` | | `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when the grant changes | `push-catalogue.yaml` | @@ -663,9 +752,12 @@ live here and in `go/protocol`, held by golden vectors in both repositories. | Largest frame from the service | 4 KiB | | Roster `ttlS`, renewal | 24 hours, every 6 hours | | Extension token | 7 days | +| Alert age for buttons | 10 minutes | | `box.unreachable` after | 600 s | | Readings served after silence | 1 hour | +| Sessions closed | When the roster runs out | | Tokens and APNs dropped after | 30 days | | Home deleted after | 90 days without a roster; 24 hours for a new home without one | | Statistics rows kept | 13 months from the hour they describe | -| Clock lead allowed under rule N | 2 s | +| Rule N clock lead allowed | 2 s | +| Rule U drift margin, lifetime | 0.01 % of the time since learned; 24 hours | From 62103bab5c5f68b10731795f8a0cb5d21136a55e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 09:48:16 +0200 Subject: [PATCH 3/3] docs(cloud): take in what C2 and C3 built The protocol was written before the code. srcfl/ftw#1595 (C2) and srcfl/ftw#1603 (C3) each list where the code had to differ. This moves those differences into the text so it stays normative. From C2: fieldSources travels only with sources; a roster at every connect; a deadband on lp's measured values; [] and null on the wire; session as a plug-in count; manual.reason as the limit reason; hello.caps as the classes sent; prices from the start of today; the service signs over the box's home id; no step-up on the grant; a restore never brings cloud.key back. From C3: /v1/app?home=; message 2 as an unsigned snapshot envelope with spill-over frames; a phone's watch holds until it changes, and the service renews the box's every 50 s; close codes 4400, 4403, 4408, 4409, 4429 and 1013, with the phone's retries; session limits; hello_ok.cloud (K11); the statistics consent routes, which push nothing; case 12 shows no sources rather than stale ones; role names are not generated in go/protocol. ADR 0008: turning FTW Cloud on or off notifies owners, not every change; the protocol has sixteen cases, not eight. TestFieldsMatchTheProtocolDocument passes against this text on the C3 branch (c6301a0b). Co-Authored-By: Claude Opus 5.5 --- docs/adr/0008-ftw-cloud.md | 8 +- docs/protocol-cloud.md | 374 ++++++++++++++++++++++++++----------- 2 files changed, 267 insertions(+), 115 deletions(-) diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index 8f9a34c28..8ff1cfa13 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -129,8 +129,8 @@ control path as two states. - **The grant lives on the box.** A missing grant reads as **off**, the opposite of `app_link`. An owner grants over an app session; any door may - revoke. Every change notifies all owners. A viewer's app shows that the - home uses FTW Cloud, and viewers get no alerts. + revoke. Turning it on or off notifies all owners. A viewer's app shows that + the home uses FTW Cloud, and viewers get no alerts. - **Scopes.** Live, history, plan, health and the new `ftw.events.read`. Inventory stays out. - **Roster.** The box signs the list of paired devices, their keys and the @@ -149,8 +149,8 @@ looks fresher than the box would grade it. The phone trusts a send time only through an anchor it can check, and shows unanchored data as "cloud, age unverified", never live. The service cannot sign, so it cannot clear staleness. The -[protocol](../protocol-cloud.md#freshness) states the rules and eight test -vectors. +[protocol](../protocol-cloud.md#freshness) states the rules and sixteen test +cases. ## Statistics diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index d1a8a6290..046686569 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -2,15 +2,19 @@ Proposed with [ADR 0008](adr/0008-ftw-cloud.md). Nothing here has shipped. This document is normative: "must" is a requirement on the box, the service -or the phone. Byte-level test vectors land with the code, in this repository -and in `srcfl/ftw-cloud`. Until then the text below is the reference, and the -vectors described in [Freshness](#freshness) are the cases they must cover. +or the phone. The bytes live in `go/protocol/cloudwire`, with golden vectors +in its `testdata`: `cloud-vectors.json` for the uplink and +`cloud-app-vectors.json` for one whole app session. `srcfl/ftw-cloud` and the +phone replay them. `cloudwire.View` is the phone's +[freshness](#freshness) rules in Go, and the cases listed there are its tests. The app protocol, which runs inside Noise between a phone and its box, is specified in [srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md). -FTW Cloud reuses its frame layout, CBOR rules, field encoding and tile format, -and changes nothing in it. Lane 0 and the relay stay as they are. The Go types +FTW Cloud reuses its frame layout, CBOR rules, field encoding and tile format. +It adds the `cloud.link` capability and one optional field to the app +protocol's `hello_ok`, `cloud` (see +[Session information](#session-information)), and changes nothing else. Lane 0 and the relay stay as they are. The Go types for both live in `go/protocol`, under Apache-2.0 ([#1588](https://github.com/srcfl/ftw/pull/1588)). @@ -25,7 +29,7 @@ for both live in `go/protocol`, under Apache-2.0 | Connection | Opened by | Transport | What protects it | |---|---|---|---| | Uplink | Box | `wss://cloud.ftw.energy/v1/box` | TLS 1.3, a handshake bound to the TLS session on both sides, every box message signed | -| App session | Phone | `wss://cloud.ftw.energy/v1/app` | TLS, then Noise IK to the service's static key | +| App session | Phone | `wss://cloud.ftw.energy/v1/app?home=` | TLS 1.3, then Noise IK to the service's static key | | Extension read | Extension | `GET https://cloud.ftw.energy/v1/now` | TLS and an extension token; the data inside is box-signed | | Push | Service | APNs | Box-signed payloads where they carry data | @@ -64,7 +68,7 @@ offset field type value on FTW Cloud - A receiver closes the connection on a lane 0 frame, a nonzero flag, bytes after `len`, or a text message. - Inside the app session the same frames ride as Noise transport messages, - also without padding. + one frame per message, also without padding. The app protocol pads lane 0 to a fixed size and cadence because the relay must learn nothing from the stream. The service reads the content, so padding @@ -79,7 +83,11 @@ decision 5). Envelope = { t: text, b: bstr, sig: bstr } ``` -- CBOR, core deterministic encoding, text keys. +- CBOR, core deterministic encoding, text keys. A map with a repeated key is + refused. +- An empty list or map is `[]` or `{}`, never null. A number the box does not + know is `null`. Of the box's fields, only `wallMs`, `sources` and + `fieldSources` are ever left out. - `b` is the body, itself deterministic CBOR, carried as a byte string so that the signature covers exact bytes and the service can forward them unchanged. - `sig` is ES256 (ECDSA on P-256 with SHA-256), 64 bytes, `r ‖ s` @@ -88,12 +96,14 @@ Envelope = { t: text, b: bstr, sig: bstr } byte), then the deterministic CBOR encoding of the array `[home, t, b]`, where `home` is the text home id. - The box signs with `cloud.key`. The service signs `hello_ok` and `purged` - with its service key. Every other envelope the service sends, and every - envelope between phone and service, has an empty `sig`. + with its service key, over the box's home id. Every other envelope the + service sends, and every envelope between phone and service, has an empty + `sig`. - A receiver verifies over the bytes it received, never over a re-encoding. -- The box's public key reaches phones over Noise, beside the table of opaque - ids, the home id and the box's current `boot`, in the session information - every role receives. It never comes from the service. +- The box's public key reaches phones over Noise, beside the tables of opaque + ids, the home id and the box's current `boot`, in the + [session information](#session-information) every role receives. It never + comes from the service. - Receivers ignore unknown keys in a body, as in the app protocol. The service drops and counts an unknown `t` from the box, so a newer box works with an older service. An unknown `t` from the service closes the uplink. @@ -122,7 +132,7 @@ not repeat them. | Field | Type | Meaning | |---|---|---| -| `seq` | uint | Starts at 1 and rises by 1 per message, per boot | +| `seq` | uint | Starts at 1 and rises by 1 per message, per boot, across reconnects | | `boot` | bstr, 16 B | Random, drawn at each Core start | | `uptimeMs` | uint | Box monotonic time since start, at send | | `wallMs` | uint | Box wall time at send. Present only while NTP has synced the box clock | @@ -152,11 +162,12 @@ with two different values, so neither side's check passes through it. | `boxNonce` | bstr, 32 B | Random, for the service to sign | | `build` | text | Core version | | `clock` | text | `ntp` or `none` | - | `caps` | [text] | Uplink features this box speaks | + | `caps` | [text] | The classes this box sends under the grant, `roster` included | | `rosterSeq` | uint | The box's current roster number | | `grantId` | bstr, 16 B | The grant this connection serves | -3. **Service → box: `hello_ok`**, signed by the service key. +3. **Service → box: `hello_ok`**, signed by the service key over the box's + home id. | Field | Type | Meaning | |---|---|---| @@ -170,10 +181,10 @@ with two different values, so neither side's check passes through it. check fails, the box closes and backs off. **It sends nothing but `hello` before this check passes.** A proxy that reads `hello` learns the box's public key and grant id, and cannot use them. -4. **Box → service:** a `roster` when the service's number differs from its - own (see [Roster](#roster)); then one full message of each class the grant - allows; then the tiles missing from `have`. While a purge is pending, the - box sends only `bye` instead (see [Revocation](#revocation-and-deletion)). +4. **Box → service:** a `roster`, at every connect (see [Roster](#roster)); + then one full message of each class the grant allows; then the tiles + missing from `have`. While a purge is pending, the box sends only `bye` + instead (see [Revocation](#revocation-and-deletion)). 5. After that the box sends on change, by the cadence rules. ## Classes @@ -181,7 +192,10 @@ with two different values, so neither side's check passes through it. The box sends these. Each class belongs to one scope, and the service forwards a class only to devices whose effective scope includes it. These tables are the allowlist: a field the box sends must have a row here, and a -test fails on any field without one. Driver names and device capabilities +test fails on any field without one. `cloudwire.Fields` holds the same +tables, and `TestFieldsMatchTheProtocolDocument` compares the two. It reads +the rows under each class heading, each bold shape name and each numbered +handshake step, so keep that layout. Driver names and device capabilities have no rows. Source kinds and counts, health counts and the statistics `has` list still show what kinds of equipment a site has. @@ -190,7 +204,7 @@ list still show what kinds of equipment a site has. | Field | Type | Meaning | |---|---|---| | `fields` | map text→int | Frozen field ids 1–6 and 10, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W | -| `fieldSources` | map text→[text] | The sources each field's freshness follows | +| `fieldSources` | map text→[text] | The sources each field's freshness follows. Sent only with `sources` | | `sources` | map text→Source | The source table. Sent whenever `srcRev` changes, and in every full message | | `srcRev` | uint | Rises by 1, per boot, each time the source table changes | | `ages` | map text→uint | For each source in state `live` or `lagging`, the age of its newest reading at send, in ms of box uptime | @@ -220,15 +234,15 @@ list still show what kinds of equipment a site has. | `id` | text | Opaque loadpoint id | | `src` | text | Opaque source id of the charger, whose freshness the loadpoint follows | | `plugged` | bool | A car is connected | -| `socPermille` | uint or null | Car charge level, null when unknown | +| `socPermille` | uint or null | Car charge level, null when unplugged | | `socSource` | text | `vehicle`, `inferred` or `assumed`; empty when unplugged | -| `socAgeS` | uint or null | Seconds since the level was last confirmed, at send | +| `socAgeS` | uint or null | Seconds since the car last reported the level, at send. Null unless `socSource` is `vehicle` | | `targetPermille` | uint or null | The level to reach | | `readyByMs` | int or null | Wall time the car should be ready | | `recurring` | bool | The target comes from a weekly schedule | | `powerW` | int | Charging power, positive while charging | | `sessionWh` | uint | Energy delivered this session | -| `session` | text | Session id; changes at each plug-in | +| `session` | text | The number of plug-ins at this loadpoint since the box started, as text; empty when unplugged. With `boot` it names one plug-in. Core has no session id yet | | `manual` | Manual or null | Manual charging state, null when none | | `commandedW` | int or null | Power last commanded | | `commandedReason` | text | Stable code for that command | @@ -247,8 +261,8 @@ list still show what kinds of equipment a site has. | `state` | text | The manual state | | `sinceAgeMs` | uint | Time in that state, at send | | `requestedW` | int or null | Power the user asked for | -| `commandedW` | int or null | Power commanded for it | -| `reason` | text | Stable code | +| `commandedW` | int or null | Power commanded for it; null when none or zero | +| `reason` | text | Why the hold is limited, as a stable code | **Feedback** @@ -264,8 +278,19 @@ list still show what kinds of equipment a site has. | `actualW` | int or null | Measured power | | `ageMs` | uint or null | Age of that reading by box uptime, at send | -The change test ignores `socAgeS`, `sinceAgeMs` and `ageMs`. The phone adds -the time since the message. +The change test ignores `socAgeS`, `sinceAgeMs` and `ageMs`; the phone adds +the time since the message. It compares measured values with a deadband, +because a charging car moves its power at every reading: + +- `powerW` and `actualW` count as changed when they move by more than + max(100 W, 5 % of the larger value), or `actualW` appears or goes; +- `sessionWh` when it moves by 100 Wh or more; +- `socPermille` when it moves by 10 or more, or appears or goes. + +Any other difference counts as a change. The full `lp` every 15 minutes +carries exact values, and live field 10 carries the site's charging power at +every period. Without the deadband, a simulated house with a car charging at +11 kW sent 12.7 MB a day. ### `prices`: `ftw.live.read` @@ -273,14 +298,17 @@ the time since the message. |---|---|---| | `zone` | text | Price zone | | `currency` | text | ISO 4217 code | -| `t0Ms` | int | Wall time the first slot starts | +| `t0Ms` | int | Wall time the first slot starts: the first stored slot from the start of today on the box's clock | | `slotMs` | uint | Slot length. Where stored slots differ in length, the box repeats a longer slot's price across `slotMs` slots | | `spotMinor` | [int or null] | Market price, minor units per kWh | | `totalMinor` | [int or null] | What the household pays, tariff and VAT included | `totalMinor` shows the household's tariff, and the privacy notice says so. The -phone sees how far the slots reach. The box sends `prices` only when a hash of -`t0Ms`, `slotMs`, `spotMinor` and `totalMinor` changes. +phone sees how far the slots reach. The window runs from the start of today to +the end of tomorrow. Because it starts at midnight and not now, its hash moves +only when new prices land. The box looks at prices once a minute and sends +`prices` only when a hash of `t0Ms`, `slotMs`, `spotMinor` and `totalMinor` +changes. A gap between stored slots is `null`. ### `plan`: `ftw.plan.read` @@ -379,7 +407,7 @@ The service never forwards these to a phone. |---|---|---| | `id` | bstr, 16 B | Grant id | | `scopes` | [text] | A subset of `cloud_scopes` | -| `purposes` | [text] | A subset of `cloud_purposes` | +| `purposes` | [text] | A subset of `cloud_purposes`: `serve` always, and `stats` while the owner contributes to statistics | | `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet | **`stats`**, only while `grant.purposes` contains `stats`. @@ -397,8 +425,8 @@ energy ledger's flow names: `grid_import`, `grid_export`, `pv_generation`, `battery_charge`, `battery_discharge`, `consumer_use`, `vehicle_charge` and `vehicle_discharge`. A flow the site lacks is absent. -The service refuses `stats` for a `statsId` it has deleted, so a box restored -from an older backup cannot turn a withdrawn consent back on. +The service refuses `stats` for a `statsId` it has deleted, so an old grant +record cannot turn a withdrawn consent back on. **`stats.withdraw`** `{statsId}`. The service deletes that id's rows within 24 hours. @@ -412,15 +440,18 @@ back `box.unreachable` until the box returns or an hour passes. - The box keeps the latest value per class and sends a class only when its content changed. The change test ignores `lastOkMs`, as the app protocol's source comparison does, and the age fields named under `live` and `lp`. + `lp` adds the deadband described there. - Each class goes at most once per period: 5 s by default, 1 s while a `watch` is in force. `lp` goes at most every 2 s until a Pi 4 bench shows - what composing it costs. -- Two messages skip the period. A `live` whose source table changed goes at - once, with `sources`. An `hb` goes when it is due. + what composing it costs. The box reads its state no faster than a message + could go. +- Two messages skip the period. The box looks at its source table every + second, and a `live` whose table changed goes then, with `sources` and + `fieldSources`. An `hb` goes when it is due. - `plan` goes only when `decisionId` changes, `prices` only when its hash changes, `tiles` when a bucket closes, and `event` at once. - Every 15 minutes, and after a `resync`, the box sends a full `live` with - `sources` and a full `lp`. + `sources` and `fieldSources`, and a full `lp`. - **Heartbeat.** The box sends `hb` whenever neither `live` nor `hb` has left for H, where H = clamp(the smallest `staleAfterMs` among sources ÷ 2, 2 s, 15 s), and 15 s when there are no sources. It sends no `hb` while a @@ -436,7 +467,7 @@ a text message or a lane 0 frame closes the uplink. A fuzz test covers it. |---|---|---| | `challenge` | `{v, nonce}` | First message. Unsigned | | `hello_ok` | See Handshake | Signed by the service key | -| `watch` | `{hz: 1}` | Raises the period to 1 s for 60 s. The service resends it while a phone asks. Without it the box returns to 5 s | +| `watch` | `{hz: 1}` | Raises the period to 1 s for 60 s. While any phone of the home looks, the service sends it at once, every 50 s after that, and again when the box connects. Without it the box returns to 5 s. The box ignores any other `hz` | | `resync` | `{what, tiles}` | `what` holds `full`, `tiles` or `roster`; `tiles` lists tile ids. The box honours at most one per 5 minutes and ignores the rest | | `purged` | `{atMs, what, grantId, statsId}` | Signed by the service key. `what` is `home` or `stats`. Sent once every deletion it names is done | @@ -459,39 +490,63 @@ config, update or instruction from the service. ## Roster -- The box sends a roster at connect when the service's number differs from its - own, on every change within one control tick, and every 6 hours to renew - `ttlS`. -- If `hello_ok.rosterSeq` is above the box's own, as after a restore from an - older backup, the box moves its number past the service's and sends a new - roster, so revokes made since the backup still arrive. +- The box sends a roster at every connect, on every change within one control + tick, and every 6 hours. Each one renews `ttlS`, so a box that returns after + 20 hours offline does not have 4 hours left. +- If `hello_ok.rosterSeq` is above the box's own, the box moves its number past + the service's and sends a new roster. A box whose state went back in time + therefore cannot undo revokes the service already holds. - The service accepts a roster whose `rosterSeq` is at least the one it holds. An equal number renews `ttlS`. A lower one is ignored. - When `ttlS` runs out with no newer roster, the service closes the home's - sessions and refuses new sessions and token reads until a roster arrives. + sessions with 4403 and refuses new sessions and token reads until a roster + arrives. - **Effective scope** of a device is its `scopes` intersected with `grant.scopes`. -- A device that leaves the roster, or whose `epoch` changes, loses its - sessions, tokens and APNs registrations within a second of the service - receiving the roster, which needs the uplink to be up. +- A device that leaves the roster loses its sessions (4403), tokens and APNs + registrations within a second of the service receiving the roster, which + needs the uplink to be up. A device whose `epoch`, `role` or effective scope + changes has its sessions closed with 4409, so it reconnects and learns its + new access. A new `epoch` also drops its tokens and APNs registrations. - The service learns devices only from the roster. It never adds one. ## App session -1. The phone opens `wss://cloud.ftw.energy/v1/app` and runs - `Noise_IK_25519_ChaChaPoly_SHA256` as initiator. The responder's static key - is a service Noise key built into the app. The prologue is - `ftw.cloud.v1:` followed by the home id, which the phone learned from the - box over Noise. -2. The service accepts the handshake only if the phone's static key is in the - home's current roster and not suspended. -3. Handshake message 1 carries `now {hz}`. Message 2 carries a CBOR array of - box envelopes in rising `seq`: for each class the device may read, the - newest one the service holds, the newest `live` that carried `sources`, - and the newest `hb`. After the array comes the service's own - `{lastSeenMs, online}`. -4. The service then forwards each new box envelope the device may read, - unchanged, as unpadded lane 1 frames. +1. The phone opens `wss://cloud.ftw.energy/v1/app?home=` over TLS 1.3, + with no `Origin` header, and runs `Noise_IK_25519_ChaChaPoly_SHA256` as + initiator. The responder's static key is a service Noise key built into + the app. The prologue is `ftw.cloud.v1:` followed by the home id, which the + phone learned from the box (see [Session information](#session-information)). + The query only says which home to look in: the prologue binds it, so a + query that names another home fails the handshake. +2. Handshake message 1 carries an unsigned `now` envelope, `{hz}`: 1 while the + phone is looking at the home, 0 while it is not. +3. The service admits the phone only if the static key that message 1 proved + is in the home's roster, the roster has not run out, and no owner has + suspended the device. Every refusal closes with 4403 before message 2. +4. Message 2 carries an unsigned `snapshot` envelope, below. +5. After that the service forwards each new box envelope the device may read, + unchanged and in the order the box sent them, one unpadded lane 1 frame per + Noise transport message. It takes the snapshot and starts the stream under + one lock, so no envelope falls between them. + +**`snapshot`** + +| Field | Type | Meaning | +|---|---|---| +| `envelopes` | [Envelope] | Box envelopes in rising `seq`, each the box's own CBOR map, unchanged: for each class the device may read, the newest one the service holds; the newest `live` that carried `sources`; and the newest `hb`. Empty after an hour without the box | +| `lastSeenMs` | int | Service wall time when it last heard from the box | +| `online` | bool | The box's uplink is up now | +| `more` | uint | How many envelopes did not fit in message 2. They follow it as ordinary frames, in rising `seq` | + +The phone accepts the envelopes that follow as it accepts any forwarded +envelope. An envelope too large for one Noise message is not forwarded; the +phone sees the gap through the next `hb`. + +**Watch.** While at least one phone of a home looks, the service keeps the +box's `watch` in force: at once, every 50 s, inside the box's 60 s, and when +the box connects. When no phone looks, it stops, and the box returns to 5 s +within 60 s. **Phone to service.** These use the envelope with an empty `sig`. The service acts on these and ignores other types; a reply has the type named in @@ -499,7 +554,7 @@ the table. | `t` | Who | Effect | |---|---|---| -| `watch` | Any device | Ask for 1 s updates for 60 s | +| `watch` | Any device | `{hz}`: 1 when the phone looks at the home, 0 when it looks away. It holds until the next `watch` or the end of the session | | `tiles.get` | Devices with `ftw.history.read` | `{have}`, tile id to etag. The service answers with the `tiles` envelopes the phone lacks | | `token.get` | Any device | Answered with `token {token, expiresInS}`. A new token replaces the device's old one | | `push.register` | Any device | `{apnsToken, environment, kind, activityId}`. One registration per APNs token; a Live Activity and its push-to-start each register their own | @@ -507,11 +562,64 @@ the table. | `suspend` | Owner | `{deviceId}`. Suspends a device | | `unsuspend` | Owner | `{deviceId}`. Lifts a suspension | -A suspension can only narrow access, never beyond what the roster allows. It -holds until an owner lifts it or the roster drops the device, and it works -while the box is offline, until the roster runs out. Anything the service -says itself, such as `lastSeenMs`, carries no box signature, and the phone -never shows it as a reading. +A suspension can only narrow access, never beyond what the roster allows. The +service ignores one from a viewer and one that names a device outside the +roster. Suspending a device closes its sessions with 4403. A suspension holds +until an owner lifts it, or the roster drops the device or changes its +`epoch`, and it works while the box is offline, until the roster runs out. +Anything the service says itself, such as `lastSeenMs`, carries no box +signature, and the phone never shows it as a reading. + +**Close codes.** The service ends a session with one of these. A refusal says +nothing about which check failed. + +| Code | Meaning | The phone | +|---|---|---| +| 4400 | The phone broke the protocol | Retries with backoff | +| 4403 | Refused: no such home, a device not in the roster, a suspended device, a roster that ran out, a grant that is off, a purged home, or a handshake to another service key or home | Before message 2, tries the app's other service key once, at once. Otherwise asks again slowly | +| 4408 | A newer session from the same device took over | Does not retry | +| 4409 | The roster changed what this device may read | Reconnects at once, once; then retries with backoff | +| 4429 | The home has as many sessions as the service allows, as at the relay | Retries with backoff | +| 1013 | The phone fell too far behind | Retries with backoff and gets a fresh snapshot | + +- **Backoff** is full jitter from 0.5 s to 60 s, as on the uplink. It also + covers a lost socket. +- **Slowly** is a random delay between half and all of min(15 s × 2ⁿ, 5 min), + where n counts the refusals in a row: 7.5 to 15 s at first, at most 5 min. + A refusal changes only when the roster or grant does, or when a service + that restarted has heard from the box again. +- The phone dials at once, whatever it waits for, when the app comes to the + front or the network changes. + +**Limits.** Per home, 16 sessions. Per device, one session; a newer one +replaces the older with 4408. Per phone, 256 envelopes queued, past which the +session closes with 1013. A phone message larger than 4 KiB, a text message, +or more than 20 messages in a burst and then 2 a second closes the session +with 4400. Message 1 must arrive within 10 s. Pings and deadlines are the +uplink's. A restarted service refuses a home's phones with 4403 until that +home's box has connected again (see [Service state](#service-state)). + +### Session information + +Every role learns what it needs to read FTW Cloud from the box, over its own +Noise session, in the app protocol's `hello_ok.cloud`. + +| Field | Type | Meaning | +|---|---|---| +| `pk` | bstr, 65 B | The box's `cloud.key` public key. Every box envelope from the service must verify with it | +| `home` | text | The home id, cut from `pk` | +| `boot` | bstr, 16 B | The uplink's current `boot`. It counts from the same Core start as `hello_ok.clock.uptimeMs`, which anchors rule U | +| `sources` | map text→text | Opaque source id to driver name | +| `loadpoints` | map text→text | Opaque loadpoint id to loadpoint id | + +- The box sends `cloud` only while the grant is on. It leaves it out while + the grant is off or a revoke waits for its receipt, when it holds no + `cloud.key`, and in floor mode. Without it the phone reads through the + relay only. +- The tables name the drivers and loadpoints the box knows at `hello`. One + added later has no name on the phone until its next session with the box. +- The box lists `cloud.link` in `hello_ok.caps` when its build can run the + uplink. A build that names no service does not. ## Extension token @@ -564,21 +672,25 @@ accepted per class and per boot, and drops a message of that class whose `seq` is not higher. It processes the envelopes of a snapshot in rising `seq`. A message from another boot replaces the current boot only if it is anchored and its send time is later than the newest message the phone holds. -When the boot changes, the phone drops the source table and field values it -held and waits for that boot's full `live`. +When the boot changes, the phone drops the source table, field values and +loadpoints it held, and shows none until that boot's full `live` arrives. The +phone checks the signature, decodes the body and checks `seq` before it +changes anything, so a message it drops leaves what it holds as it was. **Anchors.** The phone trusts a send time only by one of two rules. - **Rule N.** The message has `wallMs`. Send time = the earlier of `wallMs` and the phone's time of arrival, so a fast box clock cannot make data look - fresher. If any message of a boot arrives with `wallMs` more than 2 s ahead - of the phone clock, the phone stops using rule N for that boot. + fresher. If the phone accepts a message of a boot whose `wallMs` is more + than 2 s ahead of its arrival, it stops using rule N for that boot. - **Rule U.** During a Noise session with the box, the phone records phone - time minus box uptime for messages that carry the box's `boot` and - `uptimeMs`, and keeps the smallest offset per boot. Send time = offset + - `uptimeMs`. Box clocks drift, so the phone adds 0.01 % of the time since the - offset was last learned to every age, and drops the anchor 24 hours after - it was last learned. + time minus box uptime for each message from the box that carries + `uptimeMs`, `hello_ok.clock` among them, under the boot that + `hello_ok.cloud.boot` names. It keeps the smallest offset per boot, and + keeps it across app starts. Send time = offset + `uptimeMs`. Box clocks + drift, so when rule U gives the send time, the phone adds 0.01 % of the time + since the offset was last learned to every age. It drops the anchor + 24 hours after it was last learned. - When both apply, the phone uses the earlier send time. When neither does, the data is unanchored: it shows as "cloud, age unverified", at best `stale`, and never `live`. @@ -598,15 +710,18 @@ zero. that is `live` up to S, `lagging` up to 2S, `stale` up to 6S and `down` beyond. A source missing from `ages` keeps B. 4. The effective state is the worse of B and that grade, in the order `live`, - `lagging`, `stale`, `down`, `never`. + `lagging`, `stale`, `down`, `never`. A state the phone does not know ranks + as `never`. 5. A field follows the worst of its sources in `fieldSources`. A field with no source, such as the mode, is `live` while t ≤ 2H and `stale` after. A - loadpoint follows its charger source in `src`. + loadpoint follows its charger source in `src`. A source the table lacks + counts as `stale`. The service cannot sign, so it cannot make a source look fresher. It can only delay, drop or replay, and each of those ages the data or marks it stale. -**Vectors.** Each case below becomes a byte vector, replayed in Go and Swift. +**Cases.** `cloudwire.View` runs each case below as a test, except case 15, +which belongs to the notification extension. The phone's port replays them. In all of them the phone uses rule N with 100 ms in transit unless stated. 1. **A quiet live source stays live.** One source, S = 10 s, read every @@ -643,8 +758,9 @@ In all of them the phone uses rule N with 100 ms in transit unless stated. data ages as in case 2. 12. **A reboot clears the old table.** The phone holds boot X's table with `srcRev` 9. Boot Y's `hb` arrives, anchored and later, with `srcRev` 3, - and boot Y's full `live` is dropped. The phone shows every source `stale` - and no field as current until boot Y's full `live` arrives. + and boot Y's full `live` is dropped. The phone drops boot X's table and + values, and shows no sources and no fields until boot Y's full `live` + arrives. 13. **Rule U drift counts against freshness.** The offset was learned 10 hours ago. Every age gains 3.6 s, and at 24 hours the anchor lapses. 14. **A snapshot with mixed `seq`.** Message 2 holds `prices` with `seq` 5 and @@ -661,29 +777,43 @@ In all of them the phone uses rule N with 100 ms in transit unless stated. The grant lives in the box's state store under `cloud/grant_v1`, not in the config document, so a whole-document config write cannot reset it. It holds the grant id, scopes, purposes, a fingerprint of `cloud.key`, `grantKey`, the -granting device, a hash of the policy text, the time or `clock-unknown`, the -data controller and, with statistics on, the `statsId`. +granting device or door, the version of the consent text the owner accepted, +the time or 0 when the clock was not set, and the data controller. With +statistics on, it also holds the `statsId` and the version of the statistics +consent text. - A missing record, or a fingerprint that does not match `cloud.key`, reads - as **off**, and the box says so. This is the opposite of `app_link` and - `fleet_ping`, where a missing section reads as on. -- The box never backs up `cloud.key`. A box restored onto new hardware has - FTW Cloud off until an owner turns it on again, under a new home id. A box - restored in place keeps its key; the roster and `stats` rules above stop an - older grant from undoing later changes. + as **off**, and `GET /api/cloud` says why. This is the opposite of + `app_link` and `fleet_ping`, where a missing section reads as on. +- The box never backs up `cloud.key`, and a restore never brings it back: + restore moves the whole data directory aside. A restored box, in place or + on new hardware, has FTW Cloud off until an owner turns it on again, under + a new home id and a new `grantKey`. The old home at the service ages out as + under [Silence](#revocation-and-deletion). - A grant works with the relay off. | Route | Who may call it | |---|---| -| `GET /api/cloud` | Owner. State, scopes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached | -| `GET /api/cloud/preview` | Owner. The exact bytes that leave the house | -| `POST /api/cloud/grant` | Owner over an app session. On the LAN, only from loopback or with the house password | -| `POST /api/cloud/stats` | Same gate, as a separate action | -| `DELETE /api/cloud/grant`, `DELETE /api/cloud/stats` | Any door, because narrowing access is safe | - -Every change pushes `cloud.granted` or `cloud.revoked` to all owners and -writes an audit line on the box. The box cannot verify the app's step-up, so -the grant screen claims no more than an owner session. +| `GET /api/cloud` | An owner's session, or a client on the LAN. State, scopes, purposes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached | +| `GET /api/cloud/preview` | The same. The exact bytes that leave the house | +| `POST /api/cloud/grant {scopes, policy}` | An owner's session. On the LAN, only from loopback or with the house password. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` | +| `POST /api/cloud/stats {policy}` | The same gate, as a separate consent. 409 while the grant is off or a revoke waits for its receipt | +| `DELETE /api/cloud/grant`, `DELETE /api/cloud/stats` | An owner's session, or any client on the LAN, because narrowing access is safe | + +A viewer's session gets 403 on every route, and so does a request with +forwarding headers. No route asks for a step-up: the box cannot verify the +app's, so the grant screen claims no more than an owner's session. + +- Turning FTW Cloud on pushes `cloud.granted` to all owners, and turning it + off pushes `cloud.revoked`. A change of scopes, and turning statistics on + or off, push nothing. Every change writes an audit line on the box. A grant, + a change of scopes and a statistics change each send a new roster. +- `POST /api/cloud/stats` draws a `statsId` the first time and keeps it while + statistics stay on. Turning statistics on again after a withdrawal draws a + new one. +- `DELETE /api/cloud/stats` removes `stats` from the purposes at once. The + box keeps the `statsId` until `purged {what: "stats"}` arrives, so that + `stats.withdraw` can name it. ## Revocation and deletion @@ -714,15 +844,21 @@ only readings it stores. Its registry holds the home id, the box key, the roster, suspensions, the current and deleted `statsId`s, APNs registrations and token hashes. +A restarted service holds the registry but no readings and no rosters in +memory. It admits a home's phones again once that home's box has connected +and sent its roster. + A rebuilt host starts with an empty registry. Boxes send their rosters again and phones register again at their next start. Suspensions are lost, so an owner must suspend again. ## Registry names -The contract registry does not hold these names yet. A separate pair of pull -requests adds them in `srcfl/ftw-webapp` first, then copies them here and to -`srcfl/ftw-app`. +The contract registry does not hold these names yet. +[srcfl/ftw-webapp#81](https://github.com/srcfl/ftw-webapp/pull/81) adds them +there first; [#1595](https://github.com/srcfl/ftw/pull/1595) and +[srcfl/ftw-app#15](https://github.com/srcfl/ftw-app/pull/15) copy them here and +to the app. | Name | Kind | Goes into | |---|---|---| @@ -731,25 +867,41 @@ requests adds them in `srcfl/ftw-webapp` first, then copies them here and to | `cloud_purposes` | List: `serve`, `stats` | `registry.yaml`, new block | | `cloud.link` | Capability: the box can run the FTW Cloud uplink. Absent means the app uses the relay only and offers no FTW Cloud | `registry.yaml`, `capabilities` | | `cloud` | Carrier state, between `relay` and `cache` | `registry.yaml`, `carrier_states` | -| `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when the grant changes | `push-catalogue.yaml` | +| `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when FTW Cloud is turned on or off | `push-catalogue.yaml` | + +Class names, field names, close codes and the signing tags are not registry +names. They live here and in `go/protocol`, held by golden vectors in both +repositories. -Class names, field names and the signing tags are not registry names. They -live here and in `go/protocol`, held by golden vectors in both repositories. +Role names, `owner` and `viewer`, are registry names, but `go/protocol` does +not generate them yet, so `srcfl/ftw-cloud` spells `owner` by hand. Generating +the registry's roles would remove that copy. ## Numbers in one place | What | Value | |---|---| -| Reconnect backoff | 0.5 s to 60 s, full jitter | -| Ping, read deadline, write deadline | 15 s, 45 s, 10 s | +| Reconnect backoff, box and phone | 0.5 s to 60 s, full jitter | +| Phone retry after 4403 | Half to all of min(15 s × 2ⁿ, 5 min) | +| Ping, read deadline, write deadline | 15 s, 45 s, 10 s, on both sockets | | Period per class | 5 s; 1 s while watched; `lp` at least 2 s | -| `watch` lasts | 60 s | +| Source table change sent within | 1 s | +| `lp` deadband | Power more than max(100 W, 5 %); `sessionWh` 100 Wh; `socPermille` 10 | +| `prices` looked at | Every minute | +| Box's `watch` lasts | 60 s | +| Service renews the box's `watch` | Every 50 s while a phone looks | +| Phone's `watch` lasts | Until the next `watch` or the end of the session | | Heartbeat H | clamp(smallest `staleAfterMs` ÷ 2, 2 s, 15 s) | | Full state | Every 15 minutes | | `resync` honoured | At most one per 5 minutes | | Event ring on the box | 32 | | Byte budget | 20 MB per 24 hours of uptime | -| Largest frame from the service | 4 KiB | +| Largest frame from the service to the box | 4 KiB | +| Largest message from a phone | 4 KiB | +| Sessions | 16 per home; one per device | +| Envelopes queued per phone | 256 | +| Phone messages | 20 in a burst, then 2 a second | +| App session message 1 | Within 10 s | | Roster `ttlS`, renewal | 24 hours, every 6 hours | | Extension token | 7 days | | Alert age for buttons | 10 minutes |