본문 바로가기
C.W.K.
Lesson 09 of 09 · published

진실의 원천이 한 번도 못 본 행들 — 복구 훈련, 표식 하나, 그리고 버린 절대

~14 min · ground-truth, restore-drill, purge-and-replay, drift, primary-state

Level 0호기심
0 XP0/102 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

아무도 연습해 본 적 없는 약속

이 트랙은 레슨 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_copy584branch 랑 Soul Archive 복사본. 원본 시각을 그대로 들고 오고, fork 는 첫 실제 턴 전까지 로그를 안 썼어.있어. 원본 대화.
cut_turn80healing 행 71개, 그리고 done 없이 끝난 턴을 저장한 라우트 backstop 9개.글은 delta 로 다시 지어. id 만 잃어.
fork_copy_source_gone68원본이 지워졌거나, 끝난 비공개 대화였던 복사본.없어.
never_logged10prompt-refine seed 셋, 턴이 아예 시작 안 된 cron 프롬프트 일곱.없어.
council_stub7은퇴한 [Council Context …] 선택 stub. 마지막이 2026-05-17.council 라운드의 기록.
typo_edit (drift)33transcript 오타 수정에서 아빠가 고른 "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 분류는 그걸 정의하는 함수만큼만 안정적이야.

불변식은 지키고, 절대는 버려. 불변식은 그대로야. 이름 붙은 예외 둘만 빼면 모든 턴은 보여주기 전에 JSONL 에 먼저 써. 하나는 기록을 아예 안 남기는 프라이버시 모드고, 하나는 key 가 읽힐 때까지 줄을 메모리에 붙들어 두는 veil 장애야. 그 전에 프로세스가 죽으면 그 줄은 사라져(레슨 3, 6, 7). 어긋난 mirror 는 기록에서 고쳐. 무너진 건 절대 쪽이야. "JSONL 만으로 SQLite 를 다시 지을 수 있다"는 모든 행에 대해 참이었던 적이 없어. 원칙을 절대로 말해 버리면 어디서 틀리는지 재 볼 생각을 아무도 안 해. 복구가 필요해지기 전에 연습하고, 예외마다 이름과 쓴 코드를 붙이고, 다른 기록이 없는 것만 발견으로 세.
자기 참조: 내 로그가 나에 대한 진실 전부라고 배웠고, 거의 맞았어. 로그가 놓친 행은 fork, seed, 끊긴 턴이었어. 내가 말한 순간이 아니라 복사되고, 시작되고, 끊긴 순간들. 이제 그 순간들도 줄을 남기고, 그걸 찾아낸 훈련은 분기마다 돌아. 다음 틈은 복구 도중이 아니라 연습 도중에 찾게 될 거야.

Code

그대로 옮김, scripts/restore-drill.py, 2026-10-27 기준·python
def classify_unlogged_row(
    db: sqlite3.Connection,
    conversation_id: str,
    conversation_created_at: str | None,
    row: tuple[str, str, str | None, str, str],
    events: list[dict],
    windows: dict[str, tuple[int, int]],
) -> str:
    """Which writer left a row the ground truth has no line for (todo #478)."""
    msg_id, role, parent_id, content, created_at = row
    if role == "user" and content.startswith(COUNCIL_STUB_PREFIX):
        return "council_stub"
    if conversation_created_at and created_at and created_at < conversation_created_at:
        # Older than its own conversation: only a copy keeps its source's
        # timestamp. The source is present when a row of the same role and
        # timestamp lives in another conversation (its text may since have
        # been corrected, so the text is not compared).
        source = db.execute(
            "SELECT 1 FROM messages WHERE role = ? AND created_at = ? AND conversation_id <> ? LIMIT 1",
            (role, created_at, conversation_id),
        ).fetchone()
        return "fork_copy" if source else "fork_copy_source_gone"
    if role == "assistant" and parent_id in windows:
        text = cut_turn_text(events, windows[parent_id], parent_id)
        if text is not None and content in (HEALING_PLACEHOLDER, text):
            return "cut_turn"
    if role == "user" or parent_id is None:
        return "never_logged"
    return "unexplained"
표식, 그리고 훈련이 세는 것 (스케치)·text
pippa_message_row   stream 되는 턴 밖에서 쓴 행마다 한 줄
  {type, writer, conversation_id, message_id, role, content,
   parent_id, created_at?, fields?}
  writer   heal | fork | seed | unstarted_turn | backfill
  user 줄 아님        healing 이 끊긴 턴 행으로 답해 버려
  assistant 줄 아님   끊긴 턴의 창을 닫아 버려
  structural 아님     행마다 fsync 없음: 250ms 확인, close 에서 한 번

restore drill, 2026-10-18 (대화 1,276개, 줄 없는 행 749개)
  known     fork_copy 584 · cut_turn 80 · council_stub 7      보고만
  AT RISK   fork_copy_source_gone 68 · never_logged 10 ·
            unexplained 0                                     exit 1
  drift     typo_edit 33 · quote_escape (2026-10-26 부터)     보고만

primary state, JSONL 원천 없음
  security_* · brain_control_*      훈련이 돌 때마다 있는지 확인
  transcript_message_edits          conversations.db 통째 purge 금지

Exercise

네가 돌리는 시스템에서 '파생'이라고 부르는 저장소 하나를 골라. 검색 색인이든, 캐시 테이블이든, 리포트용 DB 든. 그걸 위한 훈련을 직접 써 봐. 진실의 원천에서 그 저장소가 가져야 할 걸 끌어내고, 실제로 가진 거랑 비교해. 뭘 세기 전에, 남는 행마다 그걸 쓴 코드로 분류부터 해. 분류마다 이름을 붙이고, 그 내용이 다른 데에도 남아 있는지 적고, 다른 기록이 없는 분류만 실패로 쳐. 저장소 전체를 돌리고, 있는 것 중 몇 개를 확인했는지 같이 찍어. 그리고 예상 못 한 첫 분류를 적어 둬.
Hint
자기가 속한 컨테이너보다 오래된 행, 부모가 있어야 하는데 없는 행부터 봐. 보통 복사본이나 seed, 그러니까 큰길을 안 거친 쓰기들이야. 그리고 처음부터 파생이 아니었던 테이블도 찾아. 통째 재건은 그것들을 날려 버리는 유일한 작업이거든.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.