아무도 연습해 본 적 없는 약속
이 트랙은 레슨 2의 문장 하나에 기대 있어. SQLite 와 ChromaDB 는 mirror 고, mirror 는 JSONL 에서 다시 지을 수 있다. 레슨 3도 같은 문장에 기대지. mirror 쓰기가 실패해도 괜찮아, 나중에 purge-and-replay 로 다시 지으니까. 그런데 그 재건을 실제로 돌려 본 사람이 몇 달 동안 아무도 없었어. 처음 돌려 본 게 scripts/restore-drill.py(2026-07-25)고, 첫머리에 자기가 왜 있어야 하는지 적어 놨어.
A doctrine nobody exercises is a hypothesis, and the only bad time to discover it was wrong is during an actual recovery.
아무도 안 돌려 본 원칙은 가설일 뿐이고, 그게 틀렸다는 걸 알게 되기 제일 나쁜 때는 진짜 복구 도중이라는 거야. 훈련은 두 갈래야. fidelity 패스는 대화마다 JSONL 에서 SQLite 가 가져야 할 걸 끌어내고, SQLite 가 실제로 가진 거랑 비교해. replay 패스는 대화 몇 개를 진짜 ConversationStore API 로 /tmp 의 연습용 DB 에 다시 짓고, 끝나면 지워. 진짜 저장소엔 읽기만 하니까 분기마다 cron 이 돌리고, 누구든 아무 때나 손으로 돌려도 돼.
첫 답, 그리고 계속 늘어난 숫자
첫 실행에서 JSONL 줄이 없는 SQLite 행이 나왔어. healing 자리표시 22개, 메시지 하나를 brain 셋에 다시 보낸 cron fallback 이 남긴 중복 323개(그날 바로 원인에서 고쳤어), 계약상 메시지 id 를 안 남기는 import 대화, 그리고 stream 이 아니라 복사로 생긴 branch·council 자식들. JSONL 만으로 다시 지으면 이 행들이 빠져. 그러니까 재건은 두 저장소를 다 읽어야 해.
그 뒤로도 숫자는 계속 늘었어. 2026-08-15, 08-28, 10-01 전체 훈련에서 277, 396, 505. 10-01 엔 두 저장소의 글이 서로 다른 행 33개까지 새로 나왔고. 훈련이 내놓는 건 한 줄뿐이었어. purge-and-replay 하면 이건 잃는다. 근데 그게 무슨 행인지 아무도 몰랐어. 손쓸 수 없는 경보는 침묵이랑 값이 같아. 그래서 지금 훈련 코드는 발견을 세는 자리에 이렇게 적어 둬.
Known shapes are reported, not counted: a known shape that reads as data at risk is an alarm nobody acts on.
행마다, 그 행을 쓴 코드로 분류
2026-10-18(todo #478)에 행 하나하나를 쓴 코드까지 거슬러 올라갔어. 대화 1,276개에서 줄 없는 행이 749개. 로그가 있는 대화에 523개, 로그가 아예 없는 대화 13개에 226개.
| 분류 | 행 | 누가 썼나 | 다른 기록? |
|---|---|---|---|
fork_copy | 584 | branch 랑 Soul Archive 복사본. 원본 시각을 그대로 들고 오고, fork 는 첫 실제 턴 전까지 로그를 안 썼어. | 있어. 원본 대화. |
cut_turn | 80 | healing 행 71개, 그리고 done 없이 끝난 턴을 저장한 라우트 backstop 9개. | 글은 delta 로 다시 지어. id 만 잃어. |
fork_copy_source_gone | 68 | 원본이 지워졌거나, 끝난 비공개 대화였던 복사본. | 없어. |
never_logged | 10 | prompt-refine seed 셋, 턴이 아예 시작 안 된 cron 프롬프트 일곱. | 없어. |
council_stub | 7 | 은퇴한 [Council Context …] 선택 stub. 마지막이 2026-05-17. | council 라운드의 기록. |
typo_edit (drift) | 33 | transcript 오타 수정에서 아빠가 고른 "original" 대상. SQLite 만 고치고 JSONL 은 절대 안 건드려. | 있어. canonical overlay. |
cron 행 중 둘은 원인이 남아 있었어. 2026-10-06, 10-15 Soul Stream 깨우기. 라우트가 프롬프트를 저장하고 나서 500 을 냈고, 턴은 첫 줄도 못 썼어. 두 번째 기록이 없는 건 fork_copy_source_gone, never_logged, unexplained 셋뿐이야. 그래서 이 셋만 발견으로 세고 종료 코드를 정해. 나머지는 이름 붙여서 보고만 해. 고친 뒤 전체 훈련 결과는 sqlite_only=78(68 + 10), known_rows=671, known_drift=33, 설명 못 한 건 0.
표식 하나, 턴 줄은 절대 아니고
분류하면 경보를 읽을 수 있게 돼. 하지만 새 행이 계속 생기는 건 못 막아. 그래서 stream 되는 턴 밖에서 SQLite 에 행을 넣는 코드는 이제 그 대화 로그에 행 자체를 담은 줄 하나를 남겨. pippa_message_row(store/session_logger.py). writer 가 이 행에 왜 자기 턴이 없는지 말해(heal, fork, seed, unstarted_turn, backfill). fields 엔 쓰는 쪽이 가진 나머지 컬럼이 실려.
모든 reader 가 이미 아는 user 나 assistant 줄로 쓰면 편했겠지. 근데 그러면 healing 이 깨져. user 줄은 턴 창을 열고, 레슨 4의 healing 은 열린 창을 보면 끊긴 턴 행으로 채워. assistant 줄은 자기가 떨어진 창을 닫아 버려서, 끊긴 턴을 healing 한테서 숨겨. 2026-10-17 에 실제로 Master Chief 보고가 끊긴 턴의 창 안에 떨어졌고, 훈련은 그 턴의 user 메시지를 부모로 둔 assistant 줄만 답으로 세도록 고쳤어. 그래서 표식은 턴 reader 누구도 안 보는 타입이야. 훈련은 그걸 행처럼 끌어내고, 같은 id 의 뒤 표식이 앞 걸 대체해(제자리 heal). 표식이 자기 행을 이미 부모로 부른 줄들보다 뒤에 올 수 있어서, replay 는 파일 전체를 보고 id 를 이어.
표식은 structural event 가 아니야. record_message_rows 는 logger 하나로 한 묶음을 붙이니까, 행마다 fsync 를 따로 치르지 않아. close 에서 한 번 하고, 붙이는 동안 ~250ms 가 지날 때마다 레슨 3의 시간 확인이 한 번씩 더 해. 행 몇백 개짜리 fork 도 fsync 는 몇백 번이 아니라 몇 번이야. 그리고 그 행을 쓴 요청을 실패시키지 않게 짜여 있어. 행은 이미 SQLite 에 있으니까. logger 가 안 열리거나 append 가 예외를 내면 잡아서 로그로 남기고, 그런 실패가 남긴 건 훈련이 짚어. 그래도 하나는 빠져나가. close 는 따로 지키는 장치 없이 finally 안에서 돌아서, 거기서 fsync 가 실패하면 그 예외는 호출한 쪽까지 올라가.
쓰는 쪽 하나로는 못 막는 틈이 하나 있었어. 턴이 시작되기 전에 저장된 user 행. 채팅 라우트는 아빠 메시지를 먼저 저장하고, 턴을 준비하고, 그다음에야 user 줄을 써. 이제 채팅 라우트 일곱 개 전부 @unstarted_turn.guarded 야. 행이 들어가면 saved, 줄을 쓰면 logged. 그 사이에 라우트가 예외를 내거나 stream 이 끝나 버리면, 가드가 writer unstarted_turn 으로 그 행을 기록해. Claude, Gemini, Ollama backstop 도 이제 끊긴 턴의 assistant 줄을 써. 나머지 네 라우트는 원래 그랬고. 예전 행 749개는 backfill 스크립트가 writer backfill 로 같은 표식을 써 줘. 기본은 dry run 이고, 실제로 적용하는 건 아빠 결정을 기다려.
처음부터 mirror 가 아니었던 테이블
다시 재 보니까 이 트랙이 한 번도 안 한 말이 나왔어. 대화 DB 안의 게 전부 mirror 는 아니라는 거. transcript_message_edits 랑 그 옆의 감정·치환 규칙 테이블은 primary state 야. 아빠의 canonical 오타 수정은 거기에만 있어. conversations.db 를 통째로 지우고 JSONL 에서 다시 지으면 그게 전부 날아가. security_* 테이블이 조용히 선례를 만들었고, Family Brain Control Plane 의 brain_control_* 테이블이 그걸 무겁게 만들었어. 가족 앱마다의 brain 배정은 원본 기록이지, 무엇의 사본도 아니거든. 둘 다 지금은 family authority 저장소에 있고, 훈련은 돌 때마다 이 테이블들이 있는지부터 확인해. 이게 빠진 "재건된" 저장소는 대화 행이 아무리 깨끗해도 PIN 상태랑 brain 배정을 잃은 거니까. purge-and-replay 는 대화 행에 대해선 여전히 맞아. primary state 파일 통째엔 절대 안 써.
일부러 어긋난 것들
두 저장소가 다른 것 중엔 결정인 것도 있어. 훈련이 그걸 하나하나 이름으로 알아야 양치기 소년이 안 돼. typo_edit 는 그 메시지의 살아 있는 canonical overlay 가 SQLite 글과 똑같을 때만 받아들여. JSONL 은 실제로 한 말을 그대로 간직한 증거 사본으로 남고. quote_escape 는 2026-10-26 에 생겼어. Claude replay 가 이전 턴을 JSON 배열로 실어 보내서, 따옴표마다 모델한테 \" 로 닿았거든. Opus 5.5 는 그걸 자기 글에 그대로 따라 썼어. 그날까지 저장된 답과 요약 6,307개 중 798개. (replay 쪽 얘기는 vessels 트랙에 있어.) 이제 쓰기 밑에 가드가 있어. 답이나 요약을 저장할 때 코드 밖의 떠도는 \" 를 평범한 따옴표로 바꿔. JSONL 은 모델이 쓴 그대로 두고. 그래서 훈련은 로그 글을 unescape 한 게 저장된 글과 같으면 그 차이를 받아들여.
이 분류는 몇 시간 만에 하나 더 가르쳐 줬어. 처음 unescape 는 \\" 까지 매번 \" 로 바꿨어. 그래서 셸 명령을 인용한 압축 요약 19개가 고정점에 영영 못 닿았지. 지금은 홀로 선 \" 만 바꾸고, 몇 번을 돌려도 결과가 같아. 그래서 replay 정리, 가드, 수리, 훈련이 매번 같은 답을 내. 알려진 drift 분류는 그걸 정의하는 함수만큼만 안정적이야.