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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/send-on-arrival.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

A phone whose session runs through FTW Cloud now gets a frame as each driver poll lands, instead of on the box's next one-second pulse, with a beat each second when no poll comes. A frame goes on every poll whether or not a value changed, so its timing shows the poll rhythm and never which reading moved. While a phone watches, the readable copy's `live` also goes when a reading lands, still at most once a second. Sessions over the relay keep their fixed cadence. Lane 0's invariant changes from "fixed length and cadence" to "fixed length; fixed cadence over the relay".
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +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
- Lane 0 frames have a fixed length; the padding is a privacy control. Over
the relay they keep a fixed cadence. Through FTW Cloud a frame goes on
every arrival, each finished driver poll whether or not a value moved,
plus a beat when none has gone for the subscribed period, so the timing
shows the poll rhythm and never which reading moved (Fredrik, 16 October
2026). The proposed FTW Cloud uplink is not padded, so it sends lane 1
frames without the bulk buckets and never lane 0. A phone's own session
rides inside it as `tun`, with its lane 0 padding intact.
- FTW Cloud may hold a readable copy of what the owner enabled. It carries the
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work.
| 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 |
| With the copy off, the service sees from the tunnel's timing when the box reads its devices while a phone watches | The privacy notice; a frame goes on every poll whether or not a value moved, frames keep their size, and a hidden session keeps its 5 s |
| 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 |
Expand Down
40 changes: 37 additions & 3 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,10 @@ back `box.unreachable` until the box returns or an hour passes.
- 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.
- While a `watch` is in force, a driver poll that changed `live` sends it at
once if the last `live` left a period ago or more, and otherwise when that
period ends. So the copy costs what it did, and a reading from a device
read once a second waits for nothing.
- `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
Expand Down Expand Up @@ -826,9 +830,37 @@ Noise session, in the app protocol's `hello_ok.cloud`.

The phone's own session with its box, `ftw.session.v1` (Noise IK to the
box's static key, as the app protocol specifies), rides inside the app
session. Nothing in it changes: lane 0 keeps its buckets and its cadence, so
the service sees when a message goes, not what it says or how large a command
is.
session. Its frames and buckets do not change, so the service sees when a
message goes, not what it says or how large a command is. Its timing does:
lane 0 goes as readings land, not on the relay's one-second pulse.

### Lane 0 through the tunnel

- When a driver finishes a poll, the box sends each visible session one
frame: a `delta` of what changed, or a `tick` when nothing did. It waits
5 ms for polls that finish with it, so they go in one frame, and sends at
most five such frames a second on average, three at once.
- When nothing has gone for the subscribed period, 1 s while the phone shows
the stream and 5 s while it is hidden, the box sends a `tick`, or a `delta`
if something changed. A hidden session gets only these.
- A source table too large for the bucket goes in a snapshot on bulk, sent
in line with lane 0 so no reading sent after it reaches the phone first.
Every lane 0 frame keeps its bucket.
- Past 60 % of the tunnel's 24-hour budget a session gets the beat alone,
and past 90 % no readings, so handshakes, command answers and ends still
go.
- Over the relay nothing changes: one frame per pulse, every 1 s or 5 s.

**What the service learns.** It cannot read a frame, and every lane 0 frame
is the same size. While a phone watches, a frame goes on every finished
poll, changed or not, so their timing shows when the box reads its devices
and never which reading moved, by how much or which way. A snapshot is
larger, and goes when the control revision or the source table changes.
With the copy off, the poll rhythm is new to the service, for the minutes
someone watches. With the copy on, the service reads in `live` the values of
the fields the grant lists, and the rhythm adds when the box read them.
While no phone has a session nothing goes, and a hidden session sends every
5 s whatever happens.

### `tun`

Expand Down Expand Up @@ -1360,6 +1392,8 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand.
| Largest message from a phone | 4 KiB; a `tun` message 16,664 bytes and Noise's 24 |
| `tun` per home and direction | 240 messages, then 60 a second; 4 MiB, then 512 KiB a second |
| `tun` queues and budget on the box | 64 lane 0, 256 bulk paced to 90 % of the limits; 250 MB per 24 hours of uptime |
| Lane 0 through the tunnel | On each finished poll, after 5 ms; at most 5 frames a second, 3 at once; a beat after 1 s quiet, 5 s while hidden; pushes stop at 60 % of the tunnel budget, beats at 90 % |
| `live` while watched | On each poll that changed it, at most once per period |
| Box handshakes through the tunnel | 12 a minute per device, 240 in all |
| Pending pairing entry | One QR code, for its own ten minutes; admits one handshake |
| Box session up after TLS | One round trip: its message 1 rides in `now.tun` |
Expand Down
12 changes: 12 additions & 0 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ func startAppLink(
Deadman: deadman,
Handler: handlerFor(false),
ExchangeHandler: handlerFor(true),
Arrivals: arrivalsFor(ctx, tel),
})
if err != nil {
return enroll, nil, enabled, err
Expand All @@ -843,6 +844,17 @@ func startAppLink(
return enroll, uplink, true, nil
}

// arrivalsFor hears each finished driver poll until ctx ends, or nothing
// without a store.
func arrivalsFor(ctx context.Context, tel *telemetry.Store) <-chan struct{} {
if tel == nil {
return nil
}
arrivals, stop := tel.Arrivals()
context.AfterFunc(ctx, stop)
return arrivals
}

func siteMeterName(ctrl *control.State, ctrlMu *sync.Mutex) string {
ctrlMu.Lock()
defer ctrlMu.Unlock()
Expand Down
1 change: 1 addition & 0 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ func startCloudLink(
Started: processStarted,
ClockSynced: cloudlink.ClockSynced,
Notify: notices.notify,
Arrivals: arrivalsFor(ctx, tel),
}
// Set only with a table: a typed nil would read as a tunnel.
if tunnel != nil {
Expand Down
Loading
Loading