기록은 아빠만 바꿔
덧붙인 글의 액션 계약은 짧은 한 문장으로 시작해. 기록은 아빠만 바꿔. 아빠가 변경을 부탁하거나 내가 낸 제안에 좋다고 하면, 나는 답 끝에 sizzle-actions라고 이름 붙인 울타리 블록 하나를 달고, 버튼으로 적용할 수 있다고 말해. 계약 마지막 줄이 빈틈을 닫아. 저장됐다고 절대 말하지 말 것. 저장은 아빠 버튼이 해. 이 줄은 중요해. 내가 저장했다고 했는데 버튼이 한 번도 안 눌렸다면, 내 말과 아빠 트리가 서로 어긋나고 아빠는 어느 쪽을 믿어야 할지 알 길이 없어.
| 액션 | 필드 | 쓸 수 있는 곳 | 두 클라이언트의 버튼 |
|---|---|---|---|
correct | target, value, note | 접시 스레드만 | Correct the reading: … (읽기 고치기) |
adopt | class_id | 어느 스레드든 | Adopt into my tree (내 트리에 채택) |
rename | class_id, name | 어느 스레드든 | Rename to “…” (이름 바꾸기) |
link | class_id | 접시 스레드만 | Tie this plate to it (이 접시를 거기 잇기) |
variant | parent_id, name, overrides, note | 어느 스레드든 | Create my version “…” (내 버전 만들기) |
verdict | class_id, verdict | 어느 스레드든 | Save my verdict (내 평결 저장) |
웹의 Talk.tsx와 폰의 Models.swift는 버튼 글자를 똑같이 써. 이유는 Swift 주석에 있어. 두 클라이언트가 같은 변경을 같은 식으로 내밀게 하려는 거야.
답에서 블록 잘라내기
split_actions는 첫 번째 sizzle-actions 울타리 블록을 찾아. 패턴은 최소 일치고 re.S로 컴파일돼서, 블록이 여러 줄에 걸쳐도 돼. 블록 앞뒤의 나머지는 전부 아빠가 읽는 답이 돼. 안쪽은 JSON으로 풀어. 풀다가 실패하면 답만 남고 제안은 하나도 없어. 객체 하나만 있으면 원소 하나짜리 목록으로 받아 주고, 객체도 목록도 아니면 액션은 없어. 엔진 테스트가 두 쪽을 다 확인해. 블록이 붙은 답은 한국어 문장 하나와 액션 하나로 돌아오고, 그냥 답은 빈 목록과 함께 그대로 돌아와.
첫 검사, 내가 제안할 때
_record_answer는 앞의 제안 여섯 개만 보고, 하나씩 Sizzle.validate_action에 넘겨. 이 함수는 액션을 다듬어 돌려주거나 예외를 던지고, 아무것도 쓰지 않아. 규칙은 짧아.
- 액션은 알려진
type을 가진 객체여야 해. correct와link는 접시 스레드 거야. 클래스나 부엌 스레드에선 거절돼.class_id와parent_id는 전부 미러에 실제로 있는 클래스를 가리켜야 해.- 변형은 요리나 다른 변형에서 나오고, 원리에선 절대 안 나와. 이름과, 비어 있지 않은 오버라이드 목록도 있어야 해.
- 정정엔 값이, 이름 바꾸기엔 이름이, 평결엔 글이 있어야 해.
통과 못 한 액션은 로그 한 줄과 함께 버려지고, 턴은 통과한 액션만 담아 turn.answered로 기록돼. 고쳐 주지도, 짐작하지도 않아. 엔진 테스트는 채택 두 개를 제안해. 하나는 진짜 원리 id, 하나는 BAD 글자로 지어낸 id야. 기록된 턴엔 정확히 첫 번째만 남아. 내가 어떤 클래스도 가리키지 않는 id를 대면 아빠한텐 그 버튼이 그냥 안 보여. 그게 맞는 결과야. 짐작한 id는 아빠 말을 엉뚱한 클래스에 붙여 버리니까.
두 번째 검사, 아빠가 누를 때
버튼이 보내는 건 거의 없어. 두 클라이언트 다 본문 없이 /api/turns/{turn_id}/actions/{index}/apply로 POST해. 엔진은 액션을 자기 기록에서 위치로 찾아 꺼내. 그래서 클라이언트는 제안을 가리킬 수만 있고, 고쳐 쓸 순 없어. apply_action은 순서대로 움직여.
- 턴을 찾고, 인덱스가 범위 안인지 봐.
- 그 인덱스가 이미 적용됐으면 저장된 결과를 돌려주고 아무것도 안 남겨. 테스트는 같은 액션을 두 번 적용해서 같은 결과가 오는지 봐.
validate_action을 다시 돌려. 이번엔 지금 모습의 기록과 지금 돌아가는 코드의 규칙에 대고 돌려.- 엔진 자기 라우트들이 기대는 도메인 메서드를 불러.
correct,adopt,rename,link,derive_variant, 아니면 평결만 넘기는revise_class인set_verdict. - 턴, 인덱스, 결과를 담아
action.applied를 기록해.
이 다섯 단계는 트랜잭션 하나가 아니야. apply_action은 단계들을 묶는 잠금을 안 잡고, 라우트는 평범한 def라서 FastAPI가 누를 때마다 따로 워커 스레드에서 돌려. 2단계의 확인과 5단계의 기록은 서로 다른 두 순간이야. 동시에 온 두 번의 누름은 둘 다 2단계를 통과하고 둘 다 적용될 수 있어. 변형이면 변형이 둘 생기는 거지. 4단계 뒤, 5단계 전에 죽으면 변경은 저널에 들어갔는데 그 뒤에 action.applied가 없어. 그러면 다음 누름이 또 적용해. 한 번만이라는 답이 지켜지는 건 첫 적용이 끝난 다음에 온 반복뿐이고, 테스트가 확인하는 것도 그거야. 경주를 닫으려면 잠금이 2단계보다 먼저 시작해야 해. 1단계가 턴을 적용 표시까지 통째로 스냅숏으로 가져오거든. 둘 다 적용되기 전에 턴을 가져온 두 번의 누름은 잠금이 있든 없든 각자 낡은 사본을 확인해. 엔진의 재진입 쓰기 잠금을 1단계부터 5단계까지 잡아서 턴을 잠금 안에서 읽어야 경주가 닫혀. 죽는 틈까지 닫으려면 더 필요해. 변경과 적용 표시를 이벤트 하나로 써야 해.
도메인 메서드엔 저마다 검사가 또 있고, 그중 하나는 첫 검사보다 엄해. derive_variant는 역할과 바꿀 값을 둘 다 적은 오버라이드만 남기고, 하나도 안 남으면 변형을 거절해. 이렇게 만든 변형은 origin dad, 상태 adopted로 태어나. 버튼을 누른 게 아빠니까. 접시 스레드에서 만들었으면 그 접시에도 이어져. link는 클래스가 아직 제안 상태면 채택까지 해. correct는 접시를 새 읽기 대기열에 넣고, 그 턴의 id를 정정의 출처로 남겨.
검사가 판단하지 않는 것
검증은 id와 스레드 종류를 지켜. 뜻까진 판단 안 해. 정정의 target은 80자로 잘리고, 비어 있으면 note가 돼. 글이기만 하면 뭐든 통과해. 접시 화면이 지금 읽기 위에 덮어 보여주는 건 summary target이나 dish:<n>:name_ko, name_original, cuisine target뿐이야. 그래도 정정은 전부 아빠의 사실로 다음 읽기에 실려. 마지막 검사는 사람이야. 아빠가 누르기 전에 버튼 글자를 읽어.
Sidekick 프레임 안에서 누르면, 대화가 바깥 페이지로 sizzle:changed 메시지를 보내고 그 페이지가 다시 불러와. 프레임 얘기는 트랙 7이 해.