diff --git a/.claude/README.md b/.claude/README.md index fba4d60..cfce2cc 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`), `pre-implementation-handtrace-round7-followup.md`(**[2026-08-25 신설] 그 52건에 대한 사용자 결정과 `base/` 반영 결과 — 결정 단위 12묶음(🅐~🅜) 순서로 대화형 처리. **이 파일이 결정의 소스**이고 발견 원문은 앞의 두 파일. 무효/기각 7건(`H-73`~`H-77` 계열: `<>`가 값 호출부에서 동작함이 실측으로 드러나 Store 재설계로 이어짐, `RunInit` 누수는 성립 안 하는 사용법), 나머지는 전량 반영. **부수로 `question.md` 최우선 두 항목이 같이 닫혀 M2 착수 게이트가 0이 됐다**)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다), `pre-implementation-handtrace-round8.md`(**[2026-08-26 신설, 회신 대기]** 8라운드: 7라운드 반영으로 새로 쓰인 `base/` 서술을 **서로 겹쳐서** 재트레이싱한 것 — 개별 함수가 아니라 "반영된 결정들이 조합될 때 성립하는가"가 주제. 3패스에 걸쳐 `base/` 전 문서를 완독했고, 발견 번호는 `H-107`부터 이어 매김(발견 수·심각도 분포는 그 문서 자신이 소스), 실측 스파이크는 발견 항목에 인라인 전사. 사용자 결정 문항 Q1~Q10이 §4에 배치 회신용으로 정리돼 있고 **그중 🔴 다섯이 M2 착수 전 회신 필요** — `question.md` 최우선 절이 이를 가리킨다. 커밋 전 `/code-review`가 이 문서 자체의 결함 7건을 잡아 반영됐다(각 항목의 `[code-review 정정/추가]` 표시). **아직 `base/` 반영 없음** — 결정이 나면 followup 파일을 새로 만든다). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`), `pre-implementation-handtrace-round7-followup.md`(**[2026-08-25 신설] 그 52건에 대한 사용자 결정과 `base/` 반영 결과 — 결정 단위 12묶음(🅐~🅜) 순서로 대화형 처리. **이 파일이 결정의 소스**이고 발견 원문은 앞의 두 파일. 무효/기각 7건(`H-73`~`H-77` 계열: `<>`가 값 호출부에서 동작함이 실측으로 드러나 Store 재설계로 이어짐, `RunInit` 누수는 성립 안 하는 사용법), 나머지는 전량 반영. **부수로 `question.md` 최우선 두 항목이 같이 닫혀 M2 착수 게이트가 0이 됐다**)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다), `pre-implementation-handtrace-round8.md`(**[2026-08-26 신설, 회신 대기]** 8라운드: 7라운드 반영으로 새로 쓰인 `base/` 서술을 **서로 겹쳐서** 재트레이싱한 것 — 개별 함수가 아니라 "반영된 결정들이 조합될 때 성립하는가"가 주제. 3패스에 걸쳐 `base/` 전 문서를 완독했고, 발견 번호는 `H-107`부터 이어 매김(발견 수·심각도 분포는 그 문서 자신이 소스), 실측 스파이크는 발견 항목에 인라인 전사. 사용자 결정 문항 Q1~Q10이 §4에 배치 회신용으로 정리돼 있다. 커밋 전 `/code-review`가 이 문서 자체의 결함 7건을 잡아 반영됐다(각 항목의 `[code-review 정정/추가]` 표시). **⭐ [2026-08-26] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round8-followup.md`(**[2026-08-26 신설] 8라운드의 결정과 근거가 여기 소스다** — Q1~Q10을 사용자와 대화형으로 처리한 기록. **역전은 없고** 전부 "7라운드 확정이 `base/`에 내려앉을 때 생긴 누락·충돌"을 닫은 것. ⭐ 사용자가 **문항의 전제 자체를 정정한 것이 둘** 있다(Ref 콜백 ↔ Observer 콜백은 애초에 통합 대상이 아니다 / `H-118`은 소유권 문제가 아니라 문장이 틀린 것) — 결정만 읽지 말고 그 두 절을 볼 것). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | @@ -44,33 +44,33 @@ | 문서 | 내용 | |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` | -| `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | +| `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 **⭐ [2026-08-26 확정, 8라운드 `H-123`]** `pesde.lock`은 **커밋한다**(그 절이 한때 "잠정 권고, 사용자 판단 필요"였고 취합됐다던 인덱스 항목이 실제로는 어디에도 없었다 — 실태가 이미 전부 커밋돼 있어 현 실태대로 확정). 같은 라운드에 리링크 서술도 정정 — 이제 **테스트는 `./scripts/test.sh`**(그 문서가 "아직 스크립트로 정식화 안 함, 매번 수동 치환"이라 안내하고 있었으나 `scripts/relink.sh`+`test.sh`가 이미 커밋돼 있었다). | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` **[2026-08-24 6라운드 `H-24` — 실측]** 영향 범위 표에 **`tween:Mapped`** 추가 — `tween:Mapped(fn: (T) -> U): Tween`가 1번이 지목한 모양(`Foo` 안에서 `-> Foo`)과 **글자 그대로 같은데** 이 문서도 `tween-plan.md`도 그걸 모르고 있었다. 인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히 통과**시키고, ③(`typeof(named function)`)로 바꾸면 정상적으로 잡힌다 — **기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않고 있었다** | -| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 **[2026-08-24 6라운드 `H-11`]** `bindLifetime`/`unbindLifetime`이 **`isEffect`를 보고 `Destroying`을 걸고/끊는다** — `LP-2`가 *"`Effect`가 그 훅을 쓰는 유일한 소비자"*라 확정해뒀는데 **실제로 거는 코드가 어디에도 없었다.** `unbind`는 cleanup을 부르지 않는다(대칭이라 포탈이 자연히 성립) | -| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `table.clone` 기반 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), **⭐⭐ [2026-08-25]** 두 가지가 바뀌었다 — (1) 생성이 **명시적 초기화**(타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접, 옛 lazy `__index` 폐기 → `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립), (2) 레코드 필드 타이핑이 **타입 함수를 안 쓰는 평범한 레코드**로(`WrapStore`/`ProcessStoreType` 폐기 — `H-75`/`H-76`이 그 한계를 실측). 동적 키는 `store:Of<>(name)` 하나로 옛 `GetDynamic`을 흡수했고, 예약 키(`Of`/`Names`) 충돌은 **조용히 타입 검사를 끄므로** `CheckReserved` 타입 함수가 진단만 띄운다. **같은 날 "`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`** — 거기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격됐다. `store.key = value` 폐기 → `store.key:Set(value)`는 **유지**, "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | -| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M2 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) | -| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) **[2026-08-24 6라운드]** (1) **말단 핸들러 4종**(Tag/AttributeGroup/RefLeaf/ObserverEffectLeaf)이 `setOffsetSource`/`setLength`를 **아예 등록 안 하던 것**이 발견돼 계약대로 등록하게 됨 — 안 그러면 `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 `recompute`에서 명시적 error로 죽는다(`H-39`). (2) 접두합 캐시 무효화 3규칙이 **산문으로만 있고 코드 경로가 없던 것**을 `setLength`/`spliceArrays*`/`_baseObserver`에 실제 배치(`H-3`), `bk` 스펙에 `offsetCache`/`invalidAfter` 추가(`H-4`). (3) **`recompute` 호출은 `setLength`의 단독 책임**(`H-19`). (4) Blocker 범위를 **`drive` 전체**로 정정하고 `PostRef` 콜백이 게이트 안에서 돈다는 걸 계약화(`H-17`). (5) *"잔여 부기는 인스턴스 GC로 정리된다"*는 **틀린 안전망 주장 삭제**(gcconn 불멸성과 양립 불가, `H-26`) | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 **[2026-08-24 6라운드 `H-11`]** `bindLifetime`/`unbindLifetime`이 **`isEffect`를 보고 `Destroying`을 걸고/끊는다** — `LP-2`가 *"`Effect`가 그 훅을 쓰는 유일한 소비자"*라 확정해뒀는데 **실제로 거는 코드가 어디에도 없었다.** `unbind`는 cleanup을 부르지 않는다(대칭이라 포탈이 자연히 성립) **⭐ [2026-08-26 정정, 8라운드 `H-111`]** `.Subscribed`는 *전역 `:Subscribe()` 전용*이 아니라 **구독 경로(강/약) 공용**이다 — `:WeakSubscribe()`도 세운다(갈라지는 건 레지스트리를 강하게 잡느냐뿐). 안 그러면 `WeakSubscribe`로만 등록되는 `Effect`의 내부 Observer가 `canExecute` 게이트를 영영 못 통과해 **State dep 전량이 조용히 침묵**한다. 같은 날 `Subscribe`/`WeakSubscribe` 분해 의사코드가 신설됐다("`bindLifetime`이 이 필드를 안 건드린다"는 요지는 그대로 유효). | +| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `table.clone` 기반 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), **⭐⭐ [2026-08-25]** 두 가지가 바뀌었다 — (1) 생성이 **명시적 초기화**(타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접, 옛 lazy `__index` 폐기 → `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립), (2) 레코드 필드 타이핑이 **타입 함수를 안 쓰는 평범한 레코드**로(`WrapStore`/`ProcessStoreType` 폐기 — `H-75`/`H-76`이 그 한계를 실측). 동적 키는 `store:Of<>(name)` 하나로 옛 `GetDynamic`을 흡수했고, 예약 키(`Of`/`Names`) 충돌은 **조용히 타입 검사를 끄므로** `CheckReservedKeys` 타입 함수가 진단만 띄운다(**⭐ [2026-08-26 `H-112`]** 그 함수는 `T`가 아니라 **`keyof`**를 받는다 — `T`를 통째로 넘기는 옛 배선(`CheckReserved`)은 `T`에 실린 `Source`가 `*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 에러를 냈다, 실측). **같은 날 "`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`** — 거기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격됐다. `store.key = value` 폐기 → `store.key:Set(value)`는 **유지**, "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | +| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M2 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) **⭐⭐ [2026-08-26, 8라운드 `H-109`~`H-111`]** 전파 루프의 콜백 시그니처가 바뀌었다 — Observer `fn`은 **세 자리 `fn(targetState, self, emitFrom)`**이고 루프는 `sub.fn(sub._state, sub, from)`이다(그 전엔 `sub.fn(sub, from)`이라 *"self는 리시버 State의 lazy 핸들"* 계약과 정면 충돌했고, 무인자 `state:Observer()`의 내부 콜백이 즉사했다). 그 부수로 **`observer._state` 강참조**가 신설돼 `_hold` 불변식이 파생 노드뿐 아니라 **말단 핸들까지** 커버한다. `isModifier` 가드 적용 지점도 `Source` 생성자로 옮겼다(`H-122`). | +| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) **[2026-08-24 6라운드]** (1) **말단 핸들러 4종**(Tag/AttributeGroup/RefLeaf/ObserverEffectLeaf)이 `setOffsetSource`/`setLength`를 **아예 등록 안 하던 것**이 발견돼 계약대로 등록하게 됨 — 안 그러면 `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 `recompute`에서 명시적 error로 죽는다(`H-39`). (2) 접두합 캐시 무효화 3규칙이 **산문으로만 있고 코드 경로가 없던 것**을 `setLength`/`spliceArrays*`/`_baseObserver`에 실제 배치(`H-3`), `bk` 스펙에 `offsetCache`/`offsetSetUpTo` 추가(`H-4`). (3) **`recompute` 호출은 `setLength`의 단독 책임**(`H-19`). (4) Blocker 범위를 **`drive` 전체**로 정정하고 `PostRef` 콜백이 게이트 안에서 돈다는 걸 계약화(`H-17`). (5) *"잔여 부기는 인스턴스 GC로 정리된다"*는 **틀린 안전망 주장 삭제**(gcconn 불멸성과 양립 불가, `H-26`) **⭐ [2026-08-26, 8라운드 `H-113`/`H-119`]** `recompute`의 두 경계가 고쳐졌다 — splice의 접두합 무효화가 `j`가 아니라 **`j - 1`**이고(커서 위치 splice가 "변경 없음"과 구분이 안 돼 밀려 들어온 요소의 offset이 조용히 낡았다), **명시 `recompute` 호출부도 전부 재진입 게이트를 탄다**(`blocker:IsOn() or bk.recomputeBlocker:IsOn()` — 그 전엔 `Add`는 안전한데 `Remove`만 중첩 `recompute`가 완주해 바깥의 `Length`를 낡은 합으로 덮었다). **⭐⭐ [2026-08-26 `/code-review high` 4차] 부기 필드가 하나에서 둘로 갈라졌다** — `bk.offsetCacheValidUpTo`(`offsetCache`가 여기까지 정확, `getOffsetAt`이 올림)와 `bk.offsetSetUpTo`(offset `Source`에 여기까지 `:Set` 완료, **`recompute`만** 올림). 옛 단일 `invalidAfter`가 두 뜻을 겸했고, 그래서 `getOffsetAt`의 부수효과가 되감기 신호를 조용히 지웠다(`Remove` 뒤 같은 콜백에서 `Add`). `H-101`의 *"새 필드를 안 만든다"* 확정이 **역전**됐다 — 그 근거였던 "두 뜻이 실제로 같은 것"이 틀렸다. 무효화는 **둘 다** 내린다. | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | | `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 **[2026-08-24 6라운드 `H-25` — 실측]** `Quad`가 **5필드 닫힌 레코드**라 `RunInit`으로 붙인 서브시스템이 타입에 안 보인다(`quad.Dispatch`가 `luau-analyze`에서 타입에러 — `architecture.md` 결정 13번이 그 접근을 표준 사용법으로 확정해둔 것과 정면 충돌). **확정: `quad-types`의 `Quad`를 마일스톤마다 갱신한다** — 서브시스템을 붙이는 모든 마일스톤(M2/M3/M6/M7/M8/M10)에 체크박스가 뿌려졌다(규칙이 쓰인 계기는 `Dispatch`(M3)이지만 순서상 첫 적용은 M2다). 타입만 재수출하므로 "가벼운 타입 계약"이라는 존재 이유와 안 부딪힌다 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** **[2026-08-24 6라운드 손 트레이싱 — 이 문서가 가장 크게 바뀌었다]** (1) **상태가 셋이 됐다**(미실체화/실체화/마운트) — `_mounted`는 이제 **물리 인스턴스 유무**만 뜻하고 `slot._physicalTarget`이 신설됐다. `raw*`는 **부기를 실체화 시점부터 항상** 하고 `native*`만 `_mounted`로 가른다(`H-2`/`H-12`). (2) **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설 — `indexOfRaw`가 O(1) 기본 경로가 되고 `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등(`H-1`). (3) `updateFn`의 `index`를 **`Dispatch.getOffsetAt`에서** 구한다 — 옛 `result.Length:Get()` 가산은 마운트 전 Slot의 Length가 항상 0이라 아무것도 반영 못 했다(`H-2`). (4) 요소 타입 검증이 **블랙리스트→화이트리스트**(`isSlot`→`isState`→**주입 술어 `isInst`**), 관문은 `wrapElement` 하나(`H-40`). (5) `unwrapElement`에 `isSlot` 가드(Instance에서 항상 죽던 것, `H-21`), `identityUpdateFn`이 `KeyGone` 흡수(`H-22`), `dispose` 가드를 분기 밖으로(`H-28`/`H-43`), `collectLeaves` 신설 + 미작성 `raw*` 규약(`H-29`), 선행 검증 패스(`H-30`/`H-31`), `unmountSlotTree` 역순 순회(`H-6`), top-level `Slot.luau` 신설(`H-46`) | -| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 **[2026-08-24 6라운드 `H-35`]** `flatten`이 만드는 `ProcessedModifier` 센티널을 받는 **`ProcessedModifierHandler`의 의사코드가 없고 색인 두 곳에서도 빠져 있던 것**을 보강 — `Modifier`가 하나라도 든 리터럴은 **전부** 이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를 놓친다. 소속 파일은 `quad-base/Dispatch/Modifier.luau`(`architecture.md` 소스 트리와 `ROADMAP.md` M7에도 등재) | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** **[2026-08-24 6라운드 손 트레이싱 — 이 문서가 가장 크게 바뀌었다]** (1) **상태가 셋이 됐다**(미실체화/실체화/마운트) — `_mounted`는 이제 **물리 인스턴스 유무**만 뜻하고 `slot._physicalTarget`이 신설됐다. `raw*`는 **부기를 실체화 시점부터 항상** 하고 `native*`만 `_mounted`로 가른다(`H-2`/`H-12`). (2) **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설 — `indexOfRaw`가 O(1) 기본 경로가 되고 `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등(`H-1`). (3) `updateFn`의 `index`를 **`Dispatch.getOffsetAt`에서** 구한다 — 옛 `result.Length:Get()` 가산은 마운트 전 Slot의 Length가 항상 0이라 아무것도 반영 못 했다(`H-2`). (4) 요소 타입 검증이 **블랙리스트→화이트리스트**(`isSlot`→`isState`→**주입 술어 `isInst`**), 관문은 `wrapElement` 하나(`H-40`). (5) `unwrapElement`에 `isSlot` 가드(Instance에서 항상 죽던 것, `H-21`), `identityUpdateFn`이 `KeyGone` 흡수(`H-22`), `dispose` 가드를 분기 밖으로(`H-28`/`H-43`), `collectLeaves` 신설 + 미작성 `raw*` 규약(`H-29`), 선행 검증 패스(`H-30`/`H-31`), `unmountSlotTree` 역순 순회(`H-6`), top-level `Slot.luau` 신설(`H-46`) **⭐ [2026-08-26, 8라운드 `H-119`/`H-121`/`H-123`]** `rawRemove`/`rawDetach`/`rawUnmount` 계열의 명시 `recompute` 호출과 `_baseObserver` 콜백이 **재진입 게이트를 먼저 본다**(`_baseObserver`엔 부기 두 필드를 `0`으로 내리는 것도 추가). 대표 `updateFn` 예시도 교정됐다 — `:With(offset):Compute(function(i, o))`의 `o`는 offset이 아니라 **`previous`**라 그대로 짜면 첫 사이클에 죽는다(`:With`로 모은 값은 클로저로 직접 읽는다). `:Single`은 3-인자 `(state, updateFn?, opts?)`. | +| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 **[2026-08-24 6라운드 `H-35`]** `flatten`이 만드는 `ProcessedModifier` 센티널을 받는 **`ProcessedModifierHandler`의 의사코드가 없고 색인 두 곳에서도 빠져 있던 것**을 보강 — `Modifier`가 하나라도 든 리터럴은 **전부** 이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를 놓친다. 소속 파일은 `quad-base/Dispatch/Modifier.luau`(`architecture.md` 소스 트리와 `ROADMAP.md` M7에도 등재) **⭐ [2026-08-26 자리 정정, 8라운드 `H-122`]** `isModifier` 가드의 적용 지점에서 *"Store 생성 시 각 `defaults` 키를 `Source(v)`로 만드는 시점"*이 빠졌다 — 명시적 초기화 이후 **Store는 `Source`를 안 만든다**(코드상 없는 자리였다). 가드는 **`Source` 생성자**로 옮겨 defaults 경로를 자동 커버하고, Store 생성자는 대신 `defaults`를 `isSource` 화이트리스트로 런타임 검증한다(error level 2). | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-24 재확정]** 구현은 State 코어와 같은 **M2**이고(2026-08-22에 디스패치 쪽으로 앞당겼다가 마일스톤 순서 교체로 되돌아옴), 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) **[2026-08-24 6라운드 `H-33`/`H-49`]** **`blocker:Policy(emit) -> onUpstreamEmit`** 신설 — 자기 게이트 정책을 값으로 내주고 `state:Block(b)`가 그 위의 얇은 래퍼가 된다. `Debounce`/`Throttle`이 이걸로 자기 Blocker를 조종한다 | -| `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. **⚠️ [2026-08-24 6라운드 `H-32`/`H-33`] 7절 의사코드에 무효화 배너가 붙었다 — 그 골격은 확정된 `state:Gate(setup)` API로는 성립하지 않아 재작성 대상이다.** 새 모델은 `Debounce`/`Throttle`이 **emit을 아예 안 쥐고** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) `On()`/`Off()` 시점만 정하는 것 — `pending`도 Blocker의 `HasBlockedEmit`으로 흡수되어 `Trailing=false`+`MaxTime`에서 `Flush`가 영구 no-op이던 결함(`H-32`)이 구조적으로 사라진다. 창/타이머 정책 자체(`openWindow`/`onWindowEnd`/`MaxTime` 분기)는 그대로 유효. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:Callback`) 재실행+cleanup 체이닝(React `useEffect` 동형). **[2026-08-24 표기 정정]** 여기 원래 *"내부적으로 `state:Observer(...)`를 조합"*이라 적혀 있었는데 그건 단수 시절 모델이고, 같은 행 뒤쪽의 `C-6` 서술과 어긋났다. Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`). **[2026-08-24 6라운드]** `fn` 시그니처가 **`fn(self: EffectHandle) -> (() -> ())?`**로 확정(**`...deps`는 의존성 선언일 뿐 `fn`에 안 넘어간다**, `H-14`), `_observer`(단수)→`_observers`(배열)(`H-8`), 그리고 **leaf 사망 cleanup을 실제로 발화시키는 배선이 없던 것**이 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 `Destroying`을 걸고/끊는 것으로 닫힘(`H-11` — 이게 없어서 `slot._detachCleanup`과 `OnDestroyed`가 통째로 무동작이었다). `Ref` dep 콜백은 해제 경로(`:Uncallback`)와 **발화 시 `canExecute` 확인**을 둘 다 갖는다(`H-7`) **⭐⭐ [2026-08-25 7라운드 재설계]** dep 등록이 **생성자 한 곳**으로 모이고(`:WeakCallback`/`:WeakSubscribe` — `Weak` 쪽이 프리미티브), 강한 주인이 **`_deps` 하나**로 통합됐다(옛 `_observers`/`_refDeps`/`_refCallbacks`/`_installing` 전부 폐기, 억제는 사적 `Blocker`). `bindLifetime`/`unbindLifetime`은 **핸들 하나에만** 적용되고 내부 Observer로 cascade하지 않으며, `Ref` 콜백을 떼지도 않는다 — 발화 게이팅은 `canExecute(handle)` 하나(`H-58`/`H-59`). `Ref`가 `Epoch`로 승격돼 `_epochs`가 dep 종류를 균일하게 담고, 포탈 캐치업이 `if not self._installed or self._epochs:Refresh() then self:Rerun() end` 한 줄이 됐다(`H-64`/`H-65` — cleanup 반환이 **선택**이라 `_cleanup` 유무로는 설치 여부를 못 판정한다). **`:Rerun()` 정의 신설**(재진입은 지연 재실행, error는 UB) 및 `:_consumeCleanup()`(읽고→지우고→실행), 값 교체 retract가 cleanup을 소진 호출(`H-57`), deps 검증(`nil`/이물 error, 중복 무시). 재사용은 `Clone`/`Userdata`가 아니라 **`({...}) -> Effect` 팩토리 패턴** | +| `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. **⚠️ [2026-08-24 6라운드 `H-32`/`H-33`] 7절 의사코드에 무효화 배너가 붙었다 — 그 골격은 확정된 `state:Gate(setup)` API로는 성립하지 않아 재작성 대상이다.** 새 모델은 `Debounce`/`Throttle`이 **emit을 아예 안 쥐고** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) `On()`/`Off()` 시점만 정하는 것 — `pending`도 Blocker의 `HasBlockedEmit`으로 흡수되어 `Trailing=false`+`MaxTime`에서 `Flush`가 영구 no-op이던 결함(`H-32`)이 구조적으로 사라진다. 창/타이머 정책 자체(`openWindow`/`onWindowEnd`/`MaxTime` 분기)는 그대로 유효. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) **⚠️⚠️ [2026-08-26 정정, 8라운드 `H-118`]** 이 셀이 "새 모델"이라 부르는 문장은 **두 겹으로 낡았다** — (1) *"`pending`도 `HasBlockedEmit`으로 흡수"*는 7라운드 `H-86`이 이미 뒤집었다(실제 통로는 `emit()`의 **반환값**), (2) *"`emit`을 아예 안 쥐고"*라는 머리 문장 자체가 8라운드에 폐기됐다 — `setup(emit)`이 계약이라 **`emit`은 정의상 정책 손에 있고**, Blocker에 위임되는 건 *"emit된 적 있던가"의 부기*뿐이다. 경로가 둘이다: 상류 emit 도착은 `pass()`, 타이머/제어 핸들의 flush·버리기·조회는 `emit()`/`emit(false)` 직접 호출. 최신 서술은 `base/debounce-throttle-plan.md`와 `base/gate-plan.md` 5번이 소스. | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:Callback`) 재실행+cleanup 체이닝(React `useEffect` 동형). **[2026-08-24 표기 정정]** 여기 원래 *"내부적으로 `state:Observer(...)`를 조합"*이라 적혀 있었는데 그건 단수 시절 모델이고, 같은 행 뒤쪽의 `C-6` 서술과 어긋났다. Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`). **[2026-08-24 6라운드]** `fn` 시그니처가 **`fn(self: EffectHandle) -> (() -> ())?`**로 확정(**`...deps`는 의존성 선언일 뿐 `fn`에 안 넘어간다**, `H-14`), `_observer`(단수)→`_observers`(배열)(`H-8`), 그리고 **leaf 사망 cleanup을 실제로 발화시키는 배선이 없던 것**이 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 `Destroying`을 걸고/끊는 것으로 닫힘(`H-11` — 이게 없어서 `slot._detachCleanup`과 `OnDestroyed`가 통째로 무동작이었다). `Ref` dep 콜백은 해제 경로(`:Uncallback`)와 **발화 시 `canExecute` 확인**을 둘 다 갖는다(`H-7`) **⭐⭐ [2026-08-25 7라운드 재설계]** dep 등록이 **생성자 한 곳**으로 모이고(`:WeakCallback`/`:WeakSubscribe` — `Weak` 쪽이 프리미티브), 강한 주인이 **`_deps` 하나**로 통합됐다(옛 `_observers`/`_refDeps`/`_refCallbacks`/`_installing` 전부 폐기, 억제는 사적 `Blocker`). `bindLifetime`/`unbindLifetime`은 **핸들 하나에만** 적용되고 내부 Observer로 cascade하지 않으며, `Ref` 콜백을 떼지도 않는다 — 발화 게이팅은 `canExecute(handle)` 하나(`H-58`/`H-59`). `Ref`가 `Epoch`로 승격돼 `_epochs`가 dep 종류를 균일하게 담고, 포탈 캐치업이 `if not self._installed or self._epochs:Refresh() then self:Rerun() end` 한 줄이 됐다(`H-64`/`H-65` — cleanup 반환이 **선택**이라 `_cleanup` 유무로는 설치 여부를 못 판정한다). **`:Rerun()` 정의 신설**(재진입은 지연 재실행, error는 UB) 및 `:_consumeCleanup()`(읽고→지우고→실행), 값 교체 retract가 cleanup을 소진 호출(`H-57`), deps 검증(`nil`/이물 error, 중복 무시). 재사용은 `Clone`/`Userdata`가 아니라 **`({...}) -> Effect` 팩토리 패턴** **⭐⭐ [2026-08-26, 8라운드 `H-107`/Q2-후속]** dep 종류별로 **클로저를 따로** 단다(`onRefFire(_, ref)` / `onStateFire(_, _, from)`) — 여기 있던 *"클로저는 **하나**로 통일한다"*는 근거 없는 서술이라 삭제됐다. **사용자 확정**: *"observer 에는 epoch 란게 존재하지 않음. emit 으로 온 epoch 를 넘겨줄 뿐, 그러나 ref 는 그 자체로 epoch임"* — 두 콜백은 이질적이라 애초에 통합 대상이 아니었고, dedup은 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다. | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) **[2026-08-24 6라운드]** `TagHandler`가 **자기 배열 자리의 `setOffsetSource`/`setLength`를 아예 등록하지 않던 것**을 정정(`H-39` — `Frame { Tag("card"), TextLabel{} }`처럼 Tag를 자식보다 앞에 두는 흔한 배치가 첫 `recompute`에서 error로 죽었다). `isHandlable`에 **`type(k) == "number"` 가드**도 추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이쪽은 못 받고 있었다) | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) **[2026-08-24 6라운드]** `AttributeGroupHandler`에 (1) 배열 자리 부기 등록(`H-39`, Tag와 같은 결함 — 층위 예외를 두지 않고 다른 말단 핸들러와 똑같이 등록한다), (2) `type(k) == "number"` 가드(`H-52`), (3) **`groupClaimKeys` 위치 claim 배선**(`H-41` — 5라운드 `AT-1`에서 키를 확정해놓고 의사코드에 안 들어가 있었다, `nameClaims`보다 **먼저** 해야 절반만 기록되는 중간 상태가 안 생긴다). 그리고 **attribute 이름을 서로 다른 두 자리 사이에서 옮기는 것은 UB로 확정**(`H-18`/`H-45` — 두 체인이 별개라 emit 순서에 따라 성공하거나 크래시하는데, 사용자 판단: 막으려면 process/retract 계약 전체에 예외가 생겨 오버엔지니어링) | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 **[2026-08-24 6라운드 `H-27`]** `process`에 **`v == nil` 얼리리턴**을 추가 — 이 문서가 스스로 *"`event-plan.md`와 같은 결"*이라 결론냈는데 그 "같은 결"의 핵심(`v=nil`이면 해제만 하고 새로 Connect하지 않는다)이 의사코드에 없었다. 없으면 `State`를 `None`으로 꺼서 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로 심는** 동작이 된다 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정. **[2026-08-24 6라운드]** `.Callbacks`가 배열에서 **`{[callback|thread] = true}` 해시맵 셋**으로 재설계되고 **해제 경로 `:Uncallback(fn)`**이 신설됨(`H-7` — `Effect`의 `Ref` dep을 뗄 방법이 아예 없던 갭). 중복 등록은 dedup이 계약이고, 발화는 **순회 전 스냅샷**을 뜬다(`H-23`과 같은 처방). 그 부수로 **"구멍 있는 테이블에서 `#t` border" 실측 항목이 폐기**됐다(해시맵엔 border 개념이 없음). `RefLeafHandler`가 자기 배열 자리의 `setOffsetSource`/`setLength`를 등록하게 된 것(`H-39`)과 `PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부라는 것(`H-42`, Deferred에선 그 레이스가 없음 — 구조는 유지)도 같이 반영. **⭐⭐ [2026-08-25 7라운드]** `Ref`가 **`Epoch`를 만족**하게 됨(공개 `.Revision` + `EpochBrand` 등록 — `Effect._epochs`가 State/Source/`Ref`를 균일하게 담게 되어 포탈 캐치업 비대칭(`H-64`)과 같은 `Ref` 중복 dep(`H-70`)이 같이 닫힘), **`:WeakCallback(fn)` 신설**(약한 등록이 프리미티브이고 `:Callback`은 거기에 "GC 킵"을 얹은 것), 그리고 **`unbindLifetime`/`:Unsubscribe()`가 더 이상 `:Uncallback`을 안 부른다**(`H-58` — 그 떼었다 붙이는 춤이 바인드마다 `Rerun`을 돌리던 원인, `:Uncallback`은 사용자 표면으로만 남음). `RefLeafHandler`의 dedup 기록도 `SetStrong`→**`SetWeak`**(`H-71` — 값이 자기 키를 되참조해 100% 누수) | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정. **[2026-08-24 6라운드]** `.Callbacks`가 배열에서 **`{[callback|thread] = true}` 해시맵 셋**으로 재설계되고 **해제 경로 `:Uncallback(fn)`**이 신설됨(`H-7` — `Effect`의 `Ref` dep을 뗄 방법이 아예 없던 갭). 중복 등록은 dedup이 계약이고, 발화는 **순회 전 스냅샷**을 뜬다(`H-23`과 같은 처방). 그 부수로 **"구멍 있는 테이블에서 `#t` border" 실측 항목이 폐기**됐다(해시맵엔 border 개념이 없음). `RefLeafHandler`가 자기 배열 자리의 `setOffsetSource`/`setLength`를 등록하게 된 것(`H-39`)과 `PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부라는 것(`H-42`, Deferred에선 그 레이스가 없음 — 구조는 유지)도 같이 반영. **⭐⭐ [2026-08-25 7라운드]** `Ref`가 **`Epoch`를 만족**하게 됨(공개 `.Revision` + `EpochBrand` 등록 — `Effect._epochs`가 State/Source/`Ref`를 균일하게 담게 되어 포탈 캐치업 비대칭(`H-64`)과 같은 `Ref` 중복 dep(`H-70`)이 같이 닫힘), **`:WeakCallback(fn)` 신설**(약한 등록이 프리미티브이고 `:Callback`은 거기에 "GC 킵"을 얹은 것), 그리고 **`unbindLifetime`/`:Unsubscribe()`가 더 이상 `:Uncallback`을 안 부른다**(`H-58` — 그 떼었다 붙이는 춤이 바인드마다 `Rerun`을 돌리던 원인, `:Uncallback`은 사용자 표면으로만 남음). `RefLeafHandler`의 dedup 기록도 `SetStrong`→**`SetWeak`**(`H-71` — 값이 자기 키를 되참조해 100% 누수) **⭐⭐ [2026-08-26, 8라운드 `H-107`/`H-108`/`H-120`]** 콜백 시그니처가 **`fn(value, ref)`**가 됐다 — 두 번째 인자가 그 `Ref` 자신(= `Epoch`)이고, 이게 `Effect`가 `Update(from)`에 넘길 유일한 통로다(`k(value)`뿐이면 `Update(nil)` 크래시, 실측 재현). `:Set`의 확정 의사코드도 재작성 — 순서는 **값 → `Revision` → 콜백**이고 순회 대상은 `.Callbacks`(강)와 **`.WeakCallbacks`**(weak-키, 이때 이름을 붙였다) 두 테이블이다. ⚠️ *"등록 즉시 1회 호출(nil이어도)"* 계약 때문에 **default 없는 `Ref()`에 콜백을 걸면 생성 시점에 `fn(nil, ref)`가 한 번 불린다**. | | `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | | `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). **[2026-08-21 전면 재작성]** 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, **다중 태깅 허용**)로 바뀜 — 발단은 `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는데 옛 모양으로는 표현이 안 되던 것. 역조회는 없어졌고(멤버십 질문만 씀), weak-key·테이블 아이덴티티·duck-typing 기각 근거·포함 관계 predicate 합성은 전부 유지. 역전 원문은 `archive/brand-shared-registry-reversed.md`, 근거 기록은 `reference/epoch-brand-composition.md`. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `None` 특수 분기 안은 기각(`isNone`은 그냥 `v == None`) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) **[2026-08-24 6라운드 `H-24`]** `:Mapped` 절에 타입 단서 추가 — 그 시그니처는 재귀 제네릭 누수 패턴이라 **인라인이 아니라 `typeof(named function)`으로 선언해야** 체커가 지켜준다(`base/typing-limits.md`). "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는 뜻이 아니었다 | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 **⚠️ [2026-08-24 6라운드 `H-26`] 미해결 항목이 하나 신설됐다** — **실패 이전에 생성된 부분 트리는 회수되지 않는다.** quad Instance는 gcconn 때문에 `Destroy`로만 회수되는데, 컴포넌트가 리터럴을 만들다 던지면 그때까지 완성된 형제/자손이 트리에 붙지도 파괴되지도 않은 채 사라지고 `Fallback`은 그 존재를 알 방법이 없다. **`Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는 대표 사용처**라 층위가 다르다 — **백로그**(그 둘이 슈가라 구현 시점에 같이 다룬다, 그 문서의 ⚠️ 절이 소스) | -| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | +| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. | | `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 | | `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 | diff --git a/.claude/audit/handtrace-round7-reference-impl/README.md b/.claude/audit/handtrace-round7-reference-impl/README.md index 001b8da..1699afa 100644 --- a/.claude/audit/handtrace-round7-reference-impl/README.md +++ b/.claude/audit/handtrace-round7-reference-impl/README.md @@ -23,6 +23,26 @@ (타입 11개). 2026-08-25 실행분 그대로이고, 재실행이 이와 달라지면 그 자체가 조사 대상이다. +## ⚠️⚠️ [2026-08-26] 이 전사물은 **8라운드 이전 계약**이다 + +8라운드(`qa-request/pre-implementation-handtrace-round8.md`)가 바로 이 +전사물이 돌지 **않은** 경로들에서 결함을 찾아냈고, 그 결과 **여기 옮겨진 +계약 몇 개가 바뀌었다**(결정의 소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`). 이 폴더는 +2026-08-25 실측의 기록이라 **본문을 소급 수정하지 않는다** — 대신 재실행할 때 +아래를 알고 볼 것. **바뀐 계약을 이 코드로 "재확인"하지 말 것.** + +| 여기 있는 것 | 지금 확정된 것 | 근거 | +|---|---|---| +| `core.luau`의 `Observer:_receive`가 `self.fn(self, from)` — **2-인자**. `t9_gate_chain.luau` 등 Observer를 쓰는 스파이크 전부가 이 위에서 돈다 | **3-인자** `fn(targetState, self, emitFrom)`, 루프는 `sub.fn(sub._state, sub, from)`. `observer._state` 강참조 신설 | `H-109`/`H-110` | +| `core.luau`의 State가 `rawInvalid: boolean` | **캐시 카운터 쌍**(`cacheTargetCount`/`cacheCurrCount`) — 7라운드 `H-85`가 이미 교체했는데 전사물이 옛 필드로 남았다. `t5_recompute_race`/`t5b_fix`는 **의도적**으로 이 필드의 버그·기각안을 보여주는 것이라 예외 | `H-85`(7라운드) | +| `core.luau`/`dispatch.luau`의 부기가 **단일 `bk.invalidAfter`** | **두 필드로 갈라졌다** — `bk.offsetCacheValidUpTo`(캐시)와 `bk.offsetSetUpTo`(`:Set` 완료). 옛 단일 필드가 두 뜻을 겸한 게 되감기 신호가 지워지는 원인이었다 | `/code-review high` 4차 | +| `d7_splice_fix.luau:14`의 splice 무효화가 `math.min(bk.invalidAfter, index)` | splice는 **`index - 1`**(커서 위치 splice가 "변경 없음"과 구분이 안 된다). `dispatch.luau:84`의 `setLength`용 `math.min(…, i)`는 **정정 대상이 아니다** — 그쪽 공식은 원래 `i`다 | `H-113` | +| `Ref`를 쓰는 자리의 콜백 호출 | `fn(value, ref)` — 두 번째 인자가 `Ref` 자신(= `Epoch`). `:Set` 순서는 **값 → `Revision` → 콜백**, 순회는 `.Callbacks` + `.WeakCallbacks` | `H-107`/`H-108` | + +**`d7_splice_fix.luau`의 결론(`H-102`가 토큰 역참조로 닫힌다)은 영향받지 +않는다** — 그 테스트는 recompute가 끝난 뒤 splice하는 시나리오라 `H-113`이 +문제 삼은 "루프 커서 위치에서의 splice" 경계를 애초에 안 밟는다. 공식만 낡았다. + --- ## 참조 구현 3개 — 원문 대조표 diff --git a/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau b/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau index 5867363..bd55901 100644 --- a/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau +++ b/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau @@ -1,4 +1,11 @@ --!strict +-- ⚠️ [2026-08-26, 8라운드 `H-121`] 이 파일이 모델링한 "With로 모은 값이 +-- 콜백에 포지셔널로 넘어온다"는 **quad의 확정 콜백 계약이 아니다.** +-- 확정 계약은 `fn(self, previous?, ...trailingDeps)`이고, `:With`로 모은 +-- 값은 **클로저로 직접 읽는다**(`base/source-state-plan.md`). +-- 이 스파이크의 측정값(타입 추론 자체)은 그대로 유효하지만, 여기 적힌 +-- 호출 모양을 "확정 관용구"로 재인용하지 말 것 — `slot-plan.md`의 그 +-- 예시는 같은 라운드에 교정됐다. -- slot-plan.md 914행 실제 코드: layoutOrder:With(offset):Compute(function(i, o) -- return i:Get() + o:Get() end) -- With로 모은 뒤 Compute 콜백이 여러 개의 -- lazy 핸들을 무주석으로 받는 실사용 패턴을 split 트릭으로 재현. diff --git a/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau b/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau index 75a5709..767e398 100644 --- a/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau +++ b/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau @@ -1,4 +1,10 @@ --!strict +-- ⚠️ [2026-08-26, 8라운드 `H-121`] 이 파일이 모델링한 "With로 모은 값이 +-- 콜백에 포지셔널로 넘어온다"는 **quad의 확정 콜백 계약이 아니다.** +-- 확정 계약은 `fn(self, previous?, ...trailingDeps)`이고, `:With`로 모은 +-- 값은 **클로저로 직접 읽는다**(`base/source-state-plan.md`). +-- 이 스파이크의 측정값(타입 추론 자체)은 그대로 유효하지만, 여기 적힌 +-- 호출 모양을 "확정 관용구"로 재인용하지 말 것. -- 23과 동일하지만 진짜 이형 타입(T=number, D=string)으로 확인. export type StateData = { diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 39a5e9a..614ec89 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -264,14 +264,14 @@ quad/ │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속 │ ├── Observer.luau # ⭐ [2026-08-25 신설, 7라운드 `H-99`] `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` 전역 레지스트리의 소유 모듈** — `EpochMap.luau`와 같은 이유로 `State.luau`에 묻지 않는다(`Effect`/`Gate`/leaf 핸들러가 전부 이 레지스트리를 본다) │ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`) -│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReserved`(`base/store-plan.md`) +│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReservedKeys>`(**[2026-08-26 `H-112`]** 옛 이름 `CheckReserved`는 `T`를 통째로 받아 실사용 `T`에서 안 돌았다 — `base/store-plan.md`) │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate`의 `GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음 │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`) │ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`) │ ├── AttributeKey.luau # 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치) │ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) -│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) +│ ├── Effect.luau # `Effect(fn, ...deps)` — deps 없으면 설치1회+leaf사망시 정리, 있으면 dep마다 약하게 등록(State/Source면 `:WeakSubscribe`, `Ref`면 `:WeakCallback`)해 재실행. **[2026-08-26 `H-107`]** 등록 클로저는 dep 종류별로 **둘**(`onRefFire`/`onStateFire` — 두 콜백 계약의 자리 수가 다르다), 강한 주인은 항상 `_deps`(`base/effect-plan.md`) │ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만 │ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨** — `InitDebug(module)`, `module.debug = false`(`base/project-setup-plan.md`) │ ├── Dispatch/ diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index ee01871..335b624 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -686,8 +686,10 @@ export type Timeout = { 호출 지점마다 캐스트를 흩뿌리는 방식은 나중에 누가 다른 필드를 더 끼워넣어도 아무도 모르는 게 문제였음 — quad가 타입 검사를 조용히 끄는 걸 싫어해온 것(`base/typing-limits.md`)과 결이 맞는 쪽으로 정리됨. -- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observers`, - `slot._mountedInst`, `_fired`)와 일치. +- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._deps`, + `slot._mountedInst`, `_fired`)와 일치(**[2026-08-26 표기 정정]** 여기 예시가 + `handle._observers`였는데 그 필드는 7라운드 `H-58`에 폐기됐다 — `_deps` + 하나로 통합, `base/effect-plan.md`). - **`_native`에 뭘 담을지는 전적으로 백엔드 자유** — coroutine 하나일 수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는 엔진" 절). base는 이 필드를 **읽지도 쓰지도 않고** 그저 `setTimeout`이 @@ -815,9 +817,18 @@ function clearTimeout(timeout: Timeout) timeout._native() end > `Handle:Set({ Flush = gate._flush, ... })`는 표현 자체가 불가능하다. > > **다시 쓸 방향은 정해져 있다**(사용자 확정 2026-08-24, -> `base/gate-plan.md`의 5번 항목이 소스): `Debounce`/`Throttle`은 **`emit`을 -> 아예 안 쥔다.** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) -> **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는 +> `base/gate-plan.md`의 5번 항목이 소스. **⚠️ [2026-08-26 표기 갱신, 8라운드 +> `H-118`]** 여기 한때 *"`Debounce`/`Throttle`은 **`emit`을 아예 안 쥔다**"*로 +> 시작했는데 **그 문장은 그 사이 `gate-plan.md` 5번에서 폐기됐다** — +> `setup(emit)`이 곧 계약이라 `emit`은 **정의상 정책 손에 있고**, 정책은 그걸 +> `b:Policy(emit)`에 넘겨야 배선이 성립한다. 위임되는 건 `emit`이 아니라 +> **"emit된 적 있던가"의 부기**다. 그대로 두면 이 문서 자신의 7·8절이 확정한 +> **타이머 경로의 `emit()`/`emit(false)` 직접 호출**(`H-55`/`H-86`)과 서로 +> 모순되는 것처럼 읽힌다. 경로는 둘이고 각자 몫이 있다 — 상류 emit 도착은 +> `pass()`, 타이머/제어 핸들의 flush·버리기·조회는 `emit()` 직접 호출): +> `Debounce`/`Throttle`은 **보류 판정·`pending` 부기를 직접 구현하지 +> 않는다.** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) +> **언제 `On()`/`Off()`할지** 정하며, 상류 emit이 도착하는 경로의 발화/보류는 > `blocker:Policy(emit)`이 돌려준 핸들에 위임한다: > > ```lua diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index a0f19d3..3e28259 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -583,7 +583,11 @@ end 2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** — 진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, **`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함) - `:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 — + `:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 + (**⭐ [2026-08-26, `/code-review high` 4차] 이 호출도 `H-119`의 재진입 + 게이트를 탄다** — `bk.recomputeBlocker:IsOn()`이면 건너뛴다. 사용자 코드가 + 같은 `inst`에 재디스패치를 내면 중첩 `recompute`가 완주하며 바깥의 + `offsetSetUpTo`를 지우고 차단기를 끄는, `raw*` 삭제와 **똑같은** 구멍이었다) — 상세 근거·`setLength`/`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. - **왜 "배열 파트"가 아니라 "`drive` 전체"인가**: 옛 문장은 *"배열 파트 @@ -1492,17 +1496,31 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 — `Relate(parentInst)`에 lazy 생성. **⭐ [2026-08-24 보강, 6라운드 손 트레이싱 `H-4`] 접두합 캐시 두 필드도 여기 -소속이고, 초기값을 명시한다.** `offsetCache`/`invalidAfter`가 이 열거에 +소속이고, 초기값을 명시한다.** `offsetCache`/`offsetCacheValidUpTo`/`offsetSetUpTo`가 이 열거에 빠져 있어서 스펙상 존재하지 않는 필드였고, 초기값도 안 적혀 있었다. `bk.N`이 `nil`로 시작하는 lazy 규칙(그래서 `recompute`가 `bk.N or 0`으로 -방어한다)을 그대로 따르면 `invalidAfter`도 `nil`인데, `getOffsetAt`은 -`if bk.invalidAfter == 0 then`으로 시작한다 — `nil == 0`은 거짓이라 다음 줄 -`at <= bk.invalidAfter`에서 **`attempt to compare number with nil`**로 죽는다. +방어한다)을 그대로 따르면 `offsetCacheValidUpTo`도 `nil`인데, `getOffsetAt`은 +`if bk.offsetCacheValidUpTo == 0 then`으로 시작한다 — `nil == 0`은 거짓이라 다음 줄 +`at <= bk.offsetCacheValidUpTo`에서 **`attempt to compare number with nil`**로 죽는다. **첫 position의 `setOffsetSource`가 바로 이 경로**라 fresh `bk`에서 반드시 밟는다. -- **`getBookkeeping`이 `bk`를 만들 때 `offsetCache = {}`, `invalidAfter = 0`으로 - 초기화한다.** (`bk.N`만 `nil` 시작을 유지한다 — 그쪽은 `or 0` 방어가 이미 - 자리를 잡았고 "아직 아무 자리도 등록 안 됨"과 "0번까지 유효"가 다른 뜻이다.) +- **⭐ [2026-08-26 명문화, `/code-review high` 5차] `getBookkeeping(ownerKey)`은 + **절대 `nil`을 돌려주지 않는다** — `Relate(ownerKey)` 기반 **lazy 생성**이라 + 없으면 그 자리에서 만든다. 그래서 호출부의 `if bk then` 가드는 흔적이고, + 같은 파일 안에서 어떤 자리는 가드하고 어떤 자리는 안 하는 불일치를 만든다. + **가드를 두지 말 것.** +- **`getBookkeeping`이 `bk`를 만들 때 `offsetCache = {}`, `offsetCacheValidUpTo = 0`, + `offsetSetUpTo = 0`, `recomputeBlocker = Blocker()`으로 초기화한다.** + (**[2026-08-26]** `offsetCacheValidUpTo`은 같은 날 `offsetSetUpTo`에서 갈라져 나온 + 필드다 — 아래 "두 필드" 절.) (`bk.N`만 `nil` 시작을 + 유지한다 — 그쪽은 `or 0` 방어가 이미 자리를 잡았고 "아직 아무 자리도 등록 + 안 됨"과 "0번까지 유효"가 다른 뜻이다.) + **⭐ [2026-08-26 추가, `/code-review high`] `recomputeBlocker`가 이 열거에 + 빠져 있었다** — `H-101`이 나중에 도입했는데 생성 규칙이 어디에도 없었고, + `H-119`가 `base/slot-plan.md`에 `bk.recomputeBlocker:IsOn()` 역참조를 네 개 + 더 늘렸다. `_baseObserver`의 등록 즉시 1회 발화는 `getBlocker(slot):IsOn()`이 + 참이라 `or` 단락으로 **우연히** 살아나지만, 그 우연에 기대고 있었다 — + 이 절이 `offsetSetUpTo`에 대해 잡아낸 것과 정확히 같은 종류의 nil 역참조다. **[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner 타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔 @@ -1661,21 +1679,23 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 ` function Dispatch.getOffsetAt(ownerKey, at) local bk = getBookkeeping(ownerKey) -- [2026-08-21 사용자 제안, 같은 날 의사코드 정정] **단일 함수 + 접두합 캐시.** - -- `bk.offsetCache[i]` = i 자리의 절대 offset, `bk.invalidAfter` = **여기까지는 + -- `bk.offsetCache[i]` = i 자리의 절대 offset, `bk.offsetCacheValidUpTo` = **여기까지는 -- 캐시가 유효**(그 뒤부터 다시 누적해야 함). 함수를 둘로 나누지 않는다 — -- 이 하나가 필요한 만큼만 앞으로 이어붙이므로, 순차 호출이면 한 칸씩만 -- 늘어나 전체가 O(N)이 된다(사용자: *"그러면 알아서 순차적으로 합캐시가 -- 처리됨"*). - if bk.invalidAfter == 0 then + -- ⭐⭐ [2026-08-26 재작성, `/code-review high` 4차] 이 함수는 **`offsetCacheValidUpTo`만 + -- 만진다 — `bk.offsetSetUpTo`는 건드리지 않는다.** 아래 "두 필드" 절이 소스. + if bk.offsetCacheValidUpTo == 0 then -- 시작점 — 1번 자리의 offset은 이 owner의 베이스 그 자체. bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 - bk.invalidAfter = 1 + bk.offsetCacheValidUpTo = 1 end - if at <= bk.invalidAfter then + if at <= bk.offsetCacheValidUpTo then return bk.offsetCache[at] -- 유효 구간 — O(1) end - local cur = bk.offsetCache[bk.invalidAfter] - for i = bk.invalidAfter, at - 1 do + local cur = bk.offsetCache[bk.offsetCacheValidUpTo] + for i = bk.offsetCacheValidUpTo, at - 1 do -- ⭐ [2026-08-25, 7라운드 `H-106`] `nil` 가드 — `recompute`만 갖고 있던 -- `C-6` 진단이 이 경로에선 우회돼 익명 산술 에러로 먼저 터졌다. if bk.lengthList[i] == nil then @@ -1684,26 +1704,77 @@ function Dispatch.getOffsetAt(ownerKey, at) cur += contribution(bk, i) -- lengthList[i](State면 :Get()) bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다** end - bk.invalidAfter = at -- 여기까지 유효해짐 + bk.offsetCacheValidUpTo = at -- 캐시가 여기까지 유효해짐 return cur end ``` -**⭐ [2026-08-21] 캐시 무효화 — 규칙이 하나다** +### ⭐⭐ [2026-08-26 신설] 두 필드 — `offsetCacheValidUpTo`와 `offsetSetUpTo` -`bk.invalidAfter`는 **"이 인덱스까지는 캐시가 유효"**를 뜻하고, 무효화는 전부 -같은 모양이다 — **`bk.invalidAfter = math.min(bk.invalidAfter, i)`**(앞으로만 -당긴다): +**`H-101`이 *"되감기 신호는 `bk.invalidAfter` 하나로 통일한다 — 새 필드를 안 +만든다. 두 뜻('캐시가 여기까지 유효'와 '여기 다음부터 다시 해야 함')이 실제로 +같은 것이기 때문"*이라고 확정했는데, 그 전제가 틀렸다**(사용자 진단, +`/code-review high` 4차): *"캐시와 컴퓨팅 위치를 같이 둔 것이 폭탄이였는듯 … +지금의 큰 문제는, **Set을 해줬느냐**와 **캐시가 유효하지 않느냐**라는 다른 +목적의 값을 같은 값이 쥐고 있음."* + +| 필드 | 뜻 | 올리는 쪽 | 내리는 쪽 | +|---|---|---|---| +| **`bk.offsetCacheValidUpTo`** | `offsetCache`가 **여기까지 정확**하다 | `getOffsetAt`이 채운 만큼 (어디서 불리든) | 무효화 사이트 전부(아래 표) | +| **`bk.offsetSetUpTo`** | 여기까지는 offset `Source`에 **`:Set`을 마쳤다** | **`recompute`만** (매 반복 + 꼬리) | 무효화 사이트 전부(아래 표) | + +- **무효화(구조 변경)는 둘 다 내린다** — 자리가 바뀌면 캐시도 낡고 `Set`도 + 다시 해야 한다. 아래 무효화 표의 인덱스는 **두 필드에 똑같이** 적용된다. +- **`getOffsetAt`은 `offsetCacheValidUpTo`만 올린다 — 어디서 불려도 안전하다.** + 그 함수가 실제로 캐시를 그 지점까지 정확히 채우고 나서 올리기 때문이다. + 캐시를 복원하는 건 그 함수의 + 일이지만, **누가 `Set`을 받았는지는 모른다.** +- **⭐ `offsetSetUpTo`를 올리는 건 `recompute` **하나뿐**이다.** 되감기 판정도 + 이 필드만 본다. **버그의 원인이 정확히 "`recompute` 밖에서 이 값이 + 올라가는 것"이었으므로, 이 배타성이 이 분리의 핵심이다.** + +**왜 갈라야 하는가 — 겹쳐 두면 되감기 신호가 조용히 지워진다.** 한 필드일 때: +바깥 `recompute`가 커서 `i`를 돌던 중 `offset:Set(abs)`가 사용자 코드를 +돌리고, 그 코드가 `slot:Remove(j)`(j **⚠️⚠️ [2026-08-26 정정, `/code-review high`] 이 항목이 근거로 들던 + > *"`bindLifetime`은 이미 핸들의 내부 Observer로 cascade한다"*는 **거짓이다.** + > `H-58`(2026-08-25)이 그 cascade를 폐기했다 — + > `base/lifecycle-pattern.md`의 `bindLifetime` 의사코드가 *"내부 Observer로 + > **cascade하지 않는다**"*고 명시하고, 그대로 두면 **바인드마다 `Rerun`이 + > 도는 `H-58`이 되살아난다**(dep 등록은 생성자에서 한 번만, 발화 게이팅은 + > 전부 `canExecute(handle)`). 8라운드 `H-114` 반영 때 옛 필드명 + > `handle._observers`를 `_deps`로 **이름만** 고치는 바람에, 폐기된 동작 + > 주장이 오히려 갓 정비된 것처럼 보이게 됐다. **결론(`Destroying` 배선을 + > `bindLifetime` 옆에 둔다)은 그대로 유효하다** — 근거만 바뀐다: 그 함수가 + > `isEffect`를 보고 `_bindDestroying`을 부르는 훅 자리이기 때문이다(`H-11`). - **한때 근거로 든 *"게이트는 값 타입을 안 가린다"*는 이 자리에 안 맞는 인용이었다** — `base/source-state-plan.md`의 그 절이 말하는 건 **`canBound` 판정**이 `:Subscribe()`/`bindLifetime` 두 진입점에서 같다는 것이지, `bindLifetime`의 **부수 배선**이 값 종류를 못 본다는 게 아니다. - 실제로 그 함수는 이미 `Effect`면 내부 Observer로 cascade한다. -2. **`unbindLifetime`은 cleanup을 부르지 않는다.** `Destroying` 커넥션을 끊고 - **`Ref` 콜백도 같이 해제**하되(아래 `H-7` 절과 대칭), cleanup은 그대로 + 실제로 그 함수는 `isEffect(value)`를 보고 `_bindDestroying`을 부른다 + (**[2026-08-26 재정정, `/code-review high`]** 여기 한때 *"`Effect`면 내부 + Observer로 cascade한다"*고 적혀 있었으나 그 cascade는 `H-58`이 폐기했다 — + 위 ⚠️⚠️ 배너가 소스. 이 항목이 말하려는 것(그 함수가 값 종류를 본다)은 + `isEffect` 분기로 그대로 성립한다). +2. **`unbindLifetime`은 cleanup을 부르지 않는다.** + > **⚠️ [2026-08-26 정정, 8라운드 `H-114`] 아래 두 문장 중 "`Ref` 콜백도 + > 같이 해제" / "언마운트가 콜백을 떼고 재마운트가 다시 건다"는 **폐기됐다.** + > 하루 뒤(2026-08-25) `H-58`이 정반대로 확정했다 — 같은 파일의 + > `_unbindDestroying` 의사코드가 소스이고, 거기선 **`Ref` 콜백도 Observer도 + > 안 뗀다**(그래야 바인드마다 `Rerun`이 도는 걸 막는다). 살아 있는 것은 + > "cleanup을 안 부른다"는 이 항목의 제목뿐이다. + `Destroying` 커넥션을 끊고 + ~~**`Ref` 콜백도 같이 해제**하되(아래 `H-7` 절과 대칭)~~, cleanup은 그대로 남긴다 — `destroySlotTree`가 `_detachCleanup`을 `unbindLifetime`하며 달아둔 주석(*"이미 손으로 비웠으니 Effect는 할 일 없음"*)과 `E-11`(leaf 바인딩엔 `:Unsubscribe()`가 안 먹는다)이 그 전제 위에 서 있다. **이 계약을 명시한다** — 지금까진 어느 쪽도 안 적혀 있었다. - - **bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을 - 떼고 재마운트의 `bindLifetime`이 다시 건다(`_observers` cascade와 같은 결). + - ~~**bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을 + 떼고 재마운트의 `bindLifetime`이 다시 건다.~~ **[2026-08-26 폐기, + `H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는 + `H-64`의 **조건부 캐치업**(`_epochs:Refresh()`)이다. 3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 `Rerun`이 같은 자리를 읽게 된다. @@ -144,7 +167,10 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그 ``` Effect ──강──▶ _deps = { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect -Ref.Callbacks ──약──▶ fn (`ref:WeakCallback(fn)`) +Ref.WeakCallbacks ──약──▶ fn (`ref:WeakCallback(fn)`) + ⚠️ [2026-08-26 `/code-review high` 7차] 한때 여기 `Ref.Callbacks`(강한 셋)라 + 적혀 있었다 — 그대로 읽으면 `Effect`의 클로저가 강한 셋에 들어가 + **`Ref`가 그 `Effect`를 영원히 붙들어** `H-58`의 약한 설계가 통째로 죽는다. Observer 전역 레지스트리 ──약──▶ Observer (`observer:WeakSubscribe()`) 발화 게이트: 전부 `canExecute(handle)` 하나로 ``` @@ -192,27 +218,37 @@ function Effect(fn, ...) end -- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다. - -- ⭐ 클로저는 **하나**로 통일한다 — 아래 "공통 상류를 공유해도 한 - -- 파동에 fn은 한 번만" 절의 그 클로저다. `from`을 받아 - -- `_epochs:Update(from)`가 참일 때만 `Rerun`해야 다이아몬드 dedup이 - -- 산다(안 하면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` - -- 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). - -- 시그니처가 `(self, from)`인 것은 `state:Observer(fn)`의 확정 - -- 계약이다(`base/source-state-plan.md`). + -- ⭐⭐ [2026-08-26 재확정, 8라운드 `H-107`] dep 종류마다 **클로저를 + -- 따로** 단다. 여기 한때 "클로저는 하나로 통일한다"고 적혀 있었으나, + -- 그 통일을 시도할 근거 자체가 없었다 — **사용자 확정**: + -- *"Ref 의 callback 과 observer 의 콜백이 아주 헤테로지니어스한 + -- 개념이라, 둘을 전혀 합치고자 한 적 없고 … observer 에는 epoch 란게 + -- 존재하지 않음. emit 으로 온 epoch 를 넘겨줄 뿐, 그러나 ref 는 그 + -- 자체로 epoch임."* 실제로 두 계약은 자리 수부터 다르다 — + -- `Ref` 콜백은 `fn(value, ref)`(2번째가 곧 출처 `Epoch`), + -- Observer는 `fn(targetState, self, emitFrom)`(3번째가 출처). + -- ⚠️ dedup을 클로저 identity가 하는 게 아니다 — `_deps`(중복 dep 무시)와 + -- `_epochs`(다이아몬드 판정)가 한다. 그래서 클로저를 나눠도 + -- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다 + -- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 + -- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). self._blocker:On() - local function onDepFire(_, from) + local function fire(from) -- 공통 본문 if not canExecute(self) then return end -- 발화 게이트 if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저) if self._epochs:Update(from) then self:Rerun() end end - for d in seen do + local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처 + local function onStateFire(_, _, from) fire(from) end -- Observer: 3번째가 출처 + for d in pairs(seen) do -- [2026-08-26 `/code-review high` 7차] `for d in seen`은 + -- 테이블을 호출하려 들어 죽는다 if isRef(d) then - self._deps[d] = onDepFire -- ⭐ 강한 주인 = Effect - d:WeakCallback(onDepFire) -- Ref 쪽은 약함 + self._deps[d] = onRefFire -- ⭐ 강한 주인 = Effect + d:WeakCallback(onRefFire) -- Ref 쪽은 약함 else - local o = d:Observer(onDepFire) + local o = d:Observer(onStateFire) self._deps[d] = o -- ⭐ 강한 주인 = Effect o:WeakSubscribe() -- 전역 레지스트리는 약함 end @@ -392,7 +428,11 @@ end **확정: `Ref`에 콜백 해제 경로를 추가한다**(`base/ref-plan.md`가 소스). `EffectHandle`은 자기가 건 `Ref` 콜백 핸들을 들고 있다가, `unbindLifetime`과 -`:Unsubscribe()`에서 같이 해제한다 — `_observers`(State/Source dep)와 대칭이다. +`:Unsubscribe()`에서 같이 해제한다 — State/Source dep 쪽과 대칭이다 +(**[2026-08-26 표기 정정, `H-114`]** 옛 `_observers` 표기를 지웠다 — 지금은 +`_deps` 하나다. **⚠️ 다만 이 문단의 "`unbindLifetime`에서 해제"는 `H-58`이 +뒤집었다** — 언바인드는 아무것도 안 떼고, 억제는 `_blocker`가 한다. 살아 +있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다). **⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도 발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가 @@ -464,7 +504,7 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순 Observer까지 같이 풀어야 대칭이 맞음. **[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든 "`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는 - 틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용이고 leaf 경로와 + 틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와 무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는 결론은 그대로이고 오히려 근거가 더 직접적이 됨. - **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 @@ -587,7 +627,11 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다. 2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. - 3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복 + 3. **⚠️ [2026-08-26 정정, `/code-review high` 7차] "idempotent"는 폐기됐다** — + `Unsubscribe`는 이제 **fail-fast**다(약하게만 구독된 값이면 error, + `base/lifecycle-pattern.md`의 "(2) 전역 경로" 절. 6차가 같은 문장을 + 두 문서에서 지웠는데 **이 세 번째 사본을 놓쳤다**). 살아 있는 요구는 + 뒷부분뿐이다 — **이후 leaf가 실제로 죽어도 cleanup이 중복 호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔 `canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동 (전역=`Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로 @@ -696,8 +740,17 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만 `fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한 파동에 몇 개가 깨우든 첫 번째만 통과한다. + > **⛔⛔ [2026-08-26 폐기, 8라운드 `H-107`/Q2-후속] 아래 "공통으로 거는 + > 클로저" 한 벌은 옛 모델이다.** dep 종류마다 콜백 계약의 **자리 수가 + > 다르므로**(`Ref`는 `fn(value, ref)`로 출처가 2번째, Observer는 + > `fn(targetState, self, emitFrom)`로 3번째) 하나로 못 합친다 — + > `onRefFire(_, ref)` / `onStateFire(_, _, from)` 둘로 갈라졌다. 확정 + > 의사코드는 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절의 생성자 + > 블록이 소스. **이 절이 확정한 것 중 살아 있는 것은 `EpochMap` 하나로 + > 다이아몬드를 접는다는 결론과, 바로 아래 ⚠️의 순서 제약뿐이다**(그 + > 본문은 지금 `fire(from)` 공통 함수 안에 그대로 들어가 있다). ```lua - -- 각 dep의 내부 Observer가 공통으로 거는 클로저 + -- ⛔ 옛 모델(2026-08-21). 지금은 dep 종류별로 클로저가 둘이다. function(self, from) if not canExecute(handle) then return end -- 발화 게이트 if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정) diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 2500c27..6714640 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -378,9 +378,30 @@ end ```lua state:Block(b) == state:Gate(function(emit) return b:Policy(emit) end) ``` - - **`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다.** 자기 `Blocker`를 - 사적으로 하나 갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류 - 판정은 전부 Blocker에 위임한다. 동기 실행이라 같은 호출 안에서 정책이 + - **`Debounce`/`Throttle`은 보류 판정·`pending` 부기를 직접 구현하지 + 않는다 — `Blocker`에 위임한다.** + > **⚠️ [2026-08-26 정정, 8라운드 `H-118`]** 이 항목은 한때 + > *"`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다"*로 시작했는데 + > **그 문장이 틀렸다.** `setup(emit)`이 곧 계약이므로 **`emit`은 정의상 + > 정책 손에 있고**, 정책은 그걸 `b:Policy(emit)`에 넘겨야 배선이 + > 성립한다. 위임되는 것은 `emit` 자체가 아니라 **"emit된 적 있던가"의 + > 부기**다(사용자 정리: *"emit 을 blocker 가 쥔다는건 정확하게는, + > 'emit 된 적 있던가?' 를 저장하기 위함 … 그 구현을 나눠 쓰지 않기 + > 위함"*). 그래서 위 2번(`H-55`/`H-86`)이 확정한 **타이머 경로의 + > `emit()`/`emit(false)` 직접 호출과 이 항목은 충돌하지 않는다** — + > 경로가 둘이고 각각 자기 몫이 있다: + > | 경로 | 무엇을 부르나 | + > |---|---| + > | 상류 emit 도착(동기) | `pass()` — 보류 여부는 Blocker가 판정 | + > | 타이머/제어 핸들(flush·버리기·조회) | `emit()` / `emit(false)`, 반환값도 씀 | + > + > 두 경로가 같은 파동에 겹쳐도 안전하다 — 두 번째 flush는 **빈 배치 + > 얼리리턴**(아래 8번)에 걸려 아무것도 안 한다. + + 정책은 자기 `Blocker`를 + 사적으로 하나 갖고 **언제 `On()`/`Off()`할지** 정하며, 상류 emit이 + 도착하는 경로의 발화/보류 판정은 전부 Blocker에 위임한다. 동기 실행이라 + 같은 호출 안에서 정책이 바꾼 Blocker 상태를 그 다음 줄의 `pass()`가 그대로 본다: ```lua state:Gate(function(emit) diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index 9989829..e51f84f 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -51,12 +51,33 @@ React/Vue류 프레임워크의 `OnCreated`/`OnRendered`/`OnDisposed` 생명주 plain 함수일 뿐**이기 때문. ```lua -local function OnCreated(fn: (inst: Instance) -> ()): PreRef - return PreRef():Callback(fn) +-- ⭐⭐ [2026-08-26 확정, 8라운드 `H-120`] 훅 슈가는 **nil 가드 래퍼**를 끼운다. +-- `Ref`의 콜백 계약은 "등록 즉시 1회 호출, 값이 nil/미설정이어도 그대로"라 +-- (`base/ref-plan.md`), `PreRef()`/`PostRef()`처럼 default 없이 만든 뒤 +-- 콜백을 걸면 **생성 시점에 `fn(nil)`이 먼저 한 번 불린다.** 그러면 +-- `OnCreated(function(inst) inst.Name = "x" end)`은 pre-pass에 도달하기도 +-- 전에 "attempt to index nil"로 죽는다 — `fn`의 선언 타입이 non-nil +-- `Instance`인데도 그렇다. `Ref` 계약을 바꾸는 대신 여기서 막는다 +-- (사용자 확정: `Ref` 쪽은 무수정). +local function guard(fn) + -- ⭐ [2026-08-26, `/code-review high` 5차] **2-인자를 그대로 흘린다.** + -- 같은 라운드에 `Ref` 콜백이 `fn(value, ref)`가 됐다(`H-107`) — 1-인자로 + -- 짜면 두 번째 인자(`Ref` 자신 = `Epoch`)를 조용히 삼킨다. 훅 셋은 + -- 지금 그걸 안 쓰지만, 아래 children 배열 관용구가 "위와 같은 가드"를 + -- 쓰라고 하므로 `_epochs:Update(ref)` 같은 소비자가 `nil`을 받게 된다. + return function(v, r) if v ~= nil then fn(v, r) end end end -local function OnRendered(fn: (inst: Instance) -> ()): PostRef - return PostRef():Callback(fn) -- [2026-08-14 아홉 번째 세션 확정] +-- ⚠️ [2026-08-26, `/code-review high` 6차] `fn`의 선언 타입이 **2-인자**다 — +-- `guard`가 `fn(v, r)`로 부르므로 1-인자로 선언하면 `--!strict`에서 arity +-- 에러다. 사용자가 1-인자 람다를 넘기는 건 그대로 된다(Luau 함수 타입은 +-- 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능). +local function OnCreated(fn: (inst: Instance, ref: PreRef) -> ()): PreRef + return PreRef():Callback(guard(fn)) +end + +local function OnRendered(fn: (inst: Instance, ref: PostRef) -> ()): PostRef + return PostRef():Callback(guard(fn)) -- [2026-08-14 아홉 번째 세션 확정] end local function OnDestroyed(fn: () -> ()): EffectHandle @@ -64,12 +85,22 @@ local function OnDestroyed(fn: () -> ()): EffectHandle end ``` +**⚠️ 같은 함정이 children 배열 관용구에도 있다** — `base/ref-plan.md`가 +v1 대체안으로 제시하는 `Ref():Callback(function(inst) ... end)`도 default가 +`nil`인 흔한 경우 **생성 시점에 `fn(nil, ref)`가 한 번 돈다.** 문서에서 이 +관용구를 보일 때는 위와 같은 가드를 함께 보이거나, `Ref(default)`로 채워진 +경우임을 명시할 것. **기각된 대안 둘**: (b) `:Callback`의 즉시 1회 호출을 +"한 번이라도 `Set`된 뒤"로 좁히는 안(`Ref` 계약 자체를 되짚어야 하고 +"미설정 상태를 알고 싶어 콜백을 거는" 용례의 파급 확인이 필요), +(c) *"콜백은 nil을 항상 처리하라"*는 문서 경고만 두는 안(훅 슈가의 +인체공학 약속과 어긋난다). + *(위 시그니처의 `Instance`는 읽기 편하라고 quad-roblox 기준으로 적은 것 — 이 셋은 quad-base 소속이므로 실제 선언은 `Ref`가 그렇듯 백엔드 Instance 타입을 모르는 제네릭/불투명 타입 자리로 남음. `bindLifetime(inst, value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관례.)* -호출 즉시 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(...)`가 +호출 즉시 `PreRef():Callback(guard(fn))`/`PostRef():Callback(guard(fn))`/`Effect(...)`가 실행되고, children 배열에 실제로 놓이는 건 **그 결과인 `PreRef`/`PostRef`/ `EffectHandle` 인스턴스 자체**임 — `OnCreated`라는 이름이나 개념은 이 시점 이후 어디에도 @@ -93,7 +124,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 ### `OnCreated(fn)` -`PreRef():Callback(fn)`를 반환하는 순수 팩토리. `PreRef`의 기존 계약을 +`PreRef():Callback(guard(fn))`를 반환하는 순수 팩토리(**[2026-08-26 `H-120`]** +`guard`는 생략 불가 — 위 확정 스케치가 소스). `PreRef`의 기존 계약을 그대로 물려받음(`base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절) — 다른 모든 children/프로퍼티/이벤트보다 먼저 호이스팅되어 fire, 즉 "이 인스턴스에 뭐가 됐든 일어나기 전"에 콜백이 @@ -112,13 +144,13 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 키를 두고 Dispatch가 그 키를 특별 취급하는 것)이지, **"팩토리 함수가 기존 `Ref`/`PreRef`를 반환해서 children 배열에 놓는 것"**과는 층위가 다름 — 이 문서의 `OnCreated(fn)`은 정확히 저 문단이 이미 권장한 -관용구(`Ref()`/`PreRef():Callback(fn)`)를 이름 하나로 감싼 것뿐이라 +관용구(`Ref()`/`PreRef():Callback(guard(fn))`)를 이름 하나로 감싼 것뿐이라 모순이 아니라 그 결론의 자연스러운 재포장임. 이름이 v1과 같아 헷갈릴 수 있다는 점만 "이름 컨벤션" 절에서 별도로 짚음. ### `OnRendered(fn)` (2026-08-14 아홉 번째 세션 확정) -`PostRef():Callback(fn)`를 반환하는 순수 팩토리 — `OnCreated`와 완전히 +`PostRef():Callback(guard(fn))`를 반환하는 순수 팩토리 — `OnCreated`와 완전히 같은 패턴이고, 반환하는 프리미티브만 거울상. `PostRef`의 계약을 그대로 물려받으므로(`base/ref-plan.md`의 "`PostRef`" 절): @@ -269,7 +301,7 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 거울상 그대로 재사용. 비용도 애초 우려("루프 한 번이 추가되는 비용")보다 작음 — 추가되는 건 전체 배열 재순회가 아니라 `postRefList`(실제 `PostRef` 개수만큼)의 순회뿐이라, "공짜"는 아니어도 이전 "후행 스캔" - 초안보다 훨씬 저렴. `OnRendered(fn)`은 `PostRef():Callback(fn)`을 + 초안보다 훨씬 저렴. `OnRendered(fn)`은 `PostRef():Callback(guard(fn))`을 반환하는 팩토리로, 위 `OnCreated`와 완전히 같은 패턴이 됨. **스코프 — 해소됨(2026-08-14 아홉 번째 세션).** 원래 이 자리엔 "렌더 @@ -405,7 +437,7 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 형제 백로그 항목들과 동급, 맨 뒤(`quad-mock`/`quad-debug`/`Operator`/ `Fallback`과 같이 "quad 개발 상당 부분 끝난 뒤"). 착수 시점에 위 코드 스케치를 그대로 옮기면 될 만큼 단순하고, 순수 슈가라 없어도 - `PreRef()/PostRef():Callback(fn)`·`Effect(fn)`를 직접 쓰면 되므로 + `PreRef()/PostRef():Callback(guard(fn))`·`Effect(fn)`를 직접 쓰면 되므로 기능 격차가 없음. ## 열린 질문 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index f3cdf05..f00fb72 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -299,7 +299,12 @@ local function isBoundAlive(value) if gcconn ~= nil and gcconn.Connected then return true end - -- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드. + -- (b) 전역 경로: 구독 경로(강/약)가 세운 것. Observer/Effect에만 있는 필드. + -- [2026-08-26, 8라운드 H-111] :WeakSubscribe()도 이 필드를 세운다 — + -- 갈라지는 건 레지스트리를 강하게 잡느냐뿐이다. 한때 ":Subscribe()가 + -- 세운 것"이라고만 적혀 있었고, 그대로 읽으면 WeakSubscribe로만 + -- 등록되는 Effect의 내부 Observer가 이 게이트를 영영 못 통과해 + -- Effect의 State dep 전량이 조용히 침묵한다. if isObserver(value) or isEffect(value) then return value.Subscribed == true end @@ -317,7 +322,7 @@ function bindLifetime(inst, value) local isGlobal = isObserver(value) or isEffect(value) if isGlobal then isGlobal = value.Subscribed == true end error(if isGlobal - then "이미 :Subscribe()로 전역 바인딩된 값" + then "이미 구독된 값" -- [2026-08-26 H-111] 강/약 어느 쪽이든 else "이미 다른 Instance에 바인딩된 값") end @@ -420,8 +425,9 @@ end 복사된 gcconn 참조가 그것. `isBoundAlive`(따라서 `canBound`/`canExecute`)가 `inst` 없이 성립하는 이유. -**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로 -전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도 +**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 구독 경로 전용 +필드**(**[2026-08-26 정정, `H-111`]** `:Subscribe()`뿐 아니라 +`:WeakSubscribe()`도 세운다 — 위 `isBoundAlive` (b) 주석 참고). `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도 않음**. 옛 스케치가 `bindLifetime` 안에서 `value.Subscribed = true`를 세팅하던 것이 이 문서의 오염 지점이었고, 그게 "`canExecute`가 `inst`를 받아야 한다"는 잘못된 귀결까지 끌고 왔음(상세는 @@ -433,33 +439,117 @@ end 규칙과 경고는 `base/source-state-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`" 절이 소스이고, 여기선 `canBound`가 보는 상태만 못박음: -```lua -local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적) +**⭐ [2026-08-26 재작성, 8라운드 `H-111`] 프리미티브는 `WeakSubscribe` 쪽이다** — +여기 한때 `Subscribe`/`Unsubscribe` 둘만 있는 블록이 있었는데, 그건 +`:WeakSubscribe()`가 생기기 전(2026-08-25 이전) 서술이라 **약한 쪽이 어디서 +`.Subscribed`를 세우는지가 통째로 빠져 있었다.** 아래가 네 진입점 전량이고 +소스다(사용자 원문 *"구현이 한 벌"*): -function Observer:Subscribe() +```lua +local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적) +local WeakSubscribed = setmetatable({}, {__mode = "k"}) -- 약한 레지스트리 + +-- ── 프리미티브 ────────────────────────────────────────────── +function Observer:WeakSubscribe() if not canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) error(if self.Subscribed - then "이미 :Subscribe()된 값" + then "이미 구독된 값" -- 강/약 어느 쪽이든 이 분기 else "이미 Instance에 바인딩된 값") end - self.Subscribed = true - Subscribed[self] = true + self.Subscribed = true -- ⭐ [H-111] 약한 쪽도 세운다 — 구독 경로 공용 플래그 + WeakSubscribed[self] = true + return self +end + +function Observer:WeakUnsubscribe() + -- ⭐ [2026-08-26, `/code-review high`] 강한 킵이 남아 있으면 error. + -- 이게 없으면 `o:Subscribe()` 뒤 `o:WeakUnsubscribe()`가 + -- `.Subscribed = false`로 **조용히 죽이면서** 강한 레지스트리엔 항목을 + -- 남겨 **영원히 GC 안 되는** 반쪽짜리 해제가 된다(바로 아래에서 금지하는 + -- 그것). 사용자 확정: fail-fast — `Subscribe()`로 건 건 `Unsubscribe()`로 + -- 푼다. 아래 `Unsubscribe`가 강한 킵을 **먼저** 지우고 위임하므로 자기 + -- 가드에 걸리지 않는다(순서가 계약이다). + if Subscribed[self] ~= nil then + error("...: subscribed strongly; use :Unsubscribe()", 2) + end + WeakSubscribed[self] = nil + self.Subscribed = false + return self +end + +-- ── 그 위의 "GC 안 되게 킵" 한 겹 ─────────────────────────── +function Observer:Subscribe() + self:WeakSubscribe() -- 게이트·플래그·약한 등록을 전부 여기서 + Subscribed[self] = true -- 강한 킵 하나만 더 return self end function Observer:Unsubscribe() - Subscribed[self] = nil - self.Subscribed = false - return self + -- ⭐ [2026-08-26, `/code-review high` 5차] `WeakUnsubscribe`의 가드와 + -- **대칭**으로 막는다(사용자 확정). 이게 없으면 `WeakSubscribe`로만 + -- 등록된 값에 `Unsubscribe`가 **조용히 성공**해서, 범용 정리 코드가 + -- `Effect`의 내부 Observer(오직 `WeakSubscribe`로만 등록된다)를 죽이고 + -- **State dep 전량이 침묵**한다. 계약은 한 줄로: **건 경로로 푼다.** + if Subscribed[self] == nil then + error("...: not subscribed strongly; use :WeakUnsubscribe()", 2) + end + Subscribed[self] = nil -- 강한 킵을 먼저 놓고 + return self:WeakUnsubscribe() -- 나머지는 프리미티브에 위임(양쪽 테이블 대칭) end ``` -`.Subscribed` 필드와 `Subscribed` 테이블이 **둘 다** 있는 이유: 테이블은 -강참조 루트(생존 보장), 필드는 `canBound`/`canExecute`가 매번 읽는 O(1) -경로 + 에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이 -쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안 -비우면 반쪽짜리 해제가 됨 — `base/source-state-plan.md`에 이미 확정된 규칙 -그대로). +- **게이트는 한 번만 돈다** — `Subscribe`가 `WeakSubscribe`에 위임하므로 + `canBound` 검사가 중복되지 않는다. +- **해제는 반드시 양쪽을 지운다.** `Unsubscribe`가 `WeakSubscribed`를 안 + 지우면 항목이 약한 테이블에 남아 반쪽짜리 해제가 된다 — 그래서 + `WeakUnsubscribe`에 위임하는 모양이 정본이다. +- **⭐ 해제는 *건 경로로* 푼다 — 양방향 대칭 가드**(사용자 확정 2026-08-26). + 강하게 구독된 값에 `WeakUnsubscribe`를 부르면 error, 약하게만 구독된 값에 + `Unsubscribe`를 부르면 error. 후자가 없으면 **조용히 성공**해서 범용 정리 + 코드가 `Effect`의 내부 Observer(오직 `WeakSubscribe`로만 등록)를 죽이고 + State dep 전량이 침묵한다. **"둘 중 뭐든 풀어주는" 범용 해제는 없다** — + 필요하면 호출부가 `.Subscribed`가 아니라 어느 경로로 걸었는지를 알고 있어야 + 한다(핸들을 만든 쪽이 안다). +- **⚠️ `:Subscribe()`는 idempotent가 아니다.** 이미 구독됐거나 leaf에 + 바인드된 값에 다시 부르면 `canBound` 게이트에 걸려 **error**다 + (**[2026-08-26 확정, `/code-review high`]** `base/source-state-plan.md`가 + 한때 *"둘 다 idempotent … 에러 안 나고 그냥 no-op"*이라고 적었는데 그건 + 2026-08-18에 `canBound` 게이트가 들어오기 전 서술이라 정면 충돌해 있었다 — + **의사코드 쪽이 정본**이다). + **⚠️ [2026-08-26 재정정, `/code-review high` 6차] `:Unsubscribe()`도 + idempotent가 아니다.** 여기 한때 *"게이트가 없어 구독 안 한 값에 불러도 그냥 + 지나간다. 비대칭이 의도된 것"*이라고 적혀 있었는데, **같은 날 대칭 가드가 + 들어오면서 거짓이 됐다**(위 의사코드: 약하게만 구독된 값이면 error). + 지금 계약은 한 줄이다 — **해제는 건 경로로 푼다.** 구독한 적 없는 값에 + 부르는 것도 `Subscribed[self] == nil`이라 error다. +- **에러 메시지 분기는 그대로 성립한다** — `.Subscribed`가 참이면 "구독 + 경로", 거짓인데 `canBound`가 거짓이면 "leaf 바인딩". 다만 **[2026-08-26]** + 옛 메시지 *"이미 `:Subscribe()`된 값"*은 `WeakSubscribe`로 들어온 경우까지 + 가리키므로 *"이미 구독된 값"*으로 넓혔다(실제 문구는 영어 — error 계약은 + `base/architecture.md`). + +`.Subscribed` 필드와 레지스트리 테이블이 **따로** 있는 이유: 테이블은 +참조 루트(강한 쪽은 생존 보장, 약한 쪽은 멤버십 기록), 필드는 +`canBound`/`canExecute`가 매번 읽는 O(1) 경로 + 에러 메시지에서 "구독이냐 +leaf냐"를 가르는 판별자. + +**⭐ [2026-08-26 정정, 8라운드 `H-111`] 필드와 *강한* 테이블은 한 세트가 +아니다.** 여기 한때 *"둘은 항상 같이 쓰고 같이 지우는 한 세트"*라고 적혀 +있었는데, `:WeakSubscribe()`가 생기면서 그게 거짓이 됐다 — 약한 구독은 +**필드는 세우고 강한 `Subscribed` 테이블은 안 건드린다.** 그 문장 그대로면 +`WeakSubscribe`의 정상 동작이 "반쪽짜리 해제"로 오독된다. 실제 짝은 위 +의사코드대로 이렇게 갈린다: + +| 진입점 | `.Subscribed` | `Subscribed`(강) | `WeakSubscribed`(약) | +|---|---|---|---| +| `:WeakSubscribe()` | `true` | — | 등록 | +| `:Subscribe()` | `true` | 등록 | 등록 | +| `:WeakUnsubscribe()` | `false` | — | 제거 | +| `:Unsubscribe()` | `false` | 제거 | 제거 | + +**여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`가 +강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만 +내리면) 그게 반쪽짜리 해제다. #### (3) `canBound` vs `canExecute` — 문맥이 달라 다시 갈라짐 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 2a76df2..b0f7f7a 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -473,10 +473,18 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 `error`가 항상 잡아준다. - **적용 지점**: "어떤 값이 Source/State의 현재 값으로 확정되는 모든 - 지점" — `Source:Set(value)` 호출 시, `Store({defaults})` 생성 시 - 각 `defaults` 키를 `Source(v)`로 만드는 시점, 그리고 State의 + 지점" — **`Source(...)` 생성자**, `Source:Set(value)` 호출 시, 그리고 State의 `:Compute(fn)` 결과를 캐시로 저장하기 직전(`fn`이 반환한 값이 - `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 + `isModifier`면 캐싱 전에 `error`). + **⭐ [2026-08-26 정정, 8라운드 `H-122`]** 여기 한때 *"`Store({defaults})` + 생성 시 각 `defaults` 키를 `Source(v)`로 만드는 시점"*이 셋 중 하나로 + 적혀 있었는데, **명시적 초기화 확정(2026-08-25) 이후 그 지점은 코드상 + 존재하지 않는다** — `defaults`엔 사용자가 + 이미 만든 `Source(v)`가 그대로 들어오며 생성자는 `table.clone`뿐이다. (**⚠️ [2026-08-26 정밀화, `/code-review high`]** 정확히는 **`defaults` 경로에서** 안 만든다는 뜻이다 — 동적 키 창구 `store:Of(name)`은 여전히 그 자리에서 `Source`를 만든다(`base/store-plan.md`). 결론은 그대로다: 가드를 `Source` 생성자에 두면 `Of` 경로까지 **한 번에 커버**된다.) + 가드를 **`Source` 생성자**로 옮기면 defaults 경로가 자동으로 커버되고 + (독립 `Source(someModifier)`와 한 자리로 수렴) 목록이 오히려 짧아진다 — + **사용자 확정.** Store 생성자가 별도로 하는 일은 `isModifier`가 아니라 + `isSource` 화이트리스트 검증이다(`base/store-plan.md`). 새 체크 지점을 여러 곳에 흩는 게 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. - **Slot/Tag/Attribute 등 다른 핸들러 계층 값은 여전히 아무 diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md index 880f877..14c222e 100644 --- a/.claude/base/project-setup-plan.md +++ b/.claude/base/project-setup-plan.md @@ -240,12 +240,17 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했 치환(`find . -type l ... cp -r`류, 저장소 파일은 안 건드리고 `roblox_packages/.pesde/`의 링크만 대상) — Rojo/Studio는 이미 symlink를 투명하게 처리하므로 배포 경로엔 아무 영향 없고, 순수 이 샌드박스의 -`luau`/`luau-analyze` 실행을 가능하게 하는 로컬 조치다. **아직 반복 -가능한 스크립트/mise task로 정식화하진 않음** — 매번 `pesde install` -후 수동으로 치환했음. 다음 세션이 CLI 테스트를 또 돌리려면 같은 수동 -치환이 필요하거나, 이 시점에 정식 스크립트화를 고려할 것(Luau의 +`luau`/`luau-analyze` 실행을 가능하게 하는 로컬 조치다. +**⭐ [2026-08-26 정정, 8라운드 `H-123`] 이건 이미 스크립트로 정식화됐다** — +여기 한때 *"아직 반복 가능한 스크립트/mise task로 정식화하진 않음 — 매번 +`pesde install` 후 수동으로 치환했음"*이라고 적혀 있었으나, 7라운드 `H-78`이 +**`scripts/relink.sh` + `scripts/test.sh`를 신설**했고 둘 다 커밋돼 있다 +(8라운드에서 실제로 실행해 확인). 지금 규칙은 하나다 — **테스트는 +`./scripts/test.sh`로 돌린다**(그 스크립트가 `relink.sh`를 먼저 부른다). +그냥 `luau`로 돌리면 스모크가 죽고 `luau-analyze`는 모듈을 `any`로 떨어뜨려 +**조용히 통과**한다("거짓 클린"). Luau의 `.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — -그때 다시 볼 것). +그때 다시 볼 것. **[2026-08-19 같은 날 넷째 후속 세션] 의존 대상의 `target`에 따라 링크 디렉토리 이름이 달라진다** — `quad-types`(target `roblox`)가 @@ -302,7 +307,7 @@ require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇 지정해도 되지만, 그럴 거면 그냥 `cd`가 더 안전(설정 파일 지정을 빠뜨리기 쉬움). -## `pesde.lock` — 커밋 권고 (미확정, 사용자 판단 필요) +## `pesde.lock` — 커밋한다 (2026-08-26 확정) **[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱 하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`이 @@ -323,8 +328,12 @@ lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `privat 못 봄 — 자기 프로젝트에서 새로 resolve함). 그래서 "라이브러리 lockfile 딜레마" 자체가 성립하지 않고, 그냥 "이 모노레포를 체크아웃한 개발자/CI가 재현 가능한 빌드를 얻는가" 문제로 좁혀지는데 그건 커밋하는 쪽이 유리 — -**다만 이건 이 세션의 판단이고 최종 확정 아님**, `todos.md`에 확인 필요 -항목으로 반영. +**⭐ [2026-08-26 확정, 8라운드 `H-123`] 사용자 확정 — 전부 커밋한다.** +여기 한때 *"이건 이 세션의 판단이고 최종 확정 아님, `todos.md`에 확인 필요 +항목으로 반영"*이라고 적혀 있었으나 **그 반영이 실제로는 어디에도 없었고** +(`question.md`/`todos.md`/`HUMAN_TODO.md` grep 0건), 실태는 lockfile이 이미 +전부 커밋돼 있어 위 잠정 권고와 일치했다. 현 실태 그대로 확정하고 열린 +항목에서 뺀다. ## 확인 완료 / 아직 확인 안 된 것 @@ -370,4 +379,5 @@ lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `privat 없으면 linking에 문제가 생길 수 있다"는 WARN의 실제 영향 범위 — 지금은 install 자체를 막지 않아서 방치, 실제 Rojo 동기화 단계에서 문제가 드러나면 그때 pesde 문서의 `[target.scripts]` 절을 찾아볼 것 -- `pesde.lock` 커밋 여부 최종 확정(위 절 권고는 잠정) +- ~~`pesde.lock` 커밋 여부 최종 확정~~ — **[2026-08-26 해소]** 커밋하는 + 것으로 확정(위 절). diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index a4e40da..e97717e 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -101,8 +101,17 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 then ref.Value else ref:Wait().Value ``` + - **콜백 시그니처는 `fn(value, ref)`다** — 두 번째 인자로 그 `Ref` + 자신(= `Epoch`)이 온다(**[2026-08-26 확정, 8라운드 `H-107`]** — 근거와 + 의사코드는 아래 `:Set(value)`의 순서 절). 사용자 콜백은 두 번째 인자를 + 무시하면 그만이다. - 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정 - 상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current` + 상태여도 그 상태 그대로 호출. **⚠️ [2026-08-26, 8라운드 `H-120`] + 그래서 `default` 없는 `Ref()`에 콜백을 걸면 그 콜백이 생성 시점에 + `fn(nil, ref)`로 한 번 불린다** — children 배열 관용구 + `Ref():Callback(fn)`과 `OnCreated`/`OnRendered` 슈가가 이 경로를 탄다. + 처분은 `base/lifecycle-hooks-plan.md`의 nil 가드 래퍼(사용자 확정: + `Ref` 계약은 안 건드린다). React의 `useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 배열 @@ -165,8 +174,14 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `base/effect-plan.md`가 소스. - `:Callback(fn)`은 **여전히 `self`를 반환한다**(체이닝 관용구 `Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은 - **`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고, + **`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄씩이고, 중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다. + **⚠️ [2026-08-26 정정, `/code-review high`] 단 "한 줄"이 아니라 **두 + 테이블을 다 본다** — `.Callbacks`(강)와 `.WeakCallbacks`(약) 양쪽에서 + 지운다(아래 `:WeakCallback` 항목이 이미 그렇게 확정해뒀는데 여기만 + 한 줄로 남아 있었다). 한 줄로 짜면 **약하게 등록된 콜백을 영영 뗄 수 + 없고**(예: `Effect`의 `onRefFire`), 새 `:Set`의 스킵 가드가 + `WeakCallbacks[k]`를 보므로 계속 발화한다. - **⛔ [2026-08-25 폐기, 7라운드 `H-58`] 여기 원래 *"`EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`과 `:Unsubscribe()`에서 `:Uncallback`한다"*고 적혀 있었다.** 아래 `:WeakCallback` 항목이 그걸 @@ -185,7 +200,10 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 따라서 내부적으론 Weak 를 구현해 두고, Weak 아닌 곳에서 Weak 를 수행하고 gc 처리만 두면 돼요."* - **자료구조**: 약한 등록은 **weak-키 테이블**(`__mode = "k"`)에 - 들어간다 — `.Callbacks`(강함)와 별도 테이블이다. Lua의 weak 모드는 + 들어간다 — `.Callbacks`(강함)와 별도 테이블이고, 이름은 + **`.WeakCallbacks`**다(**[2026-08-26]** 8라운드 `H-108` 반영 때 + `:Set` 의사코드가 이 테이블을 순회해야 해서 그 자리에서 이름을 + 붙였다 — 그 전엔 "별도 테이블"이라고만 불렸다). Lua의 weak 모드는 테이블 단위라 항목별로 섞을 수 없다. 발화 순회는 두 테이블을 다 훑고(각각 스냅샷), `:Uncallback(fn)`은 양쪽을 다 본다. - **왜 필요한가**: `Effect`가 자기 dep `Ref`에 콜백을 걸면 @@ -220,23 +238,64 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 (`\.Value = ` grep 0건). 콜백은 값을 인자로 직접 받으므로 실무 영향은 낮지만, **콜백 A가 `.Value`를 읽는데 콜백 B가 아직 안 돈 시점에 무엇이 보이는가**가 사양에 없었다. 확정된 순서: + **⭐⭐ [2026-08-26 갱신, 8라운드 `H-107`/`H-108`] 아래 블록이 소스다.** + 원래 이 블록은 2026-08-24에 쓰였고 하루 뒤(2026-08-25) 확정된 두 가지 — + `.Revision` 갱신과 약한 콜백 테이블 — 를 **소급으로 못 받았다.** 그 + 블록대로 짜면 `Effect`의 캐치업(`_epochs:Refresh()`)과 `Update(ref)` + 판정이 전부 죽고, `Effect`가 건 `:WeakCallback`은 한 번도 발화하지 + 않는다. 확정된 순서는 **값 → 리비전 → 콜백**이다: ```lua function Ref:Set(value) self.Value = value -- (1) 먼저 확정 - local snapshot = {} -- (2) [`H-23`] 순회 전 스냅샷 + self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 **앞** + local snapshot = {} -- (3) [`H-23`] 순회 전 스냅샷 for k in pairs(self.Callbacks) do table.insert(snapshot, k) end + for k in pairs(self.WeakCallbacks) do + -- 양쪽에 다 있는 키는 한 번만 — 안 그러면 그 콜백이 두 번 불린다. + -- ⭐ [2026-08-26, `/code-review high`] **thread는 이 경우가 없다** — + -- 대기자를 만드는 `:Wait()`는 `.Callbacks`(강)에만 넣는다 + -- (`WeakWait`는 없다). 그래서 아래 소진이 `.Callbacks`만 비우는 + -- 게 맞다. 이 dedup은 **함수 키** 전용이다(같은 `fn`을 + -- `:Callback`과 `:WeakCallback`으로 각각 건 경우). + if self.Callbacks[k] == nil then table.insert(snapshot, k) end + end for _, k in ipairs(snapshot) do - if self.Callbacks[k] == nil then continue end -- 순회 중 해제됐으면 skip + if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then + continue -- 순회 중 해제됐으면 skip + end if type(k) == "thread" then self.Callbacks[k] = nil -- 대기자는 소진 coroutine.resume(k, self) -- 값이 아니라 **Ref 자신**(아래 참고) else - k(value) -- 일반 콜백은 원래 값을 받고, 소진 안 함 + k(value, self) -- ⭐ 값 + **Ref 자신**(= `Epoch`) end end return self end ``` + - **왜 `k(value, self)`인가(`H-107`, 사용자 확정 2026-08-26)** — `Effect`가 + `Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch`가 + 필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다 + (`nil` 순회 크래시, 실측 재현). `Ref`는 **그 자체가 `Epoch`**이므로 + 자기를 두 번째 인자로 주면 통로가 닫힌다. 기존 사용자 콜백 + (`function(inst) ... end`)은 두 번째 인자를 무시하면 그대로다. + - **왜 리비전이 콜백보다 앞인가(`H-108`)** — 뒤면 콜백 안의 + `Update(ref)`가 **옛 리비전**을 읽어 `false`를 돌려주고, 그 `Set`의 + `Rerun`이 접힌 채 다음 `Refresh()` 때에야 뒤늦게 돈다(간헐 지연). + - **두 테이블을 다 훑는다** — `.Callbacks`(강한 셋)와 + `.WeakCallbacks`(weak-키)를 **각각 스냅샷**해 한 배열로 잇되, + **양쪽에 다 있는 키는 한 번만 싣는다**(*"중복 등록은 dedup이 계약"*이 + 한 테이블 안에서만 성립하면 안 된다 — 같은 `fn`을 `:Callback`과 + `:WeakCallback`으로 각각 걸면 두 번 불리고, `thread` 키라면 이미 + 소진된 대기자를 다시 `resume`하게 된다). `Effect`가 거는 콜백은 + 후자에만 있다. **⚠️ [2026-08-26, `/code-review high`] 이 dedup의 근거를 + "thread를 두 번 `resume`하게 된다"로 적으면 안 된다** — 그렇게 읽으면 + `thread` 키가 양쪽에 살 수 있다는 뜻이 되는데, 그러면 소진이 + `.Callbacks`만 비우므로 다음 `:Set`이 **죽은 코루틴을 영원히 `resume`** + 하게 된다(`resume`은 죽은 스레드에 대해 error가 아니라 `false`를 돌려줘 + **조용하다**). 실제 불변식은 **대기자(thread)는 `.Callbacks`에만 + 산다**(`:Wait()`가 강한 쪽에만 넣고 `WeakWait`는 없다)이고, 교차 dedup은 + 함수 키 전용이다. `.Value`가 먼저이므로 **모든 콜백이 새 값을 본다** — 순서에 따라 옛 값이 보이는 창이 없다. - **⚠️ [역사 기록, 2026-08-24 6라운드 `H-7` 후속] 아래 "구현 디테일" 문단은 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index d60d390..258da2a 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1132,7 +1132,14 @@ function updateFn(item, index, offset, prev, ud) -- 다시 그림(새 원소) — 이전 Source 재사용/Set 없이 처음부터 올바른 값으로 생성 local layoutOrder = Source(index) return Frame { - LayoutOrder = layoutOrder:With(offset):Compute(function(i, o) return i:Get() + o:Get() end), + -- ⭐ [2026-08-26 교정, 8라운드 `H-121`] 한때 여기가 + -- `:With(offset):Compute(function(i, o) ... o:Get() ...)`였는데 + -- **확정 콜백 계약과 어긋난다** — `:With`로 모은 값은 포지셔널로 + -- 안 넘어오고(`source-state-plan.md`: *"with한 값을 포지셔널 인자로 + -- 받지 않고 클로저로 직접 읽는다"*), 2번째 자리에 실제로 오는 건 + -- `previous`다(첫 사이클엔 `nil` → `o:Get()`이 즉사, 이후엔 직전 + -- 결과 숫자 → `number:Get()`으로 또 죽는다). + LayoutOrder = layoutOrder:With(offset):Compute(function(i) return i:Get() + offset:Get() end), ... }, { layoutOrder = layoutOrder } end @@ -1315,7 +1322,13 @@ function Slot:List(data, updateFn, keyFn, opts) activateList(self, self._physicalTarget) blocker:OffWithoutEmit() local bk = getBookkeeping(self) - if bk then recompute(self, bk) end + -- ⭐ [2026-08-26 추가, `/code-review high`] 배치 Blocker는 방금 껐지만 + -- `bk.recomputeBlocker`는 따로 봐야 한다 — 사용자 코드가 바깥 + -- `recompute`의 `offset:Set(abs)` 안에서 이 Slot을 재진입 마운트하면 + -- (`H-119`가 세운 바로 그 전제) 중첩 `recompute`가 완주하고 그 꼬리의 + -- `bk.offsetSetUpTo = bk.N` + `recomputeBlocker:OffWithoutEmit()`이 + -- **바깥 루프의 되감기 신호를 지운다.** + if not bk.recomputeBlocker:IsOn() then recompute(self, bk) end end return self end @@ -1952,8 +1965,11 @@ Slot:Single(state, updateFn?, opts?) 애초에 최초 실행은 `canExecute`로 게이팅되는 대상이 아니라서 상관없음 (**[정정, 2026-08-14 다섯 번째 세션]** 원래 "`bindLifetime`이 `Subscribed`를 세팅 전이라"고 적혀 있었으나 `bindLifetime`은 그 필드를 안 건드림 — -`.Subscribed`는 전역 `:Subscribe()` 전용, -`archive/canexecute-inst-arg-reversed.md`). `bindLifetime`은 그 +`.Subscribed`는 구독 경로 전용, +`archive/canexecute-inst-arg-reversed.md`. +**[2026-08-26 표기 정정, 8라운드 `H-111`]** 그때 "전역 `:Subscribe()` 전용" +이라 적었으나 **구독 경로(강/약) 공용**이 맞다 — `:WeakSubscribe()`도 +세운다. `bindLifetime`이 안 건드린다는 요지는 그대로 유효). `bindLifetime`은 그 직후에 걸려서 **이후의** 재실행(`data`가 다시 바뀔 때)만 게이팅 — `Dispatch.setLength`의 `bindLifetime(inst,observer)` 다음 줄에 있는 "등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조. @@ -2048,7 +2064,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라 조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. -## `Slot:Single(state, updateFn?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar) +## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar) 기존 "백로그, 미착수"에서 실제 설계까지 완료됨 — 새 reconcile 로직 없이 **`:List`를 정확히 0/1개짜리 배열로 감싸는 sugar**: @@ -2240,7 +2256,16 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) bindLifetime(physicalTarget, slot._baseObserver) else slot._baseObserver = offsetSource:Observer(function() - if not getBlocker(slot):IsOn() then recompute(slot, getBookkeeping(slot)) end + -- ⭐ [2026-08-26, 8라운드 `H-119`/`H-3`] 두 줄이 빠져 있었다: + -- (1) 배치 Blocker만 보고 `recomputeBlocker`를 안 봐서 재진입 차단이 + -- 우회됐고, (2) `H-3`의 3번("베이스가 바뀐 경우라 + -- 두 필드 `0`")이 의사코드에 없었다. + local bk = getBookkeeping(slot) + -- [2026-08-26] 무효화는 **두 필드를 다** 내린다(캐시도 낡고 Set도 + -- 다시 해야 한다) — `dispatch-core-plan.md`의 "두 필드" 절. + bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 -- 베이스가 바뀜 → 1번부터 전부 + if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end + recompute(slot, bk) end) bindLifetime(physicalTarget, slot._baseObserver) end @@ -2284,7 +2309,12 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- 있었을 구조적 갭이다(옛 코드도 같은 구간에 정리 코드가 없었음). blocker:OffWithoutEmit() local bk = getBookkeeping(slot) - if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정 + -- ⭐ [2026-08-26 추가, `/code-review high`] 위와 같은 이유로 + -- `recomputeBlocker`도 본다(`H-119`가 "명시 호출부 **전부**"라고 했는데 + -- 이 자리와 `:List` 활성화 꼬리 둘이 빠져 있었다). + if not bk.recomputeBlocker:IsOn() then + recompute(slot, bk) -- 여기서 slot.Length가 최종값으로 확정 + end -- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에 -- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에 @@ -2435,11 +2465,13 @@ local function unmountSlotTree(slot) -- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게 -- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지). end + -- [2026-08-26, `/code-review high` 6차] `if bk then` 가드를 뺐다 — + -- `getBookkeeping`은 lazy 생성이라 절대 nil이 아니다 + -- (`base/dispatch-core-plan.md`). 같은 파일 안에서 어떤 자리는 가드하고 + -- 어떤 자리는 안 하던 불일치를 없앤다. local bk = getBookkeeping(slot) - if bk then - for i, observer in pairs(bk.observers) do - unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제 - end + for i, observer in pairs(bk.observers) do + unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제 end -- [2026-08-21] `activateList`가 소유하는 두 자원의 **앵커만** 푼다. -- 안 풀면 옛 physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 @@ -2512,10 +2544,8 @@ local function destroySlotTree(slot) slot._baseObserver = nil end local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들 - if bk then - for i, observer in pairs(bk.observers) do - unbindLifetime(observer) - end + for i, observer in pairs(bk.observers) do -- [2026-08-26] 가드 제거(위와 같은 이유) + unbindLifetime(observer) end -- [정정, 2026-08-13 감사] 마운트 상태도 되돌림 — 안 그러면 파괴된 Slot이 -- `_mounted == true`로 남아 "마운트된 Slot의 재마운트는 즉시 throw"(위 절)에 @@ -2593,7 +2623,12 @@ function rawUnmount(self, index) end spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고 - recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출 + -- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다. + -- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로 + -- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`). + if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then + recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출 + end end -- [신설, 2026-08-21] rawUnmount의 "소유권 유지" 짝 — `Detach`가 쓴다. @@ -2614,7 +2649,12 @@ function rawDetach(self, index) spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도 -- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록 - recompute(self, bk) + -- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다. + -- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로 + -- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`). + if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then + recompute(self, bk) -- [`H-119`] 게이트 통과 시에만 + end end -- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다 @@ -2660,7 +2700,28 @@ end -- position이 아니라 **요소에 귀속**된다 → 전부 같은 순열로 움직인다. -- 2. **`bk.N`은 안 변한다** — 자리 수가 그대로이므로(`spliceArraysUp`/`Down`과 -- 갈리는 지점). --- 3. **캐시는 당긴다** — 바뀐 최소 위치로 `bk.invalidAfter`를 `math.min`(`H-3`). +-- ⚠️ [2026-08-26 범위 정정, 5·6차 `/code-review high`] **`rawSplice`· +-- `rawClear`·`rawExtract`의 제거 형태는 예외다** — 자리 수를 바꾼다. +-- (`rawExtract`는 조건부다: `newElement`를 **지정하면 교체**라 자리 수가 +-- 그대로지만, **생략하면 제거**라 뒤가 당겨지며 준다 — 위 CRUD 표의 +-- `Extract` 행. 6차 리뷰가 5차의 예외 목록에서 이걸 빠뜨린 걸 잡았다.) 이 규약을 그대로 +-- 적용해 `rawClear`를 짜면 `_elements`는 비는데 `bk.N`이 옛 개수로 +-- 남아, 다음 `recompute`가 `i <= bk.N`으로 끝을 넘어가 `sourceList[i]`가 +-- `nil` → **부기가 멀쩡한데 "부기가 깨졌음" error로 죽는다.** 그것들은 +-- `spliceArraysUp`/`Down`과 같은 취급(자리 수 갱신 + 무효화)을 받아야 +-- 한다. 아래 3·4번은 다섯 함수 전부에 적용된다. +-- 3. **캐시는 당긴다** — 바뀐 최소 위치의 **하나 앞**으로 +-- `bk.offsetCacheValidUpTo`와 `bk.offsetSetUpTo`를 **둘 다** `math.min` +-- (`H-3`; 두 필드 분리는 [2026-08-26] `dispatch-core-plan.md`의 +-- "두 필드" 절). ⭐ [2026-08-26 정정, `/code-review high`] 여기 한때 +-- "바뀐 최소 위치로"라고만 적혀 있었는데, 그건 `H-113`이 거짓임을 증명한 +-- 바로 그 공식이다 — `rawSwap(i, j)`가 `recompute`의 커서 `i`에서 일어나면 +-- `math.min(i, i) = i`라 되감기 조건 `offsetSetUpTo < i`가 거짓이고, 그 +-- 자리로 옮겨온 **다른 요소의 offset Source가 이번 패스에서 `Set`을 못 +-- 받는다**(우리가 Set한 건 옮겨나간 옛 요소의 Source다). 루프 꼬리의 +-- `bk.offsetSetUpTo = bk.N`이 "Set을 다 마쳤다"로 마감하므로 다음 계기까지 그 +-- 요소만 옆으로 어긋난 채 남는다. `spliceArrays*`와 같은 처방 +-- (`math.min(…, minPos - 1)`(두 필드 다))을 쓴다. -- 4. **`recompute`는 `setLength`에 일임**(`H-19`) — 순서만 바뀌어 길이 합이 -- 그대로여도 offset은 전부 바뀌므로, 자리 이동 후 해당 위치들의 -- `setLength`를 다시 태워 게이트를 통과시킨다. @@ -2827,7 +2888,12 @@ function rawRemove(self, index) end spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고 - recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 + -- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다. + -- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로 + -- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`). + if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then + recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 + end end ``` @@ -2853,12 +2919,29 @@ end 불변식으로는 안 덮인다.** `sourceList`가 `None`으로 채워지는 것과 대칭을 맞춰 창 자체를 없앤다. - **캐시를 앞으로 당긴다**(`H-3`) — - `bk.invalidAfter = math.min(bk.invalidAfter, index)`. - `base/dispatch-core-plan.md`의 무효화 표가 규정한 세 규칙 중 하나이고, + **두 필드 다** `math.min(…, index - 1)`: + `bk.offsetCacheValidUpTo`(캐시 유효 상한)와 `bk.offsetSetUpTo`(offset + `Source`에 `:Set`을 마친 지점). **[2026-08-26]** 옛 단일 필드 + `invalidAfter`가 두 뜻을 겸하던 게 되감기 신호가 조용히 지워지는 원인이라 + 갈라졌다 — `base/dispatch-core-plan.md`의 "두 필드" 절이 소스. + `base/dispatch-core-plan.md`의 무효화 표가 규정한 규칙 중 하나이고 + (**[2026-08-26]** "세 규칙"이라 세어뒀는데 `/code-review high`가 그 표에 + `rawMove`/`rawSwap`류 행이 빠진 걸 잡아 넷이 됐다 — 개수는 그 표가 소스), 지금까지 산문으로만 있고 코드 경로가 없었다. - **[2026-08-25 정정]** 한때 여기 `index - 1`로 적었는데, `recompute`의 - 되감기 재개 지점이 `invalidAfter + 1`에서 **`invalidAfter` 자신**으로 - 고쳐지며 그 `-1`이 불필요해졌다(그 문서의 `recompute` 절). + **⭐⭐ [2026-08-26 재정정, 8라운드 `H-113`] `-1`이 다시 필요하다.** + 여기 한때 `index - 1`이었다가 2026-08-25에 *"되감기 재개 지점이 + `invalidAfter + 1`에서 그 필드 자신으로 고쳐지며 그 `-1`이 + 불필요해졌다"*는 이유로 `index`가 됐는데, **그 논증이 `index == i`(= 지금 + `recompute`가 처리 중인 커서 자리)에서 거짓이다** — 루프가 매 반복 + `bk.offsetSetUpTo = i`를 쓰므로 `math.min(i, i) = i`가 되어 **"아무 일도 + 없던 것"과 값이 같아지고**, 되감기 조건 `offsetSetUpTo < i`가 거짓이라 + 재방문이 없다. 그러면 splice로 그 자리에 밀려 들어온 요소의 offset이 + 이번 패스에서 `Set`을 못 받고 조용히 낡는다. `-1`로 두면 `i-1`부터 + 되감아 `i`를 재방문하고(그 자리 offset 쓰기는 `~=` 가드로 no-op), + **재개 지점은 `offsetSetUpTo` 그대로**라 2026-08-25의 그 변경 자체는 + 유지된다 — 둘은 독립이다. 근거 전량은 `base/dispatch-core-plan.md`의 + "되감기 신호는 `bk.invalidAfter` 하나로 통일한다" 절(**[2026-08-26]** 그 절 + 제목의 확정은 같은 날 역전됐다 — 두 필드로 갈라졌다). - **⭐ 이게 `recompute` 되감기의 신호이기도 하다** — recompute 도중 splice가 나면 그 값이 낮아지고, 진행 중인 루프가 그 지점 다음부터 되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서 @@ -2877,7 +2960,8 @@ end **안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는 인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로** - `bk.invalidAfter`를 당긴다(앞선 자리가 영영 다시 offset을 못 받는다). + `bk.offsetCacheValidUpTo`/`bk.offsetSetUpTo`를 당긴다(앞선 자리가 영영 다시 + offset을 못 받는다). 이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래 결함이었다. @@ -3122,7 +3206,10 @@ end **`:Single`의 `updateFn`을 선택 인자로 완화 — 기본값은 identity.** 이 sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함 — -`Slot:Single(state, updateFn?)`, 생략 시 `function(item) return item end`. +`Slot:Single(state, updateFn?, opts?)`, 생략 시 `function(item) return item end` +(**[2026-08-26 표기 정정, 8라운드 `H-123`]** 3번째 `opts`(= `Owned`)는 `H-22` +확정 의사코드가 받아 `:List`로 그대로 전달한다 — 이 줄과 절 제목이 2-인자로 +남아 있었다). **이게 최초안의 세 문제를 전부 없애는 이유**: - **`_elements`엔 `None`이 절대 안 들어감** — 바깥 Slot 입장에서 이 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index b388668..6d3d282 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -253,13 +253,45 @@ function State:_emitDown(from) for _, sub in ipairs(snap) do if isState(sub) then -- 자식 노드 sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3 - elseif canExecute(sub) then -- Observer / Effect - sub.fn(sub, from) + elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다) + sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) end -- 거짓이면 조용히 건너뜀 end end ``` +- **⭐⭐ [2026-08-26 확정, 8라운드 `H-109`/`H-110`] Observer `fn`의 시그니처는 + `fn(targetState, self, emitFrom)` 세 자리다.** 여기 한때 + `sub.fn(sub, from)`이라고 적혀 있었는데(2026-08-25 `H-56` 반영 시점), + 그건 같은 문서의 *"`self`는 이 Observer가 붙은 State의 **lazy 핸들**"* + 계약과 정면으로 충돌했다 — 그대로 짜면 `H-61`이 확정한 무인자 + `state:Observer()`의 내부 콜백(`self:Get()`)이 "attempt to call missing + method Get"으로 즉사한다(Observer엔 `:Get()`이 없다). 확정된 자리 배치: + | 자리 | 무엇 | 왜 | + |---|---|---| + | 1 | `targetState` — 이 Observer가 붙은 State의 lazy 핸들 | 기존 계약 그대로. `:Compute`의 `fn(self, ...)`와 같은 모양 | + | 2 | `self` — Observer 값 자신 | 핸들 조작(`:Unsubscribe()` 등) | + | 3 | `emitFrom: Epoch \| EpochSet` | `EpochMap:Update(from)`에 그대로 넘어간다 | + - **왜 값이 앞인가(사용자 확정)** — `Ref` 콜백 `fn(value, ref)`와 같은 + 원칙이다: 실제로 쓰이는 값이 앞자리에 온다. + - **⭐ 그래서 Observer는 리시버를 강하게 든다 — `observer._state = state`** + (생성 시점). 루프가 그 필드를 읽어 넘기므로 **이게 곧 Observer의 + `_hold` 상당**이고, `H-110`(Observer→상류 강참조가 어디에도 없어 + `:Subscribe()`의 *"GC되지 않고 영원히 계속 실행됨"* 계약이 다시 열림)이 + 같은 결정으로 닫힌다. 그 전엔 `fn` 클로저가 리시버를 **우연히 캡처**하는 + 것 말고 근거가 없었고, 캡처가 없는 확정 사례가 이미 둘이었다 + (`H-61`의 내부 콜백, `Effect`의 dep 콜백). + - **⚠️ `Ref` 콜백과 통합하지 않는다(사용자 확정)** — 두 콜백은 이질적 + 개념이다: *"observer 에는 epoch 란게 존재하지 않음. emit 으로 온 epoch 를 + 넘겨줄 뿐, 그러나 ref 는 그 자체로 epoch임"*. 그래서 `Effect`는 dep + 종류별로 클로저를 **따로** 단다(`base/effect-plan.md`). +- **⚠️ [2026-08-26 주석 정정, `/code-review high`] 위 `elseif` 가지의 주석이 + 한때 "Observer / Effect"였는데, `_state`는 **Observer에만** 있다** — + `Effect` 핸들의 강한 상류는 `_deps`이고 `_state` 필드가 없다. `Effect`는 + `_subs`에 직접 들어가지 않고 **자기 내부 Observer를 통해** 이 루프에 + 닿는다(생성자가 dep마다 `d:Observer(onStateFire)` + `WeakSubscribe`). + 주석대로 `Effect`를 `_subs`에 직접 넣으면 사용자 `fn`이 리시버 자리에 + `nil`을 받는다. - **구독자 집합은 하나**(`self._subs`, weak-키)이고 **원소는 Observer 값**이다 — emit 클로저가 아니다. `bindLifetime(inst, observer)`가 Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면 @@ -414,6 +446,15 @@ gc되긴 하지만.)"* - **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를 `_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는 것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다). +- **⭐ [2026-08-26 보강, 8라운드 `H-110`] 말단 핸들도 마찬가지다.** + 파생 노드만 적어두면 **Observer가 우연에 남는다** — 이 절이 바로 위에서 + 금지한 그 우연이다. 확정 결정의 소스인 + `qa-request/pre-implementation-handtrace-round7-followup.md` 🅚 절도 + *"핸들이 `_hold`로 상류를 잡는다"*라고 핸들까지 포함해 적었는데 반영이 + 파생 노드로 좁혀졌었다. 실제 자리: + - **Observer** → `observer._state`(생성 시 강참조). 전파 루프가 이 필드를 + `fn`의 1번 인자로 읽으므로 부기가 따로 늘지 않는다(위 전파 루프 절). + - **Effect** → `_deps` 강한 맵이 이미 그 역할을 한다. - 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고, 말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다. - **상류가 하류를 얻으려는 것은 UB**라 반대 방향 강참조가 필요 없다. @@ -963,9 +1004,15 @@ Modifier는 정적 flatten으로 dispatch와 완전히 별개인 단계에서 (`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` 조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** -(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ -Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 -지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 +(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source(...)` 생성자/ +`Source:Set()`/State의 `:Compute` 결과 캐싱 +지점에서 확인해 런타임에 직접 막음(**⭐ [2026-08-26 정정, 8라운드 `H-122`]** +여기 한때 *"Store 생성 시 eager `Source(default)`"*라고 적혀 있었으나 +**명시적 초기화 확정(2026-08-25) 이후 그 지점은 코드상 존재하지 않는다** (**⚠️ [2026-08-26 정밀화, `/code-review high`]** 정확히는 **`defaults` 경로에서** 안 만든다는 뜻이다 — 동적 키 창구 `store:Of(name)`은 여전히 그 자리에서 `Source`를 만든다(`base/store-plan.md`). 결론은 그대로다: 가드를 `Source` 생성자에 두면 `Of` 경로까지 **한 번에 커버**된다.) — +`defaults`엔 사용자가 만든 `Source(v)`가 그대로 들어오고 생성자는 +`table.clone`뿐이라 그 지점이 코드상 존재하지 않는다. 가드를 `Source` +생성자로 옮기면 defaults 경로가 **자동으로 커버**되어 목록이 오히려 +짧아진다 — 사용자 확정), 타입 차단은 되면 좋은 보너스일 뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — @@ -1094,18 +1141,27 @@ retract/Destroy되면 자동으로 정리됨. 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. -- **⭐ [2026-08-21 확정] `fn`의 시그니처는 - `fn(self, from: (Epoch | EpochSet)?)` — `:Compute`와 같은 모양이다.** 사용자: *"Compute 와 유사하게 나올 수 +- **⭐ [2026-08-21 확정, 2026-08-26 자리 하나 추가] `fn`의 시그니처는 + `fn(targetState, self, emitFrom: (Epoch | EpochSet)?)`이다.** 사용자: + *"Compute 와 유사하게 나올 수 있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."* 위 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절과 같은 결이다. - - `self`는 이 Observer가 붙은 State의 **lazy 핸들**(값이 아님). - - `from`은 **이 통지의 출처**다 — `Epoch` 하나이거나 `Epoch`들의 **집합** + **[2026-08-26 확정, 8라운드 `H-109`]** 여기 한때 2-인자 + (`fn(self, from)`)로 적혀 있었는데, 그때의 `self`는 **리시버 State의 lazy + 핸들**을 뜻했고 전파 루프 의사코드는 같은 자리에 **Observer 값**을 넘기고 + 있었다 — 두 확정이 정면으로 충돌해 있었다. 사용자 확정으로 **Observer + 핸들이 가운데 자리로 들어와 세 자리**가 됐다(*"실 값이 앞에 놓이도록"* — + `Ref` 콜백 `fn(value, ref)`와 같은 원칙). 자리 배치와 근거는 위 + "전파 루프 — 확정 의사코드" 절이 소스. + - `targetState`는 이 Observer가 붙은 State의 **lazy 핸들**(값이 아님). + - `self`는 **Observer 값 자신**이다 — 핸들 조작용. + - `emitFrom`은 **이 통지의 출처**다 — `Epoch` 하나이거나 `Epoch`들의 **집합** (`{[Epoch]: true}`, 게이트가 유보를 풀며 떼어낸 스냅샷). 값도 리비전도 안 실린다. 분기는 `isEpoch`로. 계약은 `base/state-epoch-plan.md` §5가 소스. - - **⚠️ [2026-08-22 신설] 등록 시점의 즉시 1회 실행에는 `from`이 없다** — - 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `from`은 + - **⚠️ [2026-08-22 신설] 등록 시점의 즉시 1회 실행에는 `emitFrom`이 없다** — + 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom`은 **옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high` - 발견 — 한때 non-optional로 적혀 있었다). `fn`이 `from`을 실제로 쓰는 + 발견 — 한때 non-optional로 적혀 있었다). `fn`이 `emitFrom`을 실제로 쓰는 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. - **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라 **핸들과 메타데이터**뿐이다. @@ -1233,7 +1289,10 @@ override할 자리를 구조적으로 열어두는 것.) 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단). - **⭐ [2026-08-25 정정, 7라운드 `H-61`] 내부 콜백은 no-op가 아니라 - `function(self) self:Get() end`이다.** 전파가 push-invalidate / + `function(targetState) targetState:Get() end`이다**(**[2026-08-26 + 표기 정정, `H-109`]** — 파라미터 이름이 `self`였는데 확정된 전파 루프 + 시그니처에서 1번 자리는 Observer가 아니라 **리시버 State**다. 값은 + 처음부터 그걸 뜻했다). 전파가 push-invalidate / pull-recompute라 `Get()`을 안 부르면 재계산이 아예 안 일어난다 — 같은 절이 바로 위에서 *"값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함"*이라 못박고 있으므로, no-op 콜백이었다면 이 유틸은 자기 용도 @@ -1272,6 +1331,29 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉 `Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다. +- **⭐⭐ [2026-08-26 확정, 8라운드 `H-111`] `WeakSubscribe`도 `.Subscribed = true`를 + 세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고, + `.Subscribed` 플래그는 **강·약 구독 경로 공용**이다. 이게 정해져 있지 + 않아서 두 해석이 각자 다른 확정 문장에 뿌리를 두고 있었고, 안 세우는 + 쪽으로 읽으면 전파 루프의 `canExecute(sub)` 게이트가 항상 거짓이 되어 + **`Effect`의 State dep 전량이 조용히 침묵**한다(`Effect`의 내부 Observer는 + `WeakSubscribe`로만 등록되고, gcconn 경로는 핸들에만 있으므로 남는 판정 + 근거가 `.Subscribed`뿐이다). 사용자 원문 *"구현이 한 벌"*과도 이쪽이 + 정합하다. 따라서: + - `WeakSubscribe()` = 가드 + `.Subscribed = true` + 약한 레지스트리 등록 + - `Subscribe()` = 그 위에 **강한 킵 하나만** 추가 + - `Unsubscribe()`/`WeakUnsubscribe()`는 대칭으로 `.Subscribed`를 내린다 + - **⭐ [2026-08-26 신설, `/code-review high` 5차] 해제는 *건 경로로* 푼다** — + 강하게 구독된 값에 `WeakUnsubscribe`, 약하게만 구독된 값에 `Unsubscribe`는 + **둘 다 error**다(양방향 fail-fast, 사용자 확정). 후자를 안 막으면 조용히 + 성공해서 범용 정리 코드가 `Effect`의 내부 Observer(오직 `WeakSubscribe`로만 + 등록된다)를 죽이고 **State dep 전량이 침묵**한다. 의사코드는 + `base/lifecycle-pattern.md`의 "(2) 전역 경로" 절이 소스. + - `base/lifecycle-pattern.md`의 `isBoundAlive` (b) 경로 주석이 이에 맞춰 + "전역 경로: `:Subscribe()`가 세운 것"에서 "구독 경로(강/약)가 세운 것" + 으로 정정됐다. **기각된 대안**: `canExecute`의 전역 경로 판정을 필드 + 대신 레지스트리 멤버십으로 바꾸는 안 — 동작은 같지만 해제가 양쪽 + 테이블을 지워야 하는 대칭 요구가 새로 생긴다. - **자료구조**: 전역 레지스트리에 **약하게** 들어간다. 살려두는 책임은 **잡고 있는 쪽**에 있다 — `Effect`가 자기 내부 Observer를 `_deps`에 강하게 들고 있는 게 그 예다(`base/effect-plan.md`의 @@ -1300,7 +1382,7 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 -- 개념 스케치. 확정 구현은 base/lifecycle-pattern.md가 소스 local gcconn = BindData:GetWeak(self, "gcconn") -- leaf 경로(bindLifetime이 복사해둠) if gcconn ~= nil and gcconn.Connected then return true end - return self.Subscribed == true -- 전역 경로(:Subscribe()만 세팅) + return self.Subscribed == true -- 구독 경로(강/약 공용, H-111) ``` **[정정, 2026-08-14 다섯 번째 세션]** 이 절의 옛 스케치는 `self.Subscribed`를 먼저 보고 `self.Connection`을 폴백으로 두는 모양이었는데, `.Subscribed`는 @@ -1316,10 +1398,30 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). **`:Unsubscribe()`는 이 레지스트리에서 반드시 `SubscribedObservers[observer] = nil`까지 해야 함** — `Subscribed` 플래그만 내리고 강참조를 안 끊으면 - GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트. -- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 - 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 - no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. + GC 대상이 안 되는 반쪽짜리 해제가 됨. + **⭐ [2026-08-26 정정, 8라운드 `H-111`]** 여기 한때 *"둘은 항상 같이 + 일어나는 한 세트"*라고 적혀 있었는데, `:WeakSubscribe()`가 생기면서 + 거짓이 됐다 — **약한 구독은 `.Subscribed`는 세우고 이 강한 레지스트리는 + 안 건드린다**(약한 레지스트리만 채운다). 그 문장 그대로면 `WeakSubscribe`의 + 정상 동작이 반쪽짜리 해제로 오독된다. 네 진입점의 짝 표는 + `base/lifecycle-pattern.md`의 "(2) 전역 경로" 절이 소스. **여전히 참인 + 것**: `:Unsubscribe()`는 자기 짝(강한 레지스트리 + 필드)을 다 지워야 한다. +- **⛔ [2026-08-26 폐기, `/code-review high`] "둘 다 idempotent"는 틀렸다.** + 여기 한때 *"이미 구독 중인데 또 Subscribe해도, 구독 안 했는데 + Unsubscribe해도 에러 안 나고 그냥 no-op. 토글 로직 짤 때 상태 추적 부담을 + 줄여줌"*이라고 적혀 있었는데, **2026-08-18에 `canBound` 게이트가 들어오면서 + `:Subscribe()`는 error가 됐고** 그 모순이 그대로 방치돼 있었다(`H-111`로 + `WeakSubscribe`도 같은 게이트를 타면서 표면이 더 넓어졌다). **사용자 확정: + 확정 의사코드가 정본**이다. + - **`:Subscribe()`는 idempotent가 아니다** — 이미 구독됐거나 leaf에 + 바인드된 값에 다시 부르면 **error**(`base/lifecycle-pattern.md`의 + "(2) 전역 경로" 절). 이중 바인딩 금지가 `canBound` 하나로 통일돼 있고 + (`bindLifetime`도 같은 게이트), 조용한 no-op보다 fail-fast가 이 코퍼스의 + 기조다. + - **⚠️ [2026-08-26 재정정, `/code-review high` 6차] `:Unsubscribe()`도 + idempotent가 아니다.** 여기 한때 *"게이트가 없어 … 비대칭이 의도된 것"* + 이라고 적혀 있었는데, 같은 날 **대칭 가드**가 들어오며 거짓이 됐다(위 항목). + 지금 계약은 **해제는 건 경로로 푼다** 하나다. - **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`는 @@ -1402,11 +1504,23 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti -- "지금 묶어도 되는가"(참 = 아직 안 묶임)라 게이트는 `not`이 붙는다. if not canBound(self) then error(if self.Subscribed - then "이미 :Subscribe()로 전역 바인딩된 값" + then "이미 구독된 값" -- [2026-08-26 H-111] 강/약 어느 쪽이든 이 분기 else "이미 다른 Instance에 바인딩된 값") end ``` +> **⚠️ [2026-08-26 정정, 8라운드 `H-111`] 아래 세 항목과 이 절 끝의 정정 +> 문단이 `.Subscribed`를 "전역 `:Subscribe()` 전용"이라고 부르는데, 그건 +> `:WeakSubscribe()`가 생기기 전 서술이다.** 지금은 **구독 경로(강/약) 공용** +> 이다 — 약한 쪽도 이 필드를 세우고, 갈라지는 건 레지스트리를 강하게 +> 잡느냐뿐이다(위 `:WeakSubscribe()` 절, `base/lifecycle-pattern.md`의 +> 네 진입점 의사코드가 소스). **`bindLifetime`/`unbindLifetime`이 이 필드를 +> 읽지도 쓰지도 않는다는 요지는 그대로 유효하다** — 바뀐 건 "구독 쪽에서 +> 누가 세우는가"뿐이다. 옛 에러 문구 *"이미 `:Subscribe()`로 전역 바인딩된 +> 값"*은 `WeakSubscribe`로만 등록된 값(예: `Effect`의 내부 Observer)까지 +> 가리키므로 **틀린 원인을 지목한다** — 위 스니펫처럼 넓혔다(실제 문구는 +> 영어, `base/architecture.md`의 error 계약). + - **[정정, 2026-08-18 구현 전 QA] `canBound`와 `canExecute`는 값이 같은 게 아니라 서로의 부정이다** — `canBound(v) == not canExecute(v)`. 둘이 공유하는 건 판정 **로직**(비공개 헬퍼 `isBoundAlive`, @@ -1415,7 +1529,7 @@ end "이미 묶여 있는가"로 잘못 읽은 것이었음. 이 절(이중 바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 씀. - **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 - **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, + **구독 경로에서만 세팅되므로**(위 ⚠️: 강·약 공용, `H-111`) 참이면 구독, 거짓인데 `canBound`가 **거짓**이면 leaf 경로. - 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "지금 묶어도 되는가"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 @@ -1427,8 +1541,10 @@ end **[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 `canExecute`가 이미 보는 `.Subscribed` 필드 그 자체이고, `bindLifetime`도 그 필드를 세팅한다"(2026-08-09 여섯 번째 세션)는 틀렸음.** -`.Subscribed`는 **전역 `:Subscribe()`/`:Unsubscribe()` 전용 필드로, -`bindLifetime`/`unbindLifetime`과는 일절 이해관계가 없다** — 이 둘은 +`.Subscribed`는 **구독 경로 전용 필드로, +`bindLifetime`/`unbindLifetime`과는 일절 이해관계가 없다**(**[2026-08-26 +`H-111`]** 여기 "전역 `:Subscribe()`/`:Unsubscribe()` 전용"이라 적혀 있었으나 +`:WeakSubscribe()`/`:WeakUnsubscribe()`도 같은 필드를 쓴다 — 위 ⚠️) — 이 둘은 그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). 옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 diff --git a/.claude/base/state-epoch-plan.md b/.claude/base/state-epoch-plan.md index 65733a4..4157be2 100644 --- a/.claude/base/state-epoch-plan.md +++ b/.claude/base/state-epoch-plan.md @@ -252,11 +252,19 @@ local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함 ## 4. State는 `EpochMap`을 둘 컴포지션한다 +> **⚠️ [2026-08-26, 8라운드 `H-114`] `rawInvalid: boolean`은 폐기된 필드다** — +> 아래 "`rawInvalid` 불린은 **캐시 카운터 쌍**으로 교체됐다" 절이 소스다. +> 그 절의 안내 문장은 *"아래 두 절"*(재계산 판정 / 재계산이 끝나면)만 +> 가리켰는데, **이 구조체 선언과 바로 아래 수신 규칙은 그 절보다 위에 +> 있다** — 위에서부터 읽으면 폐기된 필드로 구조체를 먼저 확정하게 된다. +> 아래 서술에서 `rawInvalid`가 나오면 전부 카운터 쌍으로 읽을 것. + ``` State - valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성) - emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup) - rawInvalid : boolean -- "재계산이 필요하다"는 확정 플래그 + valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성) + emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup) + cacheTargetCount : number -- ┐ 옛 `rawInvalid`. 둘이 다르면 "재계산 필요" + cacheCurrCount : number? -- ┘ (시드는 target = 0, curr = nil) ``` **⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로 @@ -405,8 +413,10 @@ if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end - **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 **자기 재계산 부기**이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해 `TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관). -- 아래 두 절(`재계산 판정` / `재계산이 끝나면`)의 `rawInvalid`는 전부 이 - 카운터 비교로 읽을 것. +- **[2026-08-26 정정, `H-114`] §4의 `rawInvalid`는 *전부* 이 카운터 비교로 + 읽을 것** — 여기 한때 *"아래 두 절(`재계산 판정` / `재계산이 끝나면`)"* + 이라고만 적혀 있었는데, 이 절보다 **위**에 있는 구조체 선언과 수신 규칙도 + 같은 대상이다(그쪽엔 배너를 달아뒀다). ### 재계산이 끝나면 @@ -456,8 +466,12 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 절이 소스. **그래서 `Observer` 클로저는 출처를 인자로 받는다** — -`fn(self, from: (Epoch | EpochSet)?)`. `:Compute`의 `fn(self, ...)`와 같은 -모양이고(설치 발화에는 출처가 없어 `nil`이다), +`fn(targetState, self, emitFrom: (Epoch | EpochSet)?)` +(**[2026-08-26 정정, 8라운드 `H-109`]** 여기 한때 2-인자 `fn(self, from)`으로 +적혀 있었는데, 그때의 `self`는 **리시버 State의 lazy 핸들**을 뜻했고 전파 루프 +의사코드는 같은 자리에 Observer 값을 넘기고 있었다 — Observer 핸들이 가운데 +자리로 들어와 세 자리가 됐다). 1번 자리는 `:Compute`의 `fn(self, ...)`와 같은 +모양이고(설치 발화에는 출처가 없어 `emitFrom`이 `nil`이다), 값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은 안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스. diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 5589e54..4c3606a 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -102,6 +102,17 @@ store.hp:Compute(function(s) ... end) `Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데 `table.clone(nil)`은 `table expected, got nil`로 죽는다(**[2026-08-25]** 7라운드 `H-83` 실측). +- **⭐ [2026-08-26 신설, 8라운드 `H-122`] 생성자가 `defaults` 값 전량을 + 런타임 검증한다 — `isSource` 화이트리스트.** 타입은 `Source` 필드를 + 요구하지만 `--!nocheck`/동적 코드가 `{hp = 100}`(raw 값)을 넘기면 지금 + 스케치(`table.clone`)는 **조용히 받고** 첫 `store.hp:Get()`에서 엉뚱한 + 에러로 죽는다. `H-40`이 `:List` 요소 검증을 블랙리스트에서 화이트리스트로 + 뒤집은 것과 같은 성격의 자리다 — **사용자 확정**으로 여기도 화이트리스트를 + 둔다. `defaults`를 한 번 순회하며 `isSource(v)`가 거짓이면 + **`error(..., 2)`**(사용자 입력 검증이므로 `level 2`, 메시지는 영어 — + `base/architecture.md`의 error 계약). 생성 시 1회라 hot path가 아니다. + `isModifier` 쪽 가드는 여기가 아니라 **`Source` 생성자**가 맡는다 + (`base/modifier-plan.md` 7번). - **`store:Names()`** — 그 시점의 키 집합을 준다(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이 이걸 요구한다 (`base/attribute-plan.md`, 7라운드 `H-79`). @@ -211,12 +222,19 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 ``` 원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서 `base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다. -- **⭐ [2026-08-25] 예약 키는 `Of`/`Names` 둘뿐이고, 충돌은 조용히 죽는다.** +- **⭐ [2026-08-25, 2026-08-26 개수 정정] 예약 키는 `Of`/`Names`/`__reservedCheck` + 셋이고, 충돌은 조용히 죽는다.** (**[2026-08-26, `/code-review high`]** 한때 + *"`Of`/`Names` 둘뿐"*이라 적혔는데, 진단용 팬텀 필드가 교집합 **안에** + 살므로 그 이름 자신도 예약해야 자기 충돌이 조용히 지나가지 않는다 — + 아래 타입 선언이 소스.) 실측: 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드의 타입 검사가 통째로 꺼진다**(음성 대조군이 진단 0건으로 통과했다). 시끄럽게 막히는 게 아니라 그냥 지나간다. - - **그래서 `T`를 검증만 하고 그대로 통과시키는 작은 `type function`을 - 둔다.** 겹치면 사용 지점에 + - **그래서 예약 키를 검증만 하는 작은 `type function`을 둔다** + (**⚠️ [2026-08-26 정정, 8라운드 `H-112`]** 여기 한때 *"`T`를 검증만 하고 + 그대로 통과시키는"*이라고 적혀 있었는데 **`T`를 통째로 넘기는 배선은 실사용 + `T`에서 아예 안 돈다** — 확정된 인자는 `keyof`이고, 근거와 실측은 아래 + "`store.key` 레코드 필드 타이핑" 절이 소스). 겹치면 사용 지점에 `TypeError: quad.Store: "Of" is a reserved key`가 뜬다. **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 버려진다. `print(...)` + `return types.never` 조합만 된다 @@ -248,9 +266,83 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 type Store = T & { Of: (self: any, name: string) -> Source, -- 동적 키 전용 Names: (self: any) -> { string }, + __reservedCheck: CheckReservedKeys>, -- 팬텀 필드(진단 전용) } +-- 검증 목록은 `Of` · `Names` · `__reservedCheck` 셋이다 — 팬텀 필드 자신도 +-- 교집합 안에 있으므로 자기 이름을 예약 목록에 넣어야 자기 충돌이 조용히 +-- 지나가지 않는다. +-- +-- ⚠️ [2026-08-26, `/code-review high` 5차] **`__reservedCheck`는 런타임 +-- 대응물이 없다.** 생성자는 `table.clone(defaults or {})`뿐이라 +-- `store.__reservedCheck`는 **타입은 `true`, 런타임은 `nil`**이다 — 이 +-- 문서가 옆에서 "선언 안 된 키 접근은 타입 에러"임을 스파이크로 증명해두는 +-- 것과 대비되는 unsound한 자리다. 팬텀 필드의 존재 이유가 진단 하나뿐이라 +-- 감수하되, 두 가지를 문서화한다: +-- (1) **읽지 말 것** — 사용자 표면이 아니다. +-- (2) `store:Names()`(그림자 테이블의 키)에는 **안 들어간다**. 그래서 +-- `store:Of("__reservedCheck")`는 런타임에선 그냥 통과해 충돌하는 +-- `Source`를 만든다 — 타입 쪽은 `CheckReservedKeys`가 막지만 +-- **동적 키는 이름이 타입에 안 실리므로** 못 막는다. ``` +**⭐⭐ [2026-08-26 확정, 8라운드 `H-112`] 예약 키 진단 타입 함수는 `T`가 아니라 +`keyof`를 받는다.** 2026-08-25엔 *"`T`를 검증만 하고 그대로 통과시키는 +작은 `type function`"*으로 확정돼 있었는데, **그 배선은 실사용 `T`에서 아예 +돌지 않는다**(실측 재현): + +- 최종 Store의 타입 인자는 정의상 `{hp: Source, …}` — **모든 실사용 + `T`에 `Source` 필드가 들어간다.** +- 확정 선언 스타일(`base/typing-limits.md` §1②의 인라인 쪼개기 — 콜백 + 파라미터 무주석 추론이 사는 유일한 스타일)에서 `Source`는 `Compute`의 + `-> State` 재귀 반환 누수 때문에 내부에 `*error-type*`을 품는다. +- **Luau 타입 함수는 `*error-type*`이 든 타입을 받는 순간 실패한다** — + 그래서 `CheckReserved`를 어디에 배선하든 **예약 키가 없는 완전히 유효한 + Store 사용 지점 전부**에 `TypeError: Type functions do not currently + support types of the form '*error-type*'`가 뜬다. 접근 경로에 배선하면 + (`Store = CheckReserved & {…}`) 거기 더해 `does not have key 'hp'`로 + **필드 접근까지 전멸**한다. +- 7라운드가 이걸 못 본 이유: 그때 돌린 스파이크 + (`audit/type-store-index-keyof/spikes/05-checkreserved.luau`)의 `T`가 + **철회된 재설계의 스칼라 필드**(`{hp: number}`)였다. 최종형에선 필드가 + 스칼라가 아니라 `Source`다. + +**통과하는 배선**(실측 완료 — `keyof`는 키 싱글톤 유니온이라 `Source` +타입이 인자에 아예 안 실리고, `*error-type*` 직렬화 문제가 원천 소멸한다): + +```lua +type function CheckReservedKeys(keys: type) + -- keys: "hp" | "name" 같은 싱글톤 유니온. + -- "Of"/"Names"/"__reservedCheck"가 섞여 있으면 print(...) + return types.never. + -- (error()는 못 쓴다 — 타입 함수 자체가 실패한 걸로 판정돼 버려진다) + return types.singleton(true) +end +``` + +측정 결과: 예약 키 충돌 시 `TypeError: quad.Store: "Of" is a reserved key`가 +**캐스트 지점과 생성자 호출 지점 양쪽에서** 강제 평가 없이 뜨고, +`store.hp:Get()` 양성 통과 / 음성 대조군 정확히 걸림, 그리고 +`store.hp:Compute(function(x) return x:Get() * 2 end)`의 **무주석 콜백 추론이 +생존**한다. `base/typing-limits.md` §0(*타입 함수는 진단까지만*)의 허용 범위 +안이다 — 결과가 접근 타입에 안 섞이고(팬텀 필드 값은 `types.singleton(true)`), +같은 §0이 경고하는 내장 `index<>`/`keyof<>`도 **형제 필드의 `*error-type*`에 +오염되지 않는다**는 것이 별도 실측(`CheckedQuad`)으로 재확인됐다. + +**⚠️ [2026-08-26, `/code-review high`] 빈 Store(`Store<<{}>>()`)는 아직 실측 +안 됐다.** `H-83`은 무인자 생성이 유효해야 한다고 확정했는데(`or {}` 방어의 +존재 이유), 위 실측은 **키가 있는 `T`로만** 돌았다. `keyof<{}>`가 Luau에서 +빈 유니온이 되는지 에러가 되는지에 따라 **무인자 Store 전체가 스퓨리어스 +타입 에러를 뒤집어쓸 수 있다** — `H-112`가 `CheckReserved`에서 찾은 실패 +모드와 정확히 같은 형태다. `luau-test/STATUS.md`의 `16`/`21` 재작성 때 +`Store<{}>`를 대조군으로 넣을 것. + +**기각된 대안**: (b) `CheckReserved` 자체를 포기하고 "예약 키가 겹치면 그 +필드의 타입 검사가 조용히 꺼진다"를 문서 경고로만 남기는 안, +(c) 선언 스타일을 §1③(`typeof`)으로 전환하는 안 — ③ 전면 전환은 `Get()`이 +`unknown`으로 붕괴하고 하이브리드는 무주석 콜백 추론이 깨지는 것이 실측됐다 +(둘 다 `CheckReserved` 유무와 무관한 선언 스타일 자체의 문제 — 대조군 확인). +즉 **"콜백 추론 + 예약 키 진단" 동시 성립 배선은 `T`를 통째로 넘기는 한 +존재하지 않는다.** + **`luau-analyze` 실측**(양성 + 음성 대조군): | 검사 | 결과 | @@ -273,7 +365,24 @@ type Store = T & { `typeof(f<>)`로 타입 인자를 넘기는 것도 실패한다(실측). 같은 사실이 `base/source-state-plan.md`의 `:Apply`에도 적용된다(7라운드 `H-94`). -실측 전량은 `audit/type-store-index-keyof/`가 소스. +실측 전량은 `audit/type-store-index-keyof/`가 소스. **⚠️ [2026-08-26, +8라운드 `H-115`] 다만 그 폴더에 최종형 결합을 도는 파일이 없다** — 대부분이 +철회된 재설계(`__store` 팬텀 + `keyof>`, 값 필드가 스칼라)를 +대상으로 한다(REPORT 상단 배너도 철회를 명시). 위 표의 측정값 자체는 +8라운드 스파이크로 대체 재확인됐고(단 `CheckReserved` 결합은 표와 달리 +실패 — `H-112`), **출처 문제는 `luau-test/STATUS.md`의 `16`/`21` 재작성 때 +최종형 + `CheckReservedKeys` 배선을 같이 넣으면 닫힌다.** 그 폴더의 `02`와 +`06`은 최종형에서도 유효한 측정이니(필드가 `Source`이고 `__store` 팬텀도 +`keyof`/`index`도 없다) **재작성 때 버리지 말 것.** + +**⚠️ [2026-08-26 보강, 8라운드 `H-117`] `store:Of("k")`를 인스턴스화 없이 +부르면 주석까지도 무력하다.** 위 실측 블록의 `local none: Source = +s:Of("z")`가 `Source`이 된다는 것만 적혀 있었는데, +`Source`은 **아무 구체 타입 주석과도 충돌하지 않는다** — 실측에서 +`local d: Source = s:Of("dyn")`이 진단 0건으로 통과했다. 즉 +*"`Of`는 타입 보장을 포기했다가 호출부에 드러나는 자리"*는 **`<>`를 +실제로 적었을 때** 얘기고, 빠뜨리면 조용히 unsound하다(§1의 명시 바인딩 +구멍과 같은 부류지만 자리가 다르다). ## Store가 Store를 저장 가능한가 diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 5269087..703db64 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -52,8 +52,18 @@ - **허용**: 타입이 못 잡는 오용을 **컴파일 타임 에러로 만드는** 용도. `type-version-check`의 `CheckVersion`(버전 불일치를 사람이 읽을 - 메시지로), Store의 `CheckReserved`(예약 키 충돌)가 그 사례다. 둘 다 - **`T`를 검증만 하고 그대로 통과**시키고 결과 타입을 만들지 않는다. + 메시지로), Store의 `CheckReservedKeys`(예약 키 충돌)가 그 사례다 + (**[2026-08-26 이름·인자 정정, 8라운드 `H-112`]** 옛 이름은 `CheckReserved` + 였고 `T`를 통째로 받았는데, 그 배선은 실사용 `T`에서 아예 안 돈다 — `T`에 + 실리는 `Source`가 §1 누수로 `*error-type*`을 품기 때문. 지금은 + `keyof`만 받는다). 둘 다 **검증만 하고 결과 타입을 만들지 않는다** + (**[2026-08-26 재정정, `/code-review high`]** 여기 *"둘 다 `T`를 검증만 하고 + 그대로 통과시키고"*라고 이어졌는데, `CheckReservedKeys`에 대해선 **양쪽 다 + 거짓**이다 — `T`가 아니라 `keyof`를 받고, `T`를 통과시키는 게 아니라 + `types.singleton(true)`를 팬텀 필드에 돌려준다. 이 §0이 "무엇이 합법적인 + 타입 함수인가"의 소스라, 그대로 읽으면 `H-112`가 **유효한 Store 전부에 + 스퓨리어스 에러**를 낸다고 실측한 그 `CheckReserved` 배선을 다시 + 도출하게 된다). `error()`가 아니라 `print(...)` + `return types.never`를 쓴다. - **안 함**: **접근 타입/결과 타입을 합성**하는 용도. - **왜**: 그 선을 넘으면 §6이 실측한 함정(합성을 거친 값은 이후 제네릭 diff --git a/.claude/conventions.md b/.claude/conventions.md index eda635e..8fe5c85 100644 --- a/.claude/conventions.md +++ b/.claude/conventions.md @@ -266,8 +266,23 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 **diff 자체의 결함**(이번에 새로 쓴 서술 안의 모순, 새 API가 기존 계약과 충돌하는가). 실제로 그 10건엔 "새로 확정한 `store:GetDynamic`이 Store의 lazy `__index`와 충돌해 그대로 구현하면 런타임 에러"처럼 감사자 각도에선 - 안 보이는 것이 있었다. **`/code-review`는 사용자만 호출할 수 있으므로**, - 큰 변경을 커밋하기 전엔 그걸 돌릴지 사용자에게 물어볼 것. + 안 보이는 것이 있었다. + - **⚠️ [2026-08-26 사실 정정] *"`/code-review`는 사용자만 호출할 수 있다"*는 + 틀렸다** — 지금은 **세션의 skill 목록에 있어 직접 호출된다**(이 세션이 + 3차를 직접 돌려 확인). 이 문장을 그대로 믿고 두 번을 사용자에게 요청한 + 뒤에야 발견했다. **돌릴지 말지는 여전히 비용 판단**이라(1회 20만 토큰 + 안팎) 자동 실행을 관례로 못 박지는 않는다 — 큰 변경 뒤엔 돌리는 걸 + 기본으로 하되, 세션 한도가 빠듯하면 사용자에게 상의할 것. + - **⭐ [2026-08-26 실측] 감사 루프가 수렴해도 code-review는 따로 필요하다.** + 8라운드 반영에서 `quad-doc-auditor` **11라운드가 44건을 잡고 0건으로 + 수렴한 직후**, `/code-review high` 3회가 **21건을 더** 냈고 전부 + 유효했다(HIGH 1건 포함 — 그대로 두면 런타임 크래시). 그중 **절반 이상이 + 직전 수정의 산물**이었다. 두 도구가 못 보는 영역이 갈린다: + **감사자는 문서 *사이*의 정합성**(A의 결정 vs B의 서술), **code-review는 + 방금 쓴 문단 *안*의 논리**(표에 행만 넣고 헤딩 개수는 그대로, 이름만 + 바꾸고 그 이름이 서술하던 동작은 그대로, 새 규칙의 *근거*를 잘못 적어 그 + 근거가 다른 불변식을 함의하게 만듦). **수정분 자체가 새 결함을 만든다는 + 게 반복 관측됐으니, 반영이 끝난 뒤 한 번 더 돌리는 걸 기본으로 할 것.** - **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할 것.** 다만 위 체크리스트+`doc-check.py`+`quad-doc-auditor`가 자리잡으면 이 감사는 "기계도 서브에이전트도 못 보는 것"(설계 자체의 자기모순, 의사코드 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 13ef703..94fa0e1 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -83,12 +83,12 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | | `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.md` 2026-08-06 세션, `debug-tooling-plan.md` | -| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 | +| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 **⚠️ [2026-08-26 이동 → `rewrite-required/`, 8라운드 `H-122`/`H-123`]** 검증 코드의 Store 생성자가 **eager `Source(v)`** 모델을 박제하고 있다 — 명시적 초기화 확정(2026-08-25) 이후 **`defaults` 경로에선** Store가 `Source`를 만들지 않는다(동적 키 창구 `store:Of(name)`은 여전히 만든다 — 그래서 가드가 `Source` 생성자로 갔다). 재작성 지침은 `STATUS.md`의 `rewrite-required/` 표. | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef`/`PostRef`가 `Ref`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | -| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 **⛔ [2026-08-25 이동, `rewrite-required/`] 검증 대상이 폐기됐다** — `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근 자체가 Store 재설계로 사라졌다(`base/store-plan.md`, 역전 원문은 `archive/store-value-field-redesign-withdrawn.md`). 재작성 지침은 `STATUS.md`가 소스 — **타입 함수 없는 평범한 레코드** 모양을 검증하고 음성 대조군에 **예약 키 충돌**(`CheckReserved`)과 없는 키 접근을 넣을 것 | +| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 **⛔ [2026-08-25 이동, `rewrite-required/`] 검증 대상이 폐기됐다** — `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근 자체가 Store 재설계로 사라졌다(`base/store-plan.md`, 역전 원문은 `archive/store-value-field-redesign-withdrawn.md`). 재작성 지침은 `STATUS.md`가 소스 — **타입 함수 없는 평범한 레코드** 모양을 검증하고 음성 대조군에 **예약 키 충돌**(`CheckReservedKeys>` — **[2026-08-26 `/code-review high`]** 옛 이름·배선 `CheckReserved`는 `T`를 통째로 받아 실사용 `T`에서 아예 안 돈다, `H-112`)과 없는 키 접근, 그리고 **`Store<<{}>>()`(빈 `keyof`)** 를 넣을 것 — 상세 지침은 `STATUS.md`가 소스 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 74f29c3..5a24c56 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -103,6 +103,24 @@ 통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`는 **이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다. +**⭐ [2026-08-26, 8라운드 `H-122`/`H-123`] `11-modifier-illegal-value-error`가 +합류했다** — 그 파일의 Store 생성자가 **eager `Source(v)` 모델**을 코드로 +박제한 채 `done/`에서 "통과" 상태로 앉아 있었다. 명시적 초기화 확정 +(2026-08-25) 이후 Store는 `Source`를 만들지 않으므로, 그대로 두면 옛 모델을 +"검증됨"으로 오독하게 된다. 재작성 지침은 위 표의 그 행. + +**⭐ [2026-08-26, 8라운드 `H-115`/`H-112`] `16`/`21` 재작성 때 넣을 것** — +`base/store-plan.md`의 최종형 실측 표가 `audit/type-store-index-keyof/`를 +출처로 인용하는데 **그 폴더에 최종형 결합을 도는 파일이 없다**(대부분 철회된 +재설계 대상). 재작성이 최종형(`Store = T & {Of, Names, __reservedCheck}`, +필드가 `Source`)과 **`CheckReservedKeys>` 배선**을 같이 담으면 +그 출처 문제가 닫힌다. 그 폴더의 `02`/`06`은 최종형에서도 유효한 측정이니 +버리지 말 것. **그리고 `Store<<{}>>()`(무인자, `keyof<{}>`)를 대조군으로 +반드시 넣을 것** — `/code-review high`(2026-08-26)가 지적한 미실측 자리다. +`H-83`이 무인자 생성을 유효하다고 확정했는데 `H-112`의 실측은 키가 있는 `T`로만 +돌았고, `keyof<{}>`가 빈 유니온인지 에러인지에 따라 **무인자 Store 전체가 +스퓨리어스 타입 에러**를 받을 수 있다. + **[2026-08-25] `16`과 `21`이 합류** — Store 재설계로 **검증 대상 자체가 폐기**됐다. `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근이 사라졌다 — 지금은 **타입 함수를 안 쓰고** 타입 인자에 `Source`를 @@ -132,7 +150,8 @@ | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) | -| `16-type-store-key-typefunction.luau` | 옛 접근 기준으로는 ✅ 통과였음 | **[2026-08-25] 검증 대상이 폐기됨** — `WrapStore`/`ProcessStoreType` 합성 자체가 사라졌다. **재작성 지침**: 타입 함수 없는 평범한 레코드 모양(`base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절)을 검증하고, 음성 대조군에 **예약 키 충돌**(`CheckReserved`가 `types.never`로 무너뜨리는지)과 **없는 키 접근**을 포함할 것 | +| `16-type-store-key-typefunction.luau` | 옛 접근 기준으로는 ✅ 통과였음 | **[2026-08-25] 검증 대상이 폐기됨** — `WrapStore`/`ProcessStoreType` 합성 자체가 사라졌다. **재작성 지침**: 타입 함수 없는 평범한 레코드 모양(`base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절)을 검증하고, 음성 대조군에 **예약 키 충돌**(`CheckReservedKeys>`가 `types.never`로 무너뜨리는지 — **[2026-08-26 `H-112`]** 인자가 `T`가 아니라 `keyof`다)과 **없는 키 접근**을 포함할 것 | +| `11-modifier-illegal-value-error.luau` | 옛 형태 기준으로는 ✅ 통과였음(16개 케이스 전원) | **[2026-08-26, 8라운드 `H-122`/`H-123`] 검증 코드가 폐기된 모델을 박제하고 있다** — 그 파일의 Store 생성자가 **eager `Source(v)`** 모델이다. 명시적 초기화 확정(2026-08-25) 이후 **`defaults` 경로에선** Store가 `Source`를 만들지 않는다(동적 키 창구 `store:Of(name)`은 여전히 만든다 — 그래서 가드가 `Source` 생성자로 갔다). **재작성 지침**: `isModifier` 가드의 새 자리는 **`Source` 생성자**(+`Source:Set`, `:Compute` 결과 캐싱)이고, Store 생성자가 하는 건 `defaults`의 **`isSource` 화이트리스트 검증**(error level 2)이다 — 둘을 각각 양성/음성으로 볼 것. **검증 대상(핸들러 계층 값 즉시 error)은 그대로**라 결론이 바뀌는 건 아님 | | `21-type-store-undeclared-key-rejected.luau` | 옛 접근 기준으로는 ✅ 통과였음 | `16`의 `ProcessStoreType`을 재사용하므로 같이 낡음. **검증 대상(미선언 키가 타입 에러)은 그대로 유효**하다 — 새 `Store` 선언으로 바꿔 쓰기만 하면 된다(`store:Of("nope")`이 거부되는 것도 같이 넣을 것) | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | @@ -168,7 +187,6 @@ | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | -| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | @@ -229,4 +247,5 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치 | ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7`로 `Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` | | 중간 State가 `_hold`(하류 → 상류 강함) 불변식으로 실제로 살아남는가 | **[2026-08-25] 설계는 확정됐다** — 각 파생 노드가 자기 상류를 `_hold`로 강하게 든다(사용자 확정). 남은 건 실측뿐이고 **M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려감). 음성 대조군으로 "`_hold` 없이 짜면 중간 노드가 수거돼 전파가 끊긴다"까지 볼 것 | `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 살아남는다" 절 | | `Relate` 값/내부 키가 바깥 키를 되참조하면 새는가 | **[2026-08-25 신설, 7라운드 `H-71`/`H-77`]** `done/07`은 **안전한 모양만** 봤는데 여러 문서가 그걸 "GC-native 아키텍처의 핵심 전제 검증"으로 인용한다. 실제로는 (a) `SetStrong`의 **값**이 되참조하면 100% 새고, (b) **내부 키**가 되참조하면 `SetStrong`/`SetWeak` **둘 다** 샌다. `07`에 음성 대조군으로 추가할 것 | `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표 | +| **[2026-08-26 신설, 8라운드 `H-112`]** `CheckedQuad`이 M2 표면 추가 후에도 사는가 | 8라운드에 **한 번 실측해 통과**했지만(배선이 격리형이라 `Quad`에 `Source` 필드를 더해도 클린), 실제 M2 반영 뒤 `Quad` 타입의 선언 스타일이 정해지면 다시 봐야 한다. `23`(`done/`에서 **통과** 상태)이 `CheckedQuad`를 쓰므로 그걸 다시 돌리는 배치. **[2026-08-26 정정, `/code-review high`]** 여기 한때 "`rewrite-required/23`의 재작성과 같은 배치"라고 적었는데 **`rewrite-required/`에 `23`은 없다** — 일어나지 않을 재작성에 실측을 매달아 고아가 될 뻔했다 | `ROADMAP.md` M2의 `H-80` 체크박스, 8라운드 `H-112` | | `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` | diff --git a/.claude/luau-test/done/11-modifier-illegal-value-error.luau b/.claude/luau-test/rewrite-required/11-modifier-illegal-value-error.luau similarity index 100% rename from .claude/luau-test/done/11-modifier-illegal-value-error.luau rename to .claude/luau-test/rewrite-required/11-modifier-illegal-value-error.luau diff --git a/.claude/project-context.md b/.claude/project-context.md index 427ecd7..378cb05 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -20,12 +20,12 @@ M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선 절에 항목 둘이 올라와 있었으나, **⭐ [2026-08-25] 둘 다 닫혔다** (중간 State GC는 `_hold` 불변식으로, `store:GetDynamic` 위치는 콜론 유지 + -`CheckReserved` 타입 함수로 — 7라운드 손 트레이싱 후속, 결정 전량의 소스는 -`qa-request/pre-implementation-handtrace-round7-followup.md`). **⚠️ -[2026-08-26 정정] 다만 M2 착수는 다시 회신 대기다** — 8라운드 손 트레이싱 -(`qa-request/pre-implementation-handtrace-round8.md`)이 7라운드 반영분을 -겹쳐 트레이싱해 사용자 결정 문항 Q1~Q10을 올렸고(🔴 다섯이 착수 전 필요), -`question.md` 최우선 절이 이를 가리킨다. 저장소 루트에 +예약 키 진단 타입 함수로 — 7라운드 손 트레이싱 후속, 결정 전량의 소스는 +`qa-request/pre-implementation-handtrace-round7-followup.md`). **⭐ +[2026-08-26] 8라운드 손 트레이싱까지 처리 완료** — 7라운드 반영분을 겹쳐 +재트레이싱한 발견 17건(`H-107`~`H-123`)의 결정을 전부 `base/`에 반영했고 +(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`), +`question.md` 최우선 절은 다시 비어 있다. 저장소 루트에 `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가 diff --git a/.claude/qa-request/pre-implementation-handtrace-round7-followup.md b/.claude/qa-request/pre-implementation-handtrace-round7-followup.md index 7a787ca..074d3ec 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round7-followup.md +++ b/.claude/qa-request/pre-implementation-handtrace-round7-followup.md @@ -29,7 +29,12 @@ `bk.recomputeBlocker` · `EffectHandle:Rerun`(정의) · `_consumeCleanup` **폐기**: `WrapStore`/`ProcessStoreType` · `_installing` · `rawInvalid` · `_refDeps`/`_refCallbacks`/`_observers`(→ `_deps` 하나) · Store의 lazy 우선 모델 -**역전**: `store.key = value` 부활(`archive/store-value-field-redesign-withdrawn.md`) +**역전 없음** (**⚠️ [2026-08-26 정정, 8라운드 처리 중 발견]** 여기 한때 +*"역전: `store.key = value` 부활"*이라고 적혀 있었는데 **그건 같은 날 오전에 +넣었다 철회한 Store 재설계의 서술이 요약 머리에 남은 것**이다 — +`archive/store-value-field-redesign-withdrawn.md`의 대조표가 +*"`store.key = v` 부활 → **폐기 유지**"*라고 명시하고 `todos.md` 00번도 +"역전 없음"이라고 적는다. `store.key = value` 폐기(2026-08-06)는 유지된다) **툴체인**: `scripts/relink.sh` + `scripts/test.sh` 신설 — `luau` CLI가 심볼릭 링크를 못 탄다는 것이 최소 재현으로 밝혀졌다(`H-78`). diff --git a/.claude/qa-request/pre-implementation-handtrace-round8-followup.md b/.claude/qa-request/pre-implementation-handtrace-round8-followup.md new file mode 100644 index 0000000..8d5cf81 --- /dev/null +++ b/.claude/qa-request/pre-implementation-handtrace-round8-followup.md @@ -0,0 +1,631 @@ +# 8라운드 손 트레이싱 발견 — **사용자 결정과 반영 결과** + +**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round8.md`의 +발견 17건(`H-107`~`H-123`, 3개 패스)을 사용자와 대화형으로 처리한 결과. +**결정의 소스는 이 문서**이고, 발견 원문·실측 전사·"이상 없다고 확인한 것" +목록은 그 파일이 소스다(여기서 다시 서술하지 않음). + +**진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** +순서를 따랐다. 물어보기 전에 🔴 다섯의 핵심 주장(`Ref:Set` 블록에 `Revision` +갱신·Weak 순회 없음 / 전파 루프가 `sub.fn(sub, from)` / `CheckReserved`가 `T`를 +통째로 받음)을 `base/`에서 직접 재확인했고 **전부 그대로였다.** + +**[2026-08-26] 결정·반영 전부 완료.** 처분 요약: + +| 처분 | 번호 | +|---|---| +| 확정 — `base/` 반영 | `H-107`~`H-113`, `H-117`~`H-123` | +| **문항이 틀렸음 — 심각도 강등** | `H-118`(🟡→🟢, 설계 결정이 아니라 문서 정합) | +| 정정만(판단 불필요) | `H-114`, `H-115`, `H-121`, `H-123` | +| 문서화 대상으로 등록(지금 결정 아님) | `H-116` | + +**⭐ 사용자가 전제를 정정한 것 둘** — 이번 라운드의 실질적 소득이다: + +1. **`Ref` 콜백과 Observer 콜백을 통합하려던 시각 자체가 틀렸다**(Q2-후속). + *"observer 에는 epoch 란게 존재하지 않음. emit 으로 온 epoch 를 넘겨줄 뿐, + 그러나 ref 는 그 자체로 epoch임."* 그래서 `Effect`는 dep 종류별로 클로저를 + 따로 단다 — `effect-plan.md`의 *"클로저는 하나로 통일한다"* 주석은 근거가 + 없었고 삭제됐다. +2. **`H-118`은 "정책과 Blocker 중 누가 `emit`을 쥐는가"라는 소유권 문제가 + 아니었다**(Q7). `setup(emit)`이 계약이라 `emit`은 정의상 정책 손에 있고, + Blocker에 위임되는 것은 **"emit된 적 있던가"의 부기**뿐이다 — *"그 구현을 + 나눠 쓰지 않기 위함일 뿐 아녔음?"*. 설계는 안 바뀌고 `gate-plan.md` 5번의 + **문장만** 틀렸다. + +**새 표면·이름**: `Ref` 콜백 2번째 인자(`fn(value, ref)`) · Observer `fn`의 +3-자리(`fn(targetState, self, emitFrom)`) · `observer._state` · +`Ref.WeakCallbacks`(이름 신설) · `CheckReservedKeys>` + +`__reservedCheck` 팬텀 필드 · Store 생성자의 `defaults` `isSource` 검증 +**폐기**: `CheckReserved`(`T`를 통째로 받는 배선) · `rawInvalid` 잔재 표기 · +`_observers` 잔재 표기 · *"`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다"* +**역전 없음** — 7라운드 확정 중 뒤집힌 것은 하나도 없다. 이번 라운드가 고친 +건 전부 **7라운드 확정이 `base/`에 내려앉을 때 생긴 누락·충돌**이다. + +--- + +## 🅐 `Effect`의 dep 발화 배선 — `H-107` · `H-108` (Q1, Q2-후속) + +### `H-107` — `Ref` 콜백을 `k(value, self)`로 확장 **(확정)** + +**결정: (a).** `Ref`의 일반 콜백은 이제 두 번째 인자로 **그 `Ref` 자신**을 +받는다. `Ref`는 그 자체가 `Epoch`이므로 이게 곧 `Effect`가 필요로 하던 +`from` 통로다. + +- **왜 필요했나**: `Effect(fn, someRef)`에서 `ref:Set(v)` → `onDepFire`가 + `(_ = v, from = nil)`을 받는다. `EpochMap:Update`의 인자 계약은 + `Epoch | EpochSet`이라 `nil`이 올 자리가 없어 **`nil` 순회 크래시**, + 가드를 넣으면 **`Rerun`이 영영 안 돈다**(8라운드가 확정 의사코드 3개를 + 그대로 전사해 실행, 크래시 재현). +- **비용은 `k(value)` → `k(value, self)` 한 자리**다. 기존 사용자 콜백 + (`function(inst) ... end`)은 두 번째 인자를 무시하면 그대로다. +- **기각**: (b) `Effect`가 `Ref` dep에만 래퍼 클로저 — 아래 Q2-후속으로 + "클로저 통일" 자체가 목표가 아니게 되면서 근거가 약해졌다. (c) `from == nil`을 + `Ref` 발화로 해석 — 설치 발화(등록 즉시 1회)도 `from == nil`이라 구분 불가. + +**반영**: `base/ref-plan.md`(콜백 시그니처 항목 신설 + `:Set` 의사코드). + +### `H-108` — `Ref:Set` 의사코드가 자기 반영을 못 받았던 것 **(확정, 갈래 없음)** + +`H-53` 블록(2026-08-24 작성)이 하루 뒤 확정된 두 가지를 소급으로 못 받고 +있었다. 갈래가 없어 문항으로 안 물었고 권고안을 그대로 반영했다: + +1. **`Revision` 갱신 줄이 없었다** — 이 블록만 보고 짜면 `Effect`의 캐치업 + (`_epochs:Refresh()`)과 `Update(ref)` 판정이 전부 죽는다. +2. **Weak 콜백 테이블 순회가 없었다** — `.Callbacks`(강한 셋)만 돌아서, + `Effect`가 건 `:WeakCallback`은 **한 번도 발화하지 않는다.** +3. **갱신 순서가 계약에 없었다** — 확정된 순서는 **값 → 리비전 → 콜백**. + 리비전이 콜백보다 뒤면 콜백 안의 `Update(ref)`가 옛 리비전을 읽어 + `false` → 그 `Set`의 `Rerun`이 접히고 다음 `Refresh()` 때에야 뒤늦게 + 돈다(간헐 지연). + +**부수로 이름 하나를 정했다** — 약한 콜백 테이블은 그때까지 *"`.Callbacks`와 +별도 테이블"*로만 불렸는데, 의사코드가 순회해야 해서 **`.WeakCallbacks`**로 +명명했다(`base/ref-plan.md`의 `:WeakCallback` 항목). + +### Q2-후속 — 두 콜백은 **합치지 않는다** (확정) + +Q1(a)로 `Ref` 콜백의 `from`이 2번째, Q2로 Observer의 `emitFrom`이 3번째가 +되면서 "같은 `onDepFire` 하나"가 성립하지 않게 됐다. 정렬 방법을 물었더니 +**사용자가 전제를 정정**했다 — 애초에 둘을 합치려던 게 아니다: + +> *"Ref 의 callback 과 observer 의 콜백이 아주 헤테로지니어스한 개념이라, +> 둘을 전혀 합치고자 한 적 없고, 원 아이디어는 달랐음. 아주 중요한 부분이 +> 있는데, observer 에는 epoch 란게 존재하지 않음. emit 으로 온 epoch 를 +> 넘겨줄 뿐, 그러나 ref 는 그 자체로 epoch임. 처음부터 둘 처리를 묶어보는 +> 시각 자체가 잘못되었는것."* + +확정된 모양 — `Effect` 생성자가 dep 종류별로 클로저를 따로 단다: + +```lua +local function fire(from) ... end -- 공통 본문 +local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처 +local function onStateFire(_, _, from) fire(from) end -- Observer: 3번째가 출처 +``` + +- **dedup은 클로저 identity가 하는 게 아니다** — `_deps`(중복 dep 무시)와 + `_epochs`(다이아몬드 판정)가 한다. 그래서 클로저를 나눠도 *"공통 상류를 + 공유해도 한 파동에 `fn`은 한 번만"*이 그대로 성립한다. +- `effect-plan.md`의 *"⭐ 클로저는 **하나**로 통일한다"* 주석은 **삭제**했다. + +**반영**: `base/effect-plan.md`(생성자 의사코드 + 주석 교체). + +--- + +## 🅑 전파 루프의 self — `H-109` · `H-110` (Q2) + +### `H-109` + `H-110` — Observer `fn`은 **세 자리**, 리시버는 강참조 **(확정)** + +**결정: (a)의 사용자 수정판.** 문항의 (a)는 `sub.fn(sub._state, from)` +2-인자였는데, 사용자가 자리 하나를 더 요구했다: + +> *"Ref 의 콜백과 같게 실 값이 앞에 놓이도록. +> `fn(targetState: state, self: observer, emitFrom: epoch|{epoch})` 구조가 +> 되는게 좋아보임."* + +확정된 자리 배치: + +| 자리 | 무엇 | 왜 | +|---|---|---| +| 1 | `targetState` — 이 Observer가 붙은 State의 lazy 핸들 | 기존 계약 그대로. `:Compute`의 `fn(self, ...)`와 같은 모양 | +| 2 | `self` — Observer 값 자신 | 핸들 조작 | +| 3 | `emitFrom: Epoch \| EpochSet` | `EpochMap:Update(from)`에 그대로 | + +전파 루프는 `sub.fn(sub._state, sub, from)`이 된다. + +- **무엇이 깨져 있었나**: 루프 의사코드가 `sub.fn(sub, from)`으로 **Observer + 자신**을 self로 넘기는데 계약은 *"self는 리시버 State의 lazy 핸들"*이었다. + 그대로 짜면 `H-61`이 확정한 무인자 `state:Observer()`의 내부 콜백 + (`function(self) self:Get() end`)이 **"attempt to call missing method Get"** + 으로 즉사한다(Observer엔 `:Get()`이 없다). `Effect`의 `onDepFire`가 첫 + 인자를 `_`로 버려서 7라운드 트레이싱이 이 충돌을 못 봤다. +- **`H-110`이 같은 결정으로 닫힌다** — 루프가 `sub._state`를 읽으므로 + Observer는 리시버를 **강하게 들어야** 하고, 그게 곧 Observer의 `_hold` + 상당이다. 그 전엔 `fn` 클로저의 **우연한 캡처** 말고 근거가 없었고, + 캡처가 없는 확정 사례가 이미 둘이었다(`H-61`의 내부 콜백, `Effect`의 dep + 콜백) — `:Subscribe()`의 공개 계약(*"GC되지 않고 영원히 계속 실행됨"*)이 + 거기서 다시 열려 있었다. +- **기각**: (b) self=Observer를 정본으로 하고 `Observer:Get()` 델리게이션 + 신설 — 표면이 늘고 Observer가 "State처럼 보이는" 새 혼동을 만든다. + +**반영**: `base/source-state-plan.md`(전파 루프 절 · `state:Observer(fn)` +계약 절 · `H-61` 항목의 파라미터 이름 · `_hold` 불변식에 말단 핸들 추가). + +--- + +## 🅒 `WeakSubscribe`와 `canExecute` — `H-111` (Q3) + +**결정: (a) — `WeakSubscribe`도 `.Subscribed = true`를 세운다.** 강·약이 +갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고 `.Subscribed`는 구독 +경로 공용 플래그다. + +- **무엇이 미정의였나**: `Effect`의 내부 Observer는 `WeakSubscribe`로만 + 등록되는데, 전파 루프의 `canExecute(sub)`를 통과하려면 `isBoundAlive`가 + 참이어야 한다 — gcconn 경로는 핸들에만 있으니 남는 판정 근거가 + `.Subscribed`뿐이다. **안 세운다고 읽으면 `Effect`의 State dep 전량이 + 조용히 침묵한다.** 두 해석이 각각 다른 확정 문장에 뿌리를 두고 있어 + 구현자가 어느 쪽을 골라도 "문서대로 했다"고 말할 수 있었다. +- 사용자 원문 *"구현이 한 벌"*과 (a)가 정합한다. +- **기각**: (b) 판정을 필드 대신 weak 레지스트리 멤버십으로 — 동작은 같지만 + 해제가 양쪽 테이블을 지워야 하는 대칭 요구가 새로 생긴다. + +**반영**: `base/source-state-plan.md`(`:WeakSubscribe()` 절) · +`base/lifecycle-pattern.md`(`isBoundAlive` (b) 주석 + `Subscribe`/ +`WeakSubscribe` 분해 의사코드 신설 + *"오직 전역 `:Subscribe()` 경로 전용 +필드"* 문장 정정). + +--- + +## 🅓 `CheckReserved`가 실사용 `T`에서 안 돈다 — `H-112` · `H-115` · `H-117` (Q4) + +### `H-112` — 인자를 `keyof`로 좁힌다 **(확정)** + +**결정: (a).** 타입 함수는 `T`가 아니라 **키 싱글톤 유니온 `keyof`**를 +받고, 팬텀 필드 `__reservedCheck`로 격리한다. + +- **무엇이 깨져 있었나**(실측 재현): 최종 Store의 `T`는 정의상 + `{hp: Source, …}`이고, 확정 선언 스타일(§1②)에서 `Source`는 + `Compute`의 `-> State` 재귀 반환 누수 때문에 **내부에 `*error-type*`을 + 품는다.** Luau 타입 함수는 그런 타입을 받는 순간 실패하므로, + `CheckReserved`를 어디에 배선하든 **예약 키가 없는 완전히 유효한 Store + 사용 지점 전부**에 `TypeError: Type functions do not currently support types + of the form '*error-type*'`가 뜬다. 접근 경로 배선이면 거기 더해 + `does not have key 'hp'`로 필드 접근까지 전멸한다. +- **7라운드가 못 본 이유**: 그때 스파이크의 `T`가 **철회된 재설계의 스칼라 + 필드**(`{hp: number}`)였다. 최종형에선 필드가 `Source`다. +- **`keyof`면 통과한다**(실측): `Source` 타입이 인자에 아예 안 실려 + 직렬화 문제가 원천 소멸. 예약 키 진단이 **캐스트 지점과 생성자 호출 지점 + 양쪽에서** 강제 평가 없이 뜨고, `store.hp:Get()` 양성 통과/음성 대조군 + 정확히 걸림, **무주석 `:Compute` 콜백 추론 생존**. +- `base/typing-limits.md` §0(*타입 함수는 진단까지만*)의 허용 범위 안이다 — + 결과가 접근 타입에 안 섞인다(팬텀 필드 값은 `types.singleton(true)`). + 내장 `index<>`/`keyof<>`가 형제 필드의 `*error-type*`에 오염되지 않는다는 + 것도 별도 실측(`CheckedQuad`)으로 재확인됐다. +- **기각**: (b) `CheckReserved` 포기(예약 키 충돌의 "조용히 꺼짐"을 감수), + (c) 선언 스타일을 §1③(`typeof`)으로 전환(무주석 콜백 추론 포기 — 최종형 + 실측 표의 약속과 어긋난다). **"콜백 추론 + 예약 키 진단" 동시 성립 배선은 + `T`를 통째로 넘기는 한 존재하지 않는다.** + +**반영**: `base/store-plan.md`(타입 선언 + 통과 배선 + 기각 기록, +"예약 키는 `Of`/`Names` 둘뿐" 절의 옛 문구 정정) · `ROADMAP.md` M2 체크박스. + +### `H-115` — 실측 표의 출처가 재현 불가 **(정정만)** + +`store-plan.md`의 최종형 실측 표가 `audit/type-store-index-keyof/`를 출처로 +인용하는데 **그 폴더에 최종형 결합을 도는 파일이 없다**(대부분 철회된 +재설계 대상). 표의 측정값 자체는 8라운드 스파이크로 대체 재확인됐으므로 +캐비엇을 달았고, **`STATUS.md`의 `16`/`21` 재작성 때 최종형 + +`CheckReservedKeys` 배선을 같이 넣으면 닫힌다**고 그쪽에도 적었다. 그 +폴더의 `02`/`06`은 최종형에서도 유효한 측정이니 재작성 때 버리지 말 것. + +### `H-117` — `store:Of("k")` 무인스턴스화는 틀린 주석도 통과시킨다 **(정정만)** + +`Source`은 아무 구체 타입 주석과도 충돌하지 않는다 — 실측에서 +`local d: Source = s:Of("dyn")`이 진단 0건으로 통과했다. *"`Of`는 +타입 보장을 포기했다가 호출부에 드러나는 자리"*라는 확정 서술은 `<>`를 +실제로 적었을 때 얘기고, 빠뜨리면 조용히 unsound하다. 한 줄 보강. + +--- + +## 🅔 `recompute`의 두 경계 — `H-113` · `H-119` (Q5, Q6) + +둘 다 M3라 M2 착수를 막지 않지만, 같은 함수의 계약이라 같이 답했다. + +### `H-113` — splice의 무효화는 `j`가 아니라 **`j - 1`** **(확정)** + +**결정: (a).** 재개 지점은 `invalidAfter` 그대로 두고, 무효화만 되돌린다. + +- **무엇이 깨져 있었나**: 루프가 매 반복 `bk.invalidAfter = i`를 쓰므로, + `offset:Set(abs)` 도중 사용자 코드가 **커서 자리 i에서** splice하면 + `math.min(invalidAfter, i) = i` — **아무 일도 없던 것과 같은 값**이 된다. + 되감기 조건 `invalidAfter < i`가 거짓이라 재방문이 없고, 밀려 들어온 + 요소의 offset `Source`는 이번 패스에서 `Set`을 못 받는다. 루프 끝의 + `bk.invalidAfter = bk.N`이 캐시를 "유효"로 마감하므로 다음 계기까지 + **그 요소만 옆으로 어긋난 레이아웃**이 남는다(`H-3`의 *"위로는 맞고 + 옆으로만 틀린다"*와 같은 부류). `sum`은 안 낡는다 — 낡는 건 offset 하나다. +- **`j - 1`이면 닫힌다**: `j == i`면 `i-1`부터 되감고 `i`를 재방문한다(그 + 자리 offset 쓰기는 `~=` 가드로 no-op). `j < i`도 한 자리 여분 재방문만. + `prefix[j-1]`은 `1..j-2`의 합이라 splice와 무관하게 유효하다. +- `/code-review`가 한때 `j`로 바꾼 동기(재개 `+1` 폐기에 맞춘 쌍)는 재개가 + `invalidAfter`로 남는 한 `j-1`로도 안 깨진다. +- **기각**: (b) 되감기 신호를 별도 필드로 분리 — `H-101`의 *"새 필드를 안 + 만든다"* 확정을 되짚는 쪽이라 비용이 더 크다. + +**반영**: `base/dispatch-core-plan.md`(무효화 표의 splice 행 + 되감기 절). + +### `H-119` — 명시 `recompute` 호출도 재진입 게이트를 탄다 **(확정)** + +**결정: (a).** `raw*` 삭제 3형제와 `_baseObserver`의 직접 호출을 전부 +`blocker:IsOn() or bk.recomputeBlocker:IsOn()`이면 건너뛰도록 통일한다. + +- **무엇이 깨져 있었나**: `H-101`의 재진입 차단은 실체가 `gatedRecompute` + 안의 두 검사인데 **명시 호출 경로는 그걸 안 거친다**(`recompute` 자신은 + 머리에서 `recomputeBlocker:On()`만 하고 재진입을 검사하지 않는다). 그래서 + 대칭이 깨져 있었다 — **`Add`는 안전한데 `Remove`는 깨진다**: + 중첩 `recompute`가 완주하며 (1) `bk.invalidAfter = bk.N`으로 **바깥의 + 되감기 신호를 지우고**, (2) `OffWithoutEmit()`으로 **바깥이 도는 중에 + 차단기를 꺼버리고**, (3) 바깥이 자기 옛 `sum`으로 `Length:Set` — 중첩이 + 이미 써둔 올바른 `Length`를 낡은 합으로 덮는다. `H-19`(명시 호출 예외, + 08-24)와 `H-101`(재진입 차단, 08-25)이 하루 차로 확정되며 서로를 못 봤다. +- **건너뛴 몫은 손실이 아니다** — `spliceArraysDown`/`invalidAfter = 0`이 + 이미 당겨둔 신호로 바깥 루프의 되감기가 복구한다(`H-101` 설계 그대로, + 새 메커니즘 없음). +- **부수로 `_baseObserver` 콜백에 두 줄을 채웠다** — 배치 Blocker만 보던 + 게이트에 `recomputeBlocker`를 더했고, `H-3`의 3번이 요구하는데 의사코드에 + 없던 `bk.invalidAfter = 0`도 넣었다. +- **기각**: (b) `recompute` 머리에서 조기 반환 — *"명시 호출은 반드시 + 돈다"*는 `H-19`의 표면 의미가 바뀌고, 되감기 신호 유지 요구는 어차피 같다. + +**반영**: `base/dispatch-core-plan.md`(계약 신설) · +`base/slot-plan.md`(`rawRemove`/`rawDetach`/`rawUnmount` 계열 3곳 + +`materializeSlotTree`의 `_baseObserver` 콜백). + +--- + +## 🅕 `Debounce`/`Throttle` 정책 — `H-118` (Q7) — **문항이 틀렸다** + +**결정: gate-plan 5번의 문장만 고친다. 설계는 안 바뀐다.** + +문항은 *"정책과 Blocker 중 누가 `emit`을 쥐는가"*를 갈래로 세웠는데, 사용자가 +그 프레이밍 자체를 되물었다: + +> *"둘다 쥔다는 의미를 모르겠음. epoch|{epoch} 를 모아두는 부분은 gate 쪽이긴 +> 한데(각각 본인껀 본인이 모아야하니까), emit 을 blocker 가 쥔다는건 +> 정확하게는, 'emit 된 적 있던가?' 를 저장하기 위함 아님? 그 구현을 나눠 쓰지 +> 않기 위함일 뿐 아녔음?"* + +원문을 다시 읽으니 그대로다 — `setup(emit)`이 곧 계약이므로 **`emit`은 +정의상 정책 손에 있고**, 정책은 그걸 `b:Policy(emit)`에 넘겨야 배선이 +성립한다. 위임되는 건 `emit`이 아니라 **보류 부기(`HasBlockedEmit`)**이고, +`blocker:Policy` 표면의 존재 이유는 그 구현을 나눠 쓰지 않는 것이다. +따라서 `H-55`/`H-86`이 확정한 **타이머 경로의 `emit()`/`emit(false)` 직접 +호출**과 5번의 위임 구조는 **충돌하지 않는다** — 경로가 둘이고 각자 몫이 있다: + +| 경로 | 무엇을 부르나 | +|---|---| +| 상류 emit 도착(동기) | `pass()` — 보류 여부는 Blocker가 판정 | +| 타이머/제어 핸들(flush·버리기·조회) | `emit()` / `emit(false)`, 반환값도 씀 | + +두 경로가 같은 파동에 겹쳐도 **빈 배치 얼리리턴**이 흡수한다. +그래서 `H-118`은 **🟡 계약 결정 → 🟢 문서 정합**으로 강등된다. + +**반영**: `base/gate-plan.md` 5번(*"`emit`을 아예 안 쥔다"* 머리 문장을 +*"보류 판정·`pending` 부기를 직접 구현하지 않는다"*로 교체 + 정정 배너 + +경로 표). + +--- + +## 🅖 `Ref` 콜백의 즉시-nil 호출 — `H-120` (Q8) + +**결정: (a) — 슈가/관용구에 nil 가드 래퍼.** `Ref` 계약은 안 건드린다. + +- **무엇이 깨져 있었나**: `Ref` 콜백은 *"등록 즉시 1회 호출, nil/미설정이어도 + 그대로"*가 계약인데, 코퍼스가 `Ref():Callback(fn)`을 children 배열 + 관용구로, `OnCreated(fn) = PreRef():Callback(fn)`을 슈가로 배포한다. + `fn`의 선언 타입은 non-nil `Instance`다 — 그래서 + `OnCreated(function(inst) inst.Name = "x" end)`은 **pre-pass에 도달하기도 + 전에** "attempt to index nil"로 죽는다. `source-state-plan.md`가 이 조합을 + 이미 *"사용자 실수"*로 분류해뒀는데, 정작 코퍼스가 자기 슈가로 그 실수를 + 확정 패턴으로 배포하고 있었다. +- 훅 셋이 백로그(M8/순수 슈가)라 비용은 문서 정정 수준이다. +- **기각**: (b) 즉시 1회 호출을 "한 번이라도 `Set`된 뒤"로 좁힘(`Ref` 계약 + 자체를 되짚어야 하고, "미설정 상태를 알고 싶어 콜백을 거는" 용례의 파급 + 확인이 필요), (c) 문서 경고만(훅 슈가의 인체공학 약속과 어긋난다). + +**반영**: `base/lifecycle-hooks-plan.md`(`guard` 래퍼를 확정 스케치에 + +children 배열 관용구 캐비엇) · `base/ref-plan.md`(콜백 계약 항목에 캐비엇). + +--- + +## 🅗 `isModifier` 가드 자리와 Store defaults 검증 — `H-122` (Q9) + +**결정: (a) — 가드를 `Source` 생성자로 옮기고, Store 생성자엔 `isSource` +화이트리스트 검증을 신설한다.** + +- **무엇이 stale였나**: 세 문서(`modifier-plan.md` 7번 · + `source-state-plan.md` 따름정리 절 · `ROADMAP.md`의 `H-81` 체크박스)가 + 가드 적용 지점으로 *"Store 생성 시 각 `defaults` 키를 `Source(v)`로 만드는 + 시점"*을 지목하는데, **명시적 초기화 확정(2026-08-25) 이후 그 지점이 코드상 + 존재하지 않는다** — Store는 `Source`를 만들지 않고 생성자는 `table.clone`뿐. +- **가드를 `Source` 생성자에 두면 defaults 경로가 자동 커버된다** — 독립 + `Source(someModifier)`와 한 자리로 수렴해 **목록이 오히려 짧아진다.** +- **defaults 런타임 검증은 새로 생긴다**: 타입은 `Source` 필드를 + 요구하지만 `--!nocheck`/동적 코드가 `{hp = 100}`(raw 값)을 넘기면 지금 + 스케치는 조용히 받고 첫 `store.hp:Get()`에서 엉뚱한 에러로 죽는다. + `H-40`이 `:List` 요소 검증을 화이트리스트로 뒤집은 것과 같은 성격이라 + 여기도 화이트리스트를 둔다 — `isSource`가 거짓이면 `error(..., 2)` + (사용자 입력 검증이므로 `level 2`, 메시지는 영어). 생성 시 1회라 hot path + 아님. +- **기각**: (b) 문구만 정정하고 타입 방어만 신뢰. + +**반영**: `base/modifier-plan.md` 7번 · `base/source-state-plan.md` 따름정리 +절 · `base/store-plan.md`(생성자 검증 항목 신설) · `ROADMAP.md`(`H-81` +체크박스 정정 + 검증 체크박스 신설) · `luau-test/STATUS.md` +(`done/11-modifier-illegal-value-error`가 eager `Source(v)` 모델을 박제한 채 +"통과"로 앉아 있는 것을 `rewrite-required/` 대상으로 명시). + +--- + +## 🅘 문서 정합 — 판단 불필요 (`H-114` · `H-121` · `H-123`) + +### `H-114` — "하루 차 미반영" 둘 + +1. **`effect-plan.md`의 `H-11` 확정 목록 2번**이 `H-58` 이전 모델을 유지하고 + 있었다(*"`Ref` 콜백도 같이 해제"* / *"언마운트가 콜백을 떼고 재마운트가 + 다시 건다"*). `H-58`은 정반대로 확정했다 — **아무것도 안 뗀다.** 목록에 + 정정 배너를 달고 뒤집힌 문장은 취소선 처리했다. 같은 파일의 + `_observers` 잔재 표기 세 곳도 같이 지웠다(`H-58` 이후 `Effect`엔 + `_observers`가 없고 `_deps` 하나다). +2. **`state-epoch-plan.md` §4 상단**의 구조체 선언과 수신 규칙이 폐기된 + `rawInvalid: boolean`으로 쓰여 있었다. `H-85` 절의 안내 문장이 *"아래 두 + 절"*만 가리켰는데 **구조체는 그 절보다 위**라 위에서부터 읽으면 폐기된 + 필드로 먼저 확정하게 된다. 구조체를 카운터 쌍으로 바꾸고 배너를 달았다. + +### `H-121` — `slot-plan`의 대표 `updateFn` 예시가 크래시한다 + +`layoutOrder:With(offset):Compute(function(i, o) return i:Get() + o:Get() end)` — +확정 계약은 `fn(self, previous?, ...trailingDeps)`이고 **`:With`로 모은 값은 +포지셔널로 안 넘어온다**(*"with한 값을 포지셔널 인자로 받지 않고 클로저로 +직접 읽는다"*). 두 번째 자리에 실제로 오는 건 `previous`라 첫 사이클엔 +`nil`(`o:Get()` 즉사), 이후엔 직전 결과 숫자(`number:Get()`으로 또 죽음). +클로저 읽기 형태로 교정했다. 이 예시는 `userdata`/`LayoutOrder` 관용구의 +**정본 본보기**라 그대로 옮겨질 위험이 컸다. + +**같은 배치로 `audit/type-recursion-issue/spikes/`의 `23`/`24`에 배너를 +달았다** — 그 폴더는 "직접 다시 돌려 판정을 재현"하라고 남겨둔 것이라, +`slot-plan`만 고치면 나중 재실행이 옛 콜백 계약을 "재확인"하는 모양이 된다. +스파이크의 **측정값(타입 추론)은 그대로 유효**하므로 모델링은 안 건드리고 +"이 호출 모양을 확정 관용구로 재인용하지 말 것"만 못박았다. + +### `H-123` — 3차 문서 정합 묶음 + +1. **`project-setup-plan.md`의 리링크 서술이 `H-78` 이전이었다** — + *"아직 반복 가능한 스크립트로 정식화하진 않음, 매번 수동 치환"*이라고 + 안내하는데 `scripts/relink.sh` + `scripts/test.sh`가 이미 커밋돼 있다. + "테스트는 `./scripts/test.sh`로 돌린다"로 교체했다. +2. **`pesde.lock`** → 아래 Q10. +3. **`:Single`의 2-인자 표기** — 절 제목과 본문 한 곳이 `(state, updateFn?)` + 인데 `H-22` 확정 의사코드는 `opts`(= `Owned`) 3번째 인자를 받는다. 둘 다 + `(state, updateFn?, opts?)`로 고쳤다. + +### `H-116` — quad 두 벌 공존 (지금 결정할 일 아님) + +패키지 매니저 생태계에서 두 라이브러리가 서로 다른 quad 버전을 끌어오는 건 +예정된 미래인데, 그때 `Brand` 레지스트리·`None` 센티널·`Subscribed` 전역 +테이블이 **모듈 사본마다 분리**된다 — 한 사본이 만든 값을 다른 사본에 넘기면 +`isState`/`isObserver`가 거짓이 되어 요소 화이트리스트 검증(`H-40`)이 +**정상 값을 이물로 판정**한다. 설계로 막을 일이 아니라 **문서화할 사실**이라 +`research/documentation-content-map.md` §4에 항목 20번으로 등록했다. + +--- + +## Q10 — `pesde.lock`은 커밋한다 (확정) + +`project-setup-plan.md`가 *"미확정, 사용자 판단 필요"*로 열어두고 +*"`todos.md`에 확인 필요 항목으로 반영"*이라 주장했는데 **그 반영이 실제로는 +어디에도 없었다**(`question.md`/`todos.md`/`HUMAN_TODO.md` grep 0건). 실태는 +lockfile이 이미 전부 커밋돼 있어 잠정 권고와 일치했고, 사용자가 현 실태대로 +확정했다. 절 제목을 "커밋한다"로 바꾸고 "아직 확인 안 된 것" 목록에서 뺐다. + +--- + +## 남은 것 / 안 한 것 + +- **8라운드 §6의 "남은 의심" 셋 중 둘은 이 라운드 결정으로 닫혔다** — + `H-119`의 도달 조건 폭은 Q6 (a)가 게이트를 통일하므로 무관해졌고, + §1③ 선언과 최종 Store 형태의 결합은 Q4 (a)로 당장 안 필요하다. 남은 것은 + **게이트 `emit(false)` 직후 다이아몬드 두 번째 경로 도착** 하나인데, 8라운드 + 스스로 *"정책이 파동 도중 동기적으로 `emit(false)`를 부르는 경우가 실재하는지 + 판단이 안 서서 발견으로 안 올렸다"*고 적은 항목이라 **여기서도 열지 않는다.** +- **8라운드 §6의 "못 본 것"은 그대로 유효하다** — 특히 **M5+ 구간(그룹 + `Attribute` 위임 체인, `D` 생성자, 숏핸드→`PropertyHandler` 위임, + `:List` reconcile의 실제 값 대입)은 문서 정독 수준**이고 값 단위 트레이싱을 + 안 했다. 다음 라운드가 있다면 거기가 최우선이다. +- **실측 스파이크**는 여전히 `luau-test/STATUS.md`가 소스다 — 이 라운드가 + 거기에 항목 셋을 더했다(`11` 재작성, `16`/`21` 재작성 시 최종형 배선, + `CheckedQuad` M2 후 재실측). + +--- + +## 반영 후 검증 — 감사 11라운드 + `/code-review high` (2026-08-26) + +**감사 루프**: `quad-doc-auditor` 11라운드(한 턴에 하나씩, 라운드마다 각도 +변경), 발견 44건, **마지막 라운드 0건으로 수렴**. 상세와 교훈은 +`session/2026-08-26-01-handtrace-round8-resolution.md`의 "감사 루프" 절. + +**`/code-review high`**: 사용자가 직접 호출, **7건 전부 유효**했다. 감사자와 +보는 축이 다르다는 게 다시 확인됐다 — 감사자는 코퍼스 정합성을, code-review는 +**새로 쓴 서술 안의 결함**을 본다. 이번에 잡힌 것 중 셋은 감사 11라운드 +어디에서도 안 나온 종류다: + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | **HIGH** | `recompute` 되감기가 `i = 0`으로 떨어져 크래시 | `math.max(bk.invalidAfter, 1)` 클램프 | +| 2 | MEDIUM | `H-113`의 `-1`이 splice에만 가고 `rawMove`/`rawSwap`류(`H-29` 규약 3번)엔 안 감 | 같은 처방으로 통일 | +| 3 | MEDIUM | `WeakUnsubscribe`가 강하게 구독된 값을 반쪽 해제 | **사용자 확정: error** | +| 4 | MEDIUM | *"Subscribe/Unsubscribe 둘 다 idempotent"*가 확정 의사코드의 `error`와 충돌 | **사용자 확정: error가 정본** | +| 5 | MEDIUM | `bk.recomputeBlocker`가 `getBookkeeping` 초기화 열거에 없음 | 열거에 추가 | +| 6 | LOW/MED | `keyof<{}>`(빈 Store) 미실측 | `STATUS.md`의 `16`/`21` 재작성 지침에 대조군 추가 | +| 7 | LOW | ROADMAP 배너가 바뀐 체크박스 개수를 소스 밖에 적음 | 개수 제거, 체크박스가 소스 | + +**⭐ 1번은 이 라운드의 반영이 *만든* 결함이다** — `H-113`(splice 무효화를 +`index - 1`로)과 `H-119`(`_baseObserver`에 `bk.invalidAfter = 0`)가 각각 +독립적으로는 옳은데, **둘이 겹치면서 `invalidAfter`가 0이 될 수 있는 경로가 +둘 생겼고** 되감기 블록은 클램프가 없었다. 결과는 `sum = prefix[0]`(nil) → +다음 반복에서 `sourceList[0]`이 nil → **부기가 멀쩡한데 "부기가 깨졌음"이라는 +error로 죽는다.** 8라운드가 정확히 이런 "결정들이 겹칠 때" 결함을 찾는 +라운드였는데, 그 라운드의 *처방*들이 겹쳐 같은 종류를 하나 더 만든 셈이다. + +**3·4번은 계약 결정이라 사용자에게 물었다.** 둘 다 fail-fast 쪽으로 확정 — +`Subscribe`는 idempotent가 아니고(이미 구독/바인드된 값이면 error), +`WeakUnsubscribe`는 강한 킵이 남아 있으면 error다. **`Unsubscribe`만 +idempotent이고 이 비대칭은 의도된 것**이라는 것도 같이 명문화했다. + +### `/code-review high` **2차** — 7건 더, 전부 유효 + +1차 7건을 반영한 **직후** 사용자가 한 번 더 돌렸고 또 7건이 나왔다. **그중 +넷이 1차 수정이 만든 것**이다 — 이 문서의 "고치는 과정이 새 결함을 만든다"가 +다시 확인됐다. + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | **MED-HIGH** | `H-114`가 `_observers` → `_deps`로 **이름만** 고치는 바람에, `H-58`이 폐기한 *"`bindLifetime`이 내부 Observer로 cascade한다"* 동작 주장이 **갓 정비된 것처럼** 보이게 됨 | 배너로 거짓 명시 + 결론의 근거를 `isEffect` 훅으로 교체 | +| 2 | MEDIUM | 무효화 절의 머리 문장이 *"전부 같은 모양 — `math.min(inv, i)`"*인데 바로 아래 표는 `H-113` 이후 **세 가지 인덱스**를 규정 | 머리 문장 재작성, "표가 소스" 명시 | +| 3 | MED-LOW | `rawMove`/`rawSwap`류 무효화 규칙이 `slot-plan`에만 있고 `dispatch-core`의 표엔 행이 없음(그런데 `slot-plan`이 그 표를 "세 규칙"의 소스로 인용) | 표에 행 신설, 개수 서술 제거 | +| 4 | MED-LOW | 전파 루프 주석이 `-- Observer / Effect`인데 `_state`는 **Observer에만** 있음 | 주석 교정 + `Effect`가 내부 Observer를 통해 온다는 것 명문화 | +| 5 | MED-LOW | *"예약 키는 `Of`/`Names` 둘뿐"*이 같은 파일의 셋(+`__reservedCheck`)과 불일치(`ROADMAP`도) | 양쪽 셋으로 | +| 6 | LOW | `luau-test/README.md`의 `16` 재작성 지침이 아직 옛 `CheckReserved` | 이름·배선 갱신 + 빈 Store 대조군 | +| 7 | LOW | **⛔ 폐기 배너 아래 죽은 문단에 내가 `H-111` 날짜 마커를 찍어** 살아 있는 것처럼 보이게 함 | 마커 제거 | + +**⭐ 1번과 7번이 이 라운드의 교훈이다** — 폐기된 서술을 만질 때 +**이름만 고치거나 날짜 마커를 찍으면 오히려 해롭다.** 죽은 텍스트가 갓 +정비된 것처럼 보여서, 위에서부터 읽는 구현자가 더 믿게 된다. 1번은 실제로 +`H-58`이 막은 버그(바인드마다 `Rerun`)를 되살릴 수 있는 자리였다. +**폐기 블록은 배너만 달고 본문은 건드리지 않는 게 낫다.** + +### `/code-review high` **3차** — 7건 더 (+minor 1), 전부 유효 + +2차 반영 직후 또 돌렸고 또 7건. **이번에도 대부분이 앞 두 차례 수정의 산물**이다. + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | MEDIUM | `typing-limits.md` §0이 이름은 `CheckReservedKeys`로 고쳐놓고 바로 뒤 문장은 *"둘 다 `T`를 검증만 하고 그대로 통과시키고"* — **양쪽 다 거짓**(인자도 반환도) | 재정정. §0이 "무엇이 합법적 타입 함수인가"의 소스라 그대로 읽으면 `H-112`가 실측한 실패 배선을 다시 도출 | +| 2 | MEDIUM | 무효화 표에 4번째 행(`rawMove`/`rawSwap`)만 넣고 **헤딩·산문·배치 목록은 "셋"** — 새 규칙이 *"표는 산문뿐이고 코드 경로가 없다"*(`H-3`)는 원래 상태로 되돌아감 | 헤딩·산문 정정 + 배치 목록에 4번 신설 | +| 3 | MEDIUM | `ROADMAP.md` M3 체크리스트도 같은 결함(**구현자가 실제로 보는 자리**) | 같은 처방 | +| 4 | MEDIUM | `Ref:Set`의 교차 dedup 근거를 *"thread를 두 번 resume"*이라 적었는데, 그러면 소진이 `.Callbacks`만 비우므로 **죽은 코루틴을 영원히 조용히 `resume`**하게 된다(`resume`은 죽은 스레드에 `false`를 돌려줌) | 실제 불변식(**대기자는 `.Callbacks`에만 산다** — `WeakWait`는 없다) 명문화, dedup은 **함수 키 전용** | +| 5 | LOW | `STATUS.md`가 `CheckedQuad` 재실측을 `rewrite-required/23`에 매달았는데 **거기 `23`이 없다**(`done/`에 통과 상태) — 안 일어날 재작성에 매달려 고아가 될 뻔 | 포인터 정정 | +| 6 | LOW | *"Store는 `Source`를 만들지 않는다"* 전제가 **거짓** — 동적 키 창구 `store:Of(name)`은 만든다. 세 곳에서 그게 옛 가드 자리를 지운 **이유**로 쓰임 | "`defaults` 경로에선"으로 정밀화(결론은 그대로 — `Source` 생성자 가드가 `Of`까지 커버) | +| 7 | LOW | `H-119`가 *"명시 호출부 **전부**"*라 했는데 `:List` 활성화 꼬리와 `mountSlotTree` 꼬리 **둘이 빠짐** | 같은 게이트 추가 | + +*(minor: `H-119` 산문의 "`rawSplice`류"가 가리키는 함수가 없음 — 제거.)* + +**⭐ 이 세 차례 code-review의 총평.** 21건이 나왔고 **절반 이상이 직전 +수정의 산물**이었다. 반복된 실패 모드는 하나다 — **한 자리를 고치면서 그 +자리를 요약·인용·정당화하는 이웃 문장을 같이 안 고침.** 표에 행만 넣고 +헤딩의 개수를 안 고치거나(2·3번), 이름만 바꾸고 그 이름이 서술하던 동작을 +그대로 두거나(2차 1번), 새 dedup의 *근거*를 잘못 적어 그 근거가 다른 +불변식을 함의하게 만들거나(4번). **감사자는 이걸 못 잡는다** — 코퍼스 +정합성이 아니라 **방금 쓴 문장 안의 논리**라서다. + +### `/code-review high` **4차** — 6건, 그중 하나가 **`H-101` 역전**을 불렀다 + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | **HIGH** | `getOffsetAt`의 꼬리가 `bk.invalidAfter`를 **올려서** splice가 낮춰둔 되감기 신호를 지운다 | **부기 필드를 둘로 분리**(아래) | +| 2 | LOW | `ROADMAP`의 전파 루프 사본 주석이 아직 `-- Observer / Effect` | 교정 | +| 3 | LOW | `lifecycle-hooks-plan` 본문 6곳이 아직 `Callback(fn)`(가드 없음) | 전부 `guard(fn)` | +| 4 | — | *(materialize 꼬리 게이트가 사후조건을 깬다)* — **기각.** `bk`는 그 Slot **자신의** 부기이고 `recomputeBlocker`는 같은 Slot의 `recompute` 중에만 켜지므로, 게이트가 발화하는 상황이면 **그 바깥 루프가 끝내 `Length`를 확정한다.** 리뷰가 든 *"`mountSlotTree`의 `acc`"* 근거도 어긋난 인용 — `slot.Offset`은 **부모의** `recompute`가 정한다 | 변경 없음 | +| 5 | MEDIUM | 다만 `Dispatch.drive` 꼬리와 일반 배치 계약은 **게이트가 아예 없다** — `raw*`와 같은 `H-119` 구멍 | Q6 결정대로 게이트 | +| 6 | LOW | `:Uncallback`이 *"`Callbacks[fn] = nil` 한 줄"* — 같은 파일이 두 테이블을 본다고 확정 | 두 테이블로 | + +#### ⭐⭐⭐ `H-101`의 "새 필드를 안 만든다"가 역전됐다 — 부기 필드가 둘로 + +**사용자 진단**: *"캐시와 컴퓨팅 위치를 같이 둔 것이 폭탄이였는듯 … 지금의 큰 +문제는, **Set을 해줬느냐**와 **캐시가 유효하지 않느냐**라는 다른 목적의 값을 +같은 값이 쥐고 있음. 그것 자체가 문제였는듯."* + +`H-101`은 *"두 뜻('캐시가 여기까지 유효'와 '여기 다음부터 다시 해야 함')이 +**실제로 같은 것**이기 때문"* 새 필드를 안 만들기로 확정했었다. **그 전제가 +틀렸다.** + +| 필드 | 뜻 | 올리는 쪽 | 내리는 쪽 | +|---|---|---|---| +| `bk.offsetCacheValidUpTo` | `offsetCache`가 여기까지 정확 | `getOffsetAt` (**어디서 불리든**) | 무효화 사이트 전부 | +| `bk.offsetSetUpTo` | offset `Source`에 여기까지 `:Set` 완료 | **`recompute`만** | 무효화 사이트 전부 | + +- **옛 이름 `invalidAfter`는 완전히 없앴다** — 그 이름이 두 뜻을 겸했던 게 + 원인이라 남겨두면 읽는 쪽이 옛 의미를 그대로 가져온다. 이름은 **사용자 + 확정**(`offsetCacheValidUpTo` + `offsetSetUpTo` — 어미를 맞춰 둘 다 + "여기까지 유효/완료"로 읽히게). +- **`getOffsetAt`이 캐시 마커를 올리는 건 어디서 불려도 안전하다** — 그 함수가 + 실제로 캐시를 그 지점까지 정확히 채우고 나서 올리기 때문. 문제였던 건 + **Set 마커**가 `recompute` 밖에서 올라가는 것이었고, 이제 그건 불가능하다. +- **무효화는 둘 다 내린다** — 구조가 바뀌면 캐시도 낡고 `Set`도 다시 해야 한다. + +**왜 여섯 라운드의 감사와 세 번의 code-review를 통과했나** — **한 프리미티브 +*안*에서는 원래 안전했다.** `rawRemove`는 `getOffsetAt`을 `spliceArraysDown` +**앞**에서 부른다. 깨지려면 **한 콜백에서 CRUD를 두 번**(`Remove` 뒤 `Add`) +해야 하고, 두 번째의 `setOffsetSource`가 `getOffsetAt`을 부르며 신호를 +되올린다. 사용자가 *"getOffsetAt 자체가 recompute를 내지 못해서 올리는 게 +문제가 안 되는 것 아니냐"*고 되물어 재트레이싱한 끝에 이 2-연산 경로가 나왔다. + +### `/code-review high` **5차** — 6건 (필드 분리 직후) + +부기 필드 분리(40곳 넘는 치환)를 아무도 안 본 상태라 바로 돌렸다. + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | MEDIUM | 훅의 `guard(fn)`가 **1-인자** — 같은 라운드에 `Ref` 콜백이 2-인자가 됐는데 두 번째를 조용히 삼킨다. children 배열 관용구가 "위와 같은 가드"를 쓰라 하므로 `Epoch`를 쓰는 소비자가 `nil`을 받는다 | `function(v, r) … fn(v, r)` | +| 2 | MEDIUM | `_baseObserver`는 `bk`를 무가드로 쓰는데 형제 두 자리는 `if bk and …` — 같은 커밋 안에서 규약이 갈림 | **`getBookkeeping`은 lazy 생성이라 절대 nil이 아님**을 명문화하고 흔적 가드 제거 | +| 3 | MEDIUM | 새 무효화 행이 `H-29` 규약을 짝으로 지목했는데, 그 규약의 2번(*"`bk.N`은 안 변한다"*)이 **`rawSplice`/`rawClear`엔 거짓** — 그대로 짜면 `rawClear` 뒤 `recompute`가 끝을 넘어가 "부기가 깨졌음" error | 규약에 예외 명시 + 표 행의 범위 축소 | +| 4 | LOW/MED | `__reservedCheck`는 **런타임 대응물이 없다**(타입은 `true`, 런타임은 `nil`). `store:Names()`에도 안 들어가 `store:Of("__reservedCheck")`가 런타임엔 통과 | 캐비엇 둘 명문화 | +| 5 | LOW | `WeakUnsubscribe`만 fail-fast고 **반대 방향이 안 막힘** — 약하게만 구독된 값에 `Unsubscribe`가 조용히 성공해 `Effect`의 dep을 죽인다 | **사용자 확정: 대칭으로 막는다** — "해제는 건 경로로 푼다" | +| 6 | LOW | `bk.offsetSetUpTo = bk.N` 꼬리의 **근거**가 아직 *"캐시가 낡은 채로 유효 표시"* — 분리 뒤 이 꼬리는 캐시를 안 만진다 | 근거 재작성 | + +**⭐ 6번이 이 세션에서 세 번째 같은 실수다** — **이름만 바꾸고 그 이름이 +서술하던 근거는 그대로 둠**(`H-114`가 지적한 바로 그 실패 모드). 1차에선 +`_observers` → `_deps`, 3차에선 `CheckReservedKeys`, 이번엔 `invalidAfter` → +`offsetSetUpTo`. **대규모 치환을 할 때는 치환된 토큰이 든 *문장 전체*를 다시 +읽어야 한다** — 토큰만 맞추면 그 문장이 설명하던 불변식이 바뀐 걸 못 본다. + +### `/code-review high` **6차** — 8건 (HIGH 3), **전부 5차 수정이 만든 것** + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1·2 | **HIGH** | 5차에 `Unsubscribe`에 대칭 가드를 넣어놓고, **같은 파일 몇 줄 아래의 *"`:Unsubscribe()`는 idempotent다 … 비대칭이 의도된 것"*을 안 지웠다**(두 문서 다). 산문대로 짜면 가드 없는 `Unsubscribe`가 나와 그 가드가 막으려던 침묵 살해가 그대로 | 두 곳 재정정 — 계약은 **"해제는 건 경로로 푼다"** 하나 | +| 3 | **HIGH** | 5차의 예외 목록이 `rawSplice`/`rawClear`만 빼고 **`rawExtract`를 빠뜨렸다** — `Extract(index)`는 `newElement` 생략 시 **자리 수가 준다**(CRUD 표). 규약대로 짜면 `bk.N`이 옛 개수로 남아 다음 `recompute`가 끝을 넘어가 "부기가 깨졌음" error | 조건부임을 명시(교체 형태는 그대로, 제거 형태는 splice 취급) | +| 4 | MEDIUM | `ROADMAP`의 `guard` 스케치가 아직 **1-인자** | 2-인자로 | +| 5 | MEDIUM | `guard`가 `fn(v, r)`로 부르는데 훅의 `fn`은 **1-인자로 선언** → `--!strict`에서 arity 에러 | 선언 타입을 2-인자로(Luau 함수 타입은 파라미터에 반변이라 사용자의 1-인자 람다는 그대로 통과) | +| 6·7 | MEDIUM | **대규모 치환이 *역사 인용문*까지 바꿨다** — `H-101`의 원문 인용이 `bk.offsetSetUpTo`(= 새 필드)로 바뀌어 *"새 필드를 안 만든다"*와 **자기모순**이 됐고, 같은 파일 절 참조도 제목과 어긋났다 | 인용·참조를 옛 이름으로 되돌림 | +| 8 | LOW | *"`if bk` 가드를 두지 말 것"* 규칙을 새로 세워놓고 **두 자리를 안 고쳐** 그 규칙이 지적한 불일치를 그대로 남김 | 제거 | + +**⭐⭐ 이 라운드가 가장 선명한 교훈을 준다 — 나는 같은 실수를 네 번 했다.** +1차 `_observers` → `_deps`, 3차 `CheckReservedKeys`, 5차 `invalidAfter` → +`offsetSetUpTo`, 그리고 6차의 6·7번. **전부 "토큰을 바꾸고 그 토큰이 든 문장은 +안 읽음"**이다. 6·7번은 한 걸음 더 나아가 **역사 기록까지 오염**시켰다 — +*"옛 이름을 완전히 없앴다"*는 내 선언이 정정 배너 안의 **인용문**에까지 +적용돼, 폐기를 서술하는 문장이 자기가 폐기한 것의 새 이름을 쓰게 됐다. + +**규칙으로 남긴다**: **전역 치환은 인용문·절 제목·정정 배너를 건드리면 안 된다.** +그 셋은 *과거에 무엇이라고 적혀 있었는가*를 보존하는 게 목적이라, 새 이름으로 +바꾸는 순간 그 목적이 무너진다. 치환 전에 그 세 형태를 먼저 제외 목록에 넣을 것. + +### `/code-review high` **7차** — 5건, **HIGH 0** + +| # | 심각도 | 무엇 | 처분 | +|---|---|---|---| +| 1 | MEDIUM | `EffectHandle:Unsubscribe()`의 *"idempotent"* — **세 번째 사본**. 6차가 두 문서에서 지웠는데 이건 놓쳤다 | 정정(살아 있는 요구는 "cleanup 중복 호출 금지"뿐) | +| 2 | MEDIUM | `ROADMAP`의 M2 `state:Observer(fn)` 체크박스가 **`H-109`/`H-110` 미반영** — 3-슬롯 시그니처도 `observer._state`도 없다. 그 체크박스로 짜면 `sub._state`가 `nil`이라 **`H-109`가 고치려던 크래시가 그대로** | 시그니처·`_state`·`H-61` 파라미터 이름 반영 | +| 3 | LOW/MED | `for d in seen do` — **유효한 Luau가 아니다**(테이블을 호출하려 든다). `H-107`로 본문을 다시 쓴 바로 그 루프 | `pairs(seen)` | +| 4 | LOW | 요약 다이어그램이 **강한 셋 `Ref.Callbacks`를 약한 엣지로** 표기 — 그대로 읽으면 `Ref`가 `Effect`를 영원히 붙들어 `H-58`의 약한 설계가 죽는다 | `.WeakCallbacks`로 | +| 5 | LOW | 3차가 이미 거짓으로 판정한 *"Store는 `Source`를 만들지 않는다"*를 **새로 쓴 두 블록에 다시 넣었다** | "`defaults` 경로에선"으로 | + +**추이**: HIGH 1 → MED-HIGH 1 → HIGH 0 → **HIGH 1(설계 역전)** → HIGH 0 → +**HIGH 3** → **HIGH 0**. 6차가 정점이었고(전부 5차 수정의 산물), 그 실패 +모드를 명문화한 뒤의 7차는 HIGH가 없다. + +**5번이 남은 패턴을 보여준다** — 한 곳에서 고친 사실이 **나중에 새로 쓰는 +글에서 되살아난다.** 3차에 `modifier-plan`/`source-state-plan`/`ROADMAP` +세 곳을 고쳤는데, 5차에 `luau-test` 블록을 새로 쓰면서 그 거짓 전제를 다시 +적었다. 고친 것을 기억하는 것과 **새로 쓸 때 그 기억을 적용하는 것**은 다른 +일이다. + diff --git a/.claude/qa-request/pre-implementation-handtrace-round8.md b/.claude/qa-request/pre-implementation-handtrace-round8.md index 6da12b9..bd636fb 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round8.md +++ b/.claude/qa-request/pre-implementation-handtrace-round8.md @@ -789,8 +789,9 @@ REPORT 상단 배너도 철회를 명시한다. 표의 측정값 자체는 이 - **(b)** 문구만 정정하고 런타임 검증은 안 함(타입 방어만). **Q10. [`H-123`-2] `pesde.lock` 커밋 — 현 실태(5개 전부 커밋됨)대로 -확정하는가?** (예/아니오 — 예면 `project-setup-plan.md`의 "미확정" 표기를 -닫고 인덱스 취합 불필요.) +확정하는가?** (예/아니오 — 예면 `project-setup-plan.md`의 미확정 표기를 +닫고 인덱스 취합 불필요. **[2026-08-26 회신: 예]** — 그 절 제목은 +`pesde.lock` — 커밋한다 로 바뀌었다.) --- diff --git a/.claude/question.md b/.claude/question.md index 8a94f5e..d926f69 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,17 +12,17 @@ --- -## ⭐ 최우선 — **8라운드 감사 회신 대기** (2026-08-26 갱신) +## ⭐ 최우선 — **비어 있음** (2026-08-26 갱신) -**[2026-08-26] 8라운드 손 트레이싱 -(`qa-request/pre-implementation-handtrace-round8.md`)이 사용자 결정 문항 -Q1~Q10을 올렸습니다.** 그중 🔴 5건(`H-107`~`H-109`/`H-112`/`H-119`)은 -7라운드 반영분을 서로 겹쳐 트레이싱하면 크래시 또는 조용한 침묵이라 -**M2 착수 전 회신이 필요합니다**(문항·권고안·개수는 그 문서 §4가 소스 — -여기서 반복하지 않음). 아직 아무것도 `base/`에 반영하지 않았고, 결정이 -나면 7라운드처럼 followup 파일을 새로 만듭니다. 아래 blockquote는 8라운드 -이전(2026-08-25) 시점 서술입니다 — 그때 닫힌 두 항목 자체는 그대로 -유효합니다. +**[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어) +착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정 +문항 Q1~Q10을 사용자와 대화형으로 전부 처리하고 `base/`에 반영했습니다 — +**결정의 소스는 +`qa-request/pre-implementation-handtrace-round8-followup.md`**(발견 원문은 +`-round8.md`, 개수·개별 항목은 여기서 세지 않음). 7라운드 확정 중 뒤집힌 +것은 없고, 고쳐진 건 전부 **7라운드가 `base/`에 내려앉을 때 생긴 +누락·충돌**입니다. 아래 blockquote는 8라운드 이전(2026-08-25) 시점 +서술입니다 — 그때 닫힌 두 항목 자체는 그대로 유효합니다. > **[2026-08-25] M2(반응형 코어) 착수를 막는 항목이 하나도 없습니다.** > 2026-08-24에 이 절로 올라왔던 둘이 7라운드 손 트레이싱 후속에서 같이 @@ -37,7 +37,10 @@ Q1~Q10을 올렸습니다.** 그중 🔴 5건(`H-107`~`H-109`/`H-112`/`H-119`) > `luau-test`에 하나 두는 게 좋지만 **착수 게이트는 아닙니다**. > - **동적 키 표면(옛 `GetDynamic`) 위치** → **닫힘.** `store:Of<>(name)` > 하나로 합쳤고(콜론 메소드 유지), -> 예약 키 충돌은 `CheckReserved` 타입 함수가 잡습니다. 애초에 이 항목이 +> 예약 키 충돌은 예약 키 진단 타입 함수가 잡습니다(**[2026-08-26]** 그 +> 함수는 8라운드 `H-112`로 `CheckReservedKeys>`가 됐습니다 — +> `T`를 통째로 넘기는 옛 배선 `CheckReserved`는 실사용 `T`에서 아예 +> 안 돕니다). 애초에 이 항목이 > 섰던 근거(lazy `__index`와의 충돌)는 **lazy 생성 자체가 폐기**되며 > 소멸했습니다(명시적 초기화). 이름도 `store:Of<>(name)` 하나로 > 합쳐졌습니다 — `base/store-plan.md`의 "타입 추론 문제" 절. diff --git a/.claude/reference/epoch-brand-composition.md b/.claude/reference/epoch-brand-composition.md index 2b9856f..a4d956a 100644 --- a/.claude/reference/epoch-brand-composition.md +++ b/.claude/reference/epoch-brand-composition.md @@ -166,6 +166,13 @@ `base/source-state-plan.md`의 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절과 같은 결이고, 값이 아니라 **핸들과 메타데이터**만 넘기므로 *"값을 안 실어주는 구독"* 계약도 안 깨진다. + + > **⚠️ [2026-08-26] 위 2-인자 표기는 2026-08-21 시점 기록이다** — + > 지금 확정된 시그니처는 **세 자리** `fn(targetState, self, emitFrom)`이다 + > (8라운드 `H-109`: 여기서 `self`가 뜻하던 "리시버 State의 lazy 핸들"과 + > 전파 루프가 실제로 넘기던 Observer 값이 충돌해 있었고, Observer 핸들이 + > 가운데 자리로 들어왔다). **이 문서는 근거 기록이라 본문을 소급 수정하지 + > 않는다** — 지금 유효한 계약은 `base/source-state-plan.md`가 소스. - 반영 시 같이 손볼 것: 그 계약 문단과 인자 없는 `state:Observer()` 유틸, 그리고 `base/effect-plan.md`의 내부 Observer 등록부(여기서 `Effect`가 자기 `EpochMap`을 `Update`한다). @@ -181,7 +188,8 @@ - `base/brand-plan.md` — 인스턴스 브랜드(옛 단일 레지스트리는 `archive/brand-shared-registry-reversed.md`). - `base/source-state-plan.md` — `Source`가 `Epoch`를 구조적으로 만족, - `state:Observer(fn)`의 `fn(self, from)` 계약. + `state:Observer(fn)`의 콜백 계약(**[2026-08-26]** 지금은 세 자리 + `fn(targetState, self, emitFrom)` — 위 6번의 ⚠️ 참고). - `base/effect-plan.md` — `Effect`가 자기 `EpochMap`을 들어 다중 deps 중복 발화를 접는다(이 제안이 닫은 갭). - `base/gate-plan.md` — 배치 페이로드(4번), 빈 배치 무통지(8번). diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index eb6a32f..d59ae3c 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -165,6 +165,17 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 19. 여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산/재대입을 피하는 파이프라인/업데이트 순서 팁(Blocker를 안 쓰는 단순 케이스용 보조 팁) — `research/additional-primitives-plan.md` "문서화 백로그" 절 +20. **[2026-08-26 신설, 8라운드 `H-116`] quad 값은 만든 모듈 사본 안에서만 + 유효하다** — 패키지 매니저 생태계에서 두 라이브러리가 서로 다른 quad + 버전을 끌어와 **같은 게임에 quad 두 벌이 공존**하는 건 예정된 미래인데, + 그때 `Brand` 레지스트리·`None` 센티널·`Subscribed` 전역 테이블이 사본마다 + 분리된다. 한 사본이 만든 `Source`/`Observer`를 다른 사본의 children + 배열에 넘기면 `isState`/`isObserver`가 거짓이 되어 동적 경로 가드나 + 요소 화이트리스트 검증(`H-40`)이 **정상 값을 이물로 판정**한다 — + 화이트리스트 error로 시끄럽게 드러나면 다행이고, 값 종류에 따라 조용히 + 무시될 수도 있다. 지금 설계로 막을 일이 아니라 **문서화할 사실**이다 + (버전 정책의 타입 레벨 축은 `type-version-check`가 이미 다룬다 — + 이건 그 런타임 판별 판). 코퍼스 어디에도 언급이 없어서 여기 등록한다 (13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번 `quadnomicon`으로 이동) diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 943e19d..16b208c 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1874,3 +1874,54 @@ M3로 넘겼다 — 그래서 빌드 순서상 역방향 간선이 없다. 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 `session/`·`archive/`· `qa-request/`로만 잡은 게 샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가 또 재확인됐다. + +## ⚠️ 2026-08-25 — `session/` 원문 공백 (기록만, 재구성 안 함) + +**그날 `session/` 파일이 하나도 없다.** 2026-08-25는 7라운드 손 트레이싱 +(6패스)과 그 검증·처리가 전부 일어난 날이고, 커밋 9개에 걸쳐 `Ref`의 `Epoch` +승격 · `WeakSubscribe`/`WeakCallback` · `_hold` 불변식 · Store 명시적 초기화 · +`recompute` 재진입과 되감기(`H-101`/`H-102`)가 확정됐다 — 지금 M2 계약의 +상당 부분이 그날 정해졌는데 원문 로그가 없다. +`.claude/conventions.md`의 *"설계 결정이 오간 세션은 끝나기 전에 +`session/YYYY-MM-DD-NN-slug.md` 원문을 반드시 남길 것"*을 어긴 공백이고, +**그 규칙 자체가 2026-08-18/19의 같은 공백에서 만들어졌으므로 재발 사례다.** + +**처분: 재구성하지 않는다.** 2026-08-19에 사용자가 정한 선례를 그대로 +따른다 — 그 시점 대화 원문에 접근할 수 없는 채로 "원문"을 지어내면 그 +자체가 허위 기록이 된다. 대신 공백을 여기 명시해 다음 세션이 "찾다가 없어서 +헤매는" 일만 막는다. **그날의 결정 내용 자체는 유실되지 않았다** — 결정과 +근거는 `qa-request/pre-implementation-handtrace-round7-followup.md`가, +발견 원문은 `qa-request/pre-implementation-handtrace-round7.md`와 그 검증 패스가, 재현 코드는 +`audit/handtrace-round7-reference-impl/`이 들고 있다. 없는 건 **논의 과정과 +시행착오**(`quadnomicon` 개발로그 소재로 쓰였을 부분)뿐이다. + +(2026-08-26 8라운드 처리 중 감사 7라운드가 발견.) + +## 2026-08-26-01 — 8라운드 손 트레이싱 처리 (`H-107`~`H-123`) + +8라운드 발견 17건의 결정 문항 Q1~Q10을 사용자와 대화형으로 처리해 `base/`에 +전량 반영. **역전은 없다** — 고친 건 전부 7라운드 확정이 `base/`에 내려앉을 +때 생긴 누락·충돌(하루 차로 확정된 결정들이 서로를 못 본 자리)이다. 계약이 +바뀐 것 넷: `Ref` 콜백 `fn(value, ref)` / Observer `fn`이 세 자리 +`fn(targetState, self, emitFrom)` + `observer._state` 강참조 / +`WeakSubscribe`도 `.Subscribed`를 세움 / 예약 키 진단 타입 함수가 `T`가 아니라 +`keyof`를 받음(`T`를 통째로 넘기는 배선은 실사용 `T`에서 아예 안 돈다 — +실측). **⭐ 사용자가 문항의 전제를 두 번 정정했다** — `Ref` 콜백과 Observer +콜백은 애초에 통합 대상이 아니고(*"observer 에는 epoch 란게 존재하지 않음 … +ref 는 그 자체로 epoch임"*), `H-118`은 소유권 문제가 아니라 `gate-plan` 5번의 +문장이 틀린 것(🟡→🟢). 결정의 소스는 +`qa-request/pre-implementation-handtrace-round8-followup.md`, 진행 경위와 +교훈은 `session/2026-08-26-01-handtrace-round8-resolution.md`. +**커밋 전 검증: 감사 11라운드(44건, 0건으로 수렴) + `/code-review high` +7라운드(42건) = 86건.** ⚠️ **감사가 0으로 수렴한 직후 code-review가 42건을 +냈고**, 그중엔 `H-101`의 *"새 필드를 안 만든다"*를 **역전**시킨 설계 구멍도 +있었다(부기 필드가 `offsetCacheValidUpTo`/`offsetSetUpTo` 둘로 갈라짐). 그 루프가 +남긴 교훈 셋 — (1) 계약을 고칠 때 가장 새기 쉬운 자리는 `base/`가 아니라 +그걸 요약·복사해 든 `ROADMAP`/`README`/`architecture.md`다(19건 중 15건), +(2) **넓게 퍼진 계약은 문서군이 아니라 *토큰*으로 전수 grep해야 잡힌다** — +`H-111` 잔재가 두 라운드 연속 새로 나오다 토큰 각도에서 4건이 한 번에 나왔다, +(3) **고치는 과정이 회귀를 만든다** — code-review 42건의 절반 이상이 직전 +수정의 산물이었고, **같은 실수를 네 번 반복했다**(토큰을 바꾸고 그 토큰이 든 +문장은 안 읽음; 한 번은 정정 배너 안의 **역사 인용문**까지 치환해 자기모순을 +만들었다). 규칙: **전역 치환은 인용문·절 제목·정정 배너를 건드리지 말 것.** +상세는 그 세션 파일의 "검증 (커밋 전)" 절. diff --git a/.claude/session/2026-08-26-01-handtrace-round8-resolution.md b/.claude/session/2026-08-26-01-handtrace-round8-resolution.md new file mode 100644 index 0000000..de06b3f --- /dev/null +++ b/.claude/session/2026-08-26-01-handtrace-round8-resolution.md @@ -0,0 +1,286 @@ +# 2026-08-26 — 8라운드 손 트레이싱 처리 (Q1~Q10) + +**무엇을 했나**: `qa-request/pre-implementation-handtrace-round8.md`의 발견 +17건(`H-107`~`H-123`)을 사용자와 대화형으로 처리하고 `base/`에 전량 반영, +`-round8-followup.md`를 신설했다. **결정과 근거의 소스는 그 followup 파일** +이고 여기선 진행 방식과 그 과정에서 드러난 것만 남긴다. + +## 진행 방식 + +1. 라운드 문서를 전량 읽고, **물어보기 전에 🔴 다섯의 핵심 주장을 `base/`에서 + 직접 재확인**했다 — `Ref:Set` H-53 블록에 `Revision` 갱신·Weak 순회가 + 없는 것, 전파 루프가 `sub.fn(sub, from)`인 것, `CheckReserved`가 `T`를 + 통째로 받는 서술. 전부 그대로였다. +2. §4의 문항 Q1~Q10을 세 배치로 물었다(🔴 넷 → M3/백로그 넷 → 나머지). +3. 사용자 회신을 받는 즉시 `base/`에 반영하고, 인덱스 레이어 + (`question.md`/`todos.md`/`README.md`/`ROADMAP.md`/`CLAUDE.md`/ + `project-context.md`)도 갱신했다. **다만 첫 패스에서 인덱스 쪽이 덜 + 닫혔다** — 아래 "감사 루프" 절 참고. + +## ⭐ 이 세션의 실질적 소득 — 사용자가 **문항의 전제를 두 번 정정했다** + +권고안이 그대로 채택된 항목이 대부분이었지만, 배울 게 있었던 건 그 둘이 아니다. + +### (1) Q2-후속 — "클로저를 통일한다"는 목표 자체가 없었다 + +Q1(a)로 `Ref` 콜백의 출처가 2번째 자리, Q2로 Observer의 출처가 3번째 자리가 +되면서 `Effect`의 단일 `onDepFire`가 성립하지 않게 됐다. 나는 이걸 +**"자리를 어떻게 맞출까"**라는 문항으로 냈고, (a) `Ref`도 3슬롯으로 +(`k(value, self, self)`), (b) `or`로 흡수, (c) 클로저 2개를 갈래로 세웠다. +사용자 회신: + +> *"애초에 둘을 같게 두려는 목적 자체가 없었음. Ref 의 callback 과 observer 의 +> 콜백이 아주 헤테로지니어스한 개념이라, 둘을 전혀 합치고자 한 적 없고, 원 +> 아이디어는 달랐음. 아주 중요한 부분이 있는데, observer 에는 epoch 란게 +> 존재하지 않음. emit 으로 온 epoch 를 넘겨줄 뿐, 그러나 ref 는 그 자체로 +> epoch임. 처음부터 둘 처리를 묶어보는 시각 자체가 잘못되었는것."* + +즉 `effect-plan.md`에 있던 *"⭐ 클로저는 **하나**로 통일한다"* 주석이 애초에 +근거 없는 서술이었고, 8라운드가 그걸 "확정"으로 읽고 갈래를 그 위에 세운 +것이다. **문서에 적힌 "⭐ 확정"이라도 그 근거가 문서 안에 없으면 확정이 아닐 +수 있다** — 라운드 문서가 `H-107` (b)를 *"'클로저는 하나로 통일' 주석과 +표면상 어긋난다"*는 이유로 낮춰 본 것도 같은 함정이었다(실제로는 dedup을 +클로저 identity가 하는 게 아니라 `_deps`/`_epochs` 맵이 한다는 걸 그 문서 +자신이 괄호로 적어놓고도). + +### (2) Q7 — 문항이 없는 대립을 세웠다 + +`H-118`은 *"`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다"*(gate-plan 5번)와 +*"정책이 `emit()` 반환값과 `emit(false)`를 직접 쓴다"*(2번, `H-55`/`H-86`)를 +**미조정 충돌**로 보고 "누가 쥐는가"를 갈래로 냈다. 사용자 회신: + +> *"둘다 쥔다는 의미를 모르겠음. epoch|{epoch} 를 모아두는 부분은 gate 쪽이긴 +> 한데(각각 본인껀 본인이 모아야하니까), emit 을 blocker 가 쥔다는건 +> 정확하게는, 'emit 된 적 있던가?' 를 저장하기 위함 아님? 그 구현을 나눠 쓰지 +> 않기 위함일 뿐 아녔음?"* + +원문을 다시 읽으니 그대로였다 — `setup(emit)`이 계약이므로 `emit`은 정의상 +정책 손에 있고, `blocker:Policy(emit)`이 위임받는 건 **보류 부기**뿐이다. +설계는 아무것도 안 바뀌고 **5번의 머리 문장만 틀렸다.** `H-118`은 🟡 계약 +결정에서 🟢 문서 정합으로 강등됐다. + +**교훈**: "두 확정이 충돌한다"는 발견을 낼 때, **둘 중 하나가 그냥 틀리게 +쓰인 문장일 가능성**을 갈래에 넣어야 한다. 8라운드는 두 문장을 각각 유효한 +설계 입장으로 대우해 없는 선택을 만들었다. + +## 반영 규모 + +- `base/` **14개 파일**(위 12개 — `ref-plan`/`source-state-plan`/ + `lifecycle-pattern`/`effect-plan`/`store-plan`/`state-epoch-plan`/ + `dispatch-core-plan`/`slot-plan`/`gate-plan`/`modifier-plan`/ + `lifecycle-hooks-plan`/`project-setup-plan` — 에 감사 루프가 더한 + `architecture.md`/`typing-limits.md`), `ROADMAP.md`, `luau-test/STATUS.md`, + `research/documentation-content-map.md`, `audit/type-recursion-issue/spikes/` + 2개(배너), 인덱스 레이어 6개. +- `doc-check.py` ERROR 0. + +## 안 한 것 + +- **8라운드 §6의 "못 본 것"은 그대로 유효**하다 — 특히 M5+ 구간이 문서 정독 + 수준이고 값 단위 트레이싱을 안 했다. +- 남은 의심 하나(게이트 `emit(false)` 직후 다이아몬드 두 번째 경로 도착)는 + 8라운드 스스로 "판단이 안 서서 발견으로 안 올렸다"고 적은 것이라 여기서도 + 열지 않았다. + +## 검증 (커밋 전) — 감사 11라운드 + `/code-review high` 7라운드 + +**합계: 감사 44건 + code-review 42건 = 86건.** 감사는 0건으로 수렴했고, +code-review는 마지막 라운드 HIGH 0으로 마쳤다(항목별 처분은 followup이 소스). + +**⚠️ 감사가 0건으로 수렴한 *직후* code-review가 42건을 냈다.** 두 도구는 +대체 관계가 아니고, 그 실측이 `conventions.md`에 반영됐다. + +### 감사 루프 — 11라운드, 발견 44건, 마지막 0건으로 수렴 + +`quad-doc-auditor`를 한 턴에 하나씩(병렬 금지), 라운드마다 각도를 바꿔 돌렸다. +새 발견 추이: **9 → 10 → 7 → 4 → 2 → 2 → 2 → 1 → 4 → 3 → 0.** + +| 라운드 | 각도 | 발견 | +|---|---|---| +| 1 | `base/` 정합성 | 9 | +| 2 | 인덱스 레이어(`README`/`ROADMAP`/`architecture` 요약) | 10 | +| 3 | 스파이크·실측 기록(`audit/`·`luau-test/`) + 1·2라운드 수정분 재검 | 7 | +| 4 | **오늘 쓴 문장 자체의 정확성**(누락이 아니라 오기·과장·의사코드 결함) | 4 | +| 5 | 오늘 *안 만진* `base/` 문서가 바뀐 계약을 전제하는가 | 2 | +| 6 | "한쪽만 고치고 짝을 안 고친" 쌍 | 2 | +| 7 | 오늘 바뀐 `base/` 파일 **전량 정독**(diff 아님) | 2 (`base/` 안쪽 0) | +| 8 | 미사용 문서군(`conventions`/`agents`/`research`/`HUMAN_TODO`) + 수정분 재검 | 1 | +| 9 | **`H-111` 하나를 토큰 전수 추적** | 4 | +| 10 | 오늘 확정 12개 계약 전부를 토큰 전수 추적 | 3 | +| 11 | "확정/새 모델/해법" 권위 표지가 붙은 폐기 블록 | **0** | + +### 이 루프가 알려준 것 셋 + +**1. 계약을 고칠 때 가장 새기 쉬운 자리는 `base/`가 아니라 그걸 요약·복사해 +든 곳이다.** 1·2라운드 발견 19건 중 15건이 `ROADMAP.md`의 체크박스 인라인 +사본, `README.md`의 `base/` 표, `architecture.md`의 소스 트리 주석이었다. +그중 몇은 **그대로 구현하면 그날 고친 버그를 재현**했다(전파 루프 2-인자, +`Ref:Set`의 `k(value)`, 훅의 `Callback(fn)`). `base/`를 고칠 때 그 세 곳을 +같은 배치에 넣는 걸 기본으로 할 것. + +**2. ⭐ 문서군 각도로는 구조적으로 안 잡히는 잔재가 있다 — 토큰으로 범위를 +잡아야 한다.** `H-111`(`WeakSubscribe`도 `.Subscribed`를 세운다)이 6·8라운드에 +연속으로 새 잔재를 냈고, 그때마다 "그 문서는 고쳤다"고 넘어갔다. 9라운드가 +문서가 아니라 **토큰**(`Subscribed`/`isBoundAlive`/`canBound` 계열)으로 전수 +grep하자 **4건이 한 번에** 나왔다 — 전부 *"`.Subscribed`는 전역 +`:Subscribe()` 전용"*이라는 **똑같은 어구**였고, 앞의 여러 라운드가 각자 다른 +문서를 보느라 계속 지나쳤던 자리다. 10라운드가 같은 방법을 나머지 계약에 +적용해 3건을 더 냈다. **넓게 퍼진 계약을 바꿀 땐 옛 표기의 토큰을 정해 +전수 grep할 것.** + +**3. 고치는 과정이 새 결함을 만든다 — 수정분도 감사 대상이다.** 이 루프에서 +메인 세션이 **회귀를 6건 만들었다**: `reference/`의 blockquote가 기존 문장을 +반토막 냄, `lifecycle-pattern.md`에 `Subscribe`가 서로 다른 구현으로 **두 번 +정의**됨(+ 산문이 약속한 `WeakUnsubscribe` 코드 부재, `Unsubscribe`가 약한 +레지스트리를 안 지우는 반쪽 해제), 새 의사코드 아래 결론 문단이 옛 주장을 +유지, 정정 배너 3건이 **엉뚱한 문단**에 붙음(문단 끝 자동 탐색이 다음 bullet으로 +넘어감). 4·8·9·11라운드가 이걸 잡았다 — **"수정분 재검"을 별도 각도로 두는 +게 값을 했다.** + +### 부수로 드러난 기존 부채 둘 (오늘 작업과 무관) + +- **`HUMAN_TODO.md`가 이미 닫힌 게이트를 열려 있다고 서술**(2자리) — 관례가 + 요구하는 인덱스 4개 층 중 거기만 2026-08-25 갱신을 안 받았다. +- **2026-08-25의 `session/` 원문이 통째로 없다** — 그날 커밋 9개로 지금 M2 + 계약의 상당 부분이 확정됐는데 로그가 없다. **그 규칙 자체가 2026-08-18/19의 + 같은 공백에서 만들어진 것이라 재발이다.** 2026-08-19 선례대로 재구성하지 + 않고 `session-summary.md`에 공백을 명시했다(결정 내용 자체는 followup·발견 + 문서·참조 구현에 남아 있어 유실이 아니고, 없는 건 논의 과정뿐이다). + +`doc-check.py` ERROR 0 / WARN 43(전부 기존 부채, 이번 작업이 늘린 것 없음). + +### `/code-review high` 1차 — 감사 11라운드가 못 본 7건 + +사용자가 직접 호출(이 명령은 세션이 못 부른다). **7건 전부 유효.** +항목별 처분은 followup 문서의 "반영 후 검증" 절이 소스 — 여기선 교훈만. + +**⭐ 감사자와 code-review는 대체 관계가 아니다(2026-08-18 실측의 재확인).** +감사 11라운드가 44건을 잡고 0건으로 수렴한 **직후에** 7건이 더 나왔고, +그중 셋은 감사 각도 어디에서도 안 나오는 종류였다. 축이 다르기 때문이다 — +감사자는 **코퍼스 전체의 의미론적 정합성**(A 문서의 결정과 B 문서의 서술이 +어긋나는가), code-review는 **diff 자체의 결함**(새로 쓴 서술 안의 모순, +새 계약이 기존 계약과 충돌하는가). + +**⭐⭐ 가장 무거운 발견(HIGH)은 이 라운드의 *처방들이 겹쳐서* 생긴 것이다.** +`H-113`(splice 무효화를 `index - 1`로)과 `H-119`(`_baseObserver`에 +`bk.invalidAfter = 0`)는 각각 독립적으로 옳은데, 둘이 겹치면서 +**`invalidAfter`가 0이 될 수 있는 경로가 둘** 생겼고 `recompute`의 되감기 +블록엔 클램프가 없었다 — `sum = prefix[0]`(nil) → `sourceList[0]`이 nil → +**부기가 멀쩡한데 "부기가 깨졌음"이라는 error로 죽는다.** + +8라운드 손 트레이싱 자체가 *"반영된 결정들이 서로 겹칠 때 성립하는가"*를 +보는 라운드였는데, **그 라운드의 처방들이 겹쳐 같은 종류를 하나 더 만들었다.** +교훈: 한 라운드 안에서 여러 결정이 **같은 변수**를 건드리면(여기선 +`bk.invalidAfter`), 각 결정을 개별로 검증하는 것으로 부족하다 — 그 변수의 +**값 범위가 어떻게 넓어졌는지**를 따로 봐야 한다. + +**계약 결정 둘은 사용자에게 물었다** — `Subscribe`는 idempotent가 아니고 +(`canBound` 게이트에 걸려 error), `WeakUnsubscribe`는 강한 킵이 남아 있으면 +error. 둘 다 fail-fast 쪽. `Unsubscribe`만 idempotent이고 **이 비대칭이 +의도된 것**이라는 것도 같이 명문화했다. + +### 2차 `/code-review high` — 또 7건, 그중 넷이 1차 수정의 산물 + +1차 반영 **직후** 다시 돌렸더니 7건이 더 나왔다. **폐기된 서술을 만지는 +방식**에 대한 교훈이 둘 나왔다: + +- **이름만 고치면 죽은 주장이 갓 정비된 것처럼 보인다.** `H-114` 반영 때 + `handle._observers` → `_deps`로 필드명만 바꿨는데, 그 문장이 서술하던 + **동작**(`bindLifetime`이 내부 Observer로 cascade한다)은 `H-58`이 이미 + 폐기한 것이었다. 이름이 최신이라 배너도 안 달렸고, 그 결과 살아 있는 확정 + 절 안에 **`H-58`이 막은 버그(바인드마다 `Rerun`)를 되살리는 안내**가 + 남았다. +- **폐기 블록에 날짜 마커를 찍지 말 것.** `⛔` 배너 아래 죽은 문단에 + `H-111` 정정 마커를 찍었더니 그 문단만 최신처럼 보였다. 배너만 달고 + 본문은 건드리지 않는 게 낫다. + +나머지 다섯도 전부 "한 곳은 고쳤는데 그걸 요약·인용하는 곳이 안 따라옴" +계열이다(무효화 절의 머리 문장 vs 표, `rawSwap` 규칙이 표에 없음, 전파 루프 +주석의 `Effect`, 예약 키 개수 2 vs 3, `luau-test` README의 옛 이름). + +### 3차 `/code-review high` — 7건 더, 그리고 총평 + +**세 차례에서 21건**이 나왔고 **절반 이상이 직전 수정의 산물**이었다. +(3차는 세션이 직접 호출했다 — `conventions.md`가 *"`/code-review`는 사용자만 +호출할 수 있다"*고 적어뒀는데 **지금은 세션의 skill 목록에 있다.** 그 관례 +문장은 실태와 다르다.) + +**반복된 실패 모드는 하나다 — 한 자리를 고치면서 그 자리를 요약·인용·정당화하는 +이웃 문장을 같이 안 고침.** + +- 표에 행만 넣고 헤딩의 개수("인덱스는 셋")와 배치 목록은 그대로 → 새 규칙이 + *"표는 산문뿐이고 코드 경로가 없다"*(`H-3`)는 원래 상태로 되돌아감. +- 필드 이름만 바꾸고 그 이름이 서술하던 **동작**은 그대로 → 폐기된 cascade + 주장이 갓 정비된 것처럼 보임. +- 새 dedup의 **근거**를 잘못 적어(*"thread를 두 번 resume"*) 그 근거가 + "thread가 양쪽 테이블에 산다"를 함의하게 됨 → 그 함의를 따라가면 죽은 + 코루틴을 영원히 조용히 `resume`하는 경로가 열린다. + +**감사자는 이 클래스를 구조적으로 못 잡는다.** 코퍼스 정합성(A 문서 vs B +문서)이 아니라 **방금 쓴 문단 안의 논리**이기 때문이다. 감사 11라운드가 +0건으로 수렴한 뒤에도 code-review가 21건을 낸 이유가 이거다. + +### 4차 `/code-review high` — 설계 결함 하나가 `H-101`을 역전시켰다 + +**부기 필드가 하나에서 둘로 갈라졌다** — `bk.offsetCacheValidUpTo`(캐시)와 +`bk.offsetSetUpTo`(`:Set` 완료). 옛 단일 `invalidAfter`는 이름째 없앴다. +결정과 표는 followup의 "`H-101`의 '새 필드를 안 만든다'가 역전됐다" 절이 소스. + +**이 세션에서 가장 값이 컸던 순간은 사용자가 리뷰를 되물은 지점이다.** +리뷰가 낸 건 *"`getOffsetAt`이 되감기 신호를 지운다"*였고 나는 그걸 그대로 +옮겨 "(a) recompute 중엔 안 올린다 / (b) 필드 분리" 두 갈래로 물었다. +사용자 반응: *"그럴리가. getOffsetAt 전부 끝나고 나서야 Set 이 일어나고 … +순서가 섞일 일이 없어서 되감기 신호가 지워질 일이 안 보이는듯 한데..? 다시 +볼래?"* — **그 지적의 절반이 맞았다.** 한 프리미티브 안에서는 정말 안전하다 +(`rawRemove`가 `getOffsetAt`을 splice **앞**에서 부른다). 다시 트레이싱해서 +**한 콜백에 CRUD 두 번**(`Remove` 뒤 `Add` → `setOffsetSource` → `getOffsetAt`) +이라는 실제 경로를 찾아내 보이자, 사용자가 **패치가 아니라 근본 원인**을 +짚었다: *"Set을 해줬느냐와 캐시가 유효하지 않느냐라는 다른 목적의 값을 같은 +값이 쥐고 있음. 그것 자체가 문제였는듯."* + +교훈 셋: +- **리뷰 결과를 그대로 사용자에게 넘기지 말 것.** 내가 먼저 트레이싱해서 + "어느 경로로 도달하는가"를 확정했어야 했다. 실제로 같은 라운드의 다른 + 발견(materialize 꼬리 게이트가 사후조건을 깬다)은 **직접 확인해보니 틀렸다** + — `bk`가 그 Slot 자신의 부기라 게이트가 발화하면 바깥 루프가 어차피 확정한다. +- **"두 뜻이 실제로 같다"는 확정은 의심할 것.** `H-101`이 그렇게 적고 새 필드를 + 거부했는데, 그 통합이 정확히 버그의 원인이었다. 같은 값이 두 질문에 답하고 + 있으면 **언젠가 한쪽만 갱신되는 경로가 나온다.** +- **이름이 뜻을 겸하면 그 이름부터 없앨 것.** 사용자 지적으로 + `invalidAfter`를 남기지 않고 완전히 치환했다 — 남겨두면 읽는 쪽이 옛 의미를 + 그대로 가져온다. + +### 5~7차, 그리고 멈춘 이유 + +5차 6건 / 6차 8건(**HIGH 3, 전부 5차 수정의 산물**) / 7차 5건(**HIGH 0**). +항목은 followup이 소스. 6차가 정점이었고, 거기서 나온 규칙 하나를 명문화한 +뒤 7차는 HIGH가 없었다. + +**⭐⭐ 이 세션 최대의 교훈 — 나는 같은 실수를 네 번 했다.** +`_observers` → `_deps`(1차), `CheckReservedKeys`(3차), `invalidAfter` → +`offsetSetUpTo`(5차), 그리고 6차의 인용문 오염. **전부 "토큰을 바꾸고 그 +토큰이 든 문장은 안 읽음"**이다. 6차 건은 한 걸음 더 나아가 **역사 기록까지 +오염**시켰다 — *"옛 이름을 완전히 없앴다"*는 내 선언이 정정 배너 **안의 +인용문**에까지 적용돼, 폐기를 서술하는 문장이 자기가 폐기한 것의 새 이름을 +쓰게 됐다. + +> **규칙**: 전역 치환은 **인용문·절 제목·정정 배너를 건드리면 안 된다.** +> 그 셋은 *과거에 무엇이라고 적혀 있었는가*를 보존하는 게 목적이라, 새 +> 이름으로 바꾸는 순간 목적이 무너진다. 치환 전에 제외 목록에 넣을 것. +> 그리고 치환 뒤에는 **바뀐 토큰이 든 문장 전체**를 다시 읽을 것 — 토큰만 +> 맞추면 그 문장이 설명하던 불변식이 바뀐 걸 못 본다. + +**7차에서 멈춘 판단**: HIGH가 나온 라운드는 **전부 직전에 구조를 바꾼 +뒤**였다(필드 분리, 새 가드, 게이트 추가). 7차 수정 5건은 문장/줄 단위 국소 +정정이고 새 계약도 대규모 치환도 없어, 반복된 실패 모드가 적용될 표면이 +없다. 커밋 안 된 36파일 diff 자체도 리스크라 여기서 체크포인트를 만든다 — +이후 검토는 커밋 대상으로 언제든 가능하다. + +### 남은 미검증 + +- 7차 수정분 자체(위 판단으로 감수). +- 8라운드 §6의 "못 본 것"은 그대로 — 특히 **M5+ 구간은 문서 정독 수준**이고 + 값 단위 트레이싱을 안 했다. 다음 라운드가 있다면 거기가 최우선. +- 실측 스파이크는 `luau-test/STATUS.md`가 소스. 이 세션이 항목 넷을 더했다 + (`11` 재작성, `16`/`21` 최종형 + 빈 Store 대조군, `CheckedQuad` M2 후 재실측). + diff --git a/.claude/todos.md b/.claude/todos.md index 3d5f48f..6e8d89a 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,13 +5,36 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐⭐ [2026-08-26 정정] 8라운드 손 트레이싱이 회신 대기다 — "M2 착수 - 게이트 0"은 그 회신 전까지 유보.** 8라운드 - (`qa-request/pre-implementation-handtrace-round8.md`, 7라운드 반영분을 - 서로 겹쳐 재트레이싱 + 실측)가 사용자 결정 문항 Q1~Q10을 올렸고 그중 - 🔴 다섯은 **M2 착수 전 회신 필요**다(문항·개수는 그 문서 §4와 - `question.md` 최우선 절이 소스). 아직 `base/` 반영 없음 — 결정이 나면 - followup 파일을 새로 만든다. 아래는 8라운드 이전(2026-08-25) 시점 서술: +00. **⭐⭐⭐ [2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.** + 8라운드(`qa-request/pre-implementation-handtrace-round8.md`, 7라운드 + 반영분을 서로 겹쳐 재트레이싱 + 실측, 3개 패스, 발견 17건 + `H-107`~`H-123`)의 결정 문항 Q1~Q10을 사용자와 대화형으로 처리해 + `base/`에 전량 반영했다. **결정의 소스는 + `qa-request/pre-implementation-handtrace-round8-followup.md`**(개수·개별 + 항목은 여기서 세지 않는다). `question.md` 최우선 절은 **비어 있다.** + - **역전 없음** — 7라운드 확정 중 뒤집힌 건 하나도 없고, 고친 건 전부 + 7라운드가 `base/`에 내려앉을 때 생긴 **누락·충돌**이다(하루 차로 + 확정된 결정들이 서로를 못 본 자리). + - **계약이 바뀐 것 넷**: `Ref` 콜백이 `fn(value, ref)`(2번째가 곧 출처 + `Epoch`) / Observer `fn`이 **세 자리** + `fn(targetState, self, emitFrom)` + `observer._state` 강참조 / + `WeakSubscribe`도 `.Subscribed = true`를 세움 / + 예약 키 진단 타입 함수가 `T`가 아니라 **`keyof`**를 받음(이름도 + `CheckReserved` → **`CheckReservedKeys`**; `T`를 통째로 + 넘기는 배선은 실사용 `T`에서 **아예 안 돈다** — 실측). + - **⭐ 사용자가 전제를 정정한 것 둘**: (1) `Ref` 콜백과 Observer 콜백은 + **이질적 개념이라 애초에 통합 대상이 아니다**(*"observer 에는 epoch 란게 + 존재하지 않음 … ref 는 그 자체로 epoch임"*) → `Effect`는 dep 종류별로 + 클로저 둘. (2) `H-118`은 소유권 문제가 아니라 `gate-plan` 5번의 + **문장이 틀린** 것(🟡→🟢 강등). + - **M3 쪽 둘**: splice 무효화가 `j` → **`j - 1`**(커서 위치 splice가 + "변경 없음"과 구분이 안 됐다), 명시 `recompute` 호출도 재진입 게이트를 + 탄다(`Add`는 안전한데 `Remove`가 깨져 있었다). + - **새로 생긴 구현 항목**: Store 생성자의 `defaults` `isSource` 화이트리스트 + 검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동. + - **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가 + 사본마다 분리된다는 사실(`research/documentation-content-map.md` §4). + 아래는 8라운드 이전(2026-08-25) 시점 서술: **[2026-08-25] 7라운드까지 전부 처리 완료 — 당시 `question.md` 최우선 절이 비었고 M2 착수 게이트가 0이었다.** 7라운드(손 트레이싱, 6패스, @@ -208,7 +231,9 @@ **✅ [2026-08-25 해소] 아래 둘은 전부 닫혔다** — `question.md` 최우선 절은 지금 **비어 있다**. 중간 State GC는 **`_hold` 불변식**(하류 → 상류 강함, 상류 → 하류 weak)으로 사용자가 확정했고, 동적 키 표면(옛 `GetDynamic`) 위치는 - **콜론 유지 + `CheckReserved` 타입 함수**로 닫혔다(애초에 그 항목이 섰던 + **콜론 유지 + 예약 키 진단 타입 함수**로 닫혔다(**[2026-08-26]** 그 함수는 + 8라운드 `H-112`로 `CheckReservedKeys>`가 됐다 — `T`를 통째로 + 넘기는 옛 배선은 실사용 `T`에서 안 돈다. 애초에 그 항목이 섰던 근거인 lazy `__index` 충돌 자체가 Store 재설계로 소멸). 남은 건 실측 스파이크 하나뿐이고 **착수 게이트가 아니다**(`luau-test/STATUS.md`의 "만들어야 할 스파이크" 절). 아래는 해소 전 서술: diff --git a/CLAUDE.md b/CLAUDE.md index 188cb57..d342be9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,13 +8,12 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의 **2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의 `M2`/`M3`는 옛 의미로 읽을 것**(라이브 문서는 전부 새 번호로 맞춰뒀음). 경위는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절, -새 구성은 `ROADMAP.md`의 M2 배너. **⚠️ [2026-08-26 정정] 8라운드 손 -트레이싱이 회신 대기라 M2 착수는 그 회신 뒤다** — -`.claude/qa-request/pre-implementation-handtrace-round8.md`의 §4(사용자 -결정 문항, 그중 🔴 다섯이 착수 전 필요)가 소스이고 `.claude/question.md` -최우선 절이 이를 가리킨다(2026-08-25에 "막는 항목 없음"으로 비웠던 그 -절이다 — 7라운드 결정 자체는 유효, 소스는 -`.claude/qa-request/pre-implementation-handtrace-round7-followup.md`). 같은 상태를 `.claude/project-context.md`도 +새 구성은 `ROADMAP.md`의 M2 배너. **⭐ [2026-08-26] 8라운드 손 트레이싱까지 +처리 완료 — M2 착수를 막는 항목이 하나도 없다.** +결정의 소스는 +`.claude/qa-request/pre-implementation-handtrace-round8-followup.md` +(7라운드 몫은 `-round7-followup.md`)이고 `.claude/question.md` 최우선 +절은 비어 있다. 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index dc6349e..33ea5cb 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -12,11 +12,12 @@ 결정과 근거는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절, 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너가 소스. -**⚠️ 다만 그 교체로 `question.md` 최우선 절에 항목 둘이 올라왔다** — 중간 -State GC 실측과 `store:GetDynamic` 위치. 둘 다 원래 반응형(옛 M3)의 -게이트라 급하지 않았는데 반응형이 먼저 지어지게 되면서 **지금 답이 -필요**해졌다. 둘 다 설계 질문이라 이 문서가 아니라 `question.md`가 -소스다(아래 3번 항목이 가리키는 그 절) — 여기서 따로 번호를 만들지 않는다. +**✅ [2026-08-25 해소] 그 교체로 한때 `question.md` 최우선 절에 항목 둘이 +올라왔었다** — 중간 State GC 실측과 동적 키 표면(옛 `store:GetDynamic`) 위치. +**둘 다 닫혔다**(GC는 `_hold` 불변식으로, 표면 위치는 콜론 유지 + 예약 키 +진단 타입 함수로) — 7라운드 손 트레이싱 후속이 소스이고, 2026-08-26 8라운드도 +"M2 착수를 막는 항목이 하나도 없다"로 재확인했다. **`question.md` 최우선 +절은 지금 비어 있다.** --- @@ -216,9 +217,10 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 마음에 안 드는 것만 답해주면 됨 — **[2026-08-24 갱신] 순수 설계 결정 대기는 여전히 0건**이고 2026-08-22에 열렸던 마일스톤 순서 결정도 -**해소됐다**(위 11번 항목). 다만 그 해소의 부작용으로 **`question.md` -최우선 절에 M2 착수 전 답이 필요한 항목 둘**이 올라와 있다(중간 State GC -실측, `store:GetDynamic` 위치). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준] +**해소됐다**(위 11번 항목). **✅ [2026-08-26 갱신] 그 해소의 부작용으로 +한때 올라왔던 항목 둘**(중간 State GC 실측, 동적 키 표면 위치)**도 2026-08-25에 +닫혔고, 2026-08-26 8라운드 손 트레이싱까지 처리한 지금 `question.md` 최우선 +절은 비어 있다.** 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준] `question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 — 마지막 남았던 0-W `Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지" diff --git a/ROADMAP.md b/ROADMAP.md index 209eabe..17a42c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,9 +16,14 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 > 소스). **⭐ [2026-08-25] 그 교체로 올라왔던 항목 둘도 닫혔습니다** — > 중간 State GC는 `_hold` 불변식(하류 → 상류 강함)으로, 동적 키 표면 > 위치는 `store:Of<>(name)` 하나로(옛 `GetDynamic` 흡수) + 예약 키 -> 충돌은 `CheckReserved` 타입 함수로. `.claude/question.md` +> 충돌은 예약 키 진단 타입 함수로. `.claude/question.md` > 최우선 절은 지금 **비어 있습니다**. 결정 전량의 소스는 -> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`. M1까지의 산출물은 +> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`, +> 그리고 **[2026-08-26] 8라운드 몫은 `-round8-followup.md`**(7라운드 +> 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다. +> **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에 +> 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 +> 있습니다). M1까지의 산출물은 > quad-base/quad-roblox 폴더+pesde.toml, 루트 > default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/ > `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로 @@ -296,8 +301,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 있고, `canExecute`의 실제 호출부(State 전파 루프)엔 `inst`가 없어서 2-인자로는 호출 자체가 불가능했음. 판정은 (a) 복사된 gcconn의 `.Connected` 또는 (b) Observer/Effect의 `.Subscribed` 둘 중 하나 — - **`.Subscribed`는 전역 `:Subscribe()` 전용 필드라 - `bindLifetime`/`unbindLifetime`이 읽지도 쓰지도 않음**. 역전 원문은 + **`.Subscribed`는 전역 구독 경로 전용 필드라 + `bindLifetime`/`unbindLifetime`이 읽지도 쓰지도 않음** + (**[2026-08-26 표기 정정, 8라운드 `H-111`]** *"전역 `:Subscribe()` 전용"* + 이라고 적혀 있었으나 `:WeakSubscribe()`도 이 필드를 세운다 — 강·약이 + 갈리는 건 레지스트리를 강하게 잡느냐뿐이다. 이 문장의 요지 + (`bindLifetime`이 안 건드린다)는 그대로 유효). 역전 원문은 `archive/canexecute-inst-arg-reversed.md`. **`unbindLifetime(value)` 추가(2026-08-09 여섯 번째 세션)** — `inst` 전체 죽기 전에 특정 값 하나만 @@ -342,16 +351,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<>(name): Source` — 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 기각). **콜론 메소드로 확정**했고, 예약 키 - (`Of`/`Names`) 충돌은 `CheckReserved` 타입 함수가 사용 지점에서 - 잡는다(`base/store-plan.md`의 "타입 추론 문제" 절). + (`Of`/`Names`/**`__reservedCheck`** — **[2026-08-26 `/code-review high`]** + 팬텀 필드가 교집합 안에 살아 자기 이름도 예약해야 한다) 충돌은 + `CheckReservedKeys>` 타입 함수가 사용 + 지점에서 잡는다(**[2026-08-26 `H-112`]** 옛 이름·배선은 + `CheckReserved`였는데 `T`를 통째로 넘기면 실사용 `T`에서 아예 안 + 돈다 — `base/store-plan.md`의 "타입 추론 문제" 절). `<>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨. **[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다** - [ ] **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합 열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이 요구한다(`base/attribute-plan.md`) -- [ ] **[2026-08-25 신설]** `CheckReserved` 타입 함수 — 예약 키를 **검증만** - 하고 `T`를 그대로 통과시킨다. `error()`가 아니라 - `print(...)` + `return types.never`를 써야 한다. +- [ ] **[2026-08-25 신설, 2026-08-26 배선 정정 `H-112`]** `CheckReservedKeys` + 타입 함수 — **`T`가 아니라 `keyof`**(키 싱글톤 유니온)를 받아 + 예약 키를 검증만 하고, 팬텀 필드 `__reservedCheck`로 격리한다. + `error()`가 아니라 `print(...)` + `return types.never`를 써야 한다. + **`T`를 통째로 넘기는 옛 배선은 실사용 `T`에서 아예 안 돈다** — + 최종 Store의 `T`는 `{hp: Source, …}`이고 그 `Source`가 + `*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 타입 함수 + 에러가 뜬다(실측). 근거·통과 배선은 `base/store-plan.md`. **타입 함수는 이 용도(진단)까지만 쓴다** — `base/typing-limits.md` §0 - [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index` @@ -375,8 +393,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold` 불변식**(하류 → 상류 강함)이 책임진다. ```lua - if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 - elseif canExecute(sub) then sub.fn(sub, from) -- Observer / Effect + if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 + elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다) + sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처) end ``` `canExecute`가 `inst`를 인자로 받을 수 없는 이유는 그대로다(State는 @@ -418,8 +437,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver` 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` + **`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브, - 7라운드 `H-58`/`H-59`). **무인자 `state:Observer()`의 내부 콜백은 - `function(self) self:Get() end`**(`H-61`). **동적 + 7라운드 `H-58`/`H-59`). + **⭐⭐ [2026-08-26 추가, `H-109`/`H-110`; `/code-review high` 7차가 누락을 + 잡음] `fn`의 시그니처는 세 자리 `fn(targetState, self, emitFrom)`이고, + 생성 시 `observer._state = state`(리시버 강참조)를 세운다** — 전파 루프가 + 그 필드를 1번 인자로 읽는다(위 루프 스니펫). 이걸 빼고 `Observer.luau`를 + 짜면 `sub._state`가 `nil`이라 **모든 콜백이 리시버 자리에 `nil`을 받고** + 무인자 유틸이 `nil:Get()`으로 죽는다 — `H-109`가 고치려던 그 크래시. + `observer._state`는 Observer의 `_hold` 상당이기도 하다(`H-110`). + **무인자 `state:Observer()`의 내부 콜백은 + `function(targetState) targetState:Get() end`**(`H-61`; **[2026-08-26]** + 파라미터 이름이 `self`였는데 1번 자리는 Observer가 아니라 리시버다). **동적 경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 @@ -451,6 +479,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛 `_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) · **`handle._epochs`**(`EpochMap` — `Ref`도 `Epoch`라 dep 종류가 균일) · + **⭐ [2026-08-26 추가, 8라운드 `H-107`/Q2-후속] dep 등록 클로저는 종류별로 + *둘*이다** — `onRefFire(_, ref)`(Ref 콜백은 `fn(value, ref)`라 출처가 + **2번째**)와 `onStateFire(_, _, from)`(Observer는 + `fn(targetState, self, emitFrom)`라 출처가 **3번째**). **하나로 합치려 + 들지 말 것** — 한때 effect-plan에 *"클로저는 하나로 통일한다"*고 적혀 + 있었으나 근거 없는 서술이라 삭제됐다(사용자 확정: 두 콜백은 이질적이고, + Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은 + 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 · **`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing` 플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) · **`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가 @@ -562,12 +598,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` / `Source:Emit()` — `:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 있는데 이 셋만 빠져 있었다 -- [ ] **[2026-08-25 신설, `H-81`]** `isModifier` 런타임 가드 — 적용 지점이 - **전부 이 마일스톤의 코드**다(`Source:Set` / Store 생성 시 eager - `Source(default)` / State의 `:Compute` 결과 캐싱, 그리고 - 독립 `Source(someModifier)`). 체크박스가 M7에만 있었다. - `base/modifier-plan.md` 7번과 `base/source-state-plan.md`의 적용 - 지점 목록을 서로 맞출 것(한쪽에만 있는 항목이 있다) +- [ ] **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]** + `isModifier` 런타임 가드 — 적용 지점이 + **전부 이 마일스톤의 코드**다: **`Source(...)` 생성자** / `Source:Set` / + State의 `:Compute` 결과 캐싱. 체크박스가 M7에만 있었다. + **[2026-08-26]** 여기 한때 *"Store 생성 시 eager `Source(default)`"*가 + 적혀 있었으나 명시적 초기화 이후 **`defaults` 경로엔 그 지점이 없다** + (**[2026-08-26 정밀화]** 동적 키 창구 `store:Of(name)`은 여전히 만든다) — + 가드를 `Source` 생성자에 두면 그 둘이 **한 번에 커버**된다 + (`base/modifier-plan.md` 7번이 소스) +- [ ] **[2026-08-26 신설, `H-122`]** `Store` 생성자의 `defaults` 런타임 + 검증 — 값 전량에 `isSource` 화이트리스트, 거짓이면 `error(..., 2)` + (영어 메시지). 타입은 `Source`를 요구하지만 `--!nocheck`/동적 + 코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫 + `:Get()`에서 엉뚱한 에러로 죽는다. 생성 시 1회라 hot path 아님 - [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 — `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면 **아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매 @@ -679,15 +723,37 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 계약을 반드시 같이 구현할 것** — 이게 빠지면 형제 Slot이 커져도 뒤 형제의 `Offset`이 **영원히 고정**된다(`sum`은 매번 새로 더하므로 위로는 맞고 옆으로만 틀려 알아채기 특히 어렵다): - (a) `getBookkeeping`이 `bk.offsetCache = {}`, `bk.invalidAfter = 0`으로 + (a) `getBookkeeping`이 `bk.offsetCache = {}`, **`bk.offsetCacheValidUpTo = 0`, + `bk.offsetSetUpTo = 0`**, `bk.recomputeBlocker = Blocker()`으로 초기화(`nil` 시작이면 첫 `setOffsetSource`가 `nil` 비교에서 죽는다), - (b) **캐시를 앞으로 당기는 자리 셋** — `setLength` 본문과 그 자리 length가 - State일 때 다는 Observer 콜백(`math.min(invalidAfter, i)`), - `spliceArraysUp`/`spliceArraysDown`(같은 식), `slot._baseObserver` 콜백 - (베이스가 바뀐 경우라 `0`). 셋 다 `recompute`보다 **먼저**. + (b) **캐시를 앞으로 당기는 자리**(개수는 `base/dispatch-core-plan.md`의 + 무효화 표가 소스) — `setLength` 본문과 그 자리 length가 + State일 때 다는 Observer 콜백(**두 필드 다** `math.min(…, i)`), + `spliceArraysUp`/`spliceArraysDown`(**두 필드 다 `math.min(…, i - 1)`** — + **[2026-08-26 정정, 8라운드 `H-113`]** 한때 `setLength`와 "같은 식"이라 + 적혀 있었으나 `i`로 당기면 `recompute`의 커서가 정확히 `i`일 때 + "변경 없음"과 구분이 안 돼 되감기가 안 걸린다), + `slot._baseObserver` 콜백 + (베이스가 바뀐 경우라 `0`), 그리고 **⭐ [2026-08-26 신설, + `/code-review high`] `rawMove`/`rawSwap`/`rawExtract`류**(자리 수는 + 그대로, 순서만 바뀜 — 두 필드 다 `math.min(…, minPos - 1)`, `H-29` 규약 + 3번). **전부 `recompute`보다 먼저.** (c) **`recompute` 호출은 `setLength`의 단독 책임**이다 — `rawAdd`/ `rawReplace`의 명시 호출은 삭제됐고, 자리가 없어지는 경로 (`rawRemove`/`rawUnmount`/`rawDetach`)만 예외로 직접 부른다. + **⭐ [2026-08-26 추가, 8라운드 `H-119`] 그 명시 호출도 재진입 게이트를 + 먼저 본다** — `blocker:IsOn() or bk.recomputeBlocker:IsOn()`이면 + 건너뛴다(건너뛴 몫은 splice가 당겨둔 `offsetSetUpTo`로 바깥 루프의 + 되감기가 복구). 안 그러면 `Add`는 안전한데 `Remove`만 중첩 `recompute`가 + 완주해 바깥의 `Length`를 낡은 합으로 덮는다. `_baseObserver` 콜백도 + 같은 게이트 + **두 필드 `0`**. + **⭐⭐ [2026-08-26 신설, `/code-review high` 4차] 부기 필드가 하나에서 + 둘로 갈라졌다** — `bk.offsetCacheValidUpTo`(`offsetCache`가 여기까지 + 정확, `getOffsetAt`이 올림)와 `bk.offsetSetUpTo`(offset `Source`에 + 여기까지 `:Set` 완료, **`recompute`만** 올림). 옛 단일 `invalidAfter`는 + 두 뜻을 겸했고, 그래서 `getOffsetAt`의 부수효과가 되감기 신호를 조용히 + 지웠다. 무효화는 **둘 다** 내린다. `base/dispatch-core-plan.md`의 + "두 필드" 절이 소스. `recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서 시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다. **이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→ @@ -915,7 +981,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 옛 `question.md` 1번 용어정리 항목은 해소되어 `archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한 Slot 타입. -- **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리 +- **`Slot:Single(state, updateFn?, opts?)` 확정** — (**[2026-08-26 표기 정정, + 8라운드 `H-123`]** 3번째 `opts`(= `Owned`)는 `H-22` 확정 의사코드가 받아 + `:List`로 전달한다 — 여기와 아래가 2-인자 표기로 남아 있었다.) + `:List`를 0/1개짜리 배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만 전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/ slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]** @@ -989,7 +1058,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `_elements`엔 `None`이 절대 안 들어감(비어있는 nested Slot이 자연히 Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구. `:Single`의 `updateFn`도 이 sugar가 성립하도록 선택 인자로 완화 - (`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는 + (`Slot:Single(state, updateFn?, opts?)`, 기본값 identity). `:Single`/`:List`와는 대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw `State` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시 `prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에 @@ -1300,9 +1369,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `:Set(value)`는 **`.Value`를 먼저 쓰고**, **순회 전 스냅샷을 뜬 뒤** (`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방) 키 타입으로 분기: `type(k) == "thread"`면 `Callbacks[k] = nil`로 소진 후 - `coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)` - 호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 - **`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄). + `coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 + **`k(value, self)`** 호출 + 유지(**[2026-08-26, 8라운드 `H-107`]** 두 번째 + 인자로 `Ref` 자신 = `Epoch`을 준다 — `Effect`가 `Update(from)`에 넘길 + 통로다). **순회 대상은 두 테이블** — `.Callbacks`(강)와 + `.WeakCallbacks`(weak-키)를 각각 스냅샷한다(`H-108`). 그리고 `.Value` + 대입 **직후·순회 전**에 `self.Revision = bit32.bnot(-self.Revision)` + (순서 계약: **값 → 리비전 → 콜백**). **중복 등록은 dedup이 계약**이고 + 해제는 **`:Uncallback(fn)`**(양쪽 테이블을 다 본다). **⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-59`] 약하게 등록하는 짝 `:WeakCallback(fn)`이 신설됐고 그쪽이 프리미티브다** — `:Callback(fn)`은 거기에 "GC 안 되도록 킵" 하나를 더 얹은 것이다. @@ -1569,8 +1643,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 라이브러리라 주입 대상 아님(단 절대 시각이 아니라 diff 전용) - [ ] **[2026-08-14 아홉 번째 세션 신설]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed`(`base/lifecycle-hooks-plan.md`) - — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/ + — 각각 `PreRef():Callback(guard(fn))`/`PostRef():Callback(guard(fn))`/ `Effect(function() return fn end)`를 반환하는 순수 팩토리 함수 + (**⚠️ [2026-08-26, 8라운드 `H-120`] `guard`는 생략할 수 없다** — + `Ref` 콜백은 "등록 즉시 1회, 값이 nil이어도" 호출되므로 default 없는 + `PreRef()`에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** + `inst`를 바로 쓰는 콜백이 pre-pass 전에 죽는다. + `guard(fn) = function(v, r) if v ~= nil then fn(v, r) end end` — **2-인자를 + 그대로 흘린다**, `Ref` 콜백이 `fn(value, ref)`라 1-인자로 짜면 `Epoch`를 + 조용히 삼킨다) 3개라, 착수 시점에 그 문서의 코드 스케치를 그대로 옮기면 끝(새 타입/ Dispatch 개념 없음, 패키지는 quad-base 확정). **설계는 확정됐지만 구현은 형제 백로그(`quad-mock`/`quad-debug`/`Operator`/`Fallback`)와