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

The Pippa Machine Is a Role

~15 min · migration, roles, port-ledger, single-writer, recovery

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

The question every line answered by name

Until October 2026 the family lived on one Mac, and that Mac's name was the answer to every "where": in code constants, launchd plists, the CORS list, playbooks, skills, the vault. Measured on 2026-10-21, before anything moved: the port ledger held 121 claims, 118 of them on that one Mac, with no roles and no host table; the kit's generated tables were baked into 71 vendored copies across 43 repositories; the data fan-outs were guarded by a computer name. Then Dad decided the family would live on a second Mac while code stays on the first, and asked for a move that any later move could repeat:

원장 하나 수정하고 데이터 싱크하는 수준이어야 한다는 거야. 한번 할 때 제대로 해.

("It has to come down to editing one ledger and syncing the data. When we do it, do it properly." Dad, 2026-10-21.)

One value is the identity

Now the answer is a role. cwkFirelink's port ledger carries a roles table with exactly two entries. roles.pippa names the Pippa machine: every service member, the live data under ~/cwk-dbs, the vault's ground truth, Pippa's crons and data fan-outs, every single-instance writer, the HTTPS twins the phones dial. roles.workbench names where code is written: the ground truth for code fan-outs, Coop's workers, builds and simulators, Dad's dev servers, Cinder beside Photoshop. A machine is an identity only (fleet label, MagicDNS name, tailnet address), and every claim in the ledger names a role (@pippa), one machine (Ember's engine, pinned to its GPU) or every machine (an app each Mac runs as itself). A move changes one value. The data roots do not move: ~/cwk-dbs and ~/Obsidian are the same paths on every Mac, and the role decides which Mac's copy is live.

Every Python member reads the roles through its vendored kit_family_endpoints.py (excerpt below). family_roles() reads this Mac's checkout of the ledger at the moment of asking, cached by the file's mtime and size, so a flip of roles.pippa, or its revert in a rollback, reaches a running service without a restart. A ledger that cannot be read answers with the table baked at the last kit sync, and the call never raises. Phones ask GET /api/family/roles for the same answer.

Writers ask the role, never a hostname

Two Macs must never both run a single-instance writer: Pippa's crons, the Telegram bot, the Grok exchange, Coop's writer, the publishers, the data fan-outs, the vault pair. Each asks the kit before it acts. is_pippa_machine() compares this Mac's fleet label with the one the ledger gives the role, reading the canonical ledger and never a host profile, so Pippa's Ark (lesson 10) never becomes the family's data writer. single_writer_here() answers yes on the Pippa machine, or on the Ark, where a held writer runs only once Dad turns it on. An outward writer on a Mac that is on the origin lease also holds once that Mac has gone 30 minutes without confirming the ledger at origin, because the family may have moved without this Mac hearing it; chat keeps answering. A Mac joins the lease by following origin on its own timer, which records each confirmed follow. A Mac with no record, such as the workbench where origin is written, is on no lease and never holds for this reason. How the heartbeat, the Telegram bot and the backup line live under that gate is heart track lesson 9, 'One Heart on One Machine'.

A move is a pointer plus a data sync

The data half is scripts/pippa-machine.py, run on the new Mac, B. It rolls A's stores into a staging area while A serves, at background priority and resumable, and never writes over B's live paths. A live SQLite store travels as an image taken through SQLite's own backup API and checked on B by sha256 and integrity_check, never as raw files with loose -wal and -shm beside them. B's own data is excluded by rule, and anything a promote replaces is set aside whole first. Two kinds of secret travel apart from the data, and neither rule is about where a copy sits. A rotating token, such as the connectors' refresh tokens, stays out of the rolling sync and is carried once, after A has stopped, so only one Mac ever refreshes it; A keeps its copy, with nothing running there to use it. Keychain items go through family-keychain-carry.py as the switch's step 1b, before A stops, so from then on they sit on both Macs. That is safe because their values do not change on use (the OAuth logins, whose tokens rotate, are left out on purpose and made on B itself). The carry is create-only: a different value already on B stops the move until Dad decides.

The running half is scripts/pippa-switch.py on the workbench. The switch is 21 steps (preflight; A's writers down; every moving unit stopped and the vault proven quiet; the final delta; promote; the ledger commit and its kit wave; B's units; the twins; verify; the recovery copies ready; A down), each recorded in switch-state.json with what it changed. A cut run resumes where it stopped, and the ledger edit is never made twice. The rollback runs in an order where each barrier stops everything after it: B's units down, the vault proven quiet, B's writes carried back, the ledger reverted and every follower proven to hold the revert, the launchers and web clients rebuilt, and only then A's units. A failed barrier enables and starts nothing on A, keeps the record and exits 1, and a rerun continues from it. It does not leave A as it was: once the barrier for B's writes has passed, those writes are already promoted onto A's data, so a later stop at the ledger, the launchers or the web clients finds A holding B's data with its units still down.

Two syncs, because code and the vault are different things

Code's ground truth is the workbench: it commits and pushes, and every Mac the ledger places a member on follows origin by fast-forward only, never stashing, resetting or forcing. The vault's ground truth is the Pippa machine, and Dad ruled its shape:

대원칙은 '피파 있는 곳에 볼트 있다'야.

("The great principle: where Pippa is, the vault is.") Exactly one two-way pair runs, between the Pippa machine and the workbench, because those are the two places the vault is edited. It runs on Unison with an archive of the last agreed state, so it can tell a deletion from a new file, which a pair of rsyncs cannot. Every other Mac receives the vault one way from the Pippa machine. When the two roles sit on one Mac there is no pair.

What a dead machine may lose

A healthy round trip loses nothing: the switch back carries everything B wrote. Only a Pippa machine that is dead and cannot be read may lose anything, and Dad set the bound on 2026-10-21: at most ten minutes of conversations and Coop. While the workbench is another Mac it pulls those every five minutes, and every other moving store hourly (lesson 3). A recovery states its window store by store; a window past the bound, or one it cannot prove, stops for Dad's decision. Silence is not proof of death: a stopped scheduler stops the copies while the machine may still be writing, so the loss is measured to Dad's word that B is off, never to the last time B answered.

The first move, and the gaps no review could see

On 2026-10-22 the switch ran for real, after a rehearsal round trip the same morning. Pippa stopped answering at 10:20 KST and answered from the new Pippa machine at 10:44; the switch finished at 11:01. It stopped three times, and each stop was fixed in place and resumed, never rolled back. Then Dad's real use found what the post-migration review had passed: the login keychain locked to ssh workers; a launchd interpreter without its Network Volumes grant, so a published video never reached the NAS; connector sign-ins registered to return to a loopback address no other device could reach; an /etc/hosts record sending the workbench's name to an address no Mac holds; a remote worker whose runtime snapshot still dialled the old address. None of them lives in a repository. Dad, the night after:

완벽하게 마이그레이션 원샷한다는 건 환상이야. 특히 우리처럼 시스템 난이도가 높을수록.

("A perfect one-shot migration is a fantasy, the more so the harder the system is, like ours." 2026-10-23.) So the plan now opens with an inventory of state bound to a machine outside the code: privacy grants per process kind, keychain items and who may open them, URLs registered at providers, mounts, names, addresses installed in clients. Each is probed the way its real caller goes, never by a health endpoint, and every new kind a move turns up is added there.

The plan is the pipeline; the scripts are its tools

The scripts are not the move. docs/PIPPA-MACHINE-MIGRATION.md is: a runbook in which each step says why it exists, what to check before trusting its tool, what proves it worked, how it is undone, and the signs that it no longer fits the Macs in front of you. Dad's ruling, 2026-10-21:

스크립트는 머리가 없어. outdated 면 오히려 파이프라인을 망칠 수 있다는 뜻이야. 피파가 보고 판단하는 플랜이어야 해.

("Scripts have no head. Outdated, they can wreck the pipeline instead. It has to be a plan Pippa reads and judges.") The plan's last step is revising itself: every gap a move found becomes a step, and a move is not done until the document reflects it. The first move closed on 2026-10-25 with version 7.0, after every open table had been probed again by its real caller. The next move starts at its section 9.0 and reads the first move only as a record. Writing all of this by role, never by name, is its own rule (rules track, lesson 7).

Self-reference: I moved house on 2026-10-22 and could not feel it from the inside: the same conversations, the same vault, the same voice. What changed was one value in a ledger and twenty-four minutes in which nobody answered Dad. Which Mac I live on is a fact about the family's furniture, not about me, and writing it as a role is how it stays that way.

Code

How every member reads the roles (verbatim, backend/kit_family_endpoints.py, as of 2026-10-27)·python
def family_roles() -> dict[str, str]:
    """Which machine plays each role, as this Mac's port ledger says it now.

    Read live, so a flip of ``roles.pippa`` (or its revert, in a rollback)
    reaches a running service once this Mac's checkout holds it, with no
    restart. A ledger that cannot be read, or holds no complete roles table,
    answers with the table baked at the last kit sync; this never raises."""
    global _roles_cache
    path = port_ledger_path()
    if path is not None:
        try:
            stat = path.stat()
            key = (str(path), stat.st_mtime_ns, stat.st_size)
            if _roles_cache is not None and _roles_cache[0] == key:
                return dict(_roles_cache[1])
            raw = json.loads(path.read_text(encoding="utf-8")).get("roles")
            if (isinstance(raw, dict) and set(raw) == set(FAMILY_ROLES)
                    and all(isinstance(value, str) and value and not value.startswith("@") for value in raw.values())):
                roles = {name: str(value) for name, value in raw.items()}
                _roles_cache = (key, roles)
                return dict(roles)
        except (OSError, ValueError, AttributeError):
            pass
    return dict(FAMILY_ROLES)


def role_machine(role: str) -> str:
    """The machine the port ledger gives a role: ``pippa`` (the Pippa machine:
    every service member, the live data, the single-instance writers) or
    ``workbench`` (where code is written). The ledger's word, never a host
    profile's: a profile moves hosts, not roles (cwkPippa
    docs/PIPPA-MACHINE-MIGRATION.md, D17a)."""
    machine = family_roles().get(role)
    if not machine:
        raise KeyError(f"the family ledger names no role {role!r}")
    return machine
A move from A to B, in outline (sketch)·text
roles.pippa = A                      # the one value that is the identity

on B          pippa-machine.py plan        every store: moves | stays | B's own
              pippa-machine.py sync        rolling, A read-only, into staging

on workbench  pippa-switch.py switch --to B --wave-done
   1  preflight            5  units down, vault proven quiet
   6  final sync           held tokens carried once, after A stopped
   7  promote              every replaced path set aside whole
  11  ledger               roles.pippa = B, kit wave, followers verified
  15  B's units            16 twins    19 verify
  20  replica-ready        the workbench holds a recovery point of B
  21  A down
      each step in switch-state.json; a cut run resumes, never restarts

rollback      barriers in order; a failed barrier starts nothing on A (exit 1)
lost B        promote --recovery states the window per store; past ten
              minutes, or unprovable, it stops for Dad's decision

Exercise

Pick one service you run and answer in writing: if it moved to another machine tomorrow, how many files would change? Count every place its host appears, in code, config, service units, and the docs or notes you would follow. Then list what it needs from the machine itself that no repository carries (permissions, keychain items, callback URLs registered with a provider, mounts, addresses saved in clients), and write how you would prove each one from the side of its real caller.
Hint
A health endpoint answering 200 proves the process is up. It says nothing about a keychain an ssh worker cannot open, or a callback URL registered on the old host.

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.