Skip to content

feat(once): carry a watch's exchange to its box as x - #49

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

frahlg merged 3 commits into
mainfrom
claude/watch-cloud

Conversation

@frahlg

@frahlg frahlg commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Why

A watch's commands reached its box through the relay's exchange (ADR 0009), and the relay stopped on 17 October 2026. A watch has HTTPS and nothing lower, so its one-shot request now carries each exchange message, and the service hands it to the box on the uplink, unread.

What

  • The pin moves to srcfl/ftw 657f84ae (feat(cloudwire): x carries a watch's exchange through FTW Cloud ftw#1818), which adds x.
  • /v1/app/once takes x {d}:
    • Admission is store.For with the key the handshake proved, as before. x then needs the roster entry's tun and role watch, so commands work with the readable copy off. A phone with tun gets 403 for x: its own session carries its commands.
    • Every other type now needs a non-empty effective scope, as protocol-cloud.md step 3 always said.
    • Behaviour change: a device with tun and no scopes now gets 403 for a read, where before it got an empty 200 snapshot. A watch with the copy off is such a device.
  • The uplink sends x {dev, id, d}: dev comes from the service's own handshake, and id is a number per connection. It returns the box's d within 5 s.
  • x comes back empty, never as an error, in four cases. The first three answer at once:
    • no box is connected;
    • the box did not say x;
    • 8 exchanges are already open at the box;
    • the box gave no answer within 5 s.
  • Answers are routed by id and dev. An answer nobody waits for is dropped, counted, and charged to the box's message rate. When a box's connection ends, every waiting exchange gets an empty d at once.
  • README and CLAUDE.md now list x among what the service sends a box and what a watch may do.

The service grows by about 160 lines of Go, not counting tests.

Proof

  • TestOnceCarriesTheExchangeVectors replays cloud-once-x-vectors.json through the real handler and uplink, byte for byte.
  • Admission table, now with the role: a phone with tun is refused x (403), and so is a watch without tun. No-answer cases, routing, end of uplink, malformed x, the one-at-a-time turn, the downlink's closed set, and TestAWatchExchangesWithItsBoxEndToEnd.
  • go vet ./..., go test -race -count=1 -p 2 ./..., the same with -tags ftwclouddev, and scripts/check-imports.sh. Each fuzz target ran 30 s. linux/arm64 builds.
  • On simulators: ftwcloudd from this branch, the box from feat(cloud): a watch's exchange rides FTW Cloud ftw#1819, and the watch from feat(watch): the watch on its own through FTW Cloud ftw-app#99, with the paired iPhone simulator shut down. The watch read its home and charged the car.

Nothing deployed.

Not done

/v1/app (the session socket) still admits a key whose roster role is watch. The box answers such a key on the exchange alone (TestAWatchKeyGetsNoStream in srcfl/ftw#1819), so the session would carry nothing. Refusing it at the door as well is a one-line change, left out to keep this PR to x.

Pairs

srcfl/ftw#1818 (merge first, then move the pin to its merge commit), srcfl/ftw#1819, srcfl/ftw-app#99.

🤖 Generated with Claude Code

The relay that carried a watch's commands stopped on 17 October 2026. A
watch now sends each exchange message in a one-shot request as x {d}. The
service sends x {dev, id, d} down the box's uplink, with the device id its
own handshake matched and a number of its own, and answers with the box's
d, unread and unchanged.

- Moves the go/protocol pin to srcfl/ftw 657f84ae, which adds x.
- /v1/app/once admits x by the roster entry's tun, so commands work with
  the readable copy off. The other types now need a non-empty effective
  scope, as protocol-cloud.md step 3 says; before, a tun device with the
  copy off got an empty snapshot.
- No box, a box without x in hello.caps, 8 exchanges open at it, or 5 s
  without an answer: an empty d, never an error. The one-shot limits hold
  for the whole wait.
- The uplink routes the box's x by id and dev. One nobody waits for is
  dropped, counted and charged to the box's message rate. When the box's
  connection ends, every waiting exchange gets an empty d at once.
- Tests replay cloud-once-x-vectors.json through the real handler and
  uplink, and cover admission, no answer, routing and an end-to-end round
  trip.

Pairs with srcfl/ftw 657f84ae (go/protocol and the golden vectors).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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:20.222842Z 0fef724 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fef724c65

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/uplink/uplink.go
Comment on lines +381 to +384
if c.send(exchange{Dev: dev, ID: id, D: d}) != nil {
return nil
}
t := time.NewTimer(u.xWithin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the uplink send by the exchange deadline

When the box stops reading or another uplink write holds writeMu, c.send blocks before either the exchange timer or context cancellation can take effect. The production send path uses writeFrame, which waits for that mutex and then allows another 10 seconds for the write, so an exchange can exceed both its advertised 5-second bound and ServeOnce's 10-second response deadline. In that case the watch gets a failed HTTP response instead of the promised empty d, while the request continues holding its admission slots. Apply the exchange deadline and cancellation to the send as well as the answer wait.

Useful? React with 👍 / 👎.

frahlg and others added 2 commits October 23, 2026 18:02
A roster entry with tun but another role, a phone's, has its own tunnel
and no business on the watch's door. Review of #49 (N1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o Core master 21e3d169 (#1818 merged)

The uplink keeps both the exchange waiters and the tiles resync clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 2e4869c into main Oct 23, 2026
2 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