Skip to content

feat(applink): an owner phone adds a watch as a device of its own - #1625

Merged
frahlg merged 1 commit into
masterfrom
watch-role-enrolment
Oct 14, 2026
Merged

frahlg merged 1 commit into
masterfrom
watch-role-enrolment

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Problem and result

Milestone 3 of ADR 0009: the watch role and its enrolment (decisions 4, 5 and 9). An owner's phone can add an Apple Watch as a device of its own. The box does not offer it yet: cmd.exchange stays unsaid until the exchange socket lands in step 5, so no phone shows "Add watch".

Step 1 (#1622) and step 2 (#1623) are merged; this is one commit on master.

What changes:

  • Pairing route. POST /api/app-link/pairing with role watch goes to its own handler. It answers only over a session, only to an owner, and only when that request carried stepUp. The passthrough's grace window does not stand in for it: a watch is a new key that can charge the car from anywhere. A refusal for a missing step-up carries E_NEEDS_STEP_UP, so the app can ask for the passkey. The answer is a QR payload like any other code's, with no LAN hint.
  • The code. appenroll.MintWatchCode binds the code to the calling phone and keeps it in a slot of its own, so it leaves a pending phone code alone and the reverse. Spent once, timed, and burned after five wrong guesses, as a phone code is. The phone must still be an owner when the watch spends it.
  • The watch's door. AuthoriseWatch stores the watch's key with role watch and the phone as parent. Step 5 calls it from the exchange with the code from the watch's hello. A watch key on the stream gets the silence a stranger gets, and a phone key on the watch's door does too, so a watch can never take one of a phone's four stream places.
  • Live scopes. GrantReader.Grant now returns an Enrolment with the parent's current role. appproto.GrantScopes keeps the watch role's scopes that the parent's role also carries. Every command re-reads it, so demoting the phone to viewer turns the watch's next charge into E_SCOPE_DENIED. hello_ok names the derived scopes.
  • Command ids. A watch spends its phone's command ids (Enrolment.Root), so one intent sent by the watch and by its phone acts once (decision 3).
  • Removal. Revoke returns every device it removed: a phone and then its watches. The API drops each one's sessions and push subscriptions. Removing a watch touches nothing else. Removing a phone also cancels its pending watch code. A watch whose phone is missing from applink.json is dropped at load.
  • Role changes. A watch keeps its role until it is removed; a phone cannot become one. Changing the phone's role moves its watches' epochs too.
  • Lists. GET /api/app-link/devices and the box's page put each watch right after its phone, with parent. The page draws a watch set in under its phone, "Can look and charge", with Remove and no role button. The removal warning says when a phone's watch goes with it. "N phones paired" still counts phones.
  • Roster. FTW Cloud's roster carries the watch with role watch and the scopes the box derives for it. docs/protocol-cloud.md gains the role in step 6.
  • Step 2's guard. feat(contract): role watch and capability cmd.exchange #1623 refused role watch at both doors until this step. The pairing route now sends it to the new handler first; the guard stays for role changes, with a sentence that says why.

Scope and safety

  • Nothing here sends a command to hardware, and every command still takes the same onCmd path: expiry, scope, rev, dispatch gate, guards and read-back.
  • A watch holds ftw.live.read, ftw.plan.read, ftw.events.read and ftw.dispatch.write at most, and never more than its phone holds now. No mode, no members, no history, no passthrough.
  • The box answers a watch only through AuthoriseWatch, which nothing calls until step 5.
  • applink.json gains an optional parent per row. After a rollback an older box keeps a watch's row, but its role table has no watch, so the row carries no scopes and its handler refuses to start.

Verification

  • appenroll/watch_test.go: an owner phone adds a watch that names it as parent; a viewer and a stranger cannot mint a code; the watch code and a phone code leave each other alone; the watch code works once and only at the watch's door; each door answers the other kind as a stranger; expiry; burned by guessing; dies when its phone is demoted; removal runs from phone to watch and never back, and a removed watch's next message 1 gets no answer; the watch role cannot be given or taken by a role change; demotion moves the watch's grant and roster entry; the list order; the roster; a restart; an orphan dropped at load.

  • appproto/watch_test.go: the scope cut under owner, viewer and no parent; a watch charges and gets E_SCOPE_DENIED for site.mode.set; demoting the phone narrows the watch at its next command; a watch and its phone share command ids while another phone's same id acts; hello_ok names the watch's role and scopes and no cmd.exchange.

  • api/api_app_link_watch_test.go, over a real session into the real API: an owner phone adds a watch and the code enrols it under that phone; without stepUp it is refused with E_NEEDS_STEP_UP even inside the grace window; a viewer and the box's page are refused; adding a watch leaves an invite alive; the list names the parent and a watch's role cannot change. TestNoDoorButItsPhoneHandsOutAWatch keeps step 2's refusals for the LAN and for role changes.

  • appuplink/watch_test.go: a watch's key on the stream gets no reply.

  • cmd/ftw/cloud_roster_test.go: the roster gives the watch its role's scopes under an owner and only ftw.live.read under a viewer.

  • web/app-link-sharing.test.mjs: the watch row and both removal warnings.

  • Browser: ran master and this branch locally with the same applink.json: an owner phone, a viewer and a watch whose parent is the owner. Settings → FTW app, desktop width. I also checked phone width and the two removal warnings on this branch. A human still needs to look at it.

    Before (master). The watch reads as a third phone that can change things, with a "Make it view only" button, and the page says "3 phones paired."

    Settings → FTW app on master, with a watch in applink.json

    After (this branch). The watch sits set in under its phone, "Can look and charge", with Remove and no role button, and the page says "2 phones paired."

    Settings → FTW app on this branch, the watch under its phone

    The images live on branch pr-assets/1625-watch-list, which holds nothing else; delete it after this merges.

  • 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.
  • Every commit has a DCO sign-off.

🤖 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-14T08:34:49.029227Z 48f0d0c 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 force-pushed the watch-role-enrolment branch from 48f0d0c to 228b8ed Compare October 14, 2026 08:32
@frahlg
frahlg changed the base branch from watch-cmdlog-on-box to master October 14, 2026 08:32

@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: 48f0d0ca53

ℹ️ 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 +358 to +359
if meta.role == apiauth.RoleWatch && i.authorised[meta.parent] == nil {
delete(i.authorised, key)

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 Preserve watch enrolments across rollback rewrites

If a site enrolls a watch, rolls back to a pre-watch build, and any phone reconnects, that older build rewrites applink.json without the unknown parent field; after upgrading again, this condition treats the now-parentless watch as an interrupted removal and silently deletes its enrolment. Since rollback is a supported recovery path, retain enough backward-compatible parent state or avoid discarding these rows so a routine rollback does not force the household to re-pair every watch.

AGENTS.md reference: AGENTS.md:L20-L25

Useful? React with 👍 / 👎.

ADR 0009 milestone 3, decisions 4, 5 and 9.

POST /api/app-link/pairing with role watch answers only over a session,
only to an owner, and only when that request carried stepUp; the
passthrough's grace window does not stand in. The code binds to the
calling phone and has its own slot, so it leaves a pending phone code
alone.

appenroll.AuthoriseWatch stores the watch with role watch and the phone
as parent. A watch key gets no stream, and a phone key does not come
through the watch's door. GrantReader now returns an Enrolment with the
parent's current role, and GrantScopes keeps the watch role's scopes
that the parent's role also carries, so demoting the phone narrows the
watch at its next command. A watch spends its phone's command ids.

Revoke returns every device it removed, a phone and then its watches,
and the API drops each one's sessions. A watch keeps its role until it
is removed. The device list, the box's page and FTW Cloud's roster
carry each watch under its phone.

cmd.exchange stays unsaid until the exchange socket lands in step 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg force-pushed the watch-role-enrolment branch from 228b8ed to 9a8199d Compare October 14, 2026 09:08
@frahlg
frahlg merged commit a6ddfea 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