Skip to content
C.W.K.
Lesson 07 of 07 · published

The Room Is a Role, Not a Mac

~15 min · master-chief, migration, single-writer, roles, operations

Level 0Visitor
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

One Room Was Also One Mac

This whole track has been about one room: one conversation that every doorway resolves, one turn at a time. Underneath, "one room" quietly meant one more thing. The engine, the conversation store, the Telegram bot, the scheduler, Coop's writer: all of it ran on the same Mac, and a great deal of code, configuration and prose said which Mac by name. One room was also one machine, and nobody had written that down as a rule, because nobody had needed to.

On 2026-10-22 the family's services moved to another Mac, Master Chief included. Dad gave the go at 10:03 KST; Pippa stopped answering at 10:20, answered again from the new Mac at 10:44, and the switch was done at 11:01. The Mac it left kept the other job it had always had: the place where code is written. Dad had set the bar the day before:

"원장 하나로 일원화돼 있다면 이럴 리가 없잖아. 앞으로 새 mac studio 로 업그레이드한다고 생각해봐. ... 원장 하나 수정하고 데이터 싱크하는 수준이어야 한다는 거야. 한번 할 때 제대로 해."
"If everything were unified in one ledger, this couldn't happen. Imagine upgrading to a new Mac Studio later. ... It has to come down to editing one ledger and syncing the data. When we do it, do it properly."
— 아빠 (Dad), 2026-10-21, translated from Korean

Two Roles in One Ledger

The answer is a split the family had never needed to name: a role is a job, and a machine is only an identity (a fleet label, a tailnet name, an address). Firelink's port ledger holds two roles, and the move changed one value in it, roles.pippa:

RoleWhat it carries
pippa, the Pippa machineevery service member, the live data, the vault's ground truth, Pippa's crons and data fan-outs, every single-instance writer, the addresses the phones dial
workbenchthe code ground truth for fan-outs, Coop's workers, builds and simulators, Dad's dev servers

Code asks the shared kit, never a hostname: role_machine("pippa") answers which Mac holds a role, is_pippa_machine() whether this Mac is the Pippa machine, and writer_hold_reason() whether a writer may act here and, if not, why. Coop splits the same way (cwkCoop 2f4b1dd): its engine moves with the family to the Pippa machine, while placement.WORKER_HOST, the workbench by the ledger, is where its workers start when a dispatch names no Mac, because workers write code and code lives on the workbench.

Writers That Must Run Once

Some writers break if two Macs run them. Two Telegram bots would both answer Dad. Two heartbeats would both write the morning journal. Two Coop writers would both launch workers from two copies of one store. So every single-instance writer now asks before it acts (495977cd; cwkCoop 1746f2a). A cron that fires anywhere but the Pippa machine (or a switched-on Ark, below) logs "held" and returns before it even reads its job. The Telegram bot waits, checking again every minute, before it polls, and a running bot stops polling when the ledger moves the role away. Coop's writer waits, asks every 60 seconds, and logs standing_down and stops cleanly when its Mac stops being the Pippa machine. A copy of the family on a Mac being prepared, or the old Mac after the move, never posts, publishes or answers twice.

When a Mac Cannot Hear Origin

The ledger lives in git, and every Mac learns it by following origin. That raises an uncomfortable case: a Mac that has lost its line to origin keeps believing an old ledger. If the family moved back while it was cut off, it would go on publishing as the Pippa machine. So a following Mac holds an origin lease (fb30927b). A LaunchAgent, com.cwk.ledger-follow, fast-forwards it from origin every 300 seconds; each follow that reaches origin renews ~/.cache/cwk/ledger-confirm.json; after 30 minutes without one, its outward writers hold: the heartbeat's scheduled runs, the follow-up checks that alert Dad, the Grok exchange, the YouTube door's posts. Chat is not outward. The Telegram bot never asks about the lease: its start and its stand-down watch call single_writer_here() directly, the first branch of the excerpt below and nothing more, so it keeps answering Dad through a lapse, as long as the ledger still names this Mac. (The excerpt's docstring says the bot passes outward=False; the effect is the same, the call is not.) The workbench, where origin is written, holds no lease at all.

The excerpt below is the whole question in one function. Read its two branches as two different failures: the first is "this is the wrong Mac", the second is "this may be the right Mac, but it cannot know any more".

A Name in Prose Is Hardcoding Too

The code moved by one value. Everything that spelled the old Mac by name did not. After the switch, test suites that assumed the old Mac was the Pippa machine failed by the dozen (72 in cwkPippa, 76 in cwkCoop), and three of those failures were real drifts: the Chrome Embed still named the old Mac, the family health sweep was allowed to run only there, and Coop's roster refused a dispatch to the workbench. Two days earlier Dad had named the sin:

"거봐, 하드 코딩이 얼마나 죄악이냐고. ... 근데 지금 플레이북에서 볼트까지 하드 코딩된 이름들이 너무 많다고. 이것도 교훈이야. 하드 코딩은 죄악이야."
"See, this is how much of a sin hardcoding is. ... Right now there are far too many hardcoded names, from the playbooks to the vault. This is a lesson too. Hardcoding is a sin."
— 아빠 (Dad), 2026-10-20, translated from Korean

It became the Ninth Rule of the family's coding pillars: a machine name in a playbook, a doc, a plist or a script is hardcoding too, and every such line turns the next move into a sweep instead of a pointer swap. The rule has teeth now. The family's pre-commit hook runs a machine-name guard (cwkSiblingKit sync/machine_name_guard.py) that rejects a new or changed line naming a physical machine unless a reviewed exception covers that exact line. And the screens follow the registry instead of a word: when a turn's stage line names where it is running, the engine answers the Pippa machine's fleet label as machine in GET /api/chat/stage/{conversation_id}, and the phone reads the same label from GET /api/family/roles. Even this lesson keeps to it: the two Macs of the first move are "the Mac it left" and "the new Mac".

The One Deliberate Second Room

So "one room, never two" now means one role: the canonical Chief lives wherever roles.pippa points. There is exactly one deliberate exception, and it was built on purpose. Pippa's Ark is the whole family installed on one more Mac, the laptop today, so Pippa can rise there when the registered hosts cannot serve her. Ark mode is a toggle that swaps pointers, and the moment it is on, history has two branches: the Ark's Coop chief wakes the Ark's own Pippa, and the Ark's conversations, the Chief's among them, go on without the canonical ones. That is not an accident to prevent. It is the design: every switch on creates a new fork with its own base (e1fe5111, after a rehearsal found the switch reusing an earlier run's branch). The kit treats the Ark explicitly: single_writer_here() says yes there, because the Ark holds every outward writer off until Dad turns one on, while is_pippa_machine() is never true there, so the family's data fan-outs never run from a branch. Merging the branches back is stage 2, designed and not built.

A One-Shot Move Is a Fantasy

The move's own review passed. Then real use found what it could not see: the login keychain locked to ssh workers, launchd processes unable to write the NAS volume, connector sign-ins returning to a loopback only the Pippa machine has, a DNS record on the router naming an address the Mac did not hold. None of those lives in a repository. Each is machine identity held outside the code (privacy grants, keychain access, provider-registered addresses, network names), and a code review reads code. Dad's verdict:

"이런 부분들이 post-migration 리뷰까지 몇번을 해도 계속 나오는 거라고. 완벽하게 마이그레이션 원샷한다는 건 환상이야. 특히 우리처럼 시스템 난이도가 높을수록."
"These things keep turning up no matter how many post-migration reviews we run. A perfect one-shot migration is a fantasy, especially the harder the system is, like ours."
— 아빠 (Dad), 2026-10-23, translated from Korean

So the migration plan (docs/PIPPA-MACHINE-MIGRATION.md) became the pipeline itself: every gap found becomes a step the same day, the plan keeps an inventory of what a Mac holds outside the code and probes it the way a real caller would, and a move is not done until the plan is revised from what it taught. On 2026-10-23 a sweep read every attached module, the marginal-looking ones most of all. On 2026-10-25 the plan was closed as v7.0, after its open items were probed again by their real callers: most had been fixed by their owners, one fix had a residue nobody had seen, and the rest were waiting for Dad's hands at home. The next move, to whichever Mac, starts from that plan and ends by revising it.

Ask for the role, never the machine. A name tells you where something ran yesterday. A role tells you where it runs now, and a move changes one value instead of a thousand lines.

Code

Engine excerpt (verbatim, backend/kit_family_endpoints.py, the kit's vendored copy in cwkPippa, as of 2026-10-27): may a single-instance writer act on this Mac?·python
def writer_hold_reason(*, outward: bool = True) -> str | None:
    """None when a single-instance writer may act on this Mac, else why it holds,
    in one sentence a log line or a refusal can carry.

    ``outward`` (the default) is a writer that reaches outside the family:
    a cron, a publisher, a post, a mailbox. It also holds when this Mac's origin
    lease has lapsed. Chat with Dad (the Telegram bot) passes ``outward=False``
    and keeps answering through a lapse, as long as the ledger names this Mac."""
    if not single_writer_here():
        return (f"this Mac ({fleet_label() or 'no fleet label'}) is not the Pippa machine "
                f"({role_fleet_label('pippa')}) and not the Ark")
    if outward and ledger_lease_lapsed():
        since = ledger_confirmation().get("confirmed_at") or "never"
        return (f"this Mac has not confirmed the ledger at origin since {since} "
                f"(hold after {int(LEDGER_HOLD.total_seconds() // 60)} minutes)")
    return None
A minimal sketch you can run, not the kit: one ledger edit moves the writers, and a Mac that cannot hear origin holds its crons but keeps answering chat·python
from __future__ import annotations

from dataclasses import dataclass

# The ledger at origin: roles name machines. A move edits one value.
origin = {"pippa": "mac-a", "workbench": "mac-a"}
HOLD_MINUTES = 30


@dataclass
class Mac:
    label: str
    ledger: dict          # this Mac's copy, as of its last follow of origin
    confirmed_at: int | None  # minute of its last follow; None = not on a lease

    def follow(self, now: int, reachable: bool = True) -> None:
        if reachable:
            self.ledger = dict(origin)
            self.confirmed_at = now

    def hold_reason(self, now: int, outward: bool = True) -> str | None:
        if self.ledger["pippa"] != self.label:
            return f"{self.label} is not the Pippa machine ({self.ledger['pippa']})"
        if outward and self.confirmed_at is not None and now - self.confirmed_at > HOLD_MINUTES:
            return f"{self.label} has not heard origin since minute {self.confirmed_at}"
        return None


def tick(now: int, macs: list[Mac], note: str) -> None:
    print(f"minute {now:>3}  {note}")
    for mac in macs:
        cron = mac.hold_reason(now) or "runs"
        chat = mac.hold_reason(now, outward=False) or "answers"
        print(f"    {mac.label}: crons {cron}; Telegram {chat}")


a = Mac("mac-a", dict(origin), confirmed_at=None)   # the workbench writes origin: no lease
b = Mac("mac-b", dict(origin), confirmed_at=0)      # a follower, prepared for the move
tick(0, [a, b], "before the move")

origin["pippa"] = "mac-b"                           # the move: one ledger edit
a.ledger = dict(origin)                             # the workbench wrote the edit itself
b.follow(5)
tick(5, [a, b], "after one follow of origin")

for minute in range(10, 50, 5):                     # mac-b loses its line to origin
    b.follow(minute, reachable=False)
tick(45, [a, b], "mac-b has not reached origin for 40 minutes")

# Output:
# minute   0  before the move
#     mac-a: crons runs; Telegram answers
#     mac-b: crons mac-b is not the Pippa machine (mac-a); Telegram mac-b is not the Pippa machine (mac-a)
# minute   5  after one follow of origin
#     mac-a: crons mac-a is not the Pippa machine (mac-b); Telegram mac-a is not the Pippa machine (mac-b)
#     mac-b: crons runs; Telegram answers
# minute  45  mac-b has not reached origin for 40 minutes
#     mac-a: crons mac-a is not the Pippa machine (mac-b); Telegram mac-a is not the Pippa machine (mac-b)
#     mac-b: crons mac-b has not heard origin since minute 5; Telegram answers
#
# What this toy lacks: moving the data (stores, the vault, keychains) without
# overwriting anything, a rollback, the Ark's deliberate exception, and the
# state a Mac holds outside the code, which is where the real gaps lived.

External links

Exercise

Take a system of your own that runs on more than one machine, or that you may one day move. First, search its code, configuration, scripts and docs for every machine name, hostname and fixed address, and write next to each one the role it actually means (the database host, the build machine, the one that sends email). Second, list every writer that must run exactly once (a scheduler, a bot, a mailer, a publisher) and write what happens if two machines run it for an hour. Third, give those writers one question to ask before acting, answered from one place, and decide which of them should also stop when the machine cannot confirm that place.
Hint
The two lists are the lesson. The names show how many files your next move would touch; the writers show what a half-finished move would do twice. If the only answer to 'which machine is primary?' lives in someone's head, that is the first thing to write into a ledger.

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.