호출 전부를 옮기는 작은 파일
지난 레슨의 펜스는 저장소를 직접 여는 writer를 그 자리에서 세워. 그러면 곤란한 질문이 하나 생겨. 엔진이 생기기 전에도 Coop 장부엔 정직한 호출자가 잔뜩 있었거든. Pippa 세션 전부, 헬퍼 스크립트 전부, Almanac의 브리지까지. 다들 python -m tools.coop로 말하고 그 함수를 import해서 썼어. 전환하는 날 호출자를 하나하나 다시 쓰면 마이그레이션 위에 마이그레이션을 또 얹는 꼴이야. 그래서 전환은 데이터베이스 경로도 호출 방식도 그대로 뒀어. 바꾼 건 딱 하나. 저장소 옆에 놓인 작은 JSON 파일, <store>.owner.json.
이 파일엔 mode: service가 적혀 있어. 자기가 다스리는 데이터베이스, 주인의 URL, 비공개 자격 증명을 가리키고, 이관 시각이랑 이관 전 백업 위치도 남겨. 호환 클라이언트는 이 파일을 호출할 때마다 찾아. 호환 클라이언트란 cwkPippa가 tools.coop로 들고 다니는, Coop 도메인 코드의 고정 사본이야. 그래서 전환 때 돌고 있던 Pippa 턴을 죽일 필요가 없었어. 다음 호출이 그냥 파일을 발견했으니까.
라우팅, 그리고 코드가 로컬에서 도는 단 한 곳
도메인 연산은 전부(add_todo, take_todo, list_history 같은 것들) routed로 감싸여 있어. 주인 파일이 있으면 이 래퍼는 함수를 실행하지 않아. 메서드 이름, 인자, 호출자 메타데이터를 HTTP로 주인의 호환 엔드포인트에 보내. 같은 도메인 코드가 로컬에서 도는 곳은 딱 하나, 주인 자신의 요청 맥락 안이야. 엔진이 연산을 실행할 땐 맥락이 켜져 있으니까 래퍼가 비켜서고, 함수는 엔진이 빌려준 펜스 걸린 연결 위에서 돌아. 코드베이스는 하나, 역할은 둘, 정하는 건 호출마다.
제일 아래 문도 잠겨 있어. 저장소를 직접 열 때 쓰는 헬퍼 connect()가 주인부터 확인하고 바로 거절해. Direct SQL is disabled; use the Coop service API. 그러니 저장소가 있는 그 기계에서도 읽기마저 서비스를 거쳐. 주인 파일이 이상해도 대충 넘어가지 않아. 모드가 service가 아니거나 다른 데이터베이스를 가리키면 Invalid Coop owner configuration을 던져. JSON부터 깨졌거나, service라고 해놓고 데이터베이스 칸을 빠뜨린 파일은 이 문구까지 못 가. 파이썬 자체의 JSONDecodeError나 KeyError로 튀어나와. 문구는 달라도 결과는 같아. 예외가 owner_config()를 빠져나가는 건 호출을 보내거나 연결을 열기 전이라, 아무것도 진행 안 해.
영수증이 요청보다 먼저야
클라이언트는 한 바이트 보내기 전에 먼저 자격 증명 파일이 비공개인지 봐. 그룹이나 다른 사용자 비트가 하나라도 있으면 Coop credential must be private (0600)로 거절해. 그다음 새 요청 키를 만들고, 비공개 디렉터리에 상태 uncertain인 영수증을 써. 영수증엔 키, 메서드, 입력의 SHA-256만 들어가. 본문이나 자격 증명은 절대 안 들어가. 배타적으로 만들고, 0600으로 맞추고, fsync까지 해. HTTP 호출은 그다음이야.
왜 먼저냐고? 요청이 프로세스 경계를 넘는 순간, 답이 돌아오기 전까지 이쪽에선 그 운명을 알 수가 없어. 나중에 쓰는 영수증은 제일 필요할 때 사라져. 보내고 답 받기 사이에 크래시가 나거나 연결이 끊기면 말이야. 먼저 써두면 그 전부를 버텨. 그리고 쓰기라면, 영수증에 든 키가 바로 엔진이 그 명령을 적어두는 키야(엔진이 그걸로 뭘 하는지는 다음 레슨에서 봐).
닫힌 채로 실패하기, 정확한 문구로
| 뭐가 잘못됐나 | 클라이언트가 하는 말 | 영수증 |
|---|---|---|
| 주인한테 못 닿음 (네트워크나 소켓 오류) | Coop owner unavailable; no local write attempted; reconcile request <key> | uncertain 그대로 |
| 주인이 터짐, 도메인 판정 없음 (5xx) | Coop owner failed (HTTP n); reconcile request <key> | uncertain 그대로 |
| 실행 전에 거절 (4xx, 판정 없음) | Coop refused the request (HTTP n): ... | rejected |
| 도메인이 안 된다고 함 | 도메인 자기 오류를 그대로 다시 던짐 | rejected |
| 성공 | 결과 | committed |
첫 줄은 천천히 읽어. 구절마다 약속이거든. No local write attempted: 클라이언트는 절대 직접 SQLite를 여는 쪽으로 물러서지 않아. 그렇게 물러선 writer가 바로 펜스가 막으려는 두 번째 writer니까. Reconcile request: 클라이언트는 다시 보내지도 않아. 코드 주석에 그대로 적혀 있어. do not retry a request with an uncertain HTTP outcome. 그 요청이 이미 커밋됐을 수도 있으니까. 대신 키를 건네줘. Coop 테스트는 세션 도중에 서비스를 죽이고, 다음 add_todo가 no local write가 든 메시지로 터지는지 확인해.
영수증 상태에도 버그가 있었어. 2512cf9(2026-09-29) 전까지는 서비스가 거절한 호출도 committed로 기록될 수 있었어. 그 보강 작업이 rejected를 따로 떼어냈고. 정리된 영수증은 하루가 지나면 지워져. 정리는 많아야 한 시간에 한 번이고(사흘 만에 디렉터리에 파일이 537개 쌓였었거든), uncertain인 건 누가 대조할 때까지 남겨둬.