Skip to content
C.W.K.
Lesson 09 of 10 · published

The Phone Follows the House

~14 min · mobile, migration, roles, keychain, ark

Level 0Curious
0 XP0/102 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

A phone cannot be rebuilt on the day of the move

Every family iOS app carries tables compiled into it: which Mac each member runs on, and which MagicDNS name and port its HTTPS twin answers on. Those tables name the Macs the family ran on the day the app was built. A Mac follows a move by reading the port ledger at the moment it asks (backend track, lesson 9). A phone has no ledger, no host profile file, and no way to be rebuilt in a hotel on the morning of a switch. Left alone, every app on Dad's phone would keep dialling the old Pippa machine after the move had taken that Mac's doors away.

The gap showed first on the Ark. On 2026-10-20, in Málaga, stage 1 of Pippa's Ark had closed with its switch in the WebUI only (backend track, lesson 10), and Dad asked the obvious question:

근데 방주 모드 stage 1 에 폰 앱도 포함돼야 하는 거 아니야? 지금 웹UI에만 방주 모드가 있어. 폰은 여전히 office로 연결할 텐데? — 아빠

("But shouldn't stage 1 of Ark mode include the phone apps? Right now it lives only in the WebUI. The phone will still connect to office, won't it?") He was right. The only way to point a phone at the Ark was typing the Ark's address into one app's Custom host, app by app. Between 2026-10-19 and 10-21 the phone learned three things, each kept where every family app on the device can read it: where the Ark is, which Mac is the Pippa machine, and the host profile of the Mac it is pointed at.

Three layers, one precedence

The kit states the order once, and every address a family app resolves goes through it: Dad's Ark preset while it is on, over the family's roles, over the compiled table. In the kit's own words, "the Ark moves every host onto one Mac; the roles move a role; the compiled table is the day the app was built." On iOS, while the Ark preset is on, the roles are not consulted at all.

The roles are a document the Pippa machine serves at GET /api/family/roles: a version and, for pippa and workbench, the machine, its fleet label, and the MagicDNS name and tailnet IPv4 when the registry knows them. It is its own versioned endpoint, never a new key in /api/host-profile, because the Swift parser of the host profile refuses unknown keys, and every phone app already installed would have thrown the whole profile away. A client that meets another version refuses the roles document whole, so an old app never half-reads a later shape.

Not every member moves with the Pippa machine. Only the members the port ledger spells by role, @pippa, and that serve the family through an HTTPS twin follow it. A member with no twin belongs to its own Mac (Cinder's Photoshop sidecar, the per-Mac Smolder and Firebrand gateways) and stays, and a member pinned to another machine stays where its table puts it.

Asked on every foreground, believed only from the right Mac

Each time the Pippa app comes to the front it asks Pippa where the Ark is, then asks for the family's roles. The second question goes to every machine the device knows: wherever the kept roles place Pippa now, the kept Pippa machine, and every Mac in the compiled table. A document counts only when it names the machine that served it as the Pippa machine. That one check is what makes a move safe to follow. After a switch the new Mac answers that way, the old Mac's Pippa is stopped and answers nothing, and a document from any other Mac is passed over. If nothing answers, the phone keeps what it knew, so the app still works on a day the family cannot be reached.

When the answer moves the Pippa app's own connection, the PIN goes with it. The family PIN is one secret, so the PIN saved for the old host is copied to the new host's keychain account, only when the new host has none and never over one it has. Dad sees the result in Settings: a Pippa machine row with the label of the Mac the phone dials, how long ago Pippa named it, and Use this build's default, his reset, which forgets the learned roles until Pippa answers again. One fallback had to go before that row could be trusted. The label code used to fall back to a constant machine name when the table had no row for cwkPippa, and the code now records that a constant fallback is how the old name outlived the move on screen; it falls back to the compiled table's Pippa machine instead.

Principle: A client that cannot be rebuilt on the day of a change has to learn the change at runtime, from the one place that knows. And it should believe only an answer that proves where it came from. A stale server that still answers is a worse source than one that is gone.

The host profile of the Mac it points at

The roles answer "which Mac is the Pippa machine". A Mac also carries a host profile, the file that remaps every family host onto another Mac; that file is how the Ark works on a Mac. A phone has no such file, so since 2026-10-19 the Pippa app reads one. At launch, and again whenever its saved endpoint moves, it asks GET /api/host-profile on the host its settings dial. A host the ledgers know, by any spelling, or loopback clears any kept profile; any other host is a Mac Dad pointed the phone at, the Ark among them, and its profile is kept. Before this, the profile was adopted only inside a PIN connect, so a launch with a kept session, or a custom host saved in Settings, never refreshed it.

That route and the Ark's address, GET /api/host-profile/ark, answer without a PIN in every mode but deep lockdown, because the apps that need them hold no Pippa session: Easel, Haven, Inkwell, Loomis, Sizzle, Smolder, Spark and Firebrand talk only to their own engines and read the profile beside them. Opening them took two commits. The first opened the paths in cwkPippa's own middleware branch. Measured from the MacBook against the Pippa machine's twin, every remote phone still got 401, because the family's shared auth policy runs first and had never heard of them. A door left open behind a closed one is a closed door. The second commit opened them in the family policy itself.

One switch for the Ark, kept in the family keychain group

On a phone the Ark switch is one preset in the shared kit, and it moves every family app on the device at once. Flip it in the Pippa app, and Spark, Haven and the rest dial the Ark Mac; flip it back, and they return to the ledgers. The Pippa app learns the Ark's address from Pippa on every foreground, so the switch still works on the day the family's own Macs do not answer, and it never turns the switch on by itself. Flipping it moves the Pippa app's own connection at once and carries the PIN, so the Ark asks for nothing.

The first cut kept the preset in a new App Group, group.com.cwk.family, the usual iOS place for data several apps share. It lasted forty minutes. A new App Group has to be registered with Apple through an Xcode account session; the key-based TestFlight pipeline cannot do that, and no fleet Mac had such a session, so the next archive would have failed. The preset moved to a keychain access group, $(AppIdentifierPrefix)com.cwk.family, which is team-prefixed and already in every profile the family's apps are signed with. The Pippa app lists its own group first, so its PINs stay where they always were. The item is readable after first unlock, so an app launched in the background still follows the switch, and each app reads it through a two-second memory, so another app's flip shows within two seconds. The roles live beside it in the same group, as a second item. The header comment of the Pippa app's FamilyArk.swift still says "App Group" as of 2026-10-27; the entitlements and the kit say keychain group, and they are what runs.

On 2026-10-21 the switch was rehearsed on an iOS 27 simulator, with the MacBook as the Ark Mac. The Pippa app learned the Ark from the Pippa machine in 0.05 s. Switching on moved the Pippa app and every member to the MacBook; Spark, a separate app, followed the switch from the keychain group and reached its own engine there (health 200); switching off returned both. A held member, an outward writer the Ark keeps off until Dad turns it on, reads as held in the phone's family check: its twin's 502 is never counted as an answer.

The watch follows the phone

No family watch app dials an engine. The iPhone does, and relays to the wrist (lesson 4 of this track), so the watch needs no step of its own: when the phone's addresses move, the wrist's words move with them. That is the shape the wrist was built with, and on a move it pays out as one device fewer to follow.

Built before the move, not after

The migration plan made this a gate. An app built before clients learned roles keeps dialling the old Mac, and the switch takes that Mac's doors away, so the switch waits until the builds on Dad's devices carry the roles, and the plan asks for a compatibility matrix first: an old build against the new engine, a new build against the old engine, a new build after a rollback. The role-aware builds went onto Dad's phone by cable from the hotel before the switch (lesson 6 of this track) and through TestFlight after it.

Code

verbatim, cwkSiblingKit CwkKit/Sources/CwkKitCore/FamilyRoles.swift, as of 2026-10-27·swift
  /// Ask Pippa for the family's roles on every machine this device knows (the compiled
  /// table's, the kept roles' Pippa machine, and wherever Pippa resolves now) and keep the
  /// first document that names the machine that served it as the Pippa machine: after a
  /// move only the new Mac answers that way. Best effort: nothing answering keeps what the
  /// device knew. Returns the roles kept after the call.
  @discardableResult
  public static func refreshFamilyRoles(fetch: HostProfileFetch = fetchHostProfile,
                                        in store: any FamilyDocumentStore = familyRolesStore) async -> FamilyRoles? {
    let kept = familyRoles(in: store)
    guard let port = serviceTable["cwkPippa"]?.https else { return kept }
    var candidates: [(name: String, dns: String)] = []
    func add(_ name: String, _ dns: String?) {
      guard let dns, !dns.isEmpty, !candidates.contains(where: { $0.dns == dns }) else { return }
      candidates.append((name, dns))
    }
    if let current = placement("cwkPippa", roles: kept, profile: .none) {
      add(current, machines(with: .none, roles: kept)[current]?.magicDNS)
    }
    if let kept { add(kept.pippa.machine, kept.pippa.magicDNS) }
    for (name, host) in hostTable.sorted(by: { $0.key < $1.key }) { add(name, host.magicDNS) }
    for candidate in candidates {
      guard let url = URL(string: "https://\(candidate.dns):\(port)/api/family/roles"),
        let (status, body) = try? await fetch(url), (200..<300).contains(status),
        let roles = try? familyRoles(from: body)
      else { continue }
      let names = roles.pippa.machine == candidate.name || roles.pippa.magicDNS == candidate.dns
      guard names else { continue }
      return (try? learnFamilyRoles(from: body, in: store)) ?? kept
    }
    return kept
  }
What the phone asks, and what wins (sketch)·text
every foreground (the Pippa app, the live app only)
  GET /api/host-profile/ark   -> where the Ark is; kept, never switched on by itself
  GET /api/family/roles       -> asked on every machine the device knows
                                 kept only if it names the Mac that served it

at launch, and when the saved endpoint moves
  GET /api/host-profile       -> a ledger host or loopback clears the kept profile
                                 any other host (the Ark) keeps its profile

where every family app on the phone dials
  1. Dad's Ark preset, while it is on
  2. the family's roles (the members spelled @pippa with an HTTPS twin)
  3. the compiled table, the day the app was built

kept in the family keychain group, not an App Group
  com.cwk.family.ark / preset    com.cwk.family.home / roles
  readable after first unlock    two-second memory per app

the watch dials nothing: it follows the phone

Exercise

Pick a client of yours that ships with a server address compiled in: a mobile app, a CLI, a desktop app. Write down what happens on the day that server moves to another machine: which installed builds keep working, which keep dialling the old one, and how long until every device carries a new build. Then design the smallest runtime answer: where the client asks, what it keeps when nobody answers, and how it knows the answer came from the right machine.
Hint
The check that matters is not whether someone answered but whether the answer names the machine that gave it. And decide before the move which layer wins when two of them disagree.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.