Repository navigation
feat(appuplink): the watch exchange, a watch's one-shot door to the box - #1626
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
929313a to
005d9cc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 929313abc9
ℹ️ 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".
| sess.run.Lock() | ||
| defer sess.run.Unlock() | ||
| sess.catch(gen) | ||
| done <- sess.handler.Handle(ctx, frame) |
There was a problem hiding this comment.
Keep capture ownership through the full exchange
When the relay forwards two requests from the same watch concurrently, sess.run protects only Handle: after the first handler unlocks, the second goroutine can call sess.catch(gen2) before the first caller executes release(gen1), replacing its captured acknowledgement/result. The first request then receives two ticks even though its command executed; a request that waits past the settle timeout can likewise execute only after its tick-only response was returned. Serialize capture/release per request or maintain independent captures so command acceptance and results remain attributable to the correct request.
AGENTS.md reference: AGENTS.md:L22-L23
Useful? React with 👍 / 👎.
| if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { | ||
| return | ||
| } | ||
| _ = conn.WriteMessage(websocket.BinaryMessage, message) |
There was a problem hiding this comment.
Reconnect after an exchange response write fails
When the socket still accepts requests but a response write fails because of a deadline or network error, discarding this error leaves the read loop alive, so keepExchange does not reconnect and subsequent watch HTTP calls can continue timing out until an unrelated read failure occurs. Treat a failed response write as terminal by closing/canceling the connection and returning to the reconnect loop.
AGENTS.md reference: AGENTS.md:L24-L25
Useful? React with 👍 / 👎.
ADR 0009 milestone 5, decisions 1, 2 and 6. Paired with srcfl/ftw-webapp#85, the relay's half. The uplink keeps a second socket, /r/<epoch>/<handle>/exchange, beside each connection, under the same handle and with its own backoff. The relay hands it id(16) ‖ Noise message per watch request; the box writes id ‖ answer back and reads nothing into the id. Watch sessions live in a table of eight, apart from the phones' four, and end after four exchanges, a minute idle or three minutes. At most 60 handshakes a minute. The handshake is Noise IK with prologue ftw.exchange.v1: and the box's key; message 1 carries the watch's hello with its pairing code in hello.code, and message 2 an ExchangeHelloOK in the 1 KiB bulk bucket with rev and, while it fits, cloud. appproto.Config.Exchange makes a handler that takes hello, cmd and now, answers everything else E_UNKNOWN_OP, never streams and pushes no plan. Every answer is two lane 0 frames padded with ticks, so a read, a command and a refusal have one length; an unsettled result leaves a tick and asking again returns it. now carries the frozen fields, source ages keyed by fields 7 to 9, and each charger's plug state, level and power. The box now says cmd.exchange. Golden vectors for every step are in appuplink/testdata/exchange-vectors.json. An end-to-end test runs against a real relay when FTW_RELAY_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
ADR 0009 decision F, settled by Fredrik on 14 October 2026. The timing test showed the gap: a refusal answered at once and an applied charge after its EV save, so the time to answer told the relay which. The box now answers a watch's cmd no sooner than 400 ms after it arrived, applied or refused. A command that takes longer answers when it is done and waits no more; reads and the handshake are not held. New wires the floor; a table built by hand in a test has none. Measured again with the floor, through the relay on ftw-webapp main and at the box: applied and refused both answer in about 402 ms for any EV save under 400 ms, and a 1 s save answers in 1 s. ADR 0009 records the decision and its date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
efe55ec to
6812cf0
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Problem and result
Milestone 5 of ADR 0009, decisions 1, 2 and 6: the box's half of the watch exchange. With srcfl/ftw-webapp#85 (the relay's half, step 4) an Apple Watch can charge the car and read Now without its phone, over HTTPS only.
appuplinkkeeps a second socket,/r/<epoch>/<handle>/exchange, beside each uplink connection: the same handle and epoch, its own backoff, and it ends with the connection it rides beside. Each message from the relay isid(16) ‖ body, and each answer goes back asid ‖ reply. The box copies the id and reads nothing into it. Losing the socket changes nothing for the uplink, local control or the planner.ftw.exchange.v1:and the box's static key, so a stream handshake and an exchange handshake cannot stand in for each other. Message 1 carries the watch'shelloin a lane 0 frame, with the pairing code in the newhello.codeon the first run.AuthoriseWatchfrom feat(applink): an owner phone adds a watch as a device of its own #1625 decides. Message 2 carrieshello_okin the 1 KiB bulk bucket: proto, mode, clock, role, scopes,rev(the site'scontrolRev) and, while FTW Cloud is on,cloud.appproto.Config.Exchangemakes a session that takeshello,cmdandnowand answers everything elseE_UNKNOWN_OP. It never streams and pushes no plan after a command. Commands take the sameonCmdpath as the stream: expiry, scope,rev, dispatch gate, guards, read-back and the box's command log.cmdanswers no sooner than 400 ms after it arrived (decision F, below).cmdanswerscmd.ackandcmd.result; if the result has not settled in 3 s, a tick takes its place and asking again with the samecmdIdreturns it.nowanswers the newnowframe and a tick.now. Frozen fields 1–10, the ages of the three power sources keyed by the frozen source fields 7, 8 and 9 (a watch has no field dictionary),dispatchBlockedBy,controlRev, and each charger's plug state, level and power. Trimmed to fit and flaggedTRUNC, chargers first after the first one.cmd.exchange, so a phone can offer "Add watch".DropSessionsByAppKeyalso ends a removed key's exchange sessions, so removing a watch or its phone silences it at once.docs/architecture.mdgains the exchange and the watch role; the ADR's status line names what Core has.The wire, for the Swift side (step 8)
hello {proto, app, locales, code?}in a lane 0 framehello_ok {proto, mode, clock, role, scopes, rev, cloud?}in a 1 KiB bulk framecmd, as on the streamcmd.ack+cmd.result,cmd.ack+tick, a refusal'scmd.result+tick, orerror+ticknow {}now {uptimeMs, controlRev, fields, sources, dispatchBlockedBy, loadpoints}+tickgo/internal/appuplink/testdata/exchange-vectors.jsonholds every step's bytes from fixed keys and ephemerals: the frames, the body the watch posts or receives, andid ‖ bodyon the box's socket.TestTheBoxAnswersTheVectorsHandshakedrives the box's own handshake code against it.A finding against the ADR, and decision F
hello_okfits only the 1 KiB bucket. The ADR says "the smallest bulk bucket it fits". Bulk buckets are 1, 4 and 16 KiB, and the relay answers with at most 4 KiB behind its id, so a 4 KiBhello_okplus message 2's 48 bytes would make the relay close the socket. A house with too many sources to name in 1 KiB leavescloudout, logs it, and its watch readsnowinstead.Decision F: the gap showed, and the box now holds a floor. Without a floor, an applied charge answered after the EV save and a refusal at once (250 ms save: 252 ms against 0.7 ms through the relay). Fredrik chose a 400 ms floor on 14 October 2026 ("ja kör på ditt förslag ovan med tider"). The box answers an exchange
cmdno sooner than 400 ms after it arrived, applied or refused (ExchangeCmdFloor). A command that takes longer answers when it is done and waits no more.nowand the handshake are not held. ADR 0009 records the decision and its date.Measured again with the floor in, against the relay as merged on ftw-webapp main (90067dd9, byte for byte the head tested before), and in process at the box. The EV save is the fake charger's wait.
Applied and refused now take the same time for any save under 400 ms. A save that runs longer still shows, as the decision accepts. Against the ADR's bar of p95 under 2 s from tap to receipt: the box and the relay take 0.40 s here for any save under 400 ms, which leaves about 1.6 s for the watch's TLS and network path through the phone or Wi-Fi (2.6 s on LTE). A save that hits the full 2 s timeout alone uses the bar. The bar itself is a device test, in step 9.
Before the floor, for reference:
Scope and safety
onCmdas a phone's, with the watch's live scopes from feat(applink): an owner phone adds a watch as a device of its own #1625 and the box's command log.hello_okis lane 0 in 512 B.Verification
appuplink/exchange_test.go, against the session table: a watch charges;nowin one lane 0 frame; the stream's other doors answerE_UNKNOWN_OPandsite.mode.setE_SCOPE_DENIED; shape: requests and answers have one length each across applied, asked again, scope denied, expired, conflict,nowand an unknown op; an unsettled result answers with a tick and asking again returns it, with one hold; replay: a recorded message 1 draws a new box ephemeral key that a reader without the watch's keys cannot open, a recorded command gets no answer in its own session and the stored outcome in a fresh one; both paths: onecmdIdfrom the watch and from its phone's stream acts once; only a paired watch is answered (a stranger, a stream-prologue handshake and a phone's key get silence); removal of the watch or its phone ends its sessions and silences its next message 1; a session ends after four exchanges, a minute idle or three minutes; handshakes are rationed at 60 a minute; isolation: eight watch sessions leave four phone sessions running, and a ninth ends the oldest watch's.TestACommandAnswersNoSoonerThanTheFloor(applied and refused both take 400 ms; a 600 ms save takes 600 ms, not 1,000; a read is not held) andTestTheUplinkHoldsCommandsForTheFloor.appproto/exchange_test.go:hello_okcarriesrevand no catalogue; ahello_oktoo big for 1 KiB leavescloudout; an applied command pushes no plan;nowfits lane 0 or says what it left out; a removed watch reads nothing; the pad is a lane 0 tick.TestExchangeVectorsandTestTheBoxAnswersTheVectorsHandshake.TestExchangeThroughARealRelay, skipped unlessFTW_RELAY_URLis set, ran against the relay from ftw-webapp#85's head: X1 through/x/, a charge, the same charge again (one hold), a refusal,now, and the timing above.go test -race ./internal/appuplink ./internal/appproto, three runs, andmake verifygreen.Checklist
minor, a visible new capability, which the owner approves in this PR.Paired with srcfl/ftw-webapp#85. Builds on #1622, #1623 and #1625, all merged.
🤖 Generated with Claude Code