Skip to content

feat(appuplink): the watch exchange, a watch's one-shot door to the box - #1626

Merged
frahlg merged 3 commits into
masterfrom
watch-exchange
Oct 14, 2026
Merged

frahlg merged 3 commits into
masterfrom
watch-exchange

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

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.

  • The socket. appuplink keeps 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 is id(16) ‖ body, and each answer goes back as id ‖ reply. The box copies the id and reads nothing into it. Losing the socket changes nothing for the uplink, local control or the planner.
  • The sessions. A table of at most 8, apart from the four stream places; a ninth watch ends the oldest watch's session, never a phone's. A session ends after four exchanges, 60 s idle or 3 minutes. At most 60 handshakes a minute; the rest, a stranger, a phone's key, a removed watch and a spent session all get silence.
  • The handshake. Noise IK with prologue 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's hello in a lane 0 frame, with the pairing code in the new hello.code on the first run. AuthoriseWatch from feat(applink): an owner phone adds a watch as a device of its own #1625 decides. Message 2 carries hello_ok in the 1 KiB bulk bucket: proto, mode, clock, role, scopes, rev (the site's controlRev) and, while FTW Cloud is on, cloud.
  • The handler. appproto.Config.Exchange makes a session that takes hello, cmd and now and answers everything else E_UNKNOWN_OP. It never streams and pushes no plan after a command. Commands take the same onCmd path as the stream: expiry, scope, rev, dispatch gate, guards, read-back and the box's command log.
  • One length, one time. Every request after the handshake is 536 B and every answer 1,072 B: two lane 0 frames, padded with ticks. A cmd answers no sooner than 400 ms after it arrived (decision F, below). cmd answers cmd.ack and cmd.result; if the result has not settled in 3 s, a tick takes its place and asking again with the same cmdId returns it. now answers the new now frame 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 flagged TRUNC, chargers first after the first one.
  • Capability. The box now says cmd.exchange, so a phone can offer "Add watch".
  • Removal. DropSessionsByAppKey also ends a removed key's exchange sessions, so removing a watch or its phone silences it at once.
  • Docs. docs/architecture.md gains the exchange and the watch role; the ADR's status line names what Core has.

The wire, for the Swift side (step 8)

Step Watch sends Box answers
X1 IK message 1, 608 B: hello {proto, app, locales, code?} in a lane 0 frame IK message 2, 1,072 B: hello_ok {proto, mode, clock, role, scopes, rev, cloud?} in a 1 KiB bulk frame
Command 536 B: cmd, as on the stream 1,072 B: cmd.ack + cmd.result, cmd.ack + tick, a refusal's cmd.result + tick, or error + tick
Read 536 B: now {} 1,072 B: now {uptimeMs, controlRev, fields, sources, dispatchBlockedBy, loadpoints} + tick

go/internal/appuplink/testdata/exchange-vectors.json holds every step's bytes from fixed keys and ephemerals: the frames, the body the watch posts or receives, and id ‖ body on the box's socket. TestTheBoxAnswersTheVectorsHandshake drives the box's own handshake code against it.

A finding against the ADR, and decision F

  1. hello_ok fits 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 KiB hello_ok plus message 2's 48 bytes would make the relay close the socket. A house with too many sources to name in 1 KiB leaves cloud out, logs it, and its watch reads now instead.

  2. 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 cmd no 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. now and 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.

    Where EV save Applied p50 / p95 Refused p50 / p95
    Through the relay (n=8) 0 ms 402.7 / 407.8 ms 402.4 / 403.9 ms
    Through the relay (n=8) 250 ms 402.1 / 407.5 ms 402.7 / 411.2 ms
    Through the relay (n=8) 1,000 ms 1,002 / 1,007 ms 401.8 / 402.2 ms
    At the box (n=40) 0 ms 401.1 / 402.9 ms 401.1 / 401.3 ms
    At the box (n=40) 250 ms 401.1 / 401.3 ms 401.1 / 404.1 ms
    At the box (n=40) 1,000 ms 1,001 / 1,003 ms 401.1 / 401.3 ms

    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:

    Where EV save Applied p50 / p95 Refused p50 / p95
    Through the relay (n=8) 0 ms 0.26 / 0.44 ms 0.28 / 0.33 ms
    Through the relay (n=8) 250 ms 251.8 / 252.4 ms 0.72 / 1.51 ms

Scope and safety

  • No new path to hardware. A watch's command goes through the same onCmd as 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.
  • The relay stays blind: the box writes the relay's own id back and nothing else, and every frame but hello_ok is lane 0 in 512 B.
  • Local operation: the exchange is a remote door. Without the relay, or with a relay that refuses the role, the box runs as before; the exchange socket backs off on its own.
  • One more idle socket per box on the relay, as the ADR's consequences say.

Verification

  • appuplink/exchange_test.go, against the session table: a watch charges; now in one lane 0 frame; the stream's other doors answer E_UNKNOWN_OP and site.mode.set E_SCOPE_DENIED; shape: requests and answers have one length each across applied, asked again, scope denied, expired, conflict, now and 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: one cmdId from 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.
  • Against the fake relay: the box opens the exchange socket under the uplink's handle and a watch charges through it; dropping the exchange socket leaves the phone's stream session working and the box reconnects.
  • Floor: TestACommandAnswersNoSoonerThanTheFloor (applied and refused both take 400 ms; a 600 ms save takes 600 ms, not 1,000; a read is not held) and TestTheUplinkHoldsCommandsForTheFloor.
  • appproto/exchange_test.go: hello_ok carries rev and no catalogue; a hello_ok too big for 1 KiB leaves cloud out; an applied command pushes no plan; now fits lane 0 or says what it left out; a removed watch reads nothing; the pad is a lane 0 tick.
  • Vectors: TestExchangeVectors and TestTheBoxAnswersTheVectorsHandshake.
  • End to end: TestExchangeThroughARealRelay, skipped unless FTW_RELAY_URL is 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, and make verify green.

Checklist

  • The change follows VISION.md and one selected scope.
  • I checked overlapping PRs and coordinated shared files/contracts.
  • Relevant checks cover the changed behaviour and failure paths.
  • A human reviewed changed UI in a browser, or no UI changed.
  • A Changeset is included, or the change is exempt. It is minor, a visible new capability, which the owner approves in this PR.
  • Every commit has a DCO sign-off.

Paired with srcfl/ftw-webapp#85. Builds on #1622, #1623 and #1625, all merged.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 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-14T09:26:53.726821Z 929313a 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
frahlg changed the base branch from watch-role-enrolment to master October 14, 2026 09:24

@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: 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".

Comment on lines +307 to +310
sess.run.Lock()
defer sess.run.Unlock()
sess.catch(gen)
done <- sess.handler.Handle(ctx, frame)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +478 to +481
if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
return
}
_ = conn.WriteMessage(websocket.BinaryMessage, message)

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 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 👍 / 👎.

frahlg and others added 2 commits October 14, 2026 13:03
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg merged commit 7c72a5b into master Oct 14, 2026
17 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