From 96c8c2eaa1a4555285b854a0deaf7ea02d992b49 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Wed, 19 Aug 2026 00:07:30 +0900 Subject: [PATCH] =?UTF-8?q?qa:=20=EA=B5=AC=ED=98=84=20=EC=A0=84=20QA=203?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=20=E2=80=94=20attachSlot/bk.N=20?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8B=B1=EC=9C=BC=EB=A1=9C=20RC-3/R?= =?UTF-8?q?C-4=20=EB=B0=9C=EA=B2=AC=C2=B7=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RC-1 Blocker 게이팅이 실제로 attachSlot에 반영된 걸 손으로 트레이싱하다 :List 최초 population이 이중 처리되는 결함(RC-3/RC-4)과 recompute가 의존하는 bk.N의 수명주기가 문서에 없던 갭을 발견. 필자의 최초 분석 오류(bk.N을 그때그때 실제 개수로 두면 크래시가 되돌아온다는 판단)를 사용자가 직접 정정 — Blocker 게이팅은 bk.N이 아니라 blocker:IsOn()만 보므로 무관함이 밝혀졌고, RC-3/RC-4도 slot._mounted를 activateList 호출 뒤로 미루는 사용자 설계로 해결됨. ROADMAP M2가 M3의 Blocker.luau에 의존하게 된 마일스톤 순서 불일치도 발견해 각주로 반영. quad-doc-auditor 감사 루프 4라운드(1~3라운드 총 9건 발견·수정, 4라운드 무발견으로 종료) 거쳐 doc-check.py ERROR 0 확인 후 커밋. Co-authored-by: qwreey --- .claude/README.md | 8 +- .claude/archive/question-resolved.md | 48 ++ .claude/base/blocker-plan.md | 6 +- .claude/base/dispatch-core-plan.md | 63 ++- .claude/base/slot-plan.md | 126 +++++- .claude/project-context.md | 5 +- .../pre-implementation-qa-round3.md | 422 ++++++++++++++++++ .claude/question.md | 11 + .claude/todos.md | 33 +- ROADMAP.md | 13 +- 10 files changed, 705 insertions(+), 30 deletions(-) create mode 100644 .claude/qa-request/pre-implementation-qa-round3.md diff --git a/.claude/README.md b/.claude/README.md index d652861..3eb8129 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.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` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `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-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `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` | @@ -48,14 +48,14 @@ | `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`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "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 불변식 — M3 착수 전 결론 필요) | -| `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)`/`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로 배열 파트 순회를 감쌈 | +| `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)`/`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 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | | `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 자신이라는 **명시적 예외** 반영 | -| `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 `disposeInst(inst)`로 위임(`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`는 게이팅 불필요) | +| `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 `disposeInst(inst)`로 위임(`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` 갱신도 명문화 | | `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`은 닷/콜론 둘 다 가능 | | `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를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가 | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[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)) | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | | `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`와 동일) | diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 981f5e2..38281ae 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -80,6 +80,54 @@ Blocker 재사용 설계를 직접 제시해 해결됨. 절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은 `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절. +## [해소됨, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 + `RC-3`/`RC-4`(`attachSlot`의 `:List` 초기 population 중복 처리) + +`recompute`가 순회 상한으로 쓰는 `bk.N`이 Slot을 ownerKey로 재사용할 때 +무엇이고 언제 갱신되는지 문서에 없던 갭(`qa-request/ +pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`이 +`:List`의 최초 population을 이중 처리하는 결함(`RC-3`/`RC-4`)도 같이 +발견됐고, 셋 다 같은 세션에 사용자가 직접 해법을 제시해 해결됨. + +- **`bk.N` = 그때그때 실제 개수**, `inst`/Slot 두 owner 타입 동일 규칙 — + `Dispatch.setLength`(항상 뒤에 불림, `setOffsetSource`는 안 건드림)가 + 이전에 없던 더 큰 position을 + 등록할 때마다 늘고, `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`)이 + 위치를 구조적으로 지울 때 줄어듦. **최초 분석 오류를 사용자가 직접 + 정정**: 필자는 "그때그때 실제 개수"면 배치 등록 중 `RC-1`과 같은 + 크래시가 되돌아온다고 판단했으나, Blocker 게이팅은 `bk.N`이 아니라 + `blocker:IsOn()`만 확인하므로 배치 중엔 `recompute` 자체가 안 돌아 + `bk.N`이 무엇이든 무관함 — `RC-1`의 원래 크래시는 "`bk.N`이 배치 전에 + 이미 최종 크기로 고정"이라는, 이제는 사라진 전제의 부산물이었을 뿐. + Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 아니라 배치 비용 + (O(N²)→O(N)). +- **`RC-3`/`RC-4`**: `attachSlot`이 `slot._mounted = true`를 맨 위에서 + 세팅해뒀던 탓에, `:List`의 최초 reconcile(`activateList`, 아직 + flush 루프의 Blocker가 켜지기 전)이 부르는 `rawAdd`가 "이미 마운트됨" + 경로를 타 항목마다 무게이팅 `recompute`가 돌고(`RC-3`), nested Slot + 요소는 그 자리에서 이미 `attachSlot`된 뒤 뒤이은 flush 루프가 같은 + 요소를 또 `attachSlot`해 이중 실행됐다(`RC-4`). **해법(사용자 제시)**: + `slot._mounted = true`를 `activateList` 호출 **뒤**로 옮기면 끝 — + `activateList` 도중엔 `rawAdd`가 "아직 마운트 전" 경로(그냥 + `_elements`에만 push)를 타고, flush 루프가 `:List`/수동 CRUD 구분 없이 + 모든 요소를 처음이자 한 번만 물리 마운트한다. `rawAdd`의 "이미 + 마운트됨" 즉시-`attachSlot` 분기 자체는 그대로 남음 — 최초 flush + 이후의 런타임 갱신(예: `:List`의 `data`가 나중에 바뀌어 nested Slot이 + 새로 추가되는 경우)엔 여전히 필요한 경로이기 때문. +- **부수 발견**: `spliceArraysDown`이 밀어야 할 배열 목록에 + `bk.observers`가 빠져 있었음(`_elements`/`lengthList`/`sourceList` + 셋만 서술돼 있었음) — 같이 반영. +- **`ROADMAP.md` 마일스톤 정합성**: `RC-1`의 Blocker 게이팅으로 M2 + (`Dispatch.setLength`/`setOffsetSource`)가 M3 체크박스에 있는 + `Blocker.luau`에 구조적으로 의존하게 됐는데 로드맵 어디에도 이 순서 + 의존이 명시가 안 돼 있던 것도 발견 — 가장 보수적인 조치(마일스톤 + 재편 없이 M2 체크박스에 각주만 추가)로 우선 반영, 재편 여부는 열려 + 있음. +- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "저장 위치"/"배치 + 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 + "재귀 메커니즘"/"파괴" 절, `base/blocker-plan.md`의 "두 번째 용례" + 절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은 + `qa-request/pre-implementation-qa-round3.md`. + --- # 확인/결정 필요 목록 diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 4e45289..e0f186c 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -97,7 +97,11 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 "Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다 -— 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 +— **[정정, 2026-08-18 구현 전 QA 3라운드]** 그 크래시 자체는 이후 +`bk.N`(순회 상한)의 정의를 고치며 사라졌지만(`base/dispatch-core-plan.md` +"저장 위치" 절), 이 용례는 그대로 유효하다 — 이유가 크래시 방지에서 +배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서 +`blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` 형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 24cb368..fbad0f6 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -1244,10 +1244,49 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. -**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 -`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 -최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 -lazy 생성. +**저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에 +귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장) +— `Relate(parentInst)`에 lazy 생성. + +**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner +타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔 +`bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작 +시점에 고정된 값"으로만 서술했는데, `base/slot-plan.md`의 "재귀 +메커니즘" 절이 같은 `recompute`/`getBookkeeping`을 **Slot 자신**을 +ownerKey로 재사용하면서 이 전제(N이 고정)가 안 맞는 케이스가 생겼다 — +Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이유). **사용자 +확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될 +때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에 +동일하게 적용"* — 즉: +- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`를 + 등록할 때마다 `bk.N`이 `i`로 늘어난다(`Dispatch.drive`의 배열 파트 + 순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이 + 하나의 규칙) — **`Dispatch.setOffsetSource`는 `bk.N`을 건드리지 + 않는다**, 호출 순서가 항상 `setOffsetSource(i)` → `setLength(i)`라서 + (아래 "`setLength` 구현" 절) `bk.N`을 `setLength`에서만 올려야 + `lengthList[i]`가 아직 안 채워진 채로 `bk.N`만 먼저 커지는 창이 안 + 생긴다. +- `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`가 부름, `base/ + slot-plan.md` "파괴" 절)이 position 하나를 구조적으로 제거할 때마다 + `bk.N`이 그만큼 줄어든다. +- `Dispatch.drive`의 `inst`에서는 이 규칙이 사실상 안 보인다 — 최상위 + 배열 리터럴은 구조적으로 늘거나 줄지 않으므로(재-dispatch는 전체 + 교체) `bk.N`이 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 + 케이스가 아니라 같은 규칙의 특수한 안정 상태다. + +**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치 +등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을 +안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가 +`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로, +배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도 +안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 이미 +최종 크기로 고정돼 있었던 것**의 부산물이었을 뿐 — 지금은 그 전제 자체가 +없다. 그런데도 Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 +아니라 **비용**이다(등록마다 `recompute`가 한 번씩 도는 O(N²) 대신 +배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 사용자가 직접 지적한 "이러면 +첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. 상세 트레이싱은 +`qa-request/pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기가 +명세에 없음" 절. **`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) @@ -1349,8 +1388,11 @@ end 문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치 vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. -전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 -길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +전체 순회의 O(N) 비용은 무시 가능(`Dispatch.drive`의 최상위 `inst` +기준으로는 `N`이 저작 시점에 고정된 배열 리터럴 길이, 보통 작음 — +Slot 자신이 `ownerKey`인 재귀 케이스는 `N`이 생애주기 내내 바뀌지만 +그 실제 개수 자체도 보통 작아서 결론은 같음, `N`의 정확한 수명주기는 +위 "저장 위치" 절 참고) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, `Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 일어나게 막음. @@ -1380,6 +1422,7 @@ function Dispatch.setLength(ownerKey, i, len) end bk.lengthList[i] = len + bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고 local function gatedRecompute() if not blocker:IsOn() then @@ -1413,6 +1456,14 @@ end 정적 자식 2개짜리도 재현됨, 트레이싱 상세는 `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절). +**[정정, 2026-08-18 구현 전 QA 3라운드] 위 크래시는 `bk.N`이 "배치 시작 +전에 이미 최종 크기로 고정"이라는, 그때 당시의 전제 위에서만 성립한다 — +그 전제 자체가 위 "저장 위치" 절에서 뒤집혔다(`bk.N`은 이제 그때그때 +실제 개수). 아래 게이팅은 여전히 필요하지만, 지금은 **크래시 방지가 +아니라 비용** 때문이다 — 게이팅 없이 등록마다 `recompute`가 한 번씩 +돌면 O(N²), 게이팅으로 배치 끝에 한 번만 돌면 O(N). 상세는 "저장 위치" +절 참고. + **해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 102d201..f90037e 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1497,7 +1497,23 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그 전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는 **`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야 -확정된 값으로 등록) → 물리 마운트**: +확정된 값으로 등록) → 물리 마운트**. + +**[재정정, 2026-08-18 구현 전 QA 3라운드] "확정된 값으로 등록"은 값 +자체가 아니라 이 순서를 지키는 한 자연히 따라오는 결과를 가리킨 표현이지, +`setLength` 호출 시점에 `slot.Length`의 **값**이 반드시 최종값이어야 +한다는 뜻은 아니다.** 실체화(`activateList`)와 물리 마운트(flush 루프)의 +순서를 더 트레이싱하며 `RC-3`/`RC-4`(둘 다 `activateList` 도중 아직 +`self._mounted`가 안 세팅된 상태를 요구한다는 게 드러남 — 아래 코드의 +"`_mounted`는 여기서 아직 세팅하지 않는다" 주석 참고)를 고치는 과정에서, +`slot.Length`가 실제로 최종값으로 안정되는 시점은 `activateList` 직후가 +아니라 **flush 루프가 끝난 뒤의 마지막 `recompute`**로 한 단계 더 +밀렸다 — `Dispatch.setLength(ownerKey, position, slot.Length)`가 넘기는 +건 **State 객체 자신**이라, 등록 시점에 값이 아직 안 굳어 있어도 +무해하다(부모는 객체를 구독해뒀다가 나중에 값이 바뀌면 정상 반응). +`setOffsetSource → setLength` 순서 자체(왜 `setOffsetSource`가 먼저여야 +하는지)는 안 바뀜 — 상세 트레이싱은 +`qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. ```lua -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 @@ -1509,23 +1525,52 @@ local function attachSlot(slot, physicalTarget, ownerKey, position) -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은 -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에 -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음. - slot._mounted = true - slot._mountedInst = physicalTarget local offsetSource = Source(0) Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산 slot.Offset = offsetSource + -- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 — + -- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야 + -- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속 + -- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트 + -- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리 + -- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`를 + -- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다 + -- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가 + -- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직 + -- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅 + -- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에 + -- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를 + -- 다시 순회하며 `attachSlot`을 **또** 불러 이중 실행됨(`RC-4`). + -- `_mounted`를 `activateList` 뒤로 미루면 이 함수 안에서 실제 + -- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 — + -- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은 + -- `qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. if slot._listed then - activateList(slot, physicalTarget) -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨 + activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고) end - Dispatch.setLength(ownerKey, position, slot.Length) -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음 + slot._mounted = true + slot._mountedInst = physicalTarget - -- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush — - -- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의 - -- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은 - -- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로 + -- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직 + -- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`가 + -- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함: + -- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면 + -- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의 + -- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음" + -- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가 + -- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고 + -- 이제는 안 맞아 정정. + Dispatch.setLength(ownerKey, position, slot.Length) + + -- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`된 + -- 것) **및** 방금 `activateList`가 `_elements`에만 채워둔 `:List` + -- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한 + -- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채 + -- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시 + -- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로 -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의 -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용. local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 @@ -1543,10 +1588,26 @@ local function attachSlot(slot, physicalTarget, ownerKey, position) end blocker:OffWithoutEmit() local bk = getBookkeeping(slot) - if bk then recompute(slot, bk) end + if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨 end ``` +**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서 +이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은 +`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey, +position, slot.Length)`(위 코드)는 `slot.Length`가 `State`라 등록 즉시 +1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`의 +배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어 +안전하게 스킵되지만, **배치 밖**(예: `state` 값이 steady state에서 +반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의 +`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한 +번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 +정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고 +최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이 +자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은 +`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 +없음" 절. + **최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:** ```lua -- process(inst, k, slotValue, index) @@ -1659,7 +1720,7 @@ function rawUnmount(self, index) releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음) if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end - spliceArraysDown(self, index) + spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) end @@ -1676,11 +1737,52 @@ function rawRemove(self, index) -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦 if isSlot(element) then destroySlotTree(element) else element:Destroy() end - spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김 + spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 end ``` +**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는 +배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가 +이 코퍼스 어디에도 명시된 적이 없었음.** + +- **`bk.observers`도 같이 당겨야 함** — 위 코드가 이미 `bk.observers[index]`를 + 읽어 `unbindLifetime`하지만(제거되는 그 위치의 것), `spliceArraysDown` + 자신이 이동시켜야 하는 배열 목록에 지금까지 `observers`가 빠져 있었다 + (`_elements`/`lengthList`/`sourceList` 셋만 언급됨). `bk.observers[i]`는 + `Dispatch.setLength`가 그 자리 length가 `State`일 때만 채우는(위 + "`setLength` 구현" 절) position-indexed 배열이라, 나머지 셋과 똑같이 + 뒤 position들이 한 칸씩 당겨질 때 같이 안 당기면 이후 그 position의 + observer가 엉뚱한 것(옛 이웃의 observer)을 가리키게 된다. +- **`bk.N`도 여기서 하나 줄여야 함** — `recompute`(`base/dispatch-core-plan.md` + "Length/Offset" 절)가 `for i = 1, bk.N do`로 순회하는 그 상한. **`bk.N`의 + 정의 자체가 이 코퍼스 어디에도 없던 갭**이었다(`qa-request/ + pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기" 절 — **사용자 + 확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될 + 때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에 + 동일하게 적용"*). 즉 `bk.N`은 `Dispatch.setLength`가 이전에 본 적 + 없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는 + 건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔 + `lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush + 배치도, Slot의 런타임 단건 + `rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를 + 물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`의 + `inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은 + 구조적으로 늘거나 줄지 않으므로(전체 재-dispatch만 있음) `bk.N`이 + 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은 + 규칙의 특수한 안정 상태다. +- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/ + `attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker + 게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) — + 그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도 + 안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 + 이미 최종 크기로 고정돼 있었던 것**의 부산물이었다는 게 이번에 다시 + 확인됨 — 지금은 그 전제 자체가 없다. 그 대신 Blocker 게이팅이 여전히 + 필요한 이유는 크래시 방지가 아니라 **비용**(등록마다 `recompute`가 + 한 번씩 도는 O(N²) 대신 배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 + 사용자가 직접 지적한 "이러면 첫 실행에서 계속 recompute 비용이 쌓임" + 문제 그대로. + **왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target 인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리 target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로 diff --git a/.claude/project-context.md b/.claude/project-context.md index a74f1d6..43fe517 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -71,8 +71,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `type-recursive-issue-try-callback/` 등). - `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 - 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md` - 둘 다 **완료** — 라운드마다 새 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/ + `pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새 + 파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, **[2026-08-18 기준] 폴더 자체가 아직 없음**. `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 diff --git a/.claude/qa-request/pre-implementation-qa-round3.md b/.claude/qa-request/pre-implementation-qa-round3.md new file mode 100644 index 0000000..e224cfb --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round3.md @@ -0,0 +1,422 @@ +# 구현 전 QA **3라운드** — Blocker/`attachSlot`/`recompute` 손 트레이싱 + +**상태**: **완료 — `RC-3`/`RC-4`/`bk.N` 전부 사용자 확정을 거쳐 `base/` +반영 완료.** 2라운드가 발견·해결한 `RC-1`(Blocker 게이팅) 반영분을 +대상으로, 이번엔 `attachSlot`이 그 게이팅을 실제로 어떻게 쓰는지와 +`recompute`가 의존하는 `bk.N`의 수명주기를 손으로 실행해봤다. 부수로 +`ROADMAP.md` 마일스톤 분할이 이번 라운드 발견과 맞는지도 검토했다 +(1라운드가 미룬 항목). + +**⚠️ 이 문서를 읽을 때 주의 — 아래 문제 서술 중 일부는 최초 작성 당시의 +분석 오류를 포함한 채 그대로 남아 있다(의도적으로 안 고침, 논의 과정 +보존).** 특히 `bk.N` 절의 "(a)/(b) 두 갈래 다 깨진다"는 최초 분석은 +**틀렸다** — 사용자가 직접 지적해 정정됐다("해결 — `bk.N`" 절 참고). +지금 유효한 결론은 각 절의 "해결" 소제목 아래만 — 그 위 문제 서술은 +"당시엔 이렇게 봤다"는 트레이싱 원문으로만 읽을 것. + +**이 문서의 용도**: 2라운드와 같은 톤 — 트레이싱 결과 자체가 산출물이고, +버그는 그 자리에서 기록, 방향이 갈리는 것만 사용자에게 물었다. + +--- + +## RC-3 — `activateList`가 자기 Slot의 Blocker보다 먼저 실행됨 + +**대상**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` 의사코드. + +**순서를 그대로 읽으면**: +```lua +slot._mounted = true -- (1) 이 시점부터 이미 "마운트됨" +... +if slot._listed then + activateList(slot, physicalTarget) -- (2) reconcile 실행 — 아직 Blocker 없음 +end +Dispatch.setLength(ownerKey, position, slot.Length) -- (3) +local blocker = getBlocker(slot) -- (4) Blocker가 여기서야 생성됨 +blocker:On() +for i, element in ipairs(slot._elements) do ... end -- (5) +blocker:OffWithoutEmit() +``` + +**문제**: (1)에서 `slot._mounted = true`가 이미 세팅된 채로 (2)의 +`activateList`가 실행된다. `activateList`의 `reconcile`은 새 항목마다 +`rawAdd(self, result, pos)`를 부르는데(`slot-plan.md`의 `:List` "구현" +절), "이미 마운트된 outer에 나중에 `Add`" 절이 명시하듯 `self._mounted`가 +참이면 `rawAdd`는 그 자리에서 즉시 물리 마운트 경로를 탄다 — +`isSlot(element)`면 `attachSlot(element, ...)` 재귀, 아니면(대칭적으로) +`Dispatch.setOffsetSource(self,index,None)` + `Dispatch.setLength(self,index,1)` ++ `element.Parent = physicalTarget`. `Dispatch.setLength`는 끝에서 +`gatedRecompute`를 부르고, `gatedRecompute`는 `getBlocker(ownerKey=self)`의 +`IsOn()`을 확인하는데 — **(4)의 `getBlocker(slot):On()`이 아직 실행되기 +전이므로, 새로 생성된 Blocker는 기본 off 상태고 게이트가 그냥 통과된다.** + +즉 `:List`의 초기 reconcile이 채우는 **모든 항목마다** `recompute`가 +그 자리에서 즉시(게이팅 없이) 돈다 — 이건 정확히 `RC-1`이 막으려던 +"배치 등록 중 매 position마다 recompute가 도는" 모양이다. 사용자가 +`RC-1` 논의에서 직접 지적한 문제("이러면 첫 실행에서 계속 recompute +비용이 쌓임")가 `:List`의 초기 population 경로에서는 그 처방이 적용되기 +**전** 자리에서 그대로 재현된다. + +**크래시로 이어지는지는 `bk.N`에 달려 있다** — 아래 "`bk.N` 수명주기" +절 참고. `bk.N`이 이 시점에 아직 `0`(또는 `nil`)이면 크래시는 안 나고 +그냥 매 항목마다 무의미한 `recompute(self,bk)` 호출만 쌓인다(루프가 +`for i=1,0`이라 즉시 반환). `bk.N`이 최종 개수로 미리 정해져 있는 +모델이면 `RC-1`과 완전히 같은 모양(뒤쪽 `lengthList[i]`가 아직 `nil`)의 +크래시가 난다. + +--- + +## RC-4 — flush 루프가 `:List`로 이미 마운트된 요소를 다시 처리함 + +**같은 `attachSlot`에서 이어지는 문제**: (5)의 flush 루프 +(`for i, element in ipairs(slot._elements) do ... end`)는 `slot._listed` +여부를 확인하지 않고 **항상** 돈다. 그런데 `_listed`/`_crudUsed`는 상호 +배타(`slot-plan.md`의 "CRUD API 확정" 절 — `_crudUsed`/`_listed` 역방향 +가드)이므로, `:List`가 설치된 Slot의 +`_elements`는 수동 `:Add()`가 아니라 **오직 (2)의 `activateList`가 +채운 것뿐**이다 — 그리고 위 `RC-3`에서 확인했듯 그 채움 과정 자체가 +이미 각 항목을 물리적으로 마운트(`element.Parent = physicalTarget`)하고 +`Dispatch.setOffsetSource`/`setLength`를 등록까지 마친 상태다. + +flush 루프는 이 사실을 모르고 **같은 요소들을 처음 보는 것처럼** 다시 +처리한다 — `Dispatch.setOffsetSource(slot, i, None)`/ +`Dispatch.setLength(slot, i, 1)`을 중복 호출하고, 이미 부모에 붙어있는 +`element`에 `element.Parent = physicalTarget`를 다시 대입한다(Roblox라면 +`AncestryChanged`가 불필요하게 한 번 더 발화). 값 자체는(멱등하게) 결국 +맞게 수렴하겠지만, `:List`가 nested Slot을 요소로 반환한 경우 +(`isSlot(element)`)는 **`attachSlot(element, physicalTarget, slot, i)`가 +통째로 두 번 실행**된다 — 이건 멱등하지 않다: `slot._mounted = true`를 +다시 세팅하는 정도는 무해해 보여도, "마운트된 Slot의 재마운트는 즉시 +throw" 규칙(`slot-plan.md` "마운트된 Slot의 재마운트는 즉시 throw" 절)에 +비춰보면 **nested Slot이 자기 자신을 향해 재귀적으로 이미 마운트된 +채로 다시 `attachSlot`되는 것 자체가 그 규칙이 막으려는 상황과 같은 +모양**이라, 최소한 이 규칙과의 정합성을 다시 검토해야 한다. + +**추정 원인**: flush 루프의 주석("attach 전에 이미 들어와있던 요소들 +flush")이 밝히듯, 이 루프는 **수동 CRUD로 마운트 전에 `:Add()`된 +요소**만 염두에 두고 `RC-1` 해결 과정에서 추가된 것 — `:List` 케이스가 +같은 함수를 통과한다는 걸 놓친 것으로 보인다(`RC-1` 자체는 +`Dispatch.drive`/`attachSlot`의 flush 두 자리만 위험하다고 확인했고, +`activateList`는 그 확인 대상에 없었다). + +**참고**: 이 두 결함(`RC-3`/`RC-4`)은 정확한 크래시/오작동 심각도가 +`bk.N`의 수명주기에 좌우되므로, 아래 질문의 답이 나온 뒤 같은 자리에서 +같이 고치는 게 맞아 보인다(둘 다 "`_listed`면 flush 루프를 건너뛰고, +`activateList` 자체를 `blocker:On()`/`OffWithoutEmit()`으로 감싼다"는 +같은 방향의 수정으로 닫힐 가능성이 높음 — 다만 이건 제안이지 확정 +아님, 사용자 확인 필요). + +### 해결 — `_mounted`를 `activateList` 뒤로 미룸 (2026-08-18, 같은 세션 후속, 사용자 설계) + +위에서 제안했던 "`_listed`면 flush 루프를 건너뛴다"는 **채택 안 됨** — +사용자가 더 단순한 대안을 직접 제시했다: + +> if not slot._listed then ... end 로 감싸면 안 되는거 아닌가요? 그냥 +> _mounted 를 activateList 아래 두는게 안되는 이유가 있어요? 만일, +> 그렇게 감싼다면 그건 blocker 를 안 타니까요. 그리고 또, attachSlot 은 +> 런타임 상 발생할 수 있는게 맞긴 하죠? 왜냐면, 안 그러면 List 에서 +> Frame 만 던질 수 있어요. nested slot 을 던지는 컴포넌트는 사용 +> 못하게 될텐데요. + +**`slot._mounted = true`/`slot._mountedInst = physicalTarget`를 +`attachSlot` 맨 위에서 `activateList` 호출 **뒤**(flush 루프 바로 전)로 +옮기면 `RC-3`/`RC-4`가 한 번에 닫힌다**: + +- `activateList`가 실행되는 동안 `self._mounted`가 계속 `false`이므로, + `:List`의 reconcile이 부르는 `rawAdd`는 "아직 마운트 전" 경로 + (`_elements`에만 넣고 물리 마운트/Dispatch 등록은 안 함, + `slot-plan.md`가 이미 "self가 아직 마운트 전이면 _elements에만 + 들어가고, self가 나중에 attachSlot될 때 위 flush 루프가 처리"로 + 명시해둔 바로 그 경로)를 탄다. → `RC-3`(항목마다 무게이팅 + `recompute`) 자체가 안 생김 — flush 루프 전엔 어떤 Dispatch 등록도 + 없으므로. +- flush 루프가 `slot._elements`(이제 `:List`든 수동 CRUD든 항상 여기에만 + 쌓여 있음)를 순회하며 **처음이자 유일하게** 각 요소를 물리 + 마운트한다 — nested Slot이면 `attachSlot`도 여기서 **딱 한 번만** + 불린다. → `RC-4`(이중 실행) 자체가 안 생김. `_listed` 분기가 필요 + 없어짐 — flush 루프가 두 경로(`:List`/수동 CRUD) 모두에 대해 이미 + 동일하게 옳은 유일한 마운트 지점이 됨. +- **`rawAdd`의 `self._mounted` 즉시-마운트 분기 자체는 그대로 남는다** — + 사용자가 확인한 대로 이건 삭제 대상이 아니라 **런타임에 실제로 필요한 + 경로**다: `attachSlot`으로 최초 마운트가 끝난 **뒤**(예: `data`가 + 나중에 바뀌어 `:List`의 reconcile이 다시 실행될 때) `self._mounted`는 + 이미 `true`이므로, 그 시점에 새로 추가되는 nested Slot 항목은 이 + 분기를 통해 정상적으로 즉시 `attachSlot`된다 — 그래서 `:List`가 + nested Slot을 반환하는 컴포넌트를 계속 지원한다. 이번에 바뀐 건 오직 + "`attachSlot` 자기 자신의 **최초** flush 이전엔 이 분기가 안 타야 + 한다"는 타이밍 하나뿐. + +**반영**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` +의사코드(`_mounted` 위치 이동 + 주석), `base/dispatch-core-plan.md`의 +"저장 위치"/"배치 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/ +blocker-plan.md`의 "두 번째 용례" 절(아래 `bk.N` 해결과 같이 반영). + +--- + +## `bk.N`의 수명주기가 명세에 없음 — 판단 필요 + +`recompute`(`base/dispatch-core-plan.md` "Length/Offset" 절)는 +`for i = 1, bk.N do`로 순회한다. `bk.N`의 정의는 문서에 **딱 한 곳**뿐: + +> **저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +> `inst`의 array part 크기 `N`으로 같이 저장, `Dispatch.drive`가 최초 +> 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 +> lazy 생성. + +이건 **`Dispatch.drive`가 순회하는 최상위 `inst`** 전용 서술이다 — +그 경우 `N`은 저작 시점에 고정된 배열 리터럴 길이라 정말로 "한 번 알면 +끝"이다. 그런데 `base/slot-plan.md`의 "재귀 메커니즘" 절이 **같은 +`recompute`/`getBookkeeping`을 Slot 자신을 ownerKey로 재사용**하면서 +(`Dispatch.setLength`/`setOffsetSource`가 "owner 키(`inst`)가 물리 +Instance일 필요가 없음"을 근거로), Slot의 경우 `bk.N`이 무엇이고 언제 +갱신되는지는 **어디에도 안 적혀 있다**. `getBookkeeping`/ +`spliceArraysDown` 자체도 이 코퍼스 전체에서 정의된 적이 없는(호출만 +되는) 헬퍼다(grep 확인, `bk.N =` 대입 자체가 코퍼스에 0건). + +**왜 이게 그냥 구현 디테일이 아니라 지금 결정이 필요한가**: `Dispatch.drive`의 +`inst`와 달리, **Slot의 자식 개수는 Slot 전체 생애주기 동안 계속 +바뀐다**(그게 Slot의 존재 이유) — "한 번 알면 끝"이라는 `inst` 쪽 전제가 +Slot에는 애초에 성립하지 않는다. 두 갈래 다 손으로 트레이싱해보면 각각 +다른 방식으로 깨진다: + +**(a) `bk.N`이 "고정값"이라면(배치 시작 시 저장, 이후 안 바뀜)**: +`rawRemove`(`slot-plan.md` "파괴" 절)를 트레이싱하면 — +```lua +function rawRemove(self, index) + ... + spliceArraysDown(self, index) -- _elements/lengthList/sourceList 한 칸씩 당김 + recompute(self, bk) -- bk.N은 그대로(감소 안 함) +end +``` +`spliceArraysDown`이 배열을 한 칸씩 당기고(마지막 자리는 비거나 stale +복제값으로 남음, 정의가 없어 어느 쪽인지도 불명) `bk.N` 자체를 줄이지 +않으므로, **2개짜리 Slot에서 요소 하나를 `Remove`하기만 해도** 다음 +`recompute`의 `for i=1,bk.N(=2)`가 이제 존재하지 않는 위치 2를 읽는다 +— `spliceArraysDown`이 그 자리를 `nil`로 비운다면 `RC-1`과 정확히 같은 +`sum += nil` 산술 에러, 옛 값을 그대로 둔 복제라면 그 값을 이중으로 +합산하는 조용한 오계산이다. 어느 쪽이든 **가장 흔한 조작(2개 이상인 +Slot에서 하나 제거)에서 매번 재현**된다 — `RC-1`이 "정적 자식 2개짜리 +`Frame`에서도 재현"이라고 짚었던 것과 같은 급의 흔함. + +**(b) `bk.N`이 "그때그때 실제 개수"라면(예: `#ownerKey._elements`로 매번 +파생, 또는 매 `setLength`/`spliceArraysDown` 호출마다 갱신)**: 위 +`rawRemove` 크래시는 없어진다. 대신 마운트 시점 배치(`Dispatch.drive` +최상위, `attachSlot`의 flush)에서 `RC-1`이 막으려던 **바로 그 크래시가 +되돌아온다** — 배치 도중 `bk.N`이 이미 등록된 position 개수만큼만 +증가한 상태라면 recompute 자체는 안전해지지만(순회 범위가 실제 채워진 +자리까지만), 반대로 **Blocker 게이팅이 애초에 막으려던 "배치 끝나기 +전엔 recompute 안 돈다"는 전제가 필요 없어진다는 뜻**이라 — `RC-1`의 +해법 전체가 어떤 `bk.N` 모델을 전제하는지부터 다시 맞춰야 한다. +(자세히 보면 게이팅으로 `recompute` 호출 자체를 스킵하므로 (b) 모델이어도 +크래시는 안 나지만, **배치 종료 후 딱 1회 도는 마지막 `recompute`가 이번엔 +반대로 부족한 `N`을 볼 수 있다** — 예: `attachSlot` flush 루프 중간에 +어떤 position의 `setLength`가 **State**를 받아 `Observer`의 "등록 즉시 +1회 실행"이 배치 밖 시점까지 늦게 도착하는 경합이 있다면.) + +**분기점 — 사용자 판단 필요(아래 질문 참고)**: `bk.N`을 그때그때 실제 +개수로 둘지, 배치 시작 시 저장해두는 값으로 둘지에 따라 고칠 자리가 +갈린다 — 전자면 `rawRemove`/`rawUnmount`/런타임 `rawAdd`가 문제, +후자면 `RC-3`/`RC-4`가 이미 지적한 자리가 문제. 어느 쪽이든 `RC-3`/ +`RC-4`는 별도로 고쳐야 하지만, `bk.N` 자체의 수명주기 규칙은 이 +문서가 결정하지 않는다 — 아래에서 직접 여쭤본다. + +### 해결 — `bk.N` = 그때그때 실제 개수, 위 (b) 분석은 틀렸음 (2026-08-18, 같은 세션 후속, 사용자 지적) + +위 (b) 갈래("`bk.N`이 그때그때 실제 개수면 마운트 배치에서 `RC-1`이 +막으려던 크래시가 되돌아온다")는 **분석 오류였다.** 사용자가 직접 +잡아냄: + +> 그때그때 실제 개수를 전부 적용하는건 안 돼? 사실 전부 똑같은 +> 방법으로 구현해도 상관 없지 않아? 그리고 drive 중에는 recompute +> 안나지 않아? 계속 후행 붙이기라서 약간 다를텐 + +**틀렸던 지점**: `Dispatch.drive`/`attachSlot`의 배치 등록 중 +`recompute`가 안 도는 이유는 **`bk.N`이 아니라 Blocker 게이팅** +(`blocker:IsOn()`만 확인하는 `gatedRecompute`)이다 — 이 게이트는 +`bk.N`이 무엇이든 **전혀 상관하지 않는다**. 그러므로 `bk.N`이 배치 +도중 계속 늘어나는 중이어도(아직 최종 크기가 아니어도) 배치 안에서 +`recompute` 자체가 안 도니 크래시도, 부정확한 계산도 안 생긴다 — +필자가 (b)를 쓰며 "게이팅이 배치 끝나기 전엔 recompute 안 돈다는 +전제가 필요 없어진다"고 적었던 건 스스로 반대 결론(게이팅이 여전히 +작동 중이라는 사실)을 옆에 적어두고도 놓친 것. + +**결론**: `bk.N` = **그때그때 실제 개수**로 두 owner 타입(`inst`, +Slot 자신) 모두에 동일한 규칙 적용 — `Dispatch.setLength`/ +`setOffsetSource`가 이전에 없던 더 큰 position을 등록할 때마다 +`bk.N`이 그 값으로 늘어나고, `spliceArraysDown`(Slot의 `rawRemove`/ +`rawUnmount`)이 위치를 구조적으로 지울 때 그만큼 줄어든다. `Dispatch.drive`의 +`inst`에서는 최상위 배열이 구조적으로 안 바뀌므로 이 규칙이 그냥 +"등록 끝나면 고정값처럼 보이는" 특수한 안정 상태가 될 뿐, 별도 모델이 +필요 없다 — **두 owner 타입에 정말로 똑같은 구현**(사용자가 지적한 +그대로). + +**`RC-1`의 원래 크래시가 실제로 뭐였는지 다시 정리하면**: `bk.N`이 +"배치가 시작되기도 전에 이미 최종 크기로 고정"돼 있었던 것의 부산물 +— 그 전제 자체가 이번에 사라졌다. Blocker 게이팅이 지금도 필요한 +이유는 크래시 방지가 아니라 **비용**이다: 게이팅 없이 매 position +등록마다 `recompute`가 한 번씩 돌면 배치당 O(N²), 게이팅으로 배치 끝에 +한 번만 돌면 O(N) — `RC-1` 최초 논의에서 사용자가 직접 지적한 "이러면 +첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. + +**추가로 확인된 갭 — `spliceArraysDown`이 미는 배열 목록에 `bk.observers`가 +빠져 있었음.** `rawRemove`/`rawUnmount`가 제거되는 위치의 +`bk.observers[index]`를 `unbindLifetime`하긴 하지만, `spliceArraysDown` +자신이 밀어야 할 배열로 지금까지 `_elements`/`lengthList`/`sourceList` +셋만 서술돼 있었다 — `observers`도 같이 밀지 않으면 이후 그 위치의 +observer가 옛 이웃 것을 계속 가리키게 된다. `base/slot-plan.md`에 +반영. + +**반영**: `base/dispatch-core-plan.md`의 "저장 위치" 절(`bk.N` +수명주기 정의 신설), "배치 등록을 안전하게 만드는 Blocker 게이팅" +절의 "문제 재확인" 문단(크래시 전제가 바뀌었다는 정정 추가), +`base/slot-plan.md`의 `spliceArraysDown`/`rawRemove`/`rawUnmount` +근처(`bk.observers`/`bk.N` 갱신 명문화), `base/blocker-plan.md`의 +"두 번째 용례" 절(같은 정정), `ROADMAP.md` M2 체크박스(같은 정정 + +M2/M3 교차 의존 각주). + +--- + +## 확인만 하고 새 결함 없음 — 재검증 + +- **중첩 Slot의 `Length:Set`이 부모 Blocker가 켜져 있는 동안 나가는 + 경우** — `attachSlot`이 재귀로 `attachSlot(element, physicalTarget, + slot, i)`를 부를 때, 안쪽 재귀도 자기 자신의 `getBlocker(element)`로 + 별도 Blocker를 새로 만들어(부모 Blocker와 무관) 자기 flush를 감싼다. + 안쪽 재귀 끝의 `recompute(element, bk)`가 `element.Length:Set(sum)`을 + 호출하면, 이건 **부모 쪽 관점에서 보면 `Dispatch.setLength(parentSlot, + i, element.Length)`가 이미 등록해둔 그 State 객체의 값이 바뀌는 것** — + 부모의 `gatedRecompute`가 이 변화를 받지만, 부모의 Blocker가 아직 + 켜져 있으면(외곽 배치가 안 끝났으면) 정상적으로 스킵되고, 부모 배치가 + 끝난 뒤 마지막 `recompute(parentSlot, parentBk)` 한 번에 자연스럽게 + 반영된다 — 설계 의도대로 동작, 새 문제 없음. +- **`getBlocker`의 lazy 생성 기본값이 off라는 전제가 런타임 단건 경로를 + 성립시킴** — "이미 마운트가 된 이후"의 단건 `:Add()`가 게이팅 없이 + 바로 `gatedRecompute`를 태우는 게 안전한 이유는, 그 시점 Blocker가 + (flush 때 만들어져 `OffWithoutEmit()`으로 꺼진 채 남아있으므로) 항상 + off 상태이기 때문 — 트레이싱으로 재확인, 새 발견 아님. +- **⚠️ [신설, 반영 후 자체 재검토] 재정렬로 새로 생긴 좁은 엣지 케이스 — + 배치 밖(steady state)에서 Slot이 단독으로 (재)마운트될 때, 부모의 + `recompute`가 아직 안 굳은 `slot.Length` 값으로 한 번 먼저 돌 수 + 있음.** `Dispatch.setLength(ownerKey, position, slot.Length)`(위 해결 + 절의 재정렬 뒤 코드)은 `slot.Length`가 `State`라 `Observer` "등록 즉시 + 1회 실행"을 그 자리에서 동기로 태운다 — 이게 부모의 `gatedRecompute`를 + 부르는데, **이 Slot 마운트가 `Dispatch.drive`의 배치나 부모 Slot의 + flush 루프 **안**이면** 부모의 Blocker가 아직 켜져 있어 안전하게 + 스킵되지만(트레이싱 확인, 새 결함 아님), **배치 밖에서 이 + `attachSlot`이 단독으로 불리는 경우**(예: `state` 값이 steady + state에서 반응형으로 교체돼 재-dispatch되는 경우, 부모 owner의 + Blocker는 이미 예전에 `OffWithoutEmit()`으로 꺼진 채)엔 부모의 + `gatedRecompute`가 즉시 실행돼, 아직 flush가 안 끝나 최종값이 아닌 + `slot.Length`로 부모가 한 번 (헛되이) 재계산한다 — 뒤이어 flush가 + 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 부모가 다시 정확하게 + 재계산해 값 자체는 스스로 바로잡힌다. **크래시도 영구적으로 틀린 + 값도 아니고**, `Get()~=sum` 가드 때문에 실제로 `:Set`이 두 번 나가는 + 것도 조건부(첫 번째 계산이 우연히 맞을 수도 있음)라 — Roblox 기준 + 최악의 경우 한 프레임짜리 낭비 재계산 정도. 재정렬 이전 코드(`_mounted`가 + `activateList`보다 먼저)에는 이 경로 자체가 없었음(`slot.Length`가 + 이미 등록 시점에 확정돼 있었으므로) — 그래서 완전히 새로 생긴 특성. + **크래시급이 아니라 이 라운드를 다시 열진 않지만, 다음에 이 자리를 + 만지는 세션이 알아야 할 사실로 기록.** +- **`Dispatch.drive` 자신은 코드 블록이 없다** — 이 문서 전체에서 + `Dispatch.drive`는 항상 산문으로만 서술되고(`Dispatch.drive(inst, + flattened)`가 배열→해시 두 패스로 `Dispatch.process`를 부른다는 것), + Blocker 게이팅을 그 함수 **자신**이 어떻게 여닫는지 보여주는 의사코드는 + 없다(`attachSlot`만 실제 코드로 있음). 버그는 아님 — `Dispatch.drive` + 자체가 이 코퍼스 어디에도 전체 코드로 나온 적이 없어서(항상 서술뿐), + 이번에 새로 생긴 갭이 아니라 원래부터 그랬던 문서화 수준의 차이일 + 뿐이다. 실제 구현 시(M2) `attachSlot`과 같은 패턴(자기 owner=inst의 + Blocker를 `:On()` → 배열 파트 순회 → `:OffWithoutEmit()` → + `recompute` 1회)으로 쓰면 될 걸로 보이나, 코드로 명문화돼 있지 않다는 + 점만 기록. + +--- + +## `ROADMAP.md` 마일스톤 정합성 — 새 불일치 발견 + +1라운드가 "다음에 검토"로 미뤄뒀던 항목. 이번 라운드는 `RC-1`의 Blocker +게이팅 해법이 실제로 마일스톤 순서와 맞물리는지를 봤다. + +**문제 — M2가 M3의 산출물(`Blocker`)에 구조적으로 의존하게 됐다.** +`ROADMAP.md` M2(디스패치 엔진)의 `Dispatch.setLength`/`setOffsetSource` +체크박스(90번대 줄)는 이렇게 적혀 있다: + +> **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음 +> 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로 +> 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 `recompute`를 +> 미루고 배치가 끝나면 명시적으로 한 번만 돎 + +즉 M2 체크박스 자체가 "`setLength`/`setOffsetSource`를 구현하려면 +`Blocker`가 있어야 한다"고 명시한다. 그런데 `Blocker.luau`는 M3 +체크박스(`## M3 — Store/State/Source` 절)에 있고, 그 근거는: + +> `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 +> 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 +> 밀접히 연관돼 있어 같은 마일스톤에서 개발) + +이 근거("State와 밀접히 연관돼 있어서")는 `RC-1` 이전의 오래된 이유 +그대로다(`base/blocker-plan.md` 자신도 "store 개발(M3)과 밀접하게 +연관됨... 별도 파일로 두되 State와 같은 마일스톤에서 함께 구현할 것"이라고 +써 있음, `.claude/todos.md` 4번의 "M3에서 `Blocker`를 구현할 때"도 동일). +`RC-1`로 생긴 **M2 → Blocker** 의존은 그 뒤에 어디에도 반영이 안 됐다 — +M2가 M3보다 먼저 오는 로드맵 순서상, **M2를 그대로 순서대로 구현하면 +아직 존재하지 않는 `Blocker.luau`를 참조하게 된다.** + +이건 2라운드가 확인한 "`RC-1` 언급이 텍스트로는 반영됐는가"(반영됨, +확인 완료)와는 다른 질문 — **텍스트는 맞는데 그 텍스트가 만드는 +마일스톤 간 순서 요구가 로드맵 구조와 어긋난다.** + +**참고로 M6(Slot)의 두 자리(368번대 줄 근처)는 이미 "위 M2 항목 참고"로 +정확히 교차 참조돼 있어 문제 없음** — M6는 M3보다 뒤라 Blocker가 이미 +존재한다는 전제가 깨지지 않는다. 문제는 오직 M2 하나. + +**선택지는 여기서 결정하지 않는다** — 가능한 방향만 짚어둔다(사용자 +판단 필요): +1. `Blocker.luau`(또는 그 최소 부분집합 — `On`/`Off`/`IsOn`/ + `OffWithoutEmit`만)를 M2로 옮기거나 M2 시작 부분에 선행 항목으로 추가. +2. M2 체크박스에 "M3의 `Blocker.luau`를 먼저(또는 병행) 구현해야 함"이라는 + 명시적 순서 각주를 달아, 로드맵 순서 자체는 유지하되 M2 착수 시 + 이 사실을 놓치지 않게 한다. +3. M2/M3 마일스톤 경계를 재검토(예: Blocker를 M2로 통째로 승격) — + 가장 큰 변경이라 신중히. + +**임시로 2번(각주) 채택** — 마일스톤 경계 자체를 바꾸는 1/3번은 +설계·일정에 영향이 가는 결정이라 사용자 확인 없이 고르지 않았다. 2번은 +로드맵 구조를 안 바꾸면서 "M2가 M3의 산출물에 기대고 있다"는 사실만 +빠짐없이 남기는 가장 보수적인 조치라 우선 적용해뒀음(`ROADMAP.md` M2 +체크박스) — 1/3번을 원하면 언제든 다시 정리 가능, 아직 최종 확정 +아님. + +--- + +## 진행 로그 + +**3라운드(2026-08-18) — `attachSlot`/`recompute`/Blocker 게이팅 손 +트레이싱, `ROADMAP.md` 마일스톤 정합성 재검토, 같은 세션에 전부 해결· +`base/` 반영까지 완료.** 발견 순서대로: + +1. `RC-3`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행돼 항목마다 + 무게이팅 `recompute`가 도는 것으로 보였음)와 `RC-4`(flush 루프가 + `:List`로 이미 마운트된 요소를 중복 처리 — nested Slot이면 이중 + `attachSlot`)를 발견. +2. `bk.N`(순회 상한)의 수명주기가 문서 어디에도 없다는 것도 발견, 최초 + 분석은 "고정값/그때그때 실제 개수 두 갈래 다 각기 다른 방식으로 + 깨진다"고 판단해 사용자에게 물음. +3. **사용자가 그 분석 자체를 정정** — Blocker 게이팅은 `bk.N`이 아니라 + `blocker:IsOn()`만 보므로, "그때그때 실제 개수" 모델이 배치 중 + 크래시를 되돌린다는 결론은 틀렸음을 지적("그때그때 실제 개수를 전부 + 적용하는건 안 돼? ... 그리고 drive 중에는 recompute 안나지 않아?"). + `bk.N` = 그때그때 실제 개수로 두 owner 타입에 동일 적용 확정. +4. `RC-3`/`RC-4`도 사용자가 더 단순한 해법을 직접 제시 — flush 루프를 + `_listed`로 분기하는 대신, `attachSlot`의 `slot._mounted = true`를 + `activateList` 호출 **뒤**로 옮기는 것 하나로 둘 다 닫힘("_mounted + 를 activateList 아래 두는게 안되는 이유가 있어요?"). +5. 부수로 `spliceArraysDown`이 밀어야 할 배열 목록에 `bk.observers`가 + 빠져 있던 것도 같이 발견·반영. +6. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 구조적으로 의존하게 된 + 불일치는 각주로 반영(가장 보수적인 조치, 마일스톤 재편 여부는 열림). + +**반영 완료**: `base/slot-plan.md`(`attachSlot` 의사코드 재작성, +`spliceArraysDown`/`bk.N`/`bk.observers` 명문화), `base/ +dispatch-core-plan.md`(`bk.N` 수명주기 신설, 크래시 전제 정정), +`base/blocker-plan.md`(게이팅 존재 이유 정정), `ROADMAP.md`(M2 체크박스 +정정 + M2/M3 교차 의존 각주). `python3 .claude/tools/doc-check.py`로 +ERROR 0 확인 완료. diff --git a/.claude/question.md b/.claude/question.md index 99b80f1..5450334 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -114,6 +114,17 @@ ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 +- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`에 + 구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지, + `Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로 + 앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법 + 때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가 + `getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작 + `Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직 + 없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔 + 임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전 + 필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의 + "ROADMAP.md 마일스톤 정합성" 절. - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level diff --git a/.claude/todos.md b/.claude/todos.md index e486d70..2ca18f1 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,8 +5,8 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2라운드 전부 - `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를 +00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드 + 전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은 `.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 @@ -20,12 +20,37 @@ Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다 (`archive/question-resolved.md`의 `RC-1` 절). - **M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다**(M0/M2는 여전히 - 막혀 있지 않음, 0번 항목 참고) — 대부분 `question.md` 3번에도 올라가 + **3라운드(완료, `.claude/qa-request/pre-implementation-qa-round3.md`가 + 소스) — `RC-1` 해법이 실제로 `attachSlot`에 반영된 걸 트레이싱하다 + 새 문제 발견, 같은 세션에 전부 해결·반영까지 완료.** 처음엔 `activateList`가 + 자기 Slot의 Blocker가 켜지기 **전에** 실행돼 `:List` 초기 population이 + 문제(`RC-3`/`RC-4`)를 낸다고 봤고, `recompute`가 의존하는 `bk.N`(순회 + 상한)의 수명주기도 문서에 없어 "고정값/그때그때 실제 개수 둘 다 각기 + 다른 방식으로 깨진다"고 판단했으나 — **사용자가 이 분석 자체를 + 정정**했다: Blocker 게이팅은 `bk.N`이 아니라 `blocker:IsOn()`만 보므로 + "그때그때 실제 개수" 모델이 배치 크래시를 되돌린다는 결론은 틀렸었다 + (`bk.N` = 그때그때 실제 개수로 확정). `RC-3`/`RC-4`도 사용자가 더 + 단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot`의 + `slot._mounted = true`를 `activateList` 호출 뒤로 옮기는 것 하나로 + 둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에 + `bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M2가 M3의 + `Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영(마일스톤 + 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md + 마일스톤 정합성" 절 참고). + + **아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀 + 있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야 + 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` + 3번에도 올라가 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: + - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` + M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` + (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 + 전 필요**. `qa-request/pre-implementation-qa-round3.md`의 + "ROADMAP.md 마일스톤 정합성" 절. - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — diff --git a/ROADMAP.md b/ROADMAP.md index 973962a..506a032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,7 +190,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 `recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker - 게이팅" 절. + 게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 + `bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가 + 바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker` + (`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는 + 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만 + 호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에 + 있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는 + `Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면 + (`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행) + 만들 필요가 있는지 사용자 판단 필요 — + `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 + 정합성" 절. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이