diff --git a/AGENTS.md b/AGENTS.md index 7f30fc2c..b94d6c7b 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 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. ## Drivers diff --git a/VISION.md b/VISION.md index 4ebd1db6..d7b0dfc7 100644 --- a/VISION.md +++ b/VISION.md @@ -235,6 +235,20 @@ 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 [#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 validation and matching clients. diff --git a/docs/adr/0006-app-uplink.md b/docs/adr/0006-app-uplink.md index ff07470a..9954f933 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 00000000..8ff1cfa1 --- /dev/null +++ b/docs/adr/0008-ftw-cloud.md @@ -0,0 +1,294 @@ +# 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), 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 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)) 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 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. +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. 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 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. +- 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, 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 + 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. 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 + 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 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 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 sixteen test +cases. + +## 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: 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. + +| Store | Kept | +|---|---| +| Live state | Memory only; readings dropped an hour after the box goes quiet | +| 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 | +| 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 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 +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. 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 + +- **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:** 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 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 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 2859f474..dcfc1361 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -407,6 +407,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 new file mode 100644 index 00000000..04668656 --- /dev/null +++ b/docs/protocol-cloud.md @@ -0,0 +1,915 @@ +# 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. 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. +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)). + +## 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 on both sides, every box message signed | +| 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 | + +**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. +- 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 + 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.** 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, + 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 +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 + +``` +Envelope = { t: text, b: bstr, sig: bstr } +``` + +- 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` + 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, 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 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. +- 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. 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 | +| 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, 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 | + +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, 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` | + | `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 over the box's + home id. + + | Field | Type | Meaning | + |---|---|---| + | `boxNonce` | bstr, 32 B | Echo | + | `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. 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`, 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 + +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. `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. + +### `live`: `ftw.live.read` + +| 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. 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 | +| `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, when the table was sent | +| `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 | +| `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 unplugged | +| `socSource` | text | `vehicle`, `inferred` or `assumed`; empty when unplugged | +| `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 | 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 | +| `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; null when none or zero | +| `reason` | text | Why the hold is limited, as a 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. 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` + +| Field | Type | Meaning | +|---|---|---| +| `zone` | text | Price zone | +| `currency` | text | ISO 4217 code | +| `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 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` + +| 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, 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 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`. 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` 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 + +| Field | Type | Meaning | +|---|---|---| +| `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 + +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`: `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`. + +| 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. + +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. + +**`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`, 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. 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 `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 + changed `live` or `lp` waits for its period, so an `hb` never vouches for + values the box has not sent. + +## 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. 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 | + +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 `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 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 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 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?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 +the table. + +| `t` | Who | Effect | +|---|---|---| +| `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 | +| `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. 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 + +- 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, 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 + 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 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`, 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`. 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 +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. 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, 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 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 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`. + +**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`. 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`. 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. + +**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 + 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 `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` 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 + 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 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 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 + `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 + +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 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 `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` | 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 + +- **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 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 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 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 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. +[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 | +|---|---|---| +| `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 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 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. + +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, 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 | +| 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 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 | +| `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 | +| Rule N clock lead allowed | 2 s | +| Rule U drift margin, lifetime | 0.01 % of the time since learned; 24 hours |