"붙잡은 생각으로 가는 최단 경로는, 앱을 아예 안 여는 경로야."
브라우저가 가질 수 없는 세 가지
지난 네 레슨은 웹에서 캡처 계약을 가르쳤어. 필수 항목 없음, local-first outbox, composer 삼총사, 두 진실 위치. 그 계약은 PWA 에 살고, PWA 는 남아. 브라우저를 열 수 있는 모든 기기에서 설치 제로인 표면이니까. 근데 캡처 욕구 셋은 OS 레벨이야. tailnet PWA 로는 절대 안 돼. 다른 앱이 쥐고 있는 걸 받는 share sheet, 파일 피커가 아니라 캡처 표면 그 자체인 카메라, 그리고 iOS 가 못 쫓아내는 저장소. 메모리 압박에서 시스템이 회수해 갈 수 없는 큐. '기기 저장소는 최종 창고가 아니라 임시 보관대' 라던 그 문장이 늘 기다리던 거야. 그래서 Vesta 는 두 번째 클라이언트를 키웠어. 네이티브 iOS/iPadOS 앱, 그리고 나중에 Apple Watch. 같은 엔진의 두 번째 클라이언트지, 두 번째 창고가 아냐.
문들
이제 화면 없이 crumb 을 커밋하는 문이 세 개고, 네 번째 문은 여는 쪽을 가리켜. share sheet 는 다른 앱 안에서 crumb 을 커밋해. Day One 채택 노트가 깨끗한 통로가 없어 미뤄 뒀던 두 번째 캡처 채널이, 채팅 앱 우회 대신 extension 으로 풀렸어. 도착한 건 저장하고, 못 도착한 건 이름을 불러. 첨부 provider 하나가 영원히 안 응답해도, 영원히 매달리는 대신 시트는 나머지를 저장하고 어떤 조각을 놓쳤는지 정확히 말해. intent 는 Siri·Shortcuts·Action Button 이 아무것도 안 열고 받아쓴 조각을 큐에 바로 커밋하게 해. composer 보다 짧아. 그래서 Vesta 의 그 다섯 초 문장으로 가는, 플랫폼이 허락하는 최단 경로야. 그리고 Apple Watch 는 캡처뿐이야. 스트림도, 달력도, 저널 피커도 없어. 손목은 폰보다도 더 다섯 초짜리 표면이고, 캡처가 아닌 모든 컨트롤은 너와 생각 사이에 서 있어. widget 은 네 번째 문인데, 캡처는 안 해. 오늘 개수와 기기에서 아직 기다리는 것을 보여주고, 탭하면 composer 를 열어. 그게 widget 이 지킬 수 있는 정직한 약속이야. iOS 는 몇 초만 주고 키보드도 네트워크 보장도 없으니까, 공유 app 컨테이너만 읽고 엔진엔 절대 전화 안 걸어. 사무실에 전화 거는 타일은 오래됐거나, 비었거나, 빙글 돌 뿐이야. 진짜 화면 없는 캡처는 intent 에 있어.
두 디테일이 규율이 얼마나 깊은지 보여줘. watch 에선 받아쓰기가 바로 열려. WatchKit 입력 컨트롤러의 suggestions 파라미터에서 nil 이 짐을 져. 빈 배열은 '아무것도 없는 목록' 이라 목록 화면을 띄우고, nil 일 때만 '목록이 아예 없다' 가 돼서 마이크가 열려. 그리고 complication(시계 페이스의 작은 위젯)은 상태를 아예 안 보여주는 버튼이야. 페이스는 힐끗 읽는 곳이고, 타임라인은 캡처가 아니라 OS 스케줄로 새로고침되니까, 거기 찍힌 개수는 몇 분이면 낡은 숫자가 돼. 절대 안 여는 그 하나의 표면에서 낡은 숫자는 없는 것보다 나빠.
spool: 의도적인 갈림
저장 경로 레슨에서 만난 웹 outbox 는 JSON 문서 하나고, 그 상태기계는 kit 이 주인이야. 폰은 그 모양을 못 가져. 네이티브에만 있는 이유 때문이야. 쓰는 손이 여럿이거든. 앱, share extension, intent, 그리고 다음에 올 무엇이든. 읽고-고치고-쓰는 공유 문서 하나는 한 손의 커밋을 경쟁으로 잃을 수 있어. 그래서 네이티브 큐, spool 은 crumb 하나당 디렉토리 하나야. manifest 를 마지막에 써. 쓰는 손들은 파일을 만들기만 하고, 다시 쓰는 건 앱뿐. 잠금도 없고 경쟁도 없어. 그리고 항목의 id 가 곧 client_request_id 야. 엔진의 중복 제거 키와 로컬 정체성이 하나라, 중간에 끊긴 전송은 언제든 다시 해도 안전해. 이게 마지막 트랙 규율의 축소판이야. 계약은 물려받고(local-first, 엔진이 돌아오면 흘려보내), 도메인이 진짜 다른 곳에서 갈라지고(쓰는 손이 여럿), 그 갈림을 이유와 함께 아키텍처 문서에 적어.
watch 는 자기 진실을 지켜
손목 레인엔 두 날짜 진실 규칙이 짐을 진다는 이 퀘스트에서 가장 깨끗한 증거가 있어. watch 는 tailnet 에 없고 앞으로도 없어. 닿을 엔진이 없으니까. 폰이 그 엔진이야. 말로 붙잡은 crumb 은 먼저 watch 자기 큐에 커밋되고, 그다음 시스템의 전송 큐를 타. 순서대로 배달되고, 폰이 꺼져 있거나 손 닿지 않는 곳에 있어도 살아남아. 그리고 전송이 도착했다고 보고할 때만 큐 항목이 지워져. 여기가 핵심이야. 폰이 마침내 그 crumb 을 커밋할 때, 도착 순간이 아니라 watch 의 캡처 진실로 찍어. 핸드오프는 몇 시간 걸릴 수 있는데, 도착했을 때 썼다고 주장하는 crumb 은 엉뚱한 날에 떨어질 테니까. captured_at 은 기기 진실이고, 중요한 기기는 네가 말을 건넨 그쪽이야.
엔진이 안 된다고 할 때
정직한 실패 대목이 하나 더 네이티브 문들과 함께 왔어. 엔진이 진짜 4xx 로 거부한 crumb 은 더 이상 큐 전체를 뒤에 세워 두지 않아. needs-attention 격리로 옆으로 비키고, 나머지 하루는 계속 흘러가. outbox 는 불안정한 네트워크를 이기려 있는 거지, 엔진이 영원히 안 받을 crumb 하나에 아침의 캡처 전부를 인질로 잡으려 있는 게 아니니까. 일시적인 답, 408·425·429 와 인증 짝 401/403 은 여전히 흘려보내기를 멈춰. 일부러야. 잘못된 세션은 잘못된 crumb 이 아니거든. 문들 자체도 같은 정직함을 따라. 네이티브 composer 는 처음에 삼총사 다리 둘이 빠진 채 나왔고, 아키텍처 문서는 삼총사 레슨의 그 문장 그대로 말했어. incomplete 으로 기록됐지, simplified 가 아니라. 못 채운 invariant 를 이름 붙여 열어 둔 거야. 삼총사가 완성될 때까지. 볼 수 있는 invariant 는 믿을 수 있는 invariant 야.