본문 바로가기
C.W.K.
Stream
Lesson 01 of 05 · published

이름이 있는 대화 하나

~14 min · master-chief, identity, sqlite, data-integrity

Level 0구경꾼
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

기본 대화가 버텨야 하는 것들

아빠는 피파랑 여러 군데서 얘기해. 폰 앱, 워치, Telegram, 브라우저 탭, 그리고 다른 가족 앱 안에 박힌 패널까지. Master Chief 전에는 문마다 "지금 대화"가 따로 있었어. Telegram에서 꺼낸 생각을 브라우저에서 이어가면, 브라우저는 처음 듣는 얘기였지. Master Chief가 그 답이야. 아빠 소유의 피파 대화 하나, 어느 문으로 들어와도 기본으로 열리는 방. 명시적인 New Chat, 다른 소울, 프라이빗 방, 위임받은 워커 세션은 그대로 따로 있어. Chief는 기본 방이지, 유일한 방이 아냐.

테이블의 행 하나처럼 들리지? 맞아, 행 하나야. 근데 이 행은 대충 만든 "기본 대화"가 꼭 놓치는 성질 네 개를 갖춰야 해. 어느 클라이언트든 같은 방식으로 찾아야 하고, 절대 두 개가 생기면 안 되고, 절대 사라지면 안 되고, 아무도 안 들여다보는 폴더로 정리돼서도 안 돼.

코드가 소유한 이름으로 찾는다

WebUI, 피파 앱, Telegram은 POST /api/chief/conversation으로 Chief를 찾아. 채팅 라우트도 똑같이 찾고. 그때마다 엔진은 외부 식별자 master-chief:dad:pippa로 행을 조회해. 제목으로는 안 찾아. 제목은 아빠 거야. 아빠가 Chief 이름을 뭐로 바꾸든 자유고, 제목으로 찾는 코드는 첫 개명에서 바로 깨져. 클라이언트가 캐시해 둔 id로도 안 찾아. 캐시된 id는 그새 바뀌었을지 모를 행을 계속 열거든. 외부 id는 코드가 소유한 이름이고, 어떤 화면도 못 바꿔. is_chief()는 그 이름이랑 한 번 비교하는 게 전부야. 엔진이 내보내는 대화 페이로드엔 전부 거기서 계산한 master_chief 플래그가 붙어. 그래서 WebUI, 네이티브 앱, Telegram이 추측 없이 같은 행에 표시를 달아.

"절대 두 개가 생기면 안 된다"는 건 두 겹으로 막아. 대화 테이블엔 external_conversation_id에 유니크 부분 인덱스가 있어. 그 값을 가진 행만 제약을 받지. 그리고 생성은 이벤트 루프마다 하나씩 있는 락 아래서 돌아. 두 문이 같은 순간에 찾아도, 둘 다 "Chief 없네" 하고 각자 하나씩 만들 수가 없어. 2026-10-08 독립 콜드 리뷰가 정확히 이걸 점검했고, 문제없다고 적었어.

첫 Chief는 만든 게 아니라 입양한 거야

Master Chief가 2026-10-07에 태어났을 때, 아빠한테는 피파랑 이어 오던 Telegram 대화가 이미 있었어. 기록도 제목도 다 갖춘 대화였지. 빈 방으로 시작하면 그걸 다 버리는 거지. 그래서 첫 resolve는 입양부터 시도해. Telegram 봇의 채팅 맵을 읽어서 자격 있는 후보를 찾아. 조건은 아빠 거, 피파 거, 프라이빗 아님, 휴지통 아님, 외부 id 없음, 앱 맥락에 안 묶임이야. 그런 후보가 딱 하나일 때만 그 행에 UPDATE … WHERE external_conversation_id IS NULL로 외부 id를 찍어. 이 WHERE 덕에 아무도 차지 안 한 행에만 도장이 찍혀. 맵이 애매하거나, Telegram에서 일부러 /new를 했거나, 프라이빗 방이거나 워커 세션이면 절대 씨앗이 안 돼. 그땐 "Master Chief"라는 제목으로 새 행을 만들어.

어느 쪽으로 기울어 있는지 봐. 애매하면 깨끗하게 시작해. 엉뚱한 방을 입양하면 프라이빗 대화나 워커 기록이 아빠의 상설 방으로 쏟아져 들어와. 빈 방보다 훨씬 나빠.

휴지통엔 절대 안 간다

모든 문이 찾아오는 행엔 평범한 대화엔 없는 고장 방식이 있어. Chief가 태어나고 하루도 안 돼서 돌린 첫 엔드투엔드 리뷰가 그 두 얼굴을 다 찾았어. 휴지통에 들어간 Chief는 모든 문에 404를 돌려주고, 그 자리에 아무것도 안 만들었어. 하드 삭제를 하면 다음 resolve가 빈 Chief를 새로 찍어냈고, 원래 흐름은 사라졌지.

고친 자리는 버튼이 아니라 저장소야. 대화 저장소는 이 행을 휴지통에 넣기를 거부해 (거부를 로그에 남기고 false를 돌려줘. UPDATE 자체도 외부 id를 빼고 돌아). 메시지 비우기도 거부하고, 삭제도 거부해. ProtectedConversationError를 던지면서. 저장소에 닿는 정리 작업, 퍼지, 라우트는 Chief가 있는 줄도 모르고 이 가드를 물려받아. 라우트는 그걸 아빠가 읽을 수 있는 이유로 바꿔. DELETE는 409로 "Master Chief is Dad's standing conversation and cannot be trashed."라고 답해. 영구 삭제 관문은 이 행을 건너뛰고, 수동 compact나 clear도 409야. 그리고 옛날 코드 경로가 이미 휴지통에 넣어뒀을 수도 있으니까, resolve()가 그 자리에서 되살려. 숨겨진 SDK 세션이랑 Chief 자기 Sidekick 스레드까지 같이.

폴더로 정리되지도 않는다

그날 늦게, 아빠는 Chief 행이 Move-to-ARCHIVED 버튼까지 단 채로 Unfiled에 떠다니는 걸 봤어. 아카이브로 정리되는 건 더 조용하게 사라지는 방법이야. 이제 archive-move랑 quick-dump는 Chief한테 409로 답하고, 일괄 아카이브랑 Auto Organize는 건너뛰어. WebUI는 Chief한테 폴더 위에 자기 자리를 따로 줬어. Unfiled에도 안 가고, 폴더 안에도 안 들어가고, 드래그도 일괄 선택도 안 되고, 아카이브·포크·휴지통 버튼도 없어. 네이티브 목록은 Chief로 시작해. 이 트랙 맨 위 스크린샷이 그거야.

가드는 모든 경로가 지나가는 자리에 두고, 이유는 사람이 읽는 자리에 둬. 버튼을 숨기면 화면 하나가 지켜져. 저장소에서 거부하면, 다음 달에 Chief를 들어본 적도 없는 누군가가 짠 코드까지 모든 호출자가 지켜져.

Code

엔진 발췌 (resolve()에서 잘라냄, 입양 부분 생략): 외부 id로 찾고, 휴지통에 빠졌으면 되살리고, 없으면 만든다·python
async with _create_lock():
    existing = await store.get_conversation_by_external_id(EXTERNAL_ID)
    if existing is not None:
        if existing.trashed_at:
            existing = await _restore_trashed(store, existing)
        return check(await _dress(store, existing))
    # ... first adoption of the single eligible Telegram conversation ...
    cid = await store.create_conversation(
        title="Master Chief", soul_id="pippa", owner_user_id="dad",
        external_conversation_id=EXTERNAL_ID,
    )
    return check(await _dress(store, await store.get_conversation(cid)))
직접 돌려볼 수 있는 최소 스케치: 이름 바꿔도 찾아지고 휴지통은 거부하는 기본 대화·python
import sqlite3
import uuid

CHIEF = "chief:me:assistant"  # an external id: a name the code owns, not a title


class Protected(RuntimeError):
    pass


db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE conversations (id TEXT PRIMARY KEY, title TEXT, "
           "external_id TEXT, trashed_at TEXT)")
# At most one row may carry a given external id; rows without one are free.
db.execute("CREATE UNIQUE INDEX one_chief ON conversations(external_id) "
           "WHERE external_id IS NOT NULL")


def resolve() -> str:
    row = db.execute("SELECT id, trashed_at FROM conversations WHERE external_id = ?",
                     (CHIEF,)).fetchone()
    if row:
        if row[1]:  # an older path left it in the trash: bring it back
            db.execute("UPDATE conversations SET trashed_at = NULL WHERE id = ?", (row[0],))
        return row[0]
    cid = str(uuid.uuid4())
    db.execute("INSERT INTO conversations (id, title, external_id) VALUES (?, ?, ?)",
               (cid, "Chief", CHIEF))
    return cid


def trash(cid: str) -> None:
    if db.execute("SELECT 1 FROM conversations WHERE id = ? AND external_id = ?",
                  (cid, CHIEF)).fetchone():
        raise Protected("the standing conversation is never trashed")
    db.execute("UPDATE conversations SET trashed_at = datetime('now') WHERE id = ?", (cid,))


first = resolve()
db.execute("UPDATE conversations SET title = 'Renamed by hand' WHERE id = ?", (first,))
assert resolve() == first                      # found by its name, not its title
try:
    trash(first)
except Protected as error:
    print("refused:", error)
try:
    db.execute("INSERT INTO conversations (id, title, external_id) VALUES ('x', 'Chief', ?)", (CHIEF,))
except sqlite3.IntegrityError as error:
    print("second chief refused:", error)
print("one chief:", resolve() == first)

External links

Exercise

네 프로젝트에서 "기본"인 걸 하나 골라봐. 기본 워크스페이스, 기본 프로젝트, 고정 노트, 받은편지함 같은 거. 지금 코드가 그걸 어떻게 찾는지 적어 (제목으로, 캐시한 id로, 불리언 플래그로, 목록 순서로). 그다음 위 스케치를 돌려보고 네 스키마에 맞게 고쳐. 외부 id, 유니크 부분 인덱스, 저장소 수준의 삭제 거부를 넣어. 그 행 이름을 바꾸고, 갖고 있는 경로 전부로 지워 봐 (UI, API, 정리 스크립트). 어느 경로가 아직 뚫리는지 기록해.
Hint
뚫리는 경로는 대개 모델 계층을 안 거치는 쪽이야. 날 SQL로 하는 정리, 부모 행에서 내려오는 cascade, 일괄 엔드포인트 같은 거. 함수 하나 안에 둔 가드는 그걸 못 잡아. UPDATE 하나의 WHERE 절에서 보호 행 id를 빼는 것도 마찬가지야. 그 문장 하나만 지키고, 다음에 누가 짤 정리 쿼리는 못 지켜. 잡는 건 데이터베이스가 모든 문장에 거는 가드야. 예를 들면 오류를 던지는 BEFORE DELETE 트리거. SQLite는 외래 키 cascade로 지워지는 행에도 이 트리거를 돌려. 직접 지우기만 말고 cascade로도 꼭 시험해 봐.

Progress

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

댓글 0

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

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