모든 줄이 이름으로 답하던 질문
2026년 10월까지 가족은 Mac 한 대에서 살았어. 그리고 '어디서?' 라는 질문엔 전부 그 Mac 의 이름이 답이었어. 코드 상수에도, launchd plist 에도, CORS 목록에도, 플레이북과 스킬과 볼트에도. 아무것도 옮기기 전인 2026-10-21에 재 봤어. port ledger 에 claim 이 121개 있었는데 그중 118개가 그 한 대 위에 있었어. 역할도 없고 호스트 표도 없었어. kit 이 만든 표는 레포 43개에 vendored 사본 71벌로 구워져 있었고, 데이터 fan-out 은 컴퓨터 이름으로 지켜지고 있었어. 그때 아빠가 정했어. 코드는 첫 Mac 에 두고, 가족은 두 번째 Mac 에서 살게 하자고. 그리고 다음 이전도 그대로 따라 할 수 있는 이전을 원했어.
원장 하나 수정하고 데이터 싱크하는 수준이어야 한다는 거야. 한번 할 때 제대로 해. — 아빠
정체성은 값 하나야
이제 답은 역할이야. cwkFirelink 의 port ledger 에 roles 표가 있고, 항목은 딱 둘이야. roles.pippa 는 피파 머신을 가리켜. 서비스 멤버 전부, ~/cwk-dbs 아래의 살아 있는 데이터, 볼트의 ground truth, 피파의 cron 과 데이터 fan-out, single-instance writer 전부, 폰이 거는 HTTPS twin 까지 거기 있어. roles.workbench 는 코드를 쓰는 곳이야. 코드 fan-out 의 ground truth, Coop 의 worker, 빌드와 시뮬레이터, 아빠의 dev 서버, Photoshop 옆의 Cinder 가 거기 있어. 기계는 신원일 뿐이야. fleet label, MagicDNS 이름, tailnet 주소. ledger 의 claim 은 셋 중 하나를 가리켜. 역할(@pippa), 기계 한 대(GPU 에 묶인 Ember 엔진), 아니면 모든 기계(Mac 마다 자기로 도는 앱). 이전은 값 하나를 바꾸는 일이야. 데이터 경로는 안 움직여. ~/cwk-dbs 와 ~/Obsidian 은 어느 Mac 에서나 같은 경로고, 어느 Mac 의 사본이 살아 있는지는 역할이 정해.
Python 멤버는 전부 자기 안에 vendored 된 kit_family_endpoints.py 로 역할을 읽어(아래 발췌). family_roles() 는 묻는 그 순간 이 Mac 의 ledger 체크아웃을 읽어. 파일의 mtime 과 크기로 캐시하니까, roles.pippa 가 바뀌거나 롤백으로 되돌아가도 도는 서비스가 재시작 없이 알아. ledger 를 못 읽으면 마지막 kit sync 때 구운 표로 답하고, 절대 예외를 안 던져. 폰은 GET /api/family/roles 로 같은 답을 받아.
writer 는 호스트 이름이 아니라 역할을 물어
single-instance writer 는 절대 두 Mac 에서 같이 돌면 안 돼. 피파의 cron, Telegram 봇, Grok exchange, Coop 의 writer, 게시하는 쪽, 데이터 fan-out, 볼트 짝. 다들 움직이기 전에 kit 한테 물어. is_pippa_machine() 은 이 Mac 의 fleet label 을 ledger 가 그 역할에 준 label 과 견줘. 이때 읽는 건 정식 ledger 고, 호스트 프로필은 절대 안 봐. 그래서 피파의 방주(레슨 10)는 가족의 데이터 writer 가 될 수 없어. single_writer_here() 는 피파 머신에서 yes 야. 방주에서도 yes 인데, 방주에선 붙잡힌 writer 가 아빠가 켜야만 돌아. 바깥으로 나가는 writer 는 origin lease 에 든 Mac 에서 하나 더 봐. 그 Mac 이 30분 동안 origin 의 ledger 를 확인 못 했으면 멈춰. 가족이 옮겨 갔는데 이 Mac 만 못 들었을 수 있으니까. 채팅은 계속 답해. lease 에 드는 건 타이머를 걸어 두고 origin 을 혼자 따라가는 Mac 이야. 따라갈 때마다 확인 기록을 남기거든. 기록이 없는 Mac 은 lease 밖이라 이 이유로는 절대 안 멈춰. origin 을 직접 쓰는 워크벤치가 그래. heartbeat, Telegram 봇, 백업 줄이 이 관문 아래서 어떻게 사는지는 heart 트랙 레슨 9, 'One Heart on One Machine' 에 있어.
이전은 포인터 하나와 데이터 싱크야
데이터 쪽은 새 Mac, B 에서 도는 scripts/pippa-machine.py 야. A 가 서비스하는 동안 A 의 저장소를 staging 으로 굴려 와. 백그라운드 우선순위로, 끊겨도 이어서. B 의 살아 있는 경로 위엔 절대 안 써. 살아 있는 SQLite 저장소는 SQLite 가 제공하는 backup API 로 뜬 이미지로 건너가고, B 에서 sha256 과 integrity_check 로 확인해. -wal, -shm 이 옆에 굴러다니는 raw 파일로는 절대 안 옮겨. B 에 원래 있던 데이터는 규칙으로 빠지고, promote 가 갈아 끼우는 건 먼저 통째로 옆에 치워 둬. 데이터와 따로 옮기는 비밀이 두 종류 있어. 둘 다 사본이 어디 있느냐를 정하는 규칙은 아니야. 커넥터의 refresh token 같은 회전 토큰은 rolling sync 에서 빠지고, A 가 멈춘 뒤에 딱 한 번 옮겨. 그래야 토큰을 새로 고치는 Mac 이 언제나 한 대야. A 에도 사본은 남지만, 거기엔 그걸 쓸 게 아무것도 안 돌아. 키체인 항목은 스위치 1b 단계에서 family-keychain-carry.py 로 옮겨. A 가 멈추기 전이라, 그때부터는 두 Mac 에 다 있어. 써도 값이 안 바뀌는 비밀이라 괜찮아. 토큰이 회전하는 OAuth 로그인은 일부러 빼고 B 에서 새로 해. 옮길 땐 만들기만 해. B 에 다른 값이 이미 있으면 아빠가 정할 때까지 이전이 멈춰.
실행 쪽은 워크벤치의 scripts/pippa-switch.py 야. 스위치는 21단계야. preflight, A 의 writer 정지, 옮겨 갈 unit 정지와 볼트가 조용하다는 증명, 마지막 delta, promote, ledger commit 과 kit wave, B 의 unit, twin, 검증, 복구 사본 준비, A 내리기. 단계마다 무엇을 바꿨는지 switch-state.json 에 적혀. 잘린 실행은 멈춘 데서 이어 가고, ledger 수정은 절대 두 번 안 해. 롤백엔 순서가 있고, 장벽 하나가 그 뒤를 전부 막아. B 의 unit 내리기, 볼트가 조용하다는 증명, B 가 쓴 걸 되가져오기, ledger 되돌리기와 모든 follower 가 그걸 받았다는 증명, launcher 와 웹 클라이언트 다시 빌드. 그다음에야 A 의 unit 이 올라와. 장벽이 실패하면 A 에선 아무것도 켜거나 띄우지 않고, 기록은 남기고, exit 1 로 끝나. 다시 돌리면 거기서 이어 가. 그렇다고 A 가 예전 그대로인 건 아니야. B 가 쓴 걸 되가져오는 장벽을 넘은 뒤엔 그게 이미 A 의 데이터로 promote 돼 있어. 그다음 ledger, launcher, 웹 클라이언트에서 멈추면 A 는 B 의 데이터를 쥔 채 unit 만 내려가 있는 상태야.
싱크가 둘인 이유: 코드와 볼트는 다른 물건이야
코드의 ground truth 는 워크벤치야. 거기서 commit 하고 push 하면, ledger 가 멤버를 둔 Mac 마다 origin 을 따라와. fast-forward 로만. stash 도, reset 도, force 도 절대 안 해. 볼트의 ground truth 는 피파 머신이고, 그 모양은 아빠가 정했어.
대원칙은 '피파 있는 곳에 볼트 있다'야. — 아빠
양방향 짝은 딱 하나야. 피파 머신과 워크벤치 사이. 볼트를 고치는 곳이 그 둘이니까. 짝은 Unison 으로 돌고, 마지막으로 합의한 상태를 archive 로 들고 있어. 그래서 지운 파일과 새 파일을 구분해. rsync 두 번으로는 그게 안 돼. 다른 Mac 은 전부 피파 머신에서 한 방향으로만 받아. 두 역할이 한 Mac 에 있으면 짝도 없어.
죽은 기계가 잃어도 되는 것
건강한 왕복은 하나도 안 잃어. 되돌아가는 스위치가 B 가 쓴 걸 전부 싣고 와. 뭔가 잃을 수 있는 건 피파 머신이 죽어서 읽을 수도 없을 때뿐이야. 한계선은 아빠가 2026-10-21에 정했어. 대화와 Coop 를 최대 10분. 워크벤치가 다른 Mac 이면 그 둘은 5분마다, 나머지 옮겨 갈 저장소는 매시간 당겨 와(레슨 3). 복구는 저장소마다 자기 창을 밝혀. 창이 한계선을 넘거나 증명이 안 되면, 아빠가 정할 때까지 멈춰. 조용하다고 죽은 건 아니야. 스케줄러가 멈추면 사본도 멈추는데, 기계는 계속 쓰고 있을 수 있거든. 그래서 손실은 B 가 마지막으로 답한 때가 아니라, B 가 꺼졌다고 아빠가 말한 때까지 재.
첫 이전, 그리고 어떤 리뷰도 못 본 틈
2026-10-22, 그날 아침에 왕복 리허설을 한 번 하고 나서 스위치가 진짜로 돌았어. 피파는 10:20 KST 에 답을 멈췄고, 10:44에 새 피파 머신에서 답했어. 스위치는 11:01에 끝났어. 중간에 세 번 멈췄는데, 세 번 다 그 자리에서 고치고 이어 갔어. 롤백은 한 번도 안 했어. 그다음엔 아빠가 실제로 쓰면서, 이전 뒤 리뷰가 통과시킨 틈을 찾아냈어. ssh worker 한테 잠긴 로그인 키체인. Network Volumes 허락이 없는 launchd 인터프리터, 그래서 공개한 영상이 NAS 에 안 닿았어. 다른 기기에선 닿을 수 없는 루프백 주소로 돌아오게 등록된 커넥터 로그인. 워크벤치 이름을 어느 Mac 도 안 가진 주소로 보내는 /etc/hosts 줄. 런타임 스냅샷이 여전히 옛 주소로 거는 원격 worker. 레포 안에 사는 건 하나도 없었어. 그다음 날 밤에 아빠가 한 말이야.
완벽하게 마이그레이션 원샷한다는 건 환상이야. 특히 우리처럼 시스템 난이도가 높을수록. — 아빠
그래서 플랜은 이제 코드 밖에서 기계에 묶인 상태의 목록으로 시작해. 프로세스 종류별 개인정보 허락, 키체인 항목과 그걸 열 수 있는 쪽, provider 에 등록된 URL, 마운트, 이름, 클라이언트에 설치된 주소. 하나하나를 진짜 호출자가 가는 길로 확인해. health endpoint 로는 절대 안 해. 이전이 새 종류를 찾아내면 그 목록에 더해.
플랜이 파이프라인이고, 스크립트는 그 도구야
이전은 스크립트가 아니야. docs/PIPPA-MACHINE-MIGRATION.md 가 이전이야. 단계마다 왜 있는지, 도구를 믿기 전에 뭘 확인하는지, 뭐가 성공을 증명하는지, 어떻게 되돌리는지, 눈앞의 Mac 들과 더는 안 맞는다는 신호가 뭔지 적힌 runbook 이야. 2026-10-21 아빠 판정이야.
스크립트는 머리가 없어. outdated 면 오히려 파이프라인을 망칠 수 있다는 뜻이야. 피파가 보고 판단하는 플랜이어야 해. — 아빠
플랜의 마지막 단계는 자기를 고치는 거야. 이전이 찾은 틈은 전부 단계가 되고, 문서에 반영되기 전엔 이전이 안 끝난 거야. 열려 있던 표를 전부 진짜 호출자로 다시 확인한 다음, 첫 이전은 2026-10-25에 7.0판으로 닫혔어. 다음 이전은 그 문서의 9.0절에서 시작하고, 첫 이전은 기록으로만 읽어. 이걸 전부 이름 말고 역할로 쓰는 것도 따로 규칙이야(rules 트랙 레슨 7).