Skip to content

feat(cloud): a watch's exchange rides FTW Cloud - #1819

Merged
frahlg merged 3 commits into
masterfrom
claude/watch-cloud
Oct 23, 2026
Merged

frahlg merged 3 commits into
masterfrom
claude/watch-cloud

Conversation

@frahlg

@frahlg frahlg commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Why

A watch without its iPhone has HTTPS and nothing lower. Its exchange sessions (ADR 0009) live on the box and never needed the relay. Only their carrier did, and the relay stopped on 17 October 2026. #1691 removes the relay and the exchange with it. This PR brings the exchange back, carried by FTW Cloud.

Base: master. Rebased on master after #1818 (21e3d16) and #1817 (C6) merged, so it carries only its own three commits. ADR 0009 keeps master's accepted wording. It no longer stacks on #1691. When #1691 rebases on top of this, it must keep the exchange files (appuplink/exchange.go, appproto/exchange.go and their tests).

What

  • appuplink: the exchange table returns, without its relay socket.
    • An Exchange takes each x the cloud link delivers, eight at a time, and answers with one x under the same dev and id.
    • A request is tried only against the sessions of the device FTW Cloud names.
    • Silence goes back as an empty d, so the service answers the watch at once rather than at its deadline.
  • appproto: the exchange handler (hello, cmd, now, the 400 ms floor, two frames of one length) returns, with wording for FTW Cloud.
  • cloudlink:
    • reads x;
    • sends x on the tun lane 0 queue, under its own share of the tun budget;
    • says x in hello.caps when it has an Exchange beside its Tunnel.
  • The roster gives a watch tun. The box answers a watch's key on the exchange alone (TestAWatchKeyGetsNoStream).
  • The box says cmd.exchange in hello_ok while the link takes x, so the phone offers "Apple Watch".
  • POST /api/app-link/pairing {"role":"watch","key":"<base64url>"}: an owner's phone adds its watch by key. It needs a session, an owner, and a step-up on the request itself. appenroll.AddWatch handles it, and adding the same watch again is a no-op. A request without a key gets 400: the watch-code path is gone (MintWatchCode, AuthoriseWatch, WatchTTL).
  • exchange-vectors.json: X1 carries no code, and each step also has the x frame it crosses the uplink in.
  • Changeset minor (approved by Fredrik on 23 October 2026).

Review fixes

  • S2, replays. Message 1 alone changes nothing on the box.
    • CheckWatch is pure. Its commit stamps the watch and saves only after the first transport message opens.
    • At most two sessions per watch and eight in all. Past either cap, the box ends the oldest unproved session first, then the oldest proved one. Past the per-watch cap it ends only that watch's own. Another watch's working session can end only when eight are open, which takes four watches with two sessions each.
    • The box looks up dev before it spends a token or a DH. Each watch has its own 12 handshakes a minute, and the box 60.
  • S4, the box's share. x replies may use a tenth of the tun budget and stop once the tunnel has used nine tenths, so lane 0 keeps its headroom. The exchange takes 120 requests a minute. A request it cannot answer runs nothing: it is dropped before the handler sees it, so no command runs whose answer cannot leave.
  • S5, restart. The command log is in memory. A restart forgets it, and a cmdId sent again after one can run again. The protocol doc and ADR 0009 say so. The watch side (feat(watch): the watch on its own through FTW Cloud ftw-app#99) compares hello_ok clocks and does not send a command again to a box that restarted.
  • S7. The exchange takes loadpoint.hold alone. Any other op gets E_OP_NOT_GRANTED.
  • N2. The watch-code path is removed, and the stale "relay's exchange" wording with it.

Proof (local)

make verify on the rebased branch (a521f7e): green. Before the rebase, go test -race on appuplink, cloudlink, appproto, appenroll and api was green too. CI runs here now that the base is master. Its first run caught a test of mine that sent two requests with one id on one session (TestAKeyThatIsNoWatchIsRefused); the last commit (a521f7e after the rebase) gives each its own id, and the test passed 20 runs in a row under -race.

  • TestAWatchCommandThroughFTWCloud runs a real cloud link against a mini FTW Cloud: the roster lists the watch with tun, the box says x, the watch charges, and only roster, notice and x leave the box with the copy off.
  • Review cases:
    • message 1 replayed does not evict another watch: TestReplaysEndNoWorkingSession;
    • junk under one watch's name does not spend another's opens: TestHandshakesAreRationed;
    • x with its share spent: TestXKeepsToItsShareOfTheTunnelBudget, TestNoAnswerMeansNoCommand;
    • a restart between a command and its retry: TestARestartedBoxSaysSoInItsClock;
    • a revoke mid-session, then a command: TestARevokeMidSessionRefusesTheNextCommand;
    • the request cap: TestTheExchangeTakesAMinutesRequestsAndNoMore;
    • only the hold: TestAnExchangeTakesTheChargersHoldAlone;
    • message 1 writes nothing: TestCheckingAWatchWritesNothingUntilItCommits;
    • no key, no watch: TestAWatchWithoutAKeyIsRefused.
  • FTW Cloud refusing x from a key that is no watch is tested in feat(once): carry a watch's exchange to its box as x ftw-cloud#49.
  • End to end on simulators, against ftwcloudd from feat(once): carry a watch's exchange to its box as x ftw-cloud#49 and this box in a test process. A paired iPhone simulator was shut down. The watch charged through FTW Cloud, and the box recorded the hold SetBy:app:<watch id>. Pictures are in feat(watch): the watch on its own through FTW Cloud ftw-app#99.

Not tested on the home box or on a real watch.

Wire: #1818. FTW Cloud: srcfl/ftw-cloud#49. App: srcfl/ftw-app#99.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T15:05:48.531263Z a37987f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

frahlg added a commit that referenced this pull request Oct 23, 2026
From the review of #1819:
- A message 1 that FTW Cloud names under a device that is no watch costs
  nothing. Handshakes: 12 a minute per watch, 60 in all.
- CheckWatch decides and changes nothing; a session commits (stamps the
  visit) once its first transport message decrypts. A watch holds two
  sessions and the box eight; the oldest unproved goes first, never the
  newcomer, so replays end no working session and no other watch's.
- The watch-code path is gone: a watch request without a key is refused,
  and a hello with a code gets silence.
- x answers keep to a tenth of the tunnel budget and stop at nine tenths;
  the box takes 120 requests a minute. A request whose answer could not
  leave is dropped and runs nothing.
- The exchange takes loadpoint.hold alone; other ops get E_OP_NOT_GRANTED.
- Tests for each, a revoke mid-session, and a restart's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the claude/watch-cloud branch from a37987f to f681e28 Compare October 23, 2026 16:10
@frahlg
frahlg changed the base branch from codex/remove-retired-relay-20261017 to master October 23, 2026 16:10
@frahlg frahlg closed this Oct 23, 2026
@frahlg frahlg reopened this Oct 23, 2026
@frahlg

frahlg commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Fredrik approved this in chat on 23 October 2026: the "Through FTW Cloud" section of ADR 0009 (all six points) and the minor bump for the watch on its own.

🤖 Generated with Claude Code

frahlg and others added 3 commits October 23, 2026 19:15
The relay stopped on 17 October 2026, and with it the socket that carried
a watch's exchange (ADR 0009). The exchange sessions live on the box and
never needed the relay; only the carrier did.

- appuplink: the exchange table keeps its sessions; its relay socket gives
  way to an Exchange the cloud link delivers x to. A request is tried only
  against the sessions of the device FTW Cloud names, and a handshake that
  proves another key gets silence, which goes back as an empty d.
- cloudlink: reads x, sends it with the tun lane 0 frames, says x in
  hello.caps beside tun.
- The roster gives a watch tun; the box says cmd.exchange while the link
  takes x.
- POST /api/app-link/pairing {"role":"watch","key":...}: an owner's phone
  adds its watch by key, with the same step-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of #1819:
- A message 1 that FTW Cloud names under a device that is no watch costs
  nothing. Handshakes: 12 a minute per watch, 60 in all.
- CheckWatch decides and changes nothing; a session commits (stamps the
  visit) once its first transport message decrypts. A watch holds two
  sessions and the box eight; the oldest unproved goes first, never the
  newcomer, so replays end no working session and no other watch's.
- The watch-code path is gone: a watch request without a key is refused,
  and a hello with a code gets silence.
- x answers keep to a tenth of the tunnel budget and stop at nine tenths;
  the box takes 120 requests a minute. A request whose answer could not
  leave is dropped and runs nothing.
- The exchange takes loadpoint.hold alone; other ops get E_OP_NOT_GRANTED.
- Tests for each, a revoke mid-session, and a restart's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestAKeyThatIsNoWatchIsRefused sent two requests with id 1 on one
session, in map order, and CI's race run read a mixed answer. Each
request now has its own id, in a fixed order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the claude/watch-cloud branch from 8442d99 to a521f7e Compare October 23, 2026 17:17
@frahlg
frahlg merged commit d68dedc into master Oct 23, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant