From 46454796ec8fc2c09ef5918ac96a652f1cfa259e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:38:10 +0200 Subject: [PATCH 1/2] docs(adr): propose ADR 0009, watch commands through a one-shot exchange An Apple Watch cannot open the WebSocket the phone uses, so it reaches the box with one HTTPS request per step through a second relay socket. Core keeps exchange sessions apart from the four stream sessions, moves the command log from the session to the box, and adds a watch role that an owner phone adds and that never holds more than that phone. Proposed only. Paired with srcfl/ftw-webapp docs/watch-exchange.md. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0009-watch-exchange.md | 158 ++++++++++++++++++++++++++++++++ docs/architecture.md | 6 ++ 2 files changed, 164 insertions(+) create mode 100644 docs/adr/0009-watch-exchange.md diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md new file mode 100644 index 00000000..7f22feb4 --- /dev/null +++ b/docs/adr/0009-watch-exchange.md @@ -0,0 +1,158 @@ +# ADR 0009: An Apple Watch commands the box through a one-shot exchange + +- Status: proposed. Nothing described here has shipped. +- Date: 2026-10-14 +- Decided by: Fredrik on 14 October 2026, that a watch controls the box + directly as well as through the phone, that an unlocked watch on the wrist + counts as unlocked, and that each watch has its own key, which the owner can + remove alone. Open decisions A–F below are recommendations until he answers. +- Pair: the wire, the relay route and the client changes are in + [ftw-webapp `docs/watch-exchange.md`](https://github.com/srcfl/ftw-webapp/blob/main/docs/watch-exchange.md). + Neither side works without the other. +- Builds on: [ADR 0006](0006-app-uplink.md). The relay stays the only path that + carries commands. [ADR 0008](0008-ftw-cloud.md) does not change: FTW Cloud + still never controls. + +## Context + +watchOS gives an ordinary app HTTPS through `URLSession` and nothing lower; +WebSocket and Network.framework are off limits +([TN3135](https://developer.apple.com/documentation/technotes/tn3135-low-level-networking-on-watchos)). +The relay speaks only WebSocket. So the W1 watch app sends its intent to the +phone, and nothing works when the phone is away. + +Four facts in Core shape the answer: + +- Every session arrives on the one uplink, shares `MaxSessions = 4`, and a + fifth evicts the oldest (`appuplink/session.go`). A watch session there + would push a phone out. +- The command log lives in the handler (`cmds: newCmdLog()` in + `appproto/handler.go`), and a handler lives as long as its session. + `docs/protocol.md` in ftw-webapp says the box keeps a `cmdId` for 24 h. + Today it keeps it for one session, so a retry in a new session would act + again. +- `notValidAfterMs` counts in box uptime. A client learns the uptime from + `hello_ok`, which travels on the bulk lane because its length varies. +- The roles are `owner` and `viewer`. Over a session a phone may mint viewer + codes only; an owner code comes from the LAN, at home + (`appLinkRoleAllowed` in `api/api_app_link.go`). + +## Decision + +**The box opens a second relay socket for one-shot exchanges. A watch sends +Noise IK message 1, gets message 2, then sends `cmd` and gets `cmd.ack` with +`cmd.result`, each step one HTTPS request the relay hands to that socket. The +watch is its own device with role `watch`. An owner phone adds it, and it never +holds more than that phone.** + +1. **An exchange socket.** `appuplink` opens + `/r///exchange` beside the uplink, with the same handle, + epoch, rotation and backoff. Each message from the relay is + `id(16) ‖ Noise message`, and each reply starts with the same id. The id is + the relay's; Core copies it back and reads nothing else into it. The Noise + prologue is `ftw.exchange.v1:` and the box's static key, so a stream + handshake and an exchange handshake can never stand in for each other. + Losing this socket changes nothing for the uplink, local control or the + planner. + +2. **Exchange sessions are a class of their own.** A table of at most 8, + apart from the four stream sessions, so a watch never evicts a phone. A + session ends after four exchanges, 60 s idle, or 3 minutes. Its handler + takes `cmd` and nothing else: `sub`, `api.req`, `hist.query`, `plan.get` + and `price.get` get `E_UNKNOWN_OP`. It never streams and skips the plan + push that follows an applied command on the stream. Every frame is lane 0, + 512 B: + - The answer to message 1 is message 2, whose payload is `hello_ok` cut to + lane 0: proto, mode, clock, role, scopes and `rev` (the site's + `controlRev`). + - The answer to `cmd` is two transport messages: `cmd.ack`, then + `cmd.result`, or `tick` in its place if the result has not settled + within 3 s. The same `cmd` again returns the stored outcome. + - At most 60 opening messages a minute; the box drops the rest unanswered. + A stranger or a removed key gets no reply, as on the uplink. + +3. **The command log belongs to the box.** It moves out of the handler, keyed + by root device and `cmdId`, and keeps each entry 24 h of uptime, as + protocol.md already promises. A watch's root device is the phone that added + it, so one intent sent by both paths acts once. This also closes the gap + for phones: a retry after a reconnect returns the first outcome instead of + acting again. + +4. **A watch is a device with role `watch`.** + - Registry role `watch`: `ftw.live.read` and `ftw.dispatch.write`. + - `POST /api/app-link/pairing` accepts role `watch` only over a session, + only from an owner, and only with `stepUp`. + - The code is bound to the calling phone's device id and has its own slot, + so minting it does not cancel a pending phone code. + - The watch's `hello` carries the code in message 1. `Authorise` stores the + watch's key with role `watch` and the phone as parent. + - The live caller for a watch holds its role's scopes, minus any scope the + parent does not hold at that moment. A watch never does more than its + phone can do now. + - Owner codes still come only from the LAN. A watch is not an owner. + +5. **Removal runs from phone to watch, never back.** Removing a phone removes its watches and + drops their sessions. Removing a watch touches nothing else. The LAN page + and `GET /api/app-link/devices` list each watch under its phone. A watch + holds no members scope, so it can neither add nor remove anyone. + +6. **Capability `cmd.exchange`.** A box with the exchange socket and watch + codes says so in `hello_ok`. Without it the phone offers no "Add watch". + +## What does not change + +- Every exchange command takes the same `onCmd` path as the stream: expiry, + scope, `rev`, dispatch gate, guards and read-back. Planner output never + reaches hardware, and stale site-meter data still stops dispatch. +- FTW Cloud gets nothing new and controls nothing. +- The relay stays blind. The paired doc lists what it reads and what it still + knows. +- Local operation. The exchange is one more remote door; without the relay + the box runs as before. + +## Open decisions for Fredrik + +- **A. Transport.** A second socket from the box and an HTTPS route on the + relay, with IK opened early. Recommended over a command in message 1 + (0-RTT), a mailbox the box polls, and a push through FTW Cloud. The paired + doc gives the reasons. +- **B. What a watch may do.** Charging commands only. No site mode and no + settings. +- **C. Who adds a watch.** An owner phone, with one passkey step-up. A viewer + cannot. +- **D. Removal.** Removing a phone removes its watches. +- **E. Order.** The watch tries the direct path first and the phone second. +- **F. Timing floor.** A refusal answers in milliseconds; an applied command + waits up to 2 s for the EV save. If the relay can see that gap through + network jitter, the box answers no sooner than a fixed floor. That hides + the outcome and slows every command by up to the floor. Recommended: measure + first (test 6 in the paired doc) and add the floor only if the gap shows. + +## Consequences + +- One more idle socket per box on the relay. +- Opening an exchange session costs the box four X25519 operations, bounded + by the relay's limits and the 60 a minute here. +- The command log grows per household rather than per session, and still + prunes at 24 h. +- Three repositories change: this one, ftw-webapp (relay and registry) and + ftw-app (phone and watch). + +## Tests + +Core's share of the paired doc's list, by its numbers: + +- 6, timing: time to answer for applied and refused commands, at the box and + through the relay. +- 7, replay: a recorded message 1 draws a message 2 nobody else can use; a + recorded `cmd` fails in its own session and returns the stored outcome in a + fresh one. +- 8, both paths: one `cmdId` from the exchange and from the parent phone's + stream acts once. +- 9, role: a watch cannot use `api.req`, `sub`, `hist.query` or + `site.mode.set`, and demoting its phone to viewer turns its next command into + `E_SCOPE_DENIED`. +- 10, removal: removing the watch, or its phone, ends its session and silences + its next message 1. +- 11, isolation: eight exchange sessions leave four stream sessions running. +- 12, vectors: Swift and Go agree on the bytes of every step. diff --git a/docs/architecture.md b/docs/architecture.md index dcfc1361..34accf40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -413,6 +413,12 @@ second outbound connection for homes whose owner turns it on. It carries to never carries control. Commands and long history stay on the relay path above. [protocol-cloud.md](protocol-cloud.md) specifies its wire format. +Proposed, not shipped: [ADR 0009](adr/0009-watch-exchange.md) lets an Apple +Watch send commands without the phone. The box opens a second relay socket for +one-shot exchanges, which the watch reaches with one HTTPS request per step, +because watchOS allows no WebSocket. The watch is a device of its own and never +holds more than the phone that added it. + ### Who is let in, and as what An enrolment carries a role from `contract/registry.yaml`: `owner`, which may From 5379882d80dcb2d3b1719ef8e151c0b2172e91a2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 09:42:29 +0200 Subject: [PATCH 2/2] docs(adr): accept ADR 0009, a watch that works without the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fredrik said yes to A–F on 14 October 2026 and asked that the watch work fully on its own, for example on LTE with the phone away. Reads now go through a one-shot Noise read at FTW Cloud, with `now` on the exchange when FTW Cloud is off. The watch gets the plan and alert scopes, its own token and push registrations, and one identity that the box's device list removes in both doors. The ADR now orders the work as ten pull requests across four repositories, each with its proof. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0009-watch-exchange.md | 250 +++++++++++++++++++++++--------- docs/architecture.md | 10 +- 2 files changed, 190 insertions(+), 70 deletions(-) diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 7f22feb4..31418346 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -1,49 +1,55 @@ -# ADR 0009: An Apple Watch commands the box through a one-shot exchange +# ADR 0009: An Apple Watch works without the phone -- Status: proposed. Nothing described here has shipped. +- Status: accepted. Nothing described here has shipped. - Date: 2026-10-14 -- Decided by: Fredrik on 14 October 2026, that a watch controls the box - directly as well as through the phone, that an unlocked watch on the wrist - counts as unlocked, and that each watch has its own key, which the owner can - remove alone. Open decisions A–F below are recommendations until he answers. -- Pair: the wire, the relay route and the client changes are in +- Decided by: Fredrik on 14 October 2026. Before the design: a watch controls + the box directly as well as through the phone; an unlocked watch on the + wrist counts as unlocked; each watch has its own key, which the owner can + remove alone. On the design: yes to A–F below ("ja på alla"), and the watch + must work fully on its own, for example on LTE with the phone away. + Decisions 7–9 follow from that last answer and from his rule that FTW Cloud + speeds things up and holds state while control never goes through it. +- Pair: the exchange's wire, the relay route and the client changes are in [ftw-webapp `docs/watch-exchange.md`](https://github.com/srcfl/ftw-webapp/blob/main/docs/watch-exchange.md). Neither side works without the other. -- Builds on: [ADR 0006](0006-app-uplink.md). The relay stays the only path that - carries commands. [ADR 0008](0008-ftw-cloud.md) does not change: FTW Cloud - still never controls. +- Builds on: [ADR 0006](0006-app-uplink.md), the relay as the only path that + carries commands, and [ADR 0008](0008-ftw-cloud.md), FTW Cloud, which still + never controls. ## Context watchOS gives an ordinary app HTTPS through `URLSession` and nothing lower; WebSocket and Network.framework are off limits ([TN3135](https://developer.apple.com/documentation/technotes/tn3135-low-level-networking-on-watchos)). -The relay speaks only WebSocket. So the W1 watch app sends its intent to the -phone, and nothing works when the phone is away. +The relay and FTW Cloud's `/v1/app` both speak WebSocket. So the W1 watch app +hands everything to the phone, and nothing works when the phone is away. -Four facts in Core shape the answer: +Five facts shape the answer: - Every session arrives on the one uplink, shares `MaxSessions = 4`, and a fifth evicts the oldest (`appuplink/session.go`). A watch session there would push a phone out. - The command log lives in the handler (`cmds: newCmdLog()` in `appproto/handler.go`), and a handler lives as long as its session. - `docs/protocol.md` in ftw-webapp says the box keeps a `cmdId` for 24 h. - Today it keeps it for one session, so a retry in a new session would act - again. + ftw-webapp's `docs/protocol.md` and the comment on `CmdRetentionMs` say the + box keeps a `cmdId` for 24 h. Today it keeps it for one session, so a retry + in a new session would act again. - `notValidAfterMs` counts in box uptime. A client learns the uptime from `hello_ok`, which travels on the bulk lane because its length varies. - The roles are `owner` and `viewer`. Over a session a phone may mint viewer codes only; an owner code comes from the LAN, at home (`appLinkRoleAllowed` in `api/api_app_link.go`). +- FTW Cloud's app session is already one Noise IK handshake whose message 2 + carries the snapshot ([protocol-cloud.md](../protocol-cloud.md#app-session)). + Only the WebSocket around it keeps a watch out. ## Decision -**The box opens a second relay socket for one-shot exchanges. A watch sends -Noise IK message 1, gets message 2, then sends `cmd` and gets `cmd.ack` with -`cmd.result`, each step one HTTPS request the relay hands to that socket. The -watch is its own device with role `watch`. An owner phone adds it, and it never -holds more than that phone.** +**An owner phone adds a watch, which is a device of its own and never holds +more than that phone. It sends commands to the box through a one-shot +exchange on the relay, and reads the home through a one-shot read at FTW +Cloud. Each step is one HTTPS request. The box's device list is the one place +to remove it.** 1. **An exchange socket.** `appuplink` opens `/r///exchange` beside the uplink, with the same handle, @@ -58,28 +64,32 @@ holds more than that phone.** 2. **Exchange sessions are a class of their own.** A table of at most 8, apart from the four stream sessions, so a watch never evicts a phone. A session ends after four exchanges, 60 s idle, or 3 minutes. Its handler - takes `cmd` and nothing else: `sub`, `api.req`, `hist.query`, `plan.get` - and `price.get` get `E_UNKNOWN_OP`. It never streams and skips the plan - push that follows an applied command on the stream. Every frame is lane 0, - 512 B: - - The answer to message 1 is message 2, whose payload is `hello_ok` cut to - lane 0: proto, mode, clock, role, scopes and `rev` (the site's - `controlRev`). + takes `cmd` and `now`; `sub`, `api.req`, `hist.query`, `plan.get` and + `price.get` get `E_UNKNOWN_OP`. It never streams and skips the plan push + that follows an applied command on the stream. + - The answer to message 1 is message 2. Its payload is `hello_ok` in the + smallest bulk bucket it fits: proto, mode, clock, role, scopes, `rev` + (the site's `controlRev`) and, while FTW Cloud is on, `cloud`. Every + other frame is lane 0, 512 B. - The answer to `cmd` is two transport messages: `cmd.ack`, then `cmd.result`, or `tick` in its place if the result has not settled within 3 s. The same `cmd` again returns the stored outcome. + - The answer to `now` is `now`, then `tick`: frozen fields 1–10, source + states and ages, and each loadpoint's plug state, level, power and + `controlRev`, flagged `TRUNC` if they do not fit. A read and a command + have the same lengths. - At most 60 opening messages a minute; the box drops the rest unanswered. A stranger or a removed key gets no reply, as on the uplink. 3. **The command log belongs to the box.** It moves out of the handler, keyed by root device and `cmdId`, and keeps each entry 24 h of uptime, as protocol.md already promises. A watch's root device is the phone that added - it, so one intent sent by both paths acts once. This also closes the gap - for phones: a retry after a reconnect returns the first outcome instead of - acting again. + it, so one intent sent by both paths acts once. This fix stands on its own + and lands first: today a phone's retry after a reconnect would act again. 4. **A watch is a device with role `watch`.** - - Registry role `watch`: `ftw.live.read` and `ftw.dispatch.write`. + - Registry role `watch`: `ftw.live.read`, `ftw.plan.read`, + `ftw.events.read` and `ftw.dispatch.write`. - `POST /api/app-link/pairing` accepts role `watch` only over a session, only from an owner, and only with `stepUp`. - The code is bound to the calling phone's device id and has its own slot, @@ -91,68 +101,178 @@ holds more than that phone.** phone can do now. - Owner codes still come only from the LAN. A watch is not an owner. -5. **Removal runs from phone to watch, never back.** Removing a phone removes its watches and - drops their sessions. Removing a watch touches nothing else. The LAN page - and `GET /api/app-link/devices` list each watch under its phone. A watch - holds no members scope, so it can neither add nor remove anyone. +5. **Removal runs from phone to watch, never back.** Removing a phone removes + its watches and drops their sessions. Removing a watch touches nothing + else. The LAN page and `GET /api/app-link/devices` list each watch under + its phone. A watch holds no members scope, so it can neither add nor + remove anyone. 6. **Capability `cmd.exchange`.** A box with the exchange socket and watch codes says so in `hello_ok`. Without it the phone offers no "Add watch". +7. **Reads go through FTW Cloud, in one request.** + `POST https://cloud.ftw.energy/v1/app/once?home=` with + `content-type: application/octet-stream`. The body is IK message 1 to the + service's Noise key, prologue `ftw.cloud.once.v1:` and the home id. + - Message 1 carries one envelope with an empty `sig`: `read {have, hz}`, + `token.get`, `push.register` or `push.unregister`. `have` names the + `boot` and the highest `seq` per class the watch holds. `hz: 1` keeps + the box's `watch` in force for 60 s, as a looking phone does. The + service refuses owner messages such as `suspend`. + - The service admits the watch as `/v1/app` admits a phone: its key in the + roster, a roster that has not run out, no suspension. A refusal is 403 + with an empty body, before message 2. + - Message 2 carries the answer: for `read`, the `snapshot` of + `/v1/app` without the envelopes `have` already covers; for the others, + the reply the app session gives. Envelopes that did not fit follow in the + same body as transport messages, each after its length as u16 big-endian. + - The service keeps nothing after the answer: no session, no Noise state. + - Limits: one read at a time per device and 30 a minute; message 1 at most + 4 KiB; the answer within 10 s; 429 past a limit, 501 without a Noise key. + - When FTW Cloud is off, the watch reads `now` through the exchange. + Prices and the plan then wait for the phone or FTW Cloud. + + Reads through the exchange alone are worse: every glance would + wake the box and cross the relay twice, and the plan and prices do + not fit lane 0, so the bucket sizes would tell the relay which one was + read. FTW Cloud already holds the same data, signed by the box, and a + delaying or replaying service only ages it ([freshness](../protocol-cloud.md#freshness)). + +8. **Complications, widgets and alerts without the phone.** + - The watch app reads when it appears, after a command, and in a + background refresh when watchOS grants one for an active complication. + It keeps the verified envelopes and their anchors in the watch's App + Group. Complications draw from that store with their age. + - Widget push (watchOS 26). The watch registers its widget push token with + `push.register {kind: widgets, app: watch}`. The service sends it the + `widgets` push it sends phones, content changed and no data, when + tomorrow's prices arrive and when a car is plugged in. The widget then + reads `GET /v1/now` with the watch's own token. Tokens still read only + `live`, `prices` and `hb`, so the car complication shows the app's last + read with its age. + - Alerts. The watch registers its APNs token with + `push.register {kind: alert, app: watch}`. The service sends the watch + the same payload it sends the phone; Apple shows one when a watch and its + phone get identical payloads + ([registerForRemoteNotifications](https://developer.apple.com/documentation/watchkit/wkapplication/registerforremotenotifications())). + A notification service extension on the watch checks the box's signature + and writes the text, as on the phone. Buttons run in the watch app and + use the exchange. + - `push.register` gains `app`, `iphone` or `watch`, so the service picks + the APNs topic. Apple sees what it sees for phones, and no more. + - The phone's Live Activity still shows on the watch when the phone is + near. The watch gets none of its own. + +9. **One identity in both doors.** + - The watch's one key opens the exchange to the box and the read at FTW + Cloud, as a phone's key does today. + - The box lists the watch in its roster with role `watch` and the scopes + the box derives. FTW Cloud holds no role table: its effective scope is + those scopes intersected with the grant, as for any device. + - Each watch has its own token (W plan decision 4, "rimligt"): `token.get` + over the one-shot read, bound to the watch's roster entry, 7 days, + renewed at each read. + - Removing the watch on the box drops its exchange sessions at once, and + its reads, token and push registrations at FTW Cloud within a second of + the next roster arriving. If the box's uplink is down, FTW Cloud learns + it when the box returns or when the roster runs out after 24 hours. An + owner can suspend the watch at FTW Cloud from the phone until then. + ## What does not change - Every exchange command takes the same `onCmd` path as the stream: expiry, scope, `rev`, dispatch gate, guards and read-back. Planner output never reaches hardware, and stale site-meter data still stops dispatch. -- FTW Cloud gets nothing new and controls nothing. +- FTW Cloud gains a read door and a device role. It sends the box nothing new + and controls nothing. - The relay stays blind. The paired doc lists what it reads and what it still knows. -- Local operation. The exchange is one more remote door; without the relay - the box runs as before. +- Local operation. The exchange and the read are remote doors; without the + relay and FTW Cloud the box runs as before. +- Lane 0 keeps its fixed size. FTW Cloud stays unpadded, as ADR 0008 + decided. + +## Accepted decisions -## Open decisions for Fredrik +Fredrik answered yes to all six on 14 October 2026. - **A. Transport.** A second socket from the box and an HTTPS route on the - relay, with IK opened early. Recommended over a command in message 1 - (0-RTT), a mailbox the box polls, and a push through FTW Cloud. The paired - doc gives the reasons. -- **B. What a watch may do.** Charging commands only. No site mode and no - settings. + relay, with IK opened early, over a command in message 1, a mailbox the box + polls, or a push through FTW Cloud. +- **B. What a watch may do.** Read, and charge. No site mode and no settings. + Reads now include the plan and alerts, which standalone use needs. - **C. Who adds a watch.** An owner phone, with one passkey step-up. A viewer cannot. - **D. Removal.** Removing a phone removes its watches. - **E. Order.** The watch tries the direct path first and the phone second. - **F. Timing floor.** A refusal answers in milliseconds; an applied command - waits up to 2 s for the EV save. If the relay can see that gap through - network jitter, the box answers no sooner than a fixed floor. That hides - the outcome and slows every command by up to the floor. Recommended: measure - first (test 6 in the paired doc) and add the floor only if the gap shows. + waits up to 2 s for the EV save. The box answers no sooner than a fixed + floor only if the timing test shows that gap through the relay. + +## Milestones + +Each step is one pull request with its own proof. A wire change lands in this +repository first, then in the repository that consumes it. + +| # | Repository | What | Proof | +|---|---|---|---| +| 1 | ftw | The command log on the box (decision 3) | The same `cmdId` in a second session acts once; another device's same id is new; the table still prunes at 24 h | +| 2 | ftw-webapp, then ftw and ftw-app | Registry: role `watch`, capability `cmd.exchange` | Drift checks green in all three | +| 3 | ftw | Watch role and enrolment (decisions 4, 5, 9): the pairing route, parent, live scopes, removal by phone, watches in the roster | Role and removal tests below; the capability stays unsaid | +| 4 | ftw-webapp | The relay exchange: role, `/x/`, limits, README claim, Caddy log | Relay tests 1–5 in the paired doc | +| 5 | ftw | Exchange socket and sessions, `now`, `hello_ok` with `cloud`, `cmd.exchange` said | Replay, both paths, isolation, timing and vectors below; box, real relay and a Go client end to end | +| 6 | ftw | `cloudwire` and protocol-cloud.md: the one-shot read, `app` on `push.register`, role `watch` in the roster | Golden vectors for one read | +| 7 | ftw-cloud | `/v1/app/once`, watch push topics. Needs ADR 0008's token, `/v1/now` and widget push first | FTW Cloud tests below | +| 8 | ftw-app | The wire for watchOS: Noise, transport, frames, CBOR, rendezvous and FTW Cloud freshness | Swift replays the exchange and read vectors; `swift test` on the watchOS simulator | +| 9 | ftw-app | Add watch on the phone; direct commands on the watch, direct first, watch-minted `cmdId` | Device tests 6, 9, 10 and 11 in the paired doc | +| 10 | ftw-app | The watch on its own: FTW Cloud reads, `now` when FTW Cloud is off, complications, widget push, alerts | Device tests 7, 8 and 11 in the paired doc | + +Step 1 needs nothing else. Steps 2 and 4 can run beside it. ## Consequences - One more idle socket per box on the relay. -- Opening an exchange session costs the box four X25519 operations, bounded - by the relay's limits and the 60 a minute here. +- Opening an exchange session costs the box up to four X25519 operations, + bounded by the relay's limits and the 60 a minute here. - The command log grows per household rather than per session, and still prunes at 24 h. -- Three repositories change: this one, ftw-webapp (relay and registry) and - ftw-app (phone and watch). +- A watch reads from FTW Cloud, so a household with FTW Cloud off gets + commands and Now on the watch, but not prices, the plan or alerts. +- Four repositories change: this one, ftw-webapp (relay and registry), + ftw-cloud (the read) and ftw-app (phone and watch). ## Tests -Core's share of the paired doc's list, by its numbers: +Core: -- 6, timing: time to answer for applied and refused commands, at the box and - through the relay. -- 7, replay: a recorded message 1 draws a message 2 nobody else can use; a +- **Command log.** As milestone 1. +- **Timing.** Time to answer for applied and refused commands, at the box and + through the relay. The result settles decision F. +- **Replay.** A recorded message 1 draws a message 2 nobody else can use; a recorded `cmd` fails in its own session and returns the stored outcome in a fresh one. -- 8, both paths: one `cmdId` from the exchange and from the parent phone's +- **Both paths.** One `cmdId` from the exchange and from the parent phone's stream acts once. -- 9, role: a watch cannot use `api.req`, `sub`, `hist.query` or - `site.mode.set`, and demoting its phone to viewer turns its next command into - `E_SCOPE_DENIED`. -- 10, removal: removing the watch, or its phone, ends its session and silences - its next message 1. -- 11, isolation: eight exchange sessions leave four stream sessions running. -- 12, vectors: Swift and Go agree on the bytes of every step. +- **Role.** A watch may send `cmd` and `now`, and gets `E_UNKNOWN_OP` for + `api.req`, `sub` and `hist.query` and `E_SCOPE_DENIED` for `site.mode.set`. + Demoting its phone to viewer turns its next command into `E_SCOPE_DENIED` + and narrows its scopes in the next roster. +- **Removal.** Removing the watch, or its phone, ends its sessions, silences + its next message 1 and drops it from the next roster. +- **Isolation.** Eight exchange sessions leave four stream sessions running. +- **Shape.** `now` and `cmd` requests and answers have equal lengths for every + op and outcome. +- **Vectors.** Swift and Go agree on the bytes of every step. + +FTW Cloud: + +- **Routes.** `/v1/app/once` is in `server.Routes`, and + `TestNoRouteReadsAHome` covers it. +- **Admission.** The one-shot read admits exactly the devices `/v1/app` + admits, run through the same table of cases. +- **Removal.** A watch the roster drops is refused within a second of that + roster arriving. +- **Token.** A watch token reads `live`, `prices` and `hb`, and nothing else. +- **Alerts.** A watch and its phone get byte-identical alert payloads. +- **Memory.** After an answer, the service holds no session or Noise state for + that read. diff --git a/docs/architecture.md b/docs/architecture.md index 34accf40..79ae12c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -413,11 +413,11 @@ second outbound connection for homes whose owner turns it on. It carries to never carries control. Commands and long history stay on the relay path above. [protocol-cloud.md](protocol-cloud.md) specifies its wire format. -Proposed, not shipped: [ADR 0009](adr/0009-watch-exchange.md) lets an Apple -Watch send commands without the phone. The box opens a second relay socket for -one-shot exchanges, which the watch reaches with one HTTPS request per step, -because watchOS allows no WebSocket. The watch is a device of its own and never -holds more than the phone that added it. +Accepted, not shipped: [ADR 0009](adr/0009-watch-exchange.md) lets an Apple +Watch work without the phone. watchOS allows no WebSocket, so each step is one +HTTPS request: commands go to the box through a second relay socket for +one-shot exchanges, and reads go to FTW Cloud in one request. The watch is a +device of its own and never holds more than the phone that added it. ### Who is let in, and as what