diff --git a/.claude/README.md b/.claude/README.md index a97e6d9..c3e10c0 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에 배치 회신용으로 정리돼 있다. 커밋 전 `/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`은 소유권 문제가 아니라 문장이 틀린 것) — 결정만 읽지 말고 그 두 절을 볼 것), `pre-implementation-handtrace-round9-brief.md`(**[2026-08-26 신설]** 9라운드를 돌릴 감사자에게 주는 **지시서** — 발견 보고가 아니라 그 앞단이다. 이런 지시서를 저장하는 건 이번이 처음으로, 7·8라운드 것은 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는 "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 스코프를 **커밋 `9dd8213` 하나의 델타**로 정의한 게 핵심 — 8라운드 결정 반영과 그 뒤 `/code-review high` 7패스 수정이 전부 그 커밋에 들어 있고 아무도 트레이싱한 적이 없다. 레인 셋(A: 그 델타의 상호 간섭 / C: `audit/handtrace-round7-reference-impl/`을 지금 계약으로 갱신해 실행 / B: 8라운드 §6이 남긴 M5+ 값 단위 트레이싱)`pre-implementation-handtrace-round9.md`(**[2026-08-26]** 9라운드 발견 보고 — 레인 A(`9dd8213` 델타 상호 간섭)·C(참조 구현을 현재 계약으로 재전사해 `luau` 실행)·G(Luau 사실 재확인)·D(ROADMAP M2 시뮬레이션)·B(M5+ 값 단위, 첫 시도) — 발견 `H-124`~`H-141`, 🔴 둘(`recompute`의 `lengthList[i]` nil 읽기 / 재마운트 시 `_baseObserver` 미바인드 캐시 stale) 다 실측 재현. §4 배치 문항 Q1~Q10 + §5 이상 없음(⭐ `keyof<{}>` 빈 Store 실측으로 클린) + §6), `pre-implementation-handtrace-round9-followup.md`(**[2026-08-27] 9라운드 결정의 소스, 진행 중** — Q1(`recompute` 되감기 판정을 읽기 앞으로) / Q2(`Offset`·`_baseObserver`를 Slot 생성자로, `materializeSlotTree` 순서, `_destroyed` 플래그) / Q3(`element → index`는 `bk.indexOfElement` 하나 — 사용자가 정한 적 없는 `token` 폐기, `H-137` 소멸, `H-141` 신설) 확정·`base/` 반영 완료, Q4~Q10 대기. 진행 표가 상태의 소스과 우선순위 근거, `-round8-followup.md`가 적어둔 **반복 실패 모드 7개**를 사냥 목록으로 옮겨 실었다. 각도 문자(A~H)는 8라운드와 같은 걸 유지해 상호참조가 되게 했다). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-roundN-brief.md`) | +| `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`은 소유권 문제가 아니라 문장이 틀린 것) — 결정만 읽지 말고 그 두 절을 볼 것), `pre-implementation-handtrace-round9-brief.md`(**[2026-08-26 신설]** 9라운드를 돌릴 감사자에게 주는 **지시서** — 발견 보고가 아니라 그 앞단이다. 이런 지시서를 저장하는 건 이번이 처음으로, 7·8라운드 것은 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는 "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 스코프를 **커밋 `9dd8213` 하나의 델타**로 정의한 게 핵심 — 8라운드 결정 반영과 그 뒤 `/code-review high` 7패스 수정이 전부 그 커밋에 들어 있고 아무도 트레이싱한 적이 없다. 레인 셋(A: 그 델타의 상호 간섭 / C: `audit/handtrace-round7-reference-impl/`을 지금 계약으로 갱신해 실행 / B: 8라운드 §6이 남긴 M5+ 값 단위 트레이싱)과 우선순위 근거, `-round8-followup.md`가 적어둔 **반복 실패 모드 7개**를 사냥 목록으로 옮겨 실었다. 각도 문자(A~H)는 8라운드와 같은 걸 유지해 상호참조가 되게 했다), `pre-implementation-handtrace-round9.md`(**[2026-08-26]** 9라운드 발견 보고 — 레인 A(`9dd8213` 델타 상호 간섭)·C(참조 구현을 현재 계약으로 재전사해 `luau` 실행)·G(Luau 사실 재확인)·D(ROADMAP M2 시뮬레이션)·B(M5+ 값 단위, 첫 시도) — 발견 `H-124`~`H-141`, 🔴 둘(`recompute`의 `lengthList[i]` nil 읽기 / 재마운트 시 `_baseObserver` 미바인드 캐시 stale) 다 실측 재현. §4 배치 문항 Q1~Q10 + §5 이상 없음(⭐ `keyof<{}>` 빈 Store 실측으로 클린) + §6), `pre-implementation-handtrace-round9-followup.md`(**[2026-08-27] 9라운드 결정의 소스 — 전량 처리 완료** — Q1(`recompute` 되감기 판정을 읽기 앞으로) / Q2(`Offset`·`_baseObserver`를 Slot 생성자로, `materializeSlotTree` 순서, `_destroyed` 플래그) / Q3(`element → index`는 `bk.indexOfElement` 하나 — 사용자가 정한 적 없는 `token` 폐기, `H-137` 소멸, `H-141` 신설) / Q4(`EffectHandle` 네 진입점 의사코드) / Q5(M2에 `Ref` 최소형) / Q6(`WeakUnsubscribe` 관대) / Q7(폐기 블록 `archive/`) / Q8(`InstanceChildHandler` 부기) / **Q9(문항 전제가 틀림 — Tween 절 스케치 한 줄 복사 오류)** / Q10(`reconcile` 배치 Blocker) + `H-138`(숏핸드 우선순위) / `H-139`(`New`/`drive` 파이프라인 의사코드) / **`H-142`(props에 `Parent` 금지 — 순서 문제 소멸)**. 진행 표가 상태의 소스, 사용자 회신 원문 둘 인용). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-roundN-brief.md`) | | `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` | @@ -46,11 +46,11 @@ | `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` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 **⭐ [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을 부르지 않는다(대칭이라 포탈이 자연히 성립) **⭐ [2026-08-26 정정, 8라운드 `H-111`]** `.Subscribed`는 *전역 `:Subscribe()` 전용*이 아니라 **구독 경로(강/약) 공용**이다 — `:WeakSubscribe()`도 세운다(갈라지는 건 레지스트리를 강하게 잡느냐뿐). 안 그러면 `WeakSubscribe`로만 등록되는 `Effect`의 내부 Observer가 `canExecute` 게이트를 영영 못 통과해 **State dep 전량이 조용히 침묵**한다. 같은 날 `Subscribe`/`WeakSubscribe` 분해 의사코드가 신설됐다("`bindLifetime`이 이 필드를 안 건드린다"는 요지는 그대로 유효). | +| `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`이 이 필드를 안 건드린다"는 요지는 그대로 유효). **[2026-08-27 9라운드 `H-133`]** `WeakUnsubscribe`는 관대(구독 안 한 값·이미 풀린 값에 조용히 통과), `Unsubscribe`는 엄격 — 대칭 가드는 *경로 교차*만 막는다는 것을 사용자 논거와 함께 명문화. | | `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`의 *"새 필드를 안 만든다"* 확정이 **역전**됐다 — 그 근거였던 "두 뜻이 실제로 같은 것"이 틀렸다. 무효화는 **둘 다** 내린다. **[2026-08-27, 9라운드 Q1/Q3]** `recompute`의 되감기 판정이 `lengthList[i]` 읽기 **앞**으로(`continue` — 옛 순서는 커서 뒤 자리 수가 줄면 `sum += nil`, `H-124`) / `setLength`가 5번째 인자로 그 자리의 요소를 받고 `gatedRecompute`는 요소를 캡처해 **`bk.indexOfElement`**를 조회 — `bk.tokens`/`indexOfToken`(사용자가 정한 적 없는 `token = {}`, `H-141`)은 폐기. | -| `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가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | +| `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가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 **[2026-08-27 9라운드 `H-139`]** `New(name)(props)` ①~④ + `Dispatch.drive` (a)~(c) 전체 파이프라인 의사코드 신설(네 문서에 흩어져 있던 순서를 한 자리에) — 쓰면서 빈 배열 파트 가드와 해시 파트 `Parent` 순서(`H-142`)가 드러남 — 후자는 **props에 `Parent` 금지**로 확정돼 순서 문제 자체가 소멸. 배치 닫는 자리는 `dispatch-core-plan.md` `H-17`이 이미 정해둔 것이라 의사코드를 그쪽에 맞춤(감사 1라운드 정정). | | `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`**(물리 요소→인덱스 역방향 맵; **[2026-08-27, 9라운드 Q3]** `bk.indexOfElement`로 Dispatch 부기에 통합됨) 신설 — `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?)`. **[2026-08-27, 9라운드 Q2/Q3]** `Offset`·`_baseObserver`가 **Slot 생성자**에서 나고(마운트 시점 생성은 첫 마운트/재마운트를 갈라 재마운트 캐시가 낡았다, `H-125`), `materializeSlotTree`는 `blocker:On()` → `bindLifetime` → `setOffsetSource` 순서, 콜백 머리에 미실체화 가드; 파괴는 **`_destroyed`** 플래그 하나가 말하고 핸들은 unbind만(mutate CRUD·`:List`·마운트 진입 error, `Owned=false`는 안 섬, 이중 `dispose` no-op); `slot._elemIndex`는 `bk.indexOfElement`로 통합; splice가 비운 자리는 세 배열(`lengthList=0`/`sourceList=None`/`observers=nil`) 전부 처리(`H-126`). | @@ -59,8 +59,8 @@ | `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`) **⚠️⚠️ [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이 공짜로 따라오게 | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:WeakCallback` — **[2026-08-27 `H-129`]** 옛 `:Callback` 표기는 `H-58` 정정의 잔재) 재실행+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` 맵이 한다. **[2026-08-27 9라운드 `H-127`/`H-130`]** `EffectHandle` 네 진입점 의사코드 신설 — Observer 것을 그대로 재사용, `Unsubscribe`만 **게이트 통과 뒤** cleanup 소진(산문 순서대로 짜면 leaf 바인딩된 핸들의 cleanup이 error보다 먼저 소진됐다). 옛 `_observers` cascade 블록은 `archive/effect-internal-observer-cascade-reversed.md`로 이전. | +| `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이 공짜로 따라오게 **[2026-08-27 9라운드 `H-138`]** 숏핸드 핸들러가 `PropertyHandler`보다 우선순위가 높다(리플렉션 거부에 기대지 않는다), 충돌 방지는 `UI` 접두어의 몫. | | `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을 새로 심는** 동작이 된다 | @@ -106,6 +106,7 @@ | 문서 | 내용 | |---|---| | `always-propagate-no-dedup-superseded.md` | **[역전됨, 2026-08-21 신설]** `source-state-plan.md`가 확정해뒀던 "emit은 자기 `invalid`와 무관하게 **항상** 전파된다 / quad가 접지 않는 것은 중복 *통지*뿐이다" — `base/state-epoch-plan.md` 채택으로 "같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접는다"로 바뀜. ⚠️ 2026-08-14의 `invalid` 기반 dedup 역전을 되돌린 게 아님(그 금지는 유효) | +| `effect-internal-observer-cascade-reversed.md` | **[역전됨, 2026-08-27 신설]** `EffectHandle`의 옛 내부 Observer 모델 — `_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade. 2026-08-25(7라운드 `H-58`/`H-59`)에 "강한 주인은 항상 `Effect`, cascade 없음, dep 등록은 생성자에서 `Weak*` 한 번"으로 역전됐고, `base/effect-plan.md` 안에 ⛔ 배너를 단 채 남아 있다가 **배너 아래 죽은 문단이 두 번 살아 있는 문장처럼 편집되는 사고**(8라운드 2차 #1, 9라운드 `H-130`)가 나서 본문에서 뺐다. 옮기는 시점 원문 그대로, 갱신 안 함 | | `brand-shared-registry-reversed.md` | **[역전됨, 2026-08-21 신설]** `Brand`의 옛 표면 — 공유 weak 레지스트리 하나 + `Brand.set`/`Brand.get(x) -> tag`(**객체당 태그 하나**). `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는 요구(`base/state-epoch-plan.md`의 `Epoch` 인터페이스)를 표현할 수 없어 **인스턴스 브랜드**로 재작성됨(`base/brand-plan.md`). 같이 버려진 역조회 `Brand.get`은 코퍼스 전수 조사에서 쓰는 자리가 하나도 없어 대가가 아니었고, "포함 관계가 코드에 드러난다"는 우려는 에이전트 착오로 철회됨 | | `store-value-field-redesign-withdrawn.md` | **[철회됨, 2026-08-25 신설]** Store를 **"값 필드 + 타입 함수 합성"**으로 바꾸려던 시도 — `store.key`를 값으로, `store:Of(k)`를 프리미티브로, `__index`/`__newindex` 슈가, 팬텀 필드 + `index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활. **같은 날 도입하고 같은 날 철회**했다. 철회 이유 다섯(읽기/쓰기 의미론 미정의, `index<>`/`keyof<>`도 타입 함수라 같은 함정, `Names()`가 런타임 구현 불가, `?`를 떠받칠 sentinel 부재 — `None`은 nil hole용이라 의미론이 다름, 표면 증가)과 원문 보존. **살아남은 것은 명시적 초기화 하나.** 여기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격 | | `store-source-proxy-reversed.md` | [역전됨] 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | diff --git a/.claude/archive/effect-internal-observer-cascade-reversed.md b/.claude/archive/effect-internal-observer-cascade-reversed.md new file mode 100644 index 0000000..4648e41 --- /dev/null +++ b/.claude/archive/effect-internal-observer-cascade-reversed.md @@ -0,0 +1,72 @@ +# [역전됨] `EffectHandle`의 내부 Observer 바인딩 세부 — `_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade + +> **역전일**: 2026-08-25(7라운드 `H-58`/`H-59`). **대체된 곳**: +> `base/effect-plan.md`의 "확정 구조 — 강한 주인은 항상 `Effect`" 절과 +> "의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`" 절 — +> `bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나에만** 적용되고 내부 +> Observer로 cascade하지 않는다. dep 등록은 생성자에서 한 번만 +> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이 +> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는 +> 전부 `_deps` 하나와 `_blocker`로 대체됐다. **이 문단대로 짜면 `H-58`의 +> 중복 `Rerun`이 되살아난다.** +> +> **왜 `archive/`로 왔나 (2026-08-27, 9라운드 `H-130`, 사용자 결정 Q7-(b))**: +> 이 블록은 `base/effect-plan.md` 안에 ⛔⛔ 폐기 배너를 단 채 남아 있었는데, +> 배너 아래 죽은 문단을 **두 번**이나 살아 있는 문장처럼 편집하는 사고가 +> 났다(8라운드 2차 #1, 그리고 커밋 `9dd8213`이 아래 40번째 줄 근처의 +> *"`.Subscribed`는 구독 경로 전용"*을 날짜 마커 없이 고친 것 — 옛 표기는 +> *"전역 `:Subscribe()` 전용"*). `conventions.md`의 핸드오버 체크리스트 3번 +> (*"뒤집힌 원문은 `archive/`로 옮기고 포인터만 남길 것"*)이 정확히 이 +> 실패 모드를 규정하고 있어 그대로 따랐다. 아래는 **옮기는 시점의 원문 +> 그대로**(그 편집 흔적 포함)이고, 더 이상 갱신하지 않는다. + +## 옮겨온 원문 (`base/effect-plan.md`, 2026-08-27 시점) + +**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 +세션, 재확인 후 명시화)**: + +> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.** +> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 — +> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고** +> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만** +> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이 +> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는 +> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록"). +> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`이 +> 되살아난다.** + +**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나" +전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.** +아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로 +구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려** +그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패 +모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로 +고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이 +배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스. + +- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] = + observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라 + (그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime` + cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것. +- **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도 + 같은 `inst`로 `handle._observers` **전부**에 대해 + `bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 + `bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가 + `handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유: + `canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이 + `bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에 + 복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면 + 그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨 + (=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부 + Observer까지 같이 풀어야 대칭이 맞음. + **[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든 + "`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는 + 틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와 + 무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는 + 결론은 그대로이고 오히려 근거가 더 직접적이 됨. +- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 + 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers` + 전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든 + "`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면 + 안 됨). + diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index fc28656..530ff5e 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -287,7 +287,7 @@ quad/ │ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) -│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 +│ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정) │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 24d034e..057bc7e 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -200,6 +200,139 @@ D.Frame = New<> "Frame" :: (({ ...타입명시 }) -> Frame) 커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만 정확하고 밖은 `any`다. +**⭐ [2026-08-27 신설, 9라운드 `H-139`] `New(name)(props)` 파이프라인 의사코드 — +네 문서에 흩어져 있던 순서를 한 자리에.** 사용자 지시: *"실 구현 전에 +의사코드를 써보자. 그걸로 인해서 감추어졌던 설계 결함이나 폭탄이 발견된 경우가 +많아서, 커지기 전에 확인해볼 필요가 있음. 중요 계층이라서"*. 각 단계의 소스는 +주석의 문서이고 **여기는 순서만 확정**한다 — 단계 안의 규칙은 그 문서가 정본. +(이름 주의: 여기 `New`는 quad-roblox `D/init.luau`의 **인스턴스 생성자**이고, +`module-lifecycle-plan.md`의 `New(): Quad`는 quad-base의 **모듈 팩토리**다. +패키지가 달라 런타임 충돌은 없지만 산문에서 섞이니, 생성자는 항상 +`New "Frame"` 꼴로, 팩토리는 `New()` 꼴로 쓸 것.) + +```lua +-- quad-roblox/src/D/init.luau — 생성기가 찍는 커링 생성자. 아래 ①~④ 순서가 계약. +local function New(className: string) + return function(props) + -- ① 물리 생성 — 백엔드의 일. base는 `Instance`를 모른다 + -- (`dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op"). + local inst = Instance.new(className) + + -- ② gcconn/gchold — 생성 **직후, 무조건**(핸들러/바인딩 유무와 무관). + -- `lifecycle-pattern.md` (0)의 코드 그대로: 클로저가 `gchold`와 `inst`를 + -- 같이 캡처해 userdata 동일성을 고정하고 `InstData:SetWeak(inst, …)`. + -- ③④보다 앞인 이유 — 거기서부터 `inst`를 키로 쓰는 `Relate` + -- (`elementOwner`/`nameClaims`/`bk`/`chains`)가 생기는데, 키의 동일성 + -- 고정이 그보다 먼저여야 한다. + -- (lifecycle-pattern.md (0)의 인라인 코드 — 헬퍼 이름은 구현 시) + + -- ③ flatten — Modifier 항목을 제자리에서 `ProcessedModifier`로 소진하고 + -- 필드를 해시 파트로 merge. 새 테이블 없음, `inst`를 안 받는 순수 변환 + -- (`modifier-plan.md`의 "flatten의 정확한 형태"). `PreRef`/`PostRef`가 + -- Modifier 필드에 오는 건 타입으로 차단돼 있어 여기선 안 다룬다. + local flattened = flatten(props) + + -- ④ 디스패치 — pre-pass → 본체 → `postRefList`. 전부 `Dispatch.drive`가 소유. + Dispatch.drive(inst, flattened) + + return inst -- `D.Frame`은 이 함수에 캐스트만 얹은 별칭(위 확정) + end +end +``` + +```lua +-- quad-base: Dispatch/init.luau +function Dispatch.drive(inst, flattened) + -- ⓪ 배치 Blocker — **진입 직후** 켜고 `drive`가 할 일을 전부 마친 뒤(post-pass + -- 포함) 끈다: `dispatch-core-plan.md`의 `H-17` 계약(*"`drive` 전체를 `inst` + -- 전용 `Blocker`로 감싼다"* / *"`PostRef` 콜백은 게이트가 켜진 채로 실행된다"*). + -- ⚠️ 배열 파트가 비어 있으면 열지 않는다 — 안 그러면 `Frame { Size = … }` + -- 처럼 자식 없는 모든 Instance마다 Blocker + `bk`가 eager 생성된다 + -- (9라운드 Q2/Q3가 Slot 쪽에서 막은 것과 같은 부류). pre-pass는 자리를 + -- 센티널로 바꿀 뿐 비우지 않으므로 이 판정은 pre-pass 앞뒤가 같다. + local batching = flattened[1] ~= nil + local blocker = if batching then getBlocker(inst) else nil + if batching then blocker:On() end + + -- (a) pre-pass — 배열 파트만, index 순서(`ref-plan.md`의 "메커니즘 — pre-pass 한 스윕" 절). + -- `PreRef`는 그 자리에서 fire(`v:Set(inst)`) + 소진, + -- `PostRef`는 수집 + 소진. `_fired` 1회용 가드는 둘 다 **여기서** 선다. + local postRefList = {} -- 이 호출에만 로컬 — Relate 아님 + for i, v in ipairs(flattened) do + if isPreRef(v) then + if v._fired then error("PreRef instance reused", 2) end + v._fired = true + v:Set(inst) -- 콜백 fire — 아직 자식도 프로퍼티도 없다 + flattened[i] = ProcessedPreRef + elseif isPostRef(v) then + if v._fired then error("PostRef instance reused", 2) end + v._fired = true + table.insert(postRefList, v) + flattened[i] = ProcessedPostRef + end + end + + -- (b) 본체 — **단일 일반화 `for`**(`F-4-1`). 배열 → 해시 순서는 Luau 순회에 + -- 기댄다. 배열 파트가 Length/Offset **배치 등록 구간**이고 해시 파트는 + -- 부기를 안 만진다(`dispatch-core-plan.md`의 "해법의 핵심" 1번·4번). + for k, v in flattened do + Dispatch.process(inst, k, v, 1) -- 체인 index는 항상 1부터 + end + + -- (c) `postRefList` — push 순서 = index 순서. 이 시점에 끝나 있는 것/아닌 것은 + -- `ref-plan.md`의 "`PostRef`" 절(자기 서브트리와 프로퍼티는 완성, + -- **자기 `.Parent`는 아직**일 수 있다). **게이트가 켜진 채 돈다** — 콜백이 + -- `slot:Add(…)`로 이 `inst`에 emit을 올려도 `gatedRecompute`는 스킵되고 + -- 정합성은 아래 마지막 `recompute` 한 번에 의존한다(`H-17`). + for _, v in ipairs(postRefList) do v:Set(inst) end + + -- ⓪' 배치 닫기 — `drive`가 할 일을 전부 마친 뒤 딱 한 번. + if batching then + blocker:OffWithoutEmit() + local bk = getBookkeeping(inst) -- 배열 파트가 있었으니 이미 존재 + if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end -- `H-119` + end +end +``` + +**이 의사코드를 쓰면서 드러난 것**(결정은 `qa-request/pre-implementation-handtrace-round9-followup.md`의 +`H-139` 절 — 여기선 목록만): +- **배치를 닫는 자리** — 처음엔 *"어디에도 안 적혀 있다"*고 보고 해시 파트 + 앞에서 닫는 모양으로 썼는데, **틀렸다**(감사 1라운드가 잡음): + `dispatch-core-plan.md`의 `H-17` 절이 이미 *"`drive` 전체(post-pass 포함)를 + 감싼다, `PostRef` 콜백은 게이트가 켜진 채 실행된다"*로 정해뒀고 그 이유(단일 + 루프에선 배열 파트의 끝이 루프 밖에서 관측되지 않는다 / `postRefList`는 해시 + 파트보다 뒤다)까지 적혀 있었다. 위 의사코드는 그 계약대로 고쳤다. 실제로 + 드러난 건 그 절의 아래쪽 "해법의 핵심" 4번이 옛 문구(*"배열 파트 순회 + 전체"*)로 남아 있던 것 하나. +- **자식 없는 Instance에도 Blocker/`bk`가 생기는 경로** — `getBlocker(inst)`를 + 무조건 부르면 그렇게 된다. `flattened[1] ~= nil` 가드로 막았다. +- **⭐ [2026-08-27 확정, 9라운드 `H-142`] props에 `Parent`는 올 수 없다 — 그건 + 부모가 하는 일이다.** 의사코드를 쓰다 "해시 파트 안의 `Parent` 대입 순서가 + 미정"이 드러났는데, 사용자는 순서를 정하는 대신 **키 자체를 금지**했다: + *"Parent대입 자체가 오면 안 돼. 그건 부모에서 할 일이거든. 자신이 바로 하는 + 경우는 없어. 그걸 허용해준다는것 자체가, '외부에서 직접 Parent 설정해주지 + 말것' 을 해치는 요인이 되기도 해.(암묵적으고 가능하도록 둬버려서)"* — + `slot-plan.md`가 *"동적 자식은 반드시 `Slot` 또는 `state`류 store-bind를 + 통해서만"* 이라고 세운 원칙(외부 코드가 `.Parent`로 자식을 끼우면 `Length`/ + 형제 순서가 조용히 어긋난다)의 **정적 리터럴 판**이다. `.Parent` 대입은 + 자식을 받는 쪽 — `InstanceChildHandler`(정적 자식, `H-134`)와 Slot의 + `native*` 주입 op — 만 한다. + - **타입**: `D` 생성기가 각 클래스의 props 타입에서 `Parent`를 **제외**한다 + (`ROADMAP.md` M5 `D/init.luau` 체크박스) — **그리고 `FrameModifier`류 + 메소드 목록에서도**(`ROADMAP.md` M7; **[2026-08-27 `/code-review`]** 두 + 목록이 같은 API 덤프에서 따로 생성되는데 한쪽만 빼면 + `Modifier():Parent(x)`가 타입을 통과하고 `flatten`이 해시 파트로 merge한다 — + `PreRef`/`PostRef`를 Modifier 타입으로 차단하는 것과 같은 자리). 범위 밖 클래스의 `New<> "X"`는 + `any`라 타입으로 못 막고 아래 런타임 가드가 잡는다. + - **런타임**: 새 메커니즘 없이 기존 계약으로 — `PropertyHandler.isHandlable`이 + `"Parent"`를 거부하면 그 키에 매치되는 핸들러가 없어 `Dispatch.process`의 + *"매치 핸들러 없음 → 즉시 error"* 계약(`ROADMAP.md` M3)에 걸린다. (이 + 배선은 사용자 확정이 아니라 **규칙을 기존 계약에 얹은 제 선택**이다 — + `H-142` 처방 후보 (a)/(b)/(c)가 전부 새 메커니즘이라 정하지 않았던 것을 + "키 금지"로 바꾸니 필요한 코드가 이 거부 한 줄뿐이다. 다른 모양이 낫다면 + 갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다. + **이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`의 `assign(instance, key, value)`가 `ReflectionService:GetPropertiesOfClass`/ diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 129328c..3abfe43 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -245,6 +245,12 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 `Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. +**[2026-08-27 9라운드 `H-136`] 이 규칙의 예외가 아닌 것 하나** — 같은 +Blocker를 소유한 호출부가 `IsOn()`으로 "이미 바깥이 켜뒀다"를 알아보고 자기 +`On()`/`Off()`를 건너뛰는 것(`base/slot-plan.md`의 `reconcile` — 최초 +population은 바깥 배치 안에서, 재실행은 밖에서 같은 클로저로 불린다). +Blocker 자신은 여전히 단순 불리언이고 두 번 켜지지 않는다 — 카운팅이 +아니라 소유권 판정이다. **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — 위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 247eef0..b5fb5eb 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -581,7 +581,11 @@ end `base/ref-plan.md`의 "`PostRef`" 절. **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결 / 범위 정정 2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** — - 진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, + 진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고 + (**[2026-08-27 9라운드 `H-139`, 사용자 확인]** 단, **배열 파트가 비어 + 있으면(`flattened[1] == nil`) 열지 않는다** — 안 그러면 자식 없는 모든 + Instance마다 Blocker + `bk`가 eager 생성된다. pre-pass는 자리를 센티널로 + 바꿀 뿐 비우지 않아 이 판정은 진입 시점에 해도 같다), **`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함) `:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 (**⭐ [2026-08-26, `/code-review high` 4차] 이 호출도 `H-119`의 재진입 @@ -1068,6 +1072,7 @@ end | `NoneHandler` | 중간 | 없음(재위임만) | | `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) | | `PropertyHandler` | 말단 | 프로퍼티 세팅 | + | `InstanceChildHandler` | 말단 | `Parent` 대입 (+ 부기 — `H-134`) | | `TagHandler` | 말단 | `addTag`/`removeTag` (+ 부기 — `H-39`) | | `AttributeKeyHandler` | 말단 | `setAttribute` | | `AttributeGroupHandler` | 자기 체인에선 말단 | 다른 키로 위임 (+ 부기 — `H-39`) | @@ -1472,6 +1477,36 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 "Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하면 `bk.N`의 의미가 바뀌어 파급이 크다(사용자 확정, 2026-08-24). + **⭐⭐ [2026-08-27 9라운드 `H-134`] 다섯째 — `InstanceChildHandler`.** 위 + 전수 grep은 **의사코드가 있는 핸들러만** 잡을 수 있었는데, 정적 자식 + Instance를 받는 이 핸들러(`quad-roblox/src/Handlers/InstanceChild.luau`, + `k:number, v:Instance` — `architecture.md`의 소스 트리)는 코퍼스 어디에도 + 의사코드가 없었고 위 표에도 행이 없었다. 아래 Length/Offset 절 머리가 + *"정적 단일 자식은 상수 `1`"*이라고만 하고 **누가** 등록하는지는 안 적어서, + `Frame { Frame{}, Slot() }`처럼 **정적 자식 뒤에 Slot이 오는 가장 흔한 + 배치가 첫 마운트에서 죽는다** — Slot의 `setOffsetSource(inst, 2, …)` → + `getOffsetAt(inst, 2)`가 `lengthList[1] == nil`을 만나 `H-106` 가드로 error + (순서를 뒤집으면 `bk.N`이 1에서 멈춰 안 터진다 — 위와 같은 "가끔" 모양). + 확정(사용자, 갈래 없음 — `H-39`의 "예외 없이"에 이 핸들러를 넣는 것뿐): + `process`에서 `setOffsetSource(inst, k, None)` → **`v.Parent = inst`** → + **`setLength(inst, k, 1, inst)`**(상수 `1`). 반환 클로저는 (A)/(B) 분기·단순 + 철거에서 **`v.Parent = nil`** → `setOffsetSource(inst, k, None)` → + `setLength(inst, k, 0)`(`SlotHandler`의 retractor가 `unmountSlotTree`를 먼저 + 부르는 것과 같은 모양, 부기 둘의 순서 근거는 아래 "해제" 문단). + **[2026-08-27 `/code-review high` 정정 셋]** — (1) 옛 자식은 **내린다**(파괴 + 아님): `store.child:Set(otherFrame)`이 이전 `Frame`을 `Destroy`하지 않고 + 트리에서 내리기만 한다는 것이 `base/slot-plan.md`의 "`State` 교체는 + 파괴가 아니라 언마운트" 절의 근거 1이라, retractor가 `Parent = nil`을 안 + 하면 옛 자식이 물리 자식으로 남은 채 `lengthList[k] == 1`이 된다. (2) 순서는 + **물리 먼저, `setLength` 나중** — 아래 "일반 계약 — 물리와 부기의 순서" 3번 + (`nativeInsert` → `setLength` → `recompute`)과 같게. 옛 서술(`setLength` → + `Parent`)은 단건 경로에서 `recompute`가 부착 전에 돌았다. (3) **5번째 인자 + `element`는 안 넘긴다** — 상수 길이라 지속 클로저가 없어 Q3 계약상 생략 + 대상이고, 넘기면 `setLength(…, 0)` 해제가 `bk.indexOfElement`의 옛 키를 + 안 지워(해제 호출엔 요소가 없다) 교체마다 옛 자식이 강참조로 쌓인다. 대안 — `Dispatch.drive`가 `type(k) == "number"` 분기에서 일괄 등록 — + 은 아래 *"모든 핸들러가 `k=number`일 때 처리하도록 두는"*에서 **이미 기각된 + 안**이라 다시 열지 않는다. `ROADMAP.md` M5 체크박스에 같은 두 줄을 적었다. + **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 @@ -2208,7 +2243,8 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser 1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가 자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그 owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작 - 전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 + 전에 `:On()`한다**(`Dispatch.drive`는 배열 파트가 있을 때만 — 위 `H-17` + 절의 2026-08-27 가드). 이 Blocker는 `state:Block()`을 거치지 않고 **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절 참고. 2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 @@ -2223,8 +2259,10 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser 미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`가 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 배치 중이라도 항상 최신값을 보게 됨. -4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 - `materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을 +4. 배치가 끝나면(`Dispatch.drive`가 할 일을 **전부** 마치면 — post-pass 포함, + 위 `H-17` 절 / 또는 `materializeSlotTree`의 등록 루프 전체가 끝나면; + **[2026-08-27 정정]** 여기 *"배열 파트 순회 전체"*라고 남아 있던 건 `H-17`이 + 범위를 넓히기 전 문구다) `blocker:OffWithoutEmit()`을 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 호출한다(**[2026-08-26]** 이 명시 호출도 `bk.recomputeBlocker:IsOn()`이면 건너뛴다 — `H-119`). 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, @@ -2378,7 +2416,17 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 **`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 -도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. +도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. **⭐ [2026-08-27 확정, +9라운드 `H-136`] 재실행 사이클도 포함한다** — 의사코드는 최초 population만 +Blocker로 감싸고 `data:Set(…)`으로 `reconcile`이 다시 돌 땐 raw op마다 +`recompute`가 완주해(O(n²) + 부모 캐스케이드 n회) 이 문장과 어긋나 있었다. +이제 `reconcile` 본문 자체가 게이트를 쥔다(`base/slot-plan.md`의 +`reconcile` 머리·꼬리). 사용자 논거: *"이전에는 native* 가 없어 모두 dom 식 +elem 입출력이 강제가 아니였는데, 이젠 그렇기 때문에 최초 방식으로 recompute +되는것 처럼 처리되어도 되는 지점 … 이미 우린 set upto 와 valid upto 가 나뉜 +지점이고, 싱크라서 괜찮아보임"* — 배치 중엔 offset이 뒤늦게 한 번에 잡히지만 +`native*` 계층이 물리 위치를 부기와 무관하게 정확히 넣고, 무효화 커서 둘이 +분리돼 있어 마지막 `recompute` 한 번이 전부 따라잡는다. **웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ `recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index d304f30..f2b11bf 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -242,8 +242,10 @@ function Effect(fn, ...) end 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`은 - -- 테이블을 호출하려 들어 죽는다 + for d in pairs(seen) do -- 스타일 통일(`pairs` 명시). **[2026-08-27 9라운드 + -- `H-131`]** 옛 근거 *"`for d in seen`은 테이블을 + -- 호출하려 들어 죽는다"*는 **거짓** — Luau의 일반화 + -- 반복은 런타임·`--!strict` 둘 다 통과한다(실측). if isRef(d) then self._deps[d] = onRefFire -- ⭐ 강한 주인 = Effect d:WeakCallback(onRefFire) -- Ref 쪽은 약함 @@ -465,53 +467,12 @@ got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입 named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로 값싸게 override 가능한 자리로 열어둠. -**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 -세션, 재확인 후 명시화)**: - -> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.** -> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 — -> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고** -> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만** -> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이 -> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는 -> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록"). -> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`이 -> 되살아난다.** - -**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나" -전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.** -아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로 -구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려** -그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패 -모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로 -고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이 -배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스. - -- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] = - observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라 - (그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime` - cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것. -- **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도 - 같은 `inst`로 `handle._observers` **전부**에 대해 - `bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 - `bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가 - `handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유: - `canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이 - `bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에 - 복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면 - 그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨 - (=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부 - Observer까지 같이 풀어야 대칭이 맞음. - **[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든 - "`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는 - 틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와 - 무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는 - 결론은 그대로이고 오히려 근거가 더 직접적이 됨. -- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 - 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers` - 전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든 - "`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면 - 안 됨). +**보강 — `EffectHandle`의 내부 Observer 바인딩 세부** — **⛔ [2026-08-25 +폐기, 7라운드 `H-58`/`H-59`; 2026-08-27 `archive/`로 이전, 9라운드 `H-130`]** +옛 모델(`_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade)의 원문은 +`archive/effect-internal-observer-cascade-reversed.md`. 위 "확정 구조 — 강한 +주인은 항상 `Effect`" 절이 정반대로 확정했고, 배너 아래 죽은 문단이 두 번 +살아 있는 문장처럼 편집되는 사고가 나서(그 파일 머리) 본문에서 뺐다. **Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React `useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, @@ -536,6 +497,40 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 **확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다 `self` 반환(Observer와 동일한 fluent 대칭). +**⭐ [2026-08-27 확정, 9라운드 `H-127`] 의사코드 — 네 진입점은 Observer의 +것을 재사용하고, `Unsubscribe`만 *통과한 뒤* cleanup을 덧붙인다.** 아래 +산문(번호 목록)은 이 블록을 풀어 쓴 것이고, **순서는 이 블록이 정본**이다. +산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에 +`:Unsubscribe()`를 불렀을 때 **cleanup을 소진한 뒤에야 error**가 나서 `E-11`이 +막으려던 피해(cleanup 앞당김, `_installed = false`)가 이미 일어난 뒤다 — +Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. + +```lua +-- 레지스트리(`Subscribed`/`WeakSubscribed`)·`canBound` 게이트·`.Subscribed` +-- 플래그 전부 `base/lifecycle-pattern.md` (2)의 Observer 네 진입점과 **같은 +-- 구현**이다 — 메소드 테이블에 그대로 배정한다. 등록되는 건 **핸들 자신뿐** +-- (내부 Observer·`Ref` 콜백은 생성자에서 이미 `Weak*`로 걸려 있다, `H-59`). +EffectHandle.Subscribe = Observer.Subscribe -- canBound 게이트 + 강한 킵 +EffectHandle.WeakSubscribe = Observer.WeakSubscribe -- 게이트 + 약한 등록 + 플래그 +EffectHandle.WeakUnsubscribe = Observer.WeakUnsubscribe -- 관대(`H-133`) — cleanup 안 건드림 + +function EffectHandle:Unsubscribe() + Observer.Unsubscribe(self) -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면(leaf + -- 바인딩·약한 구독·미구독) 여기서 error, cleanup엔 + -- 손도 안 댄다(`E-11`). 통과하면 강한 킵 해제 + + -- `.Subscribed = false`(향후 재실행 차단). + self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_installed = false` + return self +end +``` + +- **`WeakUnsubscribe`는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에 + 맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 강한 구독의 + `Unsubscribe`와 leaf 사망(`unbindLifetime`의 훅) 둘뿐. +- 실측(9라운드 `core9.luau`, `t12` 매트릭스): 이 배정으로 leaf 바인딩된 + 핸들의 `:Unsubscribe()`가 cleanup을 건드리지 않고 error, `:Subscribe()` + 후 `:Unsubscribe()`는 cleanup 1회 — 전부 기대대로. + - **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에 **핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구). @@ -611,31 +606,33 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.** - **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린 항목이 아니다. -- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 - 위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 +- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 위임한 뒤 + cleanup 하나를 덧붙인다 — Effect 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 - 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨: - 1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가 - 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라 - 적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고 - **해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위 - "확정 구조" 절). `:Unsubscribe()`가 `handle.Subscribed = false`로 - 만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로 - 끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다. - 2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 - 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. - 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 묶어주므로 - 여기 그대로 얹힘. + 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨. **순서는 위 + 의사코드가 정본이고 아래 번호는 그 순서 그대로다**(**[2026-08-27 + `/code-review high` 재정렬]** 옛 목록은 플래그 → cleanup → fail-fast 순이라 + leaf 바인딩된 핸들에서 cleanup을 소진한 뒤 error가 났다): + 1. **게이트 — fail-fast.** 강하게 구독된 적 없는 값(leaf 바인딩·약한 + 구독·미구독)이면 여기서 error, cleanup엔 손도 안 댄다(`E-11`). + **[2026-08-26 정정, `/code-review high` 7차]** 옛 "idempotent"는 폐기됐다 + (`base/lifecycle-pattern.md`의 "(2) 전역 경로" 절 — 6차가 같은 문장을 두 + 문서에서 지웠는데 이 세 번째 사본을 놓쳤었다). + 2. **강한 킵 해제 + `handle.Subscribed = false`.** 이것만으로 향후 재실행이 + 끊긴다 — `canExecute(handle)` 게이트가 곧바로 거짓이 되므로. **[2026-08-25 + 정정, 7라운드 `H-58`/`H-59`]** 옛 문장 *"`state`가 있으면 내부 Observer도 + `:Unsubscribe()`해서"*는 틀렸다 — 내부 Observer는 생성자에서 + `:WeakSubscribe()`로 걸리고 **해제하지 않는다**(위 "확정 구조" 절), 단수 + `state` 전제도 `...deps`로 대체됐다. + 3. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 + 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. **이후 leaf가 실제로 + 죽어도 cleanup이 중복 호출되면 안 됨** — 새 메커니즘 불필요, + `canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동(전역= + `Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로 + 얹힘. - **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미 `Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금 leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치. @@ -708,7 +705,10 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문 자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다. - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 - 핸들 표면에 바로 닿는다. + 핸들 표면에 바로 닿는다. **⚠️ [2026-08-27 `/code-review high`, 판단 대기 + `H-143`]** `fn` 안 `Unsubscribe`는 그 실행이 돌려준 cleanup을 `Rerun`이 + 그대로 저장해 **아무도 소진 못 하게** 만든다 — 처방(`Rerun` 꼬리 분기 / + 금지 / 문서화)은 `question.md` 최우선 절. - **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index f00fb72..e2bc2a7 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -442,8 +442,12 @@ end **⭐ [2026-08-26 재작성, 8라운드 `H-111`] 프리미티브는 `WeakSubscribe` 쪽이다** — 여기 한때 `Subscribe`/`Unsubscribe` 둘만 있는 블록이 있었는데, 그건 `:WeakSubscribe()`가 생기기 전(2026-08-25 이전) 서술이라 **약한 쪽이 어디서 -`.Subscribed`를 세우는지가 통째로 빠져 있었다.** 아래가 네 진입점 전량이고 -소스다(사용자 원문 *"구현이 한 벌"*): +`.Subscribed`를 세우는지가 통째로 빠져 있었다.** 아래가 **Observer의** 네 +진입점 전량이고 소스다(사용자 원문 *"구현이 한 벌"*). **[2026-08-27 9라운드 +`H-127`]** `EffectHandle`도 같은 넷을 **그대로 재사용**한다(같은 레지스트리, +같은 게이트) — `Unsubscribe`만 이 게이트를 통과한 *뒤에* cleanup 소진을 +덧붙이며, 그 의사코드는 `base/effect-plan.md`의 "`EffectHandle:Subscribe()`" +절이 소스: ```lua local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적) @@ -472,6 +476,9 @@ function Observer:WeakUnsubscribe() if Subscribed[self] ~= nil then error("...: subscribed strongly; use :Unsubscribe()", 2) end + -- ⭐ [2026-08-27 확정, 9라운드 `H-133`] 여기까지가 가드 전부다 — 구독한 적 + -- 없는 값·이미 약하게 풀린 값은 **조용히 통과**(아래 두 줄이 no-op). + -- 의도된 관대함이지 누락이 아니다(아래 산문). WeakSubscribed[self] = nil self.Subscribed = false return self @@ -510,6 +517,20 @@ end State dep 전량이 침묵한다. **"둘 중 뭐든 풀어주는" 범용 해제는 없다** — 필요하면 호출부가 `.Subscribed`가 아니라 어느 경로로 걸었는지를 알고 있어야 한다(핸들을 만든 쪽이 안다). +- **⭐ [2026-08-27 확정, 9라운드 `H-133`] 그 대칭은 *경로 교차*만 막는다 — + `WeakUnsubscribe`는 관대하고 `Unsubscribe`는 엄격하다.** 구독한 적 없는 + 값·이미 약하게 풀린 값에 `WeakUnsubscribe`를 부르면 error 없이 지나간다 + (`WeakSubscribed[self] = nil; .Subscribed = false`가 그대로 no-op — 실측 + `t12_subscribe_failfast.luau`). 같은 값에 `Unsubscribe`는 error다. 사용자 + 논거: *"WeakSubscribed 자체가 사라질 수 있는 요소라서, 그 사라지는걸 유저가 + 정하게 하는 요소라서 에러를 내야할지 말아야할지 애매한 부분 … 다만 weak 에 + 대한 홀드를 유저가 유지하는게 강제라면 b가 되긴 해야하나, 그럴 이유가 없어서 + a가 되는게 맞는듯"* — 약한 등록의 생존은 사용자가 쥔 참조에 달린 것이고 + quad는 그 홀드를 강제하지 않으므로, "없는 항목의 약한 해제"를 오류로 볼 + 근거가 없다. 강한 등록은 quad가 살려두는 것이라 "없음"은 반드시 호출부 + 실수다. **leaf 바인딩된 값에 `WeakUnsubscribe`를 불러도 같은 이유로 + 통과**한다 — `.Subscribed = false` 대입만 하고 gcconn 경로는 안 건드려 + 무해하다. - **⚠️ `:Subscribe()`는 idempotent가 아니다.** 이미 구독됐거나 leaf에 바인드된 값에 다시 부르면 `canBound` 게이트에 걸려 **error**다 (**[2026-08-26 확정, `/code-review high`]** `base/source-state-plan.md`가 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 090b8a5..01a4df1 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1367,6 +1367,12 @@ function Slot:List(data, updateFn, keyFn, opts) if self._physicalTarget then -- 초기 population도 `materializeSlotTree`와 같은 이유로 게이팅한다 — -- 안 그러면 아이템마다 `recompute`가 돌아 O(n²)(그 절의 `blocker:On()` 문단). + -- [2026-08-27, `H-136` 후속] `reconcile`이 이제 자기 게이트를 쥐므로(`ownsGate`) + -- **이 `if` 블록 전체가 잉여**다 — `On()`/`OffWithoutEmit()`뿐 아니라 아래 + -- `recomputeBlocker` 확인 + `recompute` 호출까지 `reconcile` 꼬리가 똑같이 + -- 한다. 안쪽에선 `ownsGate = false`로 걸러져 동작은 같다. + -- `materializeSlotTree`와 모양을 맞추려 두지만, 구현 시 블록째 지우고 + -- `activateList(self, self._physicalTarget)` 한 줄만 남겨도 무방하다. local blocker = getBlocker(self) blocker:On() activateList(self, self._physicalTarget) @@ -1541,6 +1547,21 @@ function activateList(self, physicalTarget) keys[i] = key end + -- ⭐ [2026-08-27 확정, 9라운드 `H-136`] **한 사이클은 한 배치다** — 재실행 + -- (`data:Set(…)` → 이 함수)도 최초 population과 똑같이 Blocker로 감싼다. + -- 안 그러면 raw op마다 `recompute`가 완주해 O(n²) + 부모 캐스케이드가 + -- 아이템 수만큼 나고, `dispatch-core-plan.md`의 *"한 사이클 … 한 번만"*이 + -- 거짓이 된다. `raw*`의 명시 호출은 이미 `getBlocker(self):IsOn()`을 + -- 보므로(`H-119`) 추가 배선은 없다. + -- **Blocker는 네스팅이 안 된다**(불리언, `base/blocker-plan.md`) — 최초 + -- population은 `Slot:List`/`materializeSlotTree`가 이미 켜둔 **바깥 배치 + -- 안에서** 여기 오므로, 이미 켜져 있으면 손대지 않고 바깥이 끄게 둔다. + -- `updateFn`이 도중에 던지면 켜진 채 남는 것은 `materializeSlotTree`가 + -- 이미 감수하는 것과 같은 부류(`pcall` 안 씀 — error 계약). + local blocker = getBlocker(self) + local ownsGate = not blocker:IsOn() + if ownsGate then blocker:On() end + local slotPos = 0 -- `_elements` 자리 카운터 — 생존 아이템마다 정확히 +1 for i, item in ipairs(items) do @@ -1607,6 +1628,15 @@ function activateList(self, physicalTarget) -- **다음 사이클엔 다시 안 묻는다** end end + + -- ⭐ [2026-08-27, `H-136`] 배치 닫기 — `Slot:List`의 `_physicalTarget` 분기와 + -- 같은 꼬리. `recomputeBlocker`는 따로 본다(사용자 코드가 바깥 `recompute` + -- 안에서 이 사이클을 일으킨 재진입이면 바깥 루프가 되감아 따라잡는다). + if ownsGate then + blocker:OffWithoutEmit() + local bk = getBookkeeping(self) + if not bk.recomputeBlocker:IsOn() then recompute(self, bk) end + end end -- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`가 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 6d3d282..49f4c10 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -1421,7 +1421,11 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 - **⚠️ [2026-08-26 재정정, `/code-review high` 6차] `:Unsubscribe()`도 idempotent가 아니다.** 여기 한때 *"게이트가 없어 … 비대칭이 의도된 것"* 이라고 적혀 있었는데, 같은 날 **대칭 가드**가 들어오며 거짓이 됐다(위 항목). - 지금 계약은 **해제는 건 경로로 푼다** 하나다. + 지금 계약은 **해제는 건 경로로 푼다** 하나다. **[2026-08-27 9라운드 + `H-133`]** 그 대칭 가드는 *경로 교차*(강↔약)만 막는다 — 구독한 적 없는 + 값·이미 약하게 풀린 값에 `WeakUnsubscribe`는 **조용히 통과**(의도된 관대함, + 사용자 논거와 함께 `base/lifecycle-pattern.md` (2)가 소스), 같은 값에 + `Unsubscribe`는 error. - **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`는 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 886d25e..02f8c0a 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -48,6 +48,10 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 비슷한 이름의 부가 Modifier 필드인지 구분이 안 됨(사용자 지적). 접두어 `UI`를 붙이면 실제 대응하는 Roblox Instance 클래스 이름과 1:1로 읽혀서 이 모호함 자체가 사라짐 — `Frame { UICorner = 8 }`, `mod:UICorner(8)`. +**[2026-08-27 추가, 9라운드 `H-138`]** 접두어의 두 번째 근거 — Roblox 프로퍼티 +이름엔 `UI` 접두어가 없어서 숏핸드 키가 실제 프로퍼티와 **우연히 겹치는 일을 +구조적으로 막는다**(아래 "메커니즘" 절의 우선순위 문단 — 충돌 방지는 접두어, +선택은 우선순위로 역할이 갈린다). ## 메커니즘 — 새 아키텍처 개념 불필요 @@ -67,6 +71,19 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 (`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). +**⭐ [2026-08-27 확정, 9라운드 `H-138`] 매치 우선순위 — 숏핸드 핸들러가 +`PropertyHandler`보다 높다.** `Frame { UICorner = 8 }`에서 +`getHandler(inst, "UICorner", 8)`이 `UICornerHandler`를 고르는 근거는 +`PropertyHandler`가 리플렉션으로 그 키를 거부해서가 **아니라** `priority`다 +(구체 상수는 구현 시 — `PropertyHandler`보다 높은 밴드면 된다). 사용자 논거: +*"당연히 숏핸드 우선순위가 높음. 안 그러면 프로퍼티 핸들러가 숏핸드 계층을 +인지하고 준비한다는 말이 돼"* — 거부에 기대면 하위 계층(프로퍼티)이 상위 +계층(숏핸드)의 키 집합을 알아야 하는 역방향 의존이 생긴다. 이름 충돌 방지도 +우선순위가 아니라 **`UI` 접두어**가 맡는다: *"프로퍼티에 UI를 붙인 이유도 +우연히 겹치는걸 막기 위함임. UI 프리픽스는 로블록스 프로퍼티에 발견되진 +않거든"*(위 "결론" 절의 접두어 확정에 이 근거가 하나 더 붙는다 — 그 절은 +Modifier 메소드와의 모호함만 들었다). + **[보강, 2026-08-09 열한 번째 세션] `mod:UICorner(8)`류 체이닝이 실제로 타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 `UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 @@ -165,12 +182,24 @@ function UICornerHandler.process(inst, k, v, index) end local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용 Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1) - return function(hint) - if hint == nil then destroyManagedChild(inst, k) end - end + return function() end -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고 end ``` +- **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 `function() end`다 — + 여기 한때 `function(hint) if hint == nil then destroyManagedChild(inst, k) end + end`가 적혀 있었는데, 그건 위 "`v`가 `nil`인 경우" 절의 `v == nil`(값) 규칙을 + `hint == nil`(retractor 인자)로 잘못 옮긴 **복사 오류**였다.** 이 절의 주제는 + 자식 프로퍼티 세팅을 `Dispatch.process`로 되돌려주는 것뿐이고 관리 자식의 + 생사는 그 절이 정한다. **사용자 확정**: *"v == nil 로 신규 들어오면 파괴가 + 맞고 retract 는 nop 맞아. 파괴 자체가 사실 inst 바꿔서 넣은 process 를 처리할 + 필요 없게 만들어버리고, 우린 파괴에 대해서 retract 안하던게 맞아서, Tween 과 + 무관한 것도 맞지."* — 파괴 경로는 `process(inst, k, nil)` **하나**이고, + 자식이 파괴되면 그 위에 위임됐던 `(child, "CornerRadius")` 체인·트윈 문맥은 + Instance와 함께 사라지므로 retractor가 되돌릴 것이 없다. 옛 줄대로 짜면 (A) + 분기에서 `R(nil)` + `process(nil)`이 같은 자식을 두 번 파괴하고, (B) 분기 + (`State>`의 안쪽이 값↔State로 바뀔 때)에서 자식이 파괴·재생성됐다. + - **`process` 도중에 대상 `inst`를 바꾸는 것은 UB가 아님(사용자 확정)** — 키가 바뀔 수 있는 것과 정확히 같음. `chains`가 `(inst,k)` 쌍으로 인덱싱되므로 `(inst, "UICorner")` → `(child, "CornerRadius")` 위임은 diff --git a/.claude/project-context.md b/.claude/project-context.md index cae049b..20f1a2e 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -27,7 +27,7 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선 (소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`), `question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋 `9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`에 -반영됐고 Q4~Q10 대기** — 소스는 `-round9-followup.md`. 게이트는 여전히 0. 저장소 루트에 +반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 **새 메커니즘 넷(`H-143`~`H-146`)이 `question.md` 최우선 절에 판단 대기**(M2 `Effect` 둘·M3·M5 하나씩). 게이트는 여전히 0. 저장소 루트에 `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-round9-followup.md b/.claude/qa-request/pre-implementation-handtrace-round9-followup.md index 8d8a89f..4ac9bcb 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round9-followup.md +++ b/.claude/qa-request/pre-implementation-handtrace-round9-followup.md @@ -8,21 +8,56 @@ **진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** 순서를 따른다. 8라운드와 같다. -**⚠️ [2026-08-27 기준] 진행 중이다.** 아래 표가 어디까지 왔는지의 소스다. -처음엔 "문항을 다 처리한 뒤 일괄 반영"으로 잡았으나, Q1~Q3가 같은 `slot-plan.md` -구간에 몰려 있고 서로 얽혀(Q2의 생성자 이동이 Q3의 `_elemIndex` 삭제와 같은 -줄) 사용자 지시로 **Q3까지 먼저 반영**했다. Q4 이후는 결정 뒤 반영한다. +**✅ [2026-08-27] Q1~Q10·`H-138`·`H-139`·`H-142`까지 전량 처리·반영됐다.** 아래 +표가 항목별 상태의 소스다. (경위: 처음엔 "문항을 다 처리한 뒤 일괄 반영"으로 +잡았으나, Q1~Q3가 같은 `slot-plan.md` 구간에 몰려 있고 서로 얽혀(Q2의 생성자 +이동이 Q3의 `_elemIndex` 삭제와 같은 줄) 사용자 지시로 Q3까지 먼저 반영하고 +체크포인트 커밋했고, Q4 이후는 같은 날 이어진 세션이 결정 뒤 반영했다.) | 문항 | 발견 | 상태 | |---|---|---| | Q1 | `H-124` `recompute` 루프 순서 | ✅ **확정** — (a), `continue` 형태 | | Q2 | `H-125` 재마운트 캐시 | ✅ **확정** — 생성자 이동 + (c) 순서 + `_destroyed` | | Q3 | `H-126` splice 빈자리 (+ `H-137` 소멸, `H-141` 신설) | ✅ **확정** — `element → index`는 `bk`가 소유, 토큰 폐기 | -| Q4~Q10 | `H-127`~`H-133` | ⏳ 대기 | -| — | `H-134`~`H-140` | ⏳ 대기(레인 B·부수 발견) | +| Q4 | `H-127` `EffectHandle:Unsubscribe` 순서 | ✅ **확정·반영** — (a) 의사코드, Observer 게이트 먼저 | +| Q5 | `H-128` M2×M8 `Ref` | ✅ **확정·반영** — (a) M2 공통 기반에 `Ref` 최소형 | +| Q6 | `H-133` `WeakUnsubscribe` 비대칭 | ✅ **확정·반영** — (a) 의도된 관대함 | +| Q7 | `H-130` 폐기 블록 편집 | ✅ **확정·반영** — (b) `archive/`로 이전 | +| Q8 | `H-134` `InstanceChildHandler` 부기 | ✅ **확정·반영** — (a) | +| Q9 | `H-135` 숏핸드 retractor | ✅ **확정·반영** — 문항 전제 정정, Tween 절 스케치 한 줄이 복사 오류(🟡→🟢) | +| Q10 | `H-136` reconcile 배치 Blocker | ✅ **확정·반영** — (a) | +| — | `H-129`/`H-131` | ✅ 정정 반영(판단 불필요) | +| — | `H-138` 숏핸드 우선순위 | ✅ **확정·반영** — 숏핸드가 높다, 충돌 방지는 `UI` 접두어 | +| — | `H-139` `New`/`D` 파이프라인 | ✅ **의사코드 신설** — 쓰면서 `H-142` 발견 | +| — | `H-132`/`H-137`/`H-140` | ✅ Q1~Q3 처리 때 닫힘 | +| — | `H-142` 해시 파트 `Parent` 순서 | ✅ **확정·반영** — props에 `Parent` 금지(순서 문제 소멸) | +| — | `H-143`~`H-146` (`/code-review high` 발견 중 새 메커니즘 넷) | ⏳ **판단 대기** — `fn` 안 `Unsubscribe` 고아 cleanup / 재`Subscribe` 재설치 / `indexOfElement` 잔존 / 루트 마운트 경로 | **[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과 -열어둔 확인은 아래 "반영 기록" 절. +열어둔 확인은 아래 "반영 기록" 절. **같은 날 이어서 Q4~Q8·Q10·`H-138`·`H-139`를 +확정·반영했다**(아래 Q4 이하 절). **[같은 날 마지막] Q9 확인과 `H-142` 판단도 +닫혀 9라운드 발견은 전량 처리됐다** — 그 뒤 감사 8라운드와 `/code-review high`를 +돌렸고, **리뷰가 낸 새 메커니즘 넷(`H-143`~`H-146`)이 판단 대기**(아래 +"`/code-review high`" 절). + +**사용자 회신 원문(Q4~Q10 일괄, 2026-08-27)**: *"Q5 까지는 권고에 전부 동의함. +WeakSubscribed 자체가 사라질 수 있는 요소라서, 그 사라지는걸 유저가 정하게 하는 +요소라서 에러를 내야할지 말아야할지 애매한 부분이긴 한듯. 다만 weak 에 대한 +홀드를 유저가 유지하는게 강제라면 b가 되긴 해야하나, 그럴 이유가 없어서 a가 +되는게 맞는듯. 7번은 프로젝트 컨벤션대로, 권고 b적용. 8번도 권고대로. 9는 뭔가 +이상한데? nil은 파괴가 맞고 파괴 자체가 트윈을 지움. 메니지드를 지우는 스케치가 +왜 트윈에 있는지도 모르겠는 부분..? 트윈은 inst 를 바꾸어 재프로세싱 하는거라 +연관이 없는 부분인데. 다시 생각하고 말해볼래? Q10 은 이제 그래도 되어보임. +이전에는 native* 가 없어 모두 dom 식 elem 입출력이 강제가 아니였는데, 이젠 +그렇기 때문에 최초 방식으로 recompute 되는것 처럼 처리되어도 되는 지점. +부작용으론, offset 이 먼저 설정되어진 다음 후행 요소들이 밀리거나 당겨진 다음 +후행으로 넘어가느냐가 차이가 나는데, 이미 우린 set upto 와 valid upto 가 나뉜 +지점이고, 싱크라서 괜찮아보임. 권고대로 진입해도 된다 보는데 어떻게 생각하는지? +H138은 당연히 숏핸드 우선순위가 높음. 안 그러면 프로퍼티 핸들러가 숏핸드 계층을 +인지하고 준비한다는 말이 돼. 그리고 프로퍼티에 UI를 붙인 이유도 우연히 겹치는걸 +막기 위함임. UI 프리픽스는 로블록스 프로퍼티에 발견되진 않거든. H139 은 실 구현 +전에 의사코드를 써보자. 그걸로 인해서 감추어졌던 설계 결함이나 폭탄이 발견된 +경우가 많아서, 커지기 전에 확인해볼 필요가 있음. 중요 계층이라서"* --- @@ -336,6 +371,165 @@ if slot._physicalTarget == nil then return end --- +## Q4 — `EffectHandle:Unsubscribe()`는 Observer 게이트를 **먼저** 통과한 뒤 cleanup (`H-127`, 확정 (a)) + +- `effect-plan.md`의 "`EffectHandle:Subscribe()`" 절에 의사코드 한 블록 신설 — + `Subscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 **Observer의 함수를 메소드 + 테이블에 그대로 배정**(같은 레지스트리·같은 `canBound` 게이트·같은 + `.Subscribed`), `Unsubscribe`만 `Observer.Unsubscribe(self)` 통과 뒤 + `self:_consumeCleanup()`. `WeakUnsubscribe`는 cleanup을 안 건드린다(약한 + 구독은 GC에 맡기는 경로라 해제가 종료 신호가 아니다). +- 산문의 번호 목록(플래그 → cleanup → fail-fast)은 **의미 목록이지 실행 순서가 + 아니라고** 그 자리에 적었다. `lifecycle-pattern.md`의 *"아래가 네 진입점 + 전량이고 소스다"*는 *"Observer의 네 진입점 … `EffectHandle`은 같은 넷을 그대로 + 재사용"*으로. + +## Q5 — M2 공통 기반에 `Ref` 최소형 체크박스 (`H-128`, 확정 (a)) + +- `ROADMAP.md` M2 "공통 기반" 절에 체크박스 신설 — `.Value`/`.Revision`/`:Set`/ + `:WeakCallback`/`:Callback`/`:Uncallback`/`isRef` + `EpochBrand:register`. + M8 `Ref.luau` 체크박스는 *"최소형은 M2로 앞당겨졌다 — 여기 남는 건 `:Wait`, + `PreRef`/`PostRef`, 디스패치 핸들러"*로. 절 머리의 *"셋 다 State-free"*는 + 개수 없이 *"여기 있는 것 전부"*로. + +## Q6 — `WeakUnsubscribe`의 관대함은 의도된 것 (`H-133`, 확정 (a)) + +- `lifecycle-pattern.md` (2) 의사코드 주석 + 산문 bullet 신설. **사용자 논거** + (위 원문): 약한 등록의 생존은 사용자가 쥔 참조에 달린 것이고 quad가 그 홀드를 + 강제하지 않으므로 "없는 항목의 약한 해제"를 오류로 볼 근거가 없다 — 홀드 + 유지가 강제였다면 (b)여야 했다. 강한 등록은 quad가 살려두는 것이라 "없음"이 + 곧 호출부 실수라 엄격. leaf 바인딩된 값에 `WeakUnsubscribe`도 같은 이유로 + 통과(무해). + +## Q7 — 폐기 블록을 `archive/`로 (`H-130`, 확정 (b)) + +- `effect-plan.md`의 ⛔⛔ 배너 아래 블록(`_observers` 배열 + cascade)을 + `archive/effect-internal-observer-cascade-reversed.md`로 옮기고 포인터 한 + 문단만 남겼다. 옮긴 원문은 **`9dd8213`이 배너 아래를 편집한 흔적 그대로**이고 + 파일 머리에 그 경위를 적었다. 새 규칙은 안 만들었다 — `conventions.md` + 핸드오버 체크리스트 3번이 이미 이 실패 모드를 규정한다(사용자: *"프로젝트 + 컨벤션대로"*). + +## Q8 — `InstanceChildHandler`도 부기를 등록한다 (`H-134`, 확정 (a)) + +- `dispatch-core-plan.md` 말단 표에 행 신설 + `H-39` 블록에 "다섯째" 문단: + `process` 맨 앞에서 `setOffsetSource(inst, k, None)` → + **`setLength(inst, k, 1, inst, v)`** → `v.Parent = inst`; 반환 클로저는 + `None` → `0` 순서로 해제. `ROADMAP.md` M5 체크박스에 같은 두 줄. 5번째 + 인자(요소)는 Q3의 `setLength` 시그니처를 따른 것 — 상수 길이라 지속 클로저는 + 안 생기지만 등록 모양을 다른 말단과 맞췄다. + +## Q9 — 재고: 두 "확정"이 아니라 Tween 절 스케치의 **복사 오류** (`H-135`, 확정) + +사용자가 문항의 전제를 정정했다(*"nil은 파괴가 맞고 파괴 자체가 트윈을 지움. +메니지드를 지우는 스케치가 왜 트윈에 있는지도 모르겠는 부분..? 트윈은 inst 를 +바꾸어 재프로세싱 하는거라 연관이 없는 부분인데"*). 다시 본 결과: + +- `v == nil` → `process`가 자식을 파괴하는 규칙은 두 절이 **같다**. 이상한 건 + Tween 절 스케치의 마지막 줄 `return function(hint) if hint == nil then + destroyManagedChild(inst,k) end end` 하나 — 그 절의 주제는 *자식 프로퍼티 + 세팅을 `Dispatch.process(child, "CornerRadius", …)`로 되돌려준다*뿐이라 + 관리 자식의 생사를 정할 자리가 아닌데, `v == nil`(값)과 `hint == nil` + (retractor 인자)을 겹쳐 적은 **복사 오류**로 보인다. +- 제가 (B) 분기에서 *"트윈이 끊긴다"*를 피해로 든 것은 틀렸다 — 자식이 + 파괴되면 그 위의 트윈이 사라지는 건 당연한 결과지 결함이 아니다. 그 줄을 + 지워야 하는 이유는 **파괴 경로가 하나여야 한다**(`process(inst,k,nil)`)는 것 + 하나이고, 이중 파괴는 그 중복의 증상이다. +- 트레이스: `(inst,"UICorner")` 체인은 어떤 값이든 `UICornerHandler.process`에 + `number|Tween|nil`로 닿고(`StoreBind`가 State를 풀고 `NoneHandler`가 `nil`로), + 키 자체가 사라지는 건 인스턴스 teardown뿐이라 그때는 자식이 부모와 같이 + 죽는다 — retractor가 할 일이 없다. +- **✅ [2026-08-27 확정]** 사용자: *"9 맞음. v == nil 로 신규 들어오면 파괴가 맞고 + retract 는 nop 맞아. 파괴 자체가 사실 inst 바꿔서 넣은 process 를 처리할 필요 + 없게 만들어버리고, 우린 파괴에 대해서 retract 안하던게 맞아서, Tween 과 무관한 + 것도 맞지."* — `ui-shorthand-plan.md` Tween 절 스케치의 그 줄을 + `return function() end`로 고치고 정정 bullet을 달았다. `H-135`는 🟡→🟢. + +## Q10 — `reconcile` 재실행도 배치 Blocker로 (`H-136`, 확정 (a)) + +- `slot-plan.md`의 `reconcile` 머리(중복 키 선행 패스 뒤)에 `getBlocker(self)` + 획득, 꼬리에 `OffWithoutEmit()` + `recomputeBlocker` 확인 후 `recompute` 1회. + **Blocker가 네스팅이 안 되므로**(불리언, `blocker-plan.md`) 최초 population + 처럼 바깥 배치 안에서 오면 `IsOn()`으로 알아보고 손대지 않는다(`ownsGate` — + 이 판정은 사용자가 승인한 (a)의 문구 밖에 있는 **구현 세부**다. 같은 + `reconcile` 클로저가 바깥 배치 안(최초 population)과 밖(재실행) 양쪽에서 + 불리는데 Blocker에 네스팅이 없어서 필요한 것이고, 함수 지역 변수라 표면엔 + 안 드러난다). +- **사용자 논거**(위 원문): `native*` 계층이 생기기 전엔 DOM식 요소 입출력이 + 강제가 아니라 raw op마다 `recompute`가 돌아야 했지만, 이제는 물리 위치를 + `native*`가 부기와 무관하게 정확히 넣으므로 최초 population처럼 한 번에 + 따라잡아도 된다. 부작용으로 "offset이 먼저 잡힌 뒤 후행 요소가 밀리느냐, + 밀린 뒤 넘어가느냐"가 갈리지만 `offsetSetUpTo`/`offsetCacheValidUpTo`가 + 분리돼 있고 동기라 괜찮다. `dispatch-core-plan.md`의 *"한 사이클 … 한 번만"* + 문단에 같은 근거를 적었다. +- 제 판단: 동의 — `raw*`의 명시 호출이 이미 `getBlocker(self):IsOn()`을 보므로 + (`H-119`) 추가 배선이 없고, `getOffsetAt`/`physIndex`는 Blocker와 무관하게 + 정확하다(최초 population과 같은 논증). `updateFn`이 던지면 Blocker가 켜진 채 + 남는 것도 `materializeSlotTree`와 같은 부류다. + +## `H-138` — 숏핸드 핸들러가 `PropertyHandler`보다 우선순위가 높다 (확정) + +- `ui-shorthand-plan.md` "메커니즘" 절에 문단 신설, "결론" 절의 접두어 확정에 + 두 번째 근거 추가. **사용자 논거**(위 원문): 리플렉션 거부에 기대면 하위 + 계층(프로퍼티)이 상위 계층(숏핸드)의 키 집합을 알아야 하는 역방향 의존이 + 생긴다; 충돌 방지는 우선순위가 아니라 `UI` 접두어의 몫(Roblox 프로퍼티 + 이름엔 `UI` 접두어가 없다). 구체 상수는 구현 시. + +## `H-139` — `New(name)(props)` 파이프라인 의사코드 (신설) + 거기서 나온 것 + +- `bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절에 + `New` ①~④(물리 생성 → gcconn/gchold → `flatten` → `Dispatch.drive`)와 + `drive` (a)~(c)(pre-pass → 단일 일반화 `for` 본체 + 배치 Blocker → + `postRefList`) 의사코드 신설. 단계 안의 규칙은 각 소스 문서가 정본이고 여기는 + **순서**만 확정. `ROADMAP.md` M3 `Dispatch.drive` 항목에서 가리킨다. +- 이름 충돌(모듈 팩토리 `New()` vs 생성자 `New "Frame"`)은 산문 표기 규칙 + 한 줄로 닫았다(패키지가 달라 런타임 충돌은 없다). +- **쓰면서 드러난 것 셋** — 사용자가 예상한 그대로: + 1. ~~**배치를 닫는 자리가 어디에도 없었다.**~~ **⚠️ 이 주장은 틀렸다**(감사 + 1라운드) — `dispatch-core-plan.md`의 `H-17` 절이 이미 *"`drive` 전체 + (post-pass 포함)를 감싼다, `PostRef` 콜백은 게이트가 켜진 채 실행된다"*로 + 정해뒀는데 제가 그 절을 안 보고 "해시 파트 앞에서 닫는" 의사코드를 썼고, + 사용자 확인 1번은 **그 틀린 전제 위에서** 받은 것이다. 의사코드를 `H-17`대로 + (진입 직후 On, `postRefList` 뒤 Off + `recompute`) 고쳤다 — 확인 1번은 + 무효, 계약은 원래 것 그대로. 실제로 낡아 있던 건 같은 문서 "해법의 핵심" + 4번의 옛 문구(*"배열 파트 순회 전체"*) 하나라 같이 정정. + 2. **자식 없는 Instance에도 Blocker/`bk`가 생기는 경로.** `getBlocker(inst)`를 + 무조건 부르면 `Frame { Size = … }`마다 Blocker + `bk` + `Relate` 항목이 + eager 생성된다 — Q2/Q3가 Slot 쪽에서 막은 것과 같은 부류. + `flattened[1] ~= nil` 가드로 막았다. 확인 요청. + 3. **`H-142` 🟡 신설 — 해시 파트 안의 `Parent` 순서가 미정.** `Frame { Parent + = x, Size = … }`에서 `Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 + (Luau 해시 순회 순서는 계약이 아니다). 처방 후보(순서 미루기/문서화/무시)가 + 전부 새 메커니즘이라 정하지 않고 올렸다. + **✅ [2026-08-27 확정 — 순서가 아니라 키 금지]** 사용자: *"Parent대입 자체가 + 오면 안 돼. 그건 부모에서 할 일이거든. 자신이 바로 하는 경우는 없어. 그걸 + 허용해준다는것 자체가, '외부에서 직접 Parent 설정해주지 말것' 을 해치는 + 요인이 되기도 해.(암묵적으고 가능하도록 둬버려서)"* — `slot-plan.md`의 + "동적 자식은 반드시 `Slot` 또는 `state`류 store-bind를 통해서만" 원칙의 + 정적 리터럴 판. 반영: `bind-system-plan.md` 파이프라인 절에 규칙 + `ROADMAP.md` M5 + (`D` 생성기가 props 타입에서 `Parent` 제외 / `PropertyHandler.isHandlable`이 + `"Parent"` 거부 → 기존 "매치 핸들러 없음 → 즉시 error"에 걸림). **런타임 + 배선은 제 선택**(새 메커니즘 없이 기존 계약 재사용)이라 문서에 그렇게 + 갈라 적었다. +- **1·2 확인 완료**(사용자: *"1과 2는 확인했어. 2는 특히 생성 이후 다시 process + 를 주는걸 우린 안 하기로 해서 충분히 가능한 일"*). 2에 대해 사용자가 남긴 + 의심 — *"Frame{} 으로 나온 bk없는게 Slot 안에 멀쩡히 들어가는데 문제 + 없을까"* — 를 코퍼스의 호출부 전수로 확인했다: **문제 없다.** + - `getBookkeeping`/`getOffsetAt`/`setLength`/`setOffsetSource`의 첫 인자 + (`ownerKey`)는 코퍼스 전체에서 **Slot 자신(`self`/`slot`) 또는 `drive`를 + 도는 최상위 `inst`뿐**이다. Slot에 들어간 `Frame{}`은 언제나 **요소** + (`setLength`의 5번째)나 **앵커/물리 target**(4번째, `bindLifetime`· + `nativeInsert`의 첫 인자)으로만 나온다 — 앵커는 gchold(②에서 무조건 + 생성)를 쓰지 `bk`를 안 본다. + - 그 Frame이 owner가 되는 경우는 자기 props에 배열 파트가 있을 때뿐이고, + 그땐 `flattened[1] ~= nil`이라 가드가 안 걸린다. `outerSlot:Add(innerSlot)` + 처럼 나중에 중첩 Slot이 그 Frame을 물리 target으로 써도 owner는 + `outerSlot`이다. + - "`bk.base`"는 이미 걷어낸 필드다(`dispatch-core-plan.md` 3번, 사용자 + 지적으로 — 최상위 `inst`의 베이스는 항상 0이라 저장할 게 없다). 설령 + 예상 못 한 경로가 `getBookkeeping(frame)`을 불러도 **lazy 생성**이라 + 크래시가 아니라 그때 만들어질 뿐이다. + ## 반영 기록 — Q1~Q3 (2026-08-27) **바뀐 파일**: `base/dispatch-core-plan.md`(`recompute` 루프 재배치 / `setLength` @@ -379,7 +573,10 @@ elem->index 를 위치를 재설정 해준다면 괜찮아"* — `rawReplace`의 맞아. 그래야 인덱싱 매핑을 만드니까"*. (4) *"slot._baseObserver 는 unbind 만 했고, nil 로 지우는것만 안 한다면 맞아"*. -**아직 안 한 것**: `/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README +**아직 안 한 것**(Q1~Q3 체크포인트 시점 문장 — **[2026-08-27 후속] Q4~Q10도 +전량 반영됐고**, 그 반영분의 감사 루프는 아래 "감사 루프 (2026-08-27, Q4~Q10 +반영분)" 절; 남은 건 라운드 전체에 대한 `/code-review high`와 커밋뿐): +`/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README 색인과 `todos.md` 00번 갱신은 감사 1라운드 지적으로 그 자리에서 했다. 감사 루프는 Q1~Q3 반영분에 대해 돌았고 6라운드에서 수렴했다(아래 "감사 루프" 절). @@ -396,3 +593,54 @@ nil 로 지우는것만 안 한다면 맞아"*. | 4 | 앞 라운드 수정분 자체 + followup/발견 문서 내부 정합 | 확실 1 | `CLAUDE.md`·`project-context.md`(둘 다 `@import`)가 결정 소스로 8라운드까지만 나열 — 9라운드 한 줄 추가. 그 외 앞 수정분·두 문서 내부 정합은 줄 단위 대조로 이상 없음 | | 5 | 4라운드 수정분 + 델타 밖 `base/` 문서 | 확실 1 · 판단 1 | `README.md`의 `dispatch-core`/`slot-plan` 색인 행에 Q1/Q2 요약 추가 / `architecture.md`의 `Slot.luau` 한 줄에 생성자 필드·`_destroyed` 표기(판단 항목 — 8라운드 행들이 같은 밀도라 넣음) | | 6 | 5라운드 수정분 + 신설 규칙 문단 | **확실 0** · 의심 1 | *"`Owned=false`는 `_destroyed`가 안 선다"*가 README 요약과 followup에만 있고 `slot-plan.md` 산문엔 없던 것 — 사용자 확정 인용과 함께 명문화. **수렴**(확실 0) | + +## `/code-review high` (2026-08-27, Q4~Q10 반영분 + 감사 8라운드 뒤) + +10건(검증 12 CONFIRMED · 5 PLAUSIBLE · 1 REFUTED). **여섯은 기존 규칙 적용이라 +그 자리에서 반영**, **넷은 처방이 새 메커니즘·표면이라 문항으로**(`-round9.md`의 +`H-143`~`H-146`, `question.md` 최우선 절). + +반영한 여섯: +1. **`InstanceChildHandler` retractor가 옛 자식을 안 내렸다** — `Parent = nil` + 추가(파괴 아님 — `slot-plan.md`의 "`State` 교체는 파괴가 아니라 + 언마운트" 절 근거 1이 바로 `state`의 이 동작). `state:Set(B)`에서 A가 + 물리 자식으로 남은 채 `lengthList[k] == 1`이 되던 것. +2. **같은 핸들러의 순서** — `setLength` → `Parent`였던 것을 `Parent` → + `setLength`로("일반 계약 — 물리와 부기의 순서" 3번과 같게). 단건 경로에서 + `recompute`가 부착 전에 돌았다. +3. **같은 핸들러의 5번째 인자 `v`** — 제가 "모양을 맞추려" 넣은 것인데, 해제 + `setLength(…, 0)`이 옛 키를 안 지워 교체마다 `bk.indexOfElement`에 옛 자식이 + 강참조로 쌓였다. Q3 계약대로 상수 길이는 **생략**. (Slot 쪽 같은 구멍은 + `H-145`로.) +4. **`H-17`에 빈 배열 파트 가드가 없었다** — 사용자 확인 2번이 스케치에만 적혀 + 정본(`dispatch-core-plan.md`)은 여전히 "무조건 On"이었다. 정본과 "해법의 + 핵심" 1번에 반영. +5. **`quad-types`의 `Quad`에 `Ref` 필드** — `H-128`로 최소형이 M2에 왔는데 그 + 필드 체크박스는 M8에 남아 `H-25` 공백을 다시 열었다. M2 `H-80` 목록에 `Ref` + 추가, M8 항목은 흡수 표기. +6. **Modifier 메소드 목록의 `Parent`** — props 타입에서만 빼면 + `Modifier():Parent(x)`가 타입을 통과한다. `bind-system-plan.md` `H-142` 타입 + 항목 + ROADMAP M7 체크박스. +7. **`effect-plan.md` 번호 목록 재정렬** — "실행 순서가 아니다" 괄호만 달고 옛 + 순서를 남겨둔 것이 `H-130`과 같은 모양이라 게이트 → 플래그 → cleanup으로 + 다시 씀. + +기각 1(REFUTED): "`drive` 의사코드가 `dispatch-core-plan.md`를 중복한다" — 사용자 +요청으로 신설한 블록. 캡에 밀린 하위 발견(`Slot:List` 잉여 블록 잔존 등)은 이미 +"잉여" 주석으로 처리된 것과 같은 항목. + +## 감사 루프 (2026-08-27, Q4~Q10 반영분) + +관례대로 `quad-doc-auditor` 한 턴에 하나, diff 범위, 라운드마다 각도 변경. + +| 라운드 | 각도 | 새 발견 | 처분 | +|---|---|---|---| +| 1 | `base/` 정합성 | 확실 3 · 판단 1 · 의심 1 | ⭐ `drive` 의사코드가 `H-17`(*"`drive` 전체를 감싼다, `PostRef` 콜백은 게이트 안"*)을 어김 — 제 *"닫는 자리가 없다"* 주장이 틀렸고 사용자 확인 1번은 무효(`H-139` 절) / `question.md` 최우선 절 "Q4~Q10 대기" 잔재 / README `effect-plan` 행 `:Callback` 잔재 / ROADMAP `Property.luau`에 "거부 배선은 에이전트 선택" caveat / Blocker 시작점 "진입 직후"로 | +| 2 | 인덱스 레이어 + 새 문단 자기모순 | 확실 1 · 의심 2 | ROADMAP M8 `Ref.luau` 체크박스가 자기 `H-128` 노트와 모순 → "나머지(`:Wait`)"로 재작성 / Q9 헤더 "확인 대기" 잔재 / `Slot:List` wrap이 `reconcile` 게이트와 중복 → "잉여" 주석 | +| 3 | 주변 폴더 인용처 + 인용문 역방향 + 수정분 | 확실 2 · 의심 2 | ROADMAP 머리 배너 "Q4~Q10 대기" 잔재 / 이 파일 "아직 안 한 것" 문장 / (의심) `ownsGate`는 승인 문구 밖의 구현 세부 — 지역 변수라 그대로, `H-136` 절에 그렇게 표기 / (의심) M8 체크박스의 *"서술도 같이 갔다"*가 과장 → 문구 정정. 인용문 역방향은 전부 일치 | +| 4 | 1~3라운드 수정분 + 발견/결정 문서 내부 정합 | 확실 3 · 의심 2 | 발견 문서 요약 표에 `H-142` 행 없음 / `H-135` 심각도 표 vs 헤더 / §4 아래 Q9 "확인 대기" 잔재 / (의심) `Slot:List` "잉여" 주석 범위를 블록 전체로 / (의심) M8 체크박스의 표면 목록 반복 → M2 참조로 | +| 5 | 4라운드 수정분 + 델타 밖 `base/` | 확실 1 · 의심 2 | `source-state-plan.md`의 *둘 다 error · 계약은 하나* 문장에 `H-133` 캐비엇 / `blocker-plan.md` 재진입 절에 "`IsOn()` 소유권 판정은 네스팅이 아니다" 한 줄 / `architecture.md` `Ref.luau` 한 줄에 `Epoch` 표면·M2 최소형 | +| 6 | 5라운드 수정분 + 신설 규칙 vs 기존 규정 | 확실 1 · 의심 0 | 이 파일 머리 배너 *"진행 중이다"* 잔재(5라운드 동안 빠졌던 것) → "전량 처리"로. 신설 규칙 여섯 자리(`Parent` 금지 / gcconn 시점 / `New` 표기 / `WeakUnsubscribe` 비소진 / 숏핸드 우선순위 / 다섯째 말단) 전부 기존 규정과 충돌 없음 | +| 7 | 6라운드 수정분 + 세션 기록 사실성 | 확실 3 · 의심 0 | README `bind-system-plan` 행이 "배치 닫는 자리 … `Parent` 순서 미정"으로 1라운드 정정·`H-142` 확정 이전 상태 / `todos.md` 00번에서 "`/code-review high`·커밋 남음" 액션이 사라짐 → 복원 / 이 파일의 "48줄" 개수 주장(실제 47) → 개수 삭제. 세션 파일·summary·todos 본문 서술은 전부 사실과 일치 | +| 8 | 7라운드 수정분만(좁게) | 확실 1 | `project-context.md`의 볼드 마커가 홀수(이번 갱신이 닫는 `**`를 지움) → 짝 맞춤. 지정 세 자리는 회귀 없음. **여기서 멈춤** — 1라운드 이후 설계 내용 발견은 0이고 [2026-08-27 8라운드 시점] 남은 건 기록 문서의 표기뿐이라, 비용 대비 `/code-review high`로 넘어가는 게 낫다고 판단(추이 5→3→4→5→3→1→3→1, 단조 수렴은 아님) | + diff --git a/.claude/qa-request/pre-implementation-handtrace-round9.md b/.claude/qa-request/pre-implementation-handtrace-round9.md index bbddf99..b33d971 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round9.md +++ b/.claude/qa-request/pre-implementation-handtrace-round9.md @@ -49,13 +49,18 @@ high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견 | `H-132` | 🟢 | `slot-plan.md` 2900행의 *"…해야 하는 일이 **셋** 늘었다"* 헤딩 아래 bullet이 넷(`H-102`가 넷째를 더함) | `slot-plan.md` 2900 | 사냥 #3 | — | | `H-133` | 🟢 | `WeakUnsubscribe`는 **구독한 적 없는 값**에 조용히 통과한다(가드가 "강한 킵 있음"만 본다) — *"해제는 건 경로로 푼다 … 양방향 fail-fast"* 산문과 비대칭. 의도라면 명문화 | `lifecycle-pattern.md` (2) | 계약 정합 (E) | ✅ 실측 | | `H-134` | 🟡 | `InstanceChildHandler`(`Frame { Frame {} }`의 정적 자식)의 배열 자리 부기(`setOffsetSource`/`setLength(…, 1)`)가 **어디에도 명세돼 있지 않다** — 말단 표에 행이 없고 의사코드도 없어 `H-39`의 "전수"가 못 잡았다. `Frame { Frame{}, Slot() }`이 첫 마운트에 `H-106` 가드로 즉사 | `dispatch-core-plan.md` 말단 표 × `architecture.md` × `ROADMAP.md` M5 | 레인 B (M5) | 값 트레이스 | -| `H-135` | 🟡 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘** — *"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 | +| `H-135` | 🟡→🟢 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘** — *"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 | | `H-136` | 🟡 | `:List` **재실행** reconcile은 배치 Blocker 없이 돌아 raw op마다 `recompute` + `Length:Set`이 난다 — *"한 사이클 전체가 끝난 뒤 한 번만"*(`dispatch-core-plan.md` 2344) 서술과 모순, 최초 population만 감싼 O(n²) 논거가 재실행엔 빠져 있다 | `slot-plan.md` `activateList`/`reconcile` × `dispatch-core-plan.md` | 레인 B (M6) | 값 트레이스 | | `H-137` | 🟢 | `rawMove`/`rawSwap` `H-29` 규약 1의 치환 목록에 `bk.tokens`/`bk.indexOfToken`이 없다(`H-102`가 splice에만 요구) — 규약 4(이동 구간 **전부** `setLength` 재등록)에 암묵 의존, 토큰은 자리 귀속이라 치환하면 안 된다는 것도 미명시 | `slot-plan.md` `H-29` 규약 | 레인 B | — | | `H-138` | 🟢 | `UICorner`류 숏핸드 핸들러와 `PropertyHandler`의 매치 구분(리플렉션 거부? 우선순위?)이 명세에 없다 — 이름이 우연히 프로퍼티와 겹치면 조용히 `PropertyHandler`가 먹는다 | `ui-shorthand-plan.md` × `bind-system-plan.md` | 레인 B | — | | `H-141` | 🟡 | **확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을 승인한 것** — `H-102`의 *"dispatch 로 격상"* 인용 옆에 사용자가 정한 적 없는 `bk.tokens`/`indexOfToken`(`token = {}`, 2026-08-25 `/code-review`가 발명)이 확정 메커니즘처럼 앉아 있었고, 같은 문단의 *"splice 요구 목록에 항목이 늘지 않는다"*는 구현이 스스로 깼다(둘 늘렸다). 같은 뜻의 맵(`slot._elemIndex`)이 Slot 층에 따로 살아 두 층 이원화 | `dispatch-core-plan.md` `H-102` 문단 × `slot-plan.md` | 사냥 #1(인용문 판) — Q3 처리 중 발견 | — | | `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"* — `SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — | | `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — | +| `H-142` | 🟡→닫힘 | **[2026-08-27 `H-139` 의사코드에서 발견]** `Dispatch.drive`의 해시 파트 순서가 계약이 아니라 `Frame { Parent = x, Size = … }`의 `Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 — 사용자 확정은 순서가 아니라 **props에 `Parent` 금지** | `bind-system-plan.md` × `ROADMAP.md` M5 | 사냥 밖 — 의사코드 작성이 드러냄 | — | +| `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun`이 `fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘 | — | +| `H-144` | 🟡 | **[2026-08-27 `/code-review high`]** `EffectHandle.Subscribe = Observer.Subscribe` 배정이라 `Unsubscribe`로 cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 **재설치(`Rerun`)가 없다** — leaf 재바인드 경로(`_bindDestroying`의 `not _installed → Rerun`, `H-65`)와 비대칭 | `effect-plan.md` | 리뷰 발견, 처방은 새 메커니즘 | — | +| `H-145` | 🟡 | **[2026-08-27 `/code-review high`]** 최상위 `SlotHandler` retractor가 `setLength(inst, k, 0)`으로 해제할 때 `bk(inst).indexOfElement[slot]`이 **안 지워진다**(해제 호출엔 요소가 없고 `setLength`는 `element ~= nil`일 때만 쓴다) — 교체마다 옛 Slot이 부모 `bk`에 강참조로 쌓여 부모가 죽을 때까지 산다(`InstanceChildHandler` 쪽은 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 State라 요소가 필요하다) | `dispatch-core-plan.md` `setLength` × `slot-plan.md` 494 | 리뷰 발견, 처방은 새 메커니즘 | — | +| `H-146` | 🟡 | **[2026-08-27 `/code-review high`]** `H-142`(props에 `Parent` 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 **루트**(`ScreenGui` → `PlayerGui`)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 v1 `Mount(ScreenGui, …)`, `slot-plan.md` 233행의 *"v2는 mount 함수 자체가"*의 그 함수는 어느 표면에도 없다. 부수로 거부 배선의 에러가 일반 매치 실패 메시지(*"provider가 초기화됐는지 확인"*)라 오해를 부른다 | `bind-system-plan.md` `H-142` | 리뷰 발견, 처방은 새 표면 | — | --- @@ -433,7 +438,7 @@ Length/Offset 절 머리(1365행)가 *"정적 단일 자식은 상수 `1`"*이 등록)은 이미 기각된 안(*"모든 핸들러가 `k=number`일 때 처리하도록 두는"*, 1387행)이라 비권고. 갈래는 사실상 없다 — 확인만. -### `H-135` 🟡 — 숏핸드 retractor의 두 모양 +### `H-135` 🟢 — 숏핸드 retractor의 두 모양 (**[2026-08-27] 🟡→🟢 강등 — 문항의 전제가 틀렸다**, 아래 처방 갈래가 아니라 Tween 절 스케치의 복사 오류 한 줄; `-round9-followup.md` Q9) **무엇이 문제인가.** `ui-shorthand-plan.md`의 *"`v`가 `nil`인 경우"* 절(136행)은 *"반환 클로저가 할 일이 없어 `function() end`이면 충분"*이라 확정하는데, 같은 @@ -584,8 +589,92 @@ slotPos, S.Offset)`이 `S.Offset:Set(newAbs)`를 내는데, 그 시점 `S._baseO 맵을 `bk.indexOfElement` 하나로 통일하고 `token`을 폐기**하는 형태로 닫혔다 — 그 결과 **`H-137`은 소멸**했다(토큰이 없어졌으므로). +**⚠️ [2026-08-27 같은 날 후속] Q4~Q8·Q10과 `H-138`·`H-139`도 확정·반영됐다** — +Q4/Q5/Q8/Q10은 권고 (a), Q6은 (a)(의도된 관대함), Q7은 (b)(`archive/`로). +**Q9는 사용자가 문항의 전제를 정정**했다 — 두 "확정"이 경쟁하는 게 아니라 +Tween 절 스케치의 `hint == nil` 줄이 `v == nil` 규칙을 잘못 옮긴 복사 오류이고, +(B) 분기의 "트윈이 끊긴다"는 피해가 아니다(자식 파괴가 트윈을 지우는 건 당연). +**[같은 날 확정]** 사용자: *"9 맞음 … retract 는 nop 맞아"* — `H-135`는 🟢로. +`H-139` 의사코드를 쓰면서 **`H-142`**가 나왔다(아래). 결정의 소스는 +`-round9-followup.md`. + +### `H-142` 🟡 — 해시 파트 안의 `Parent` 순서가 미정 (`H-139` 의사코드에서 발견) + +`Dispatch.drive`의 본체가 단일 일반화 `for`(`F-4-1`)라 해시 파트 순서는 Luau의 +내부 순회 순서다 — 계약이 아니다. `Frame { Parent = x, Size = …, Position = … }` +에서 `Parent` 대입이 다른 프로퍼티보다 **먼저** 올 수 있고, 그러면 부모에 붙은 +뒤에 프로퍼티가 바뀐다. 프레임 경계가 안 끼므로 사용자에게 보이는 중간 상태는 +없지만 엔진 쪽 레이아웃 재계산이 프로퍼티 수만큼 붙는다. 코퍼스 어디에도 +`Parent`의 처리 순서 서술이 없다(`PostRef` 절은 *"자기 `.Parent`는 아직일 수 +있다"*라고 부모 쪽 시점만 말한다). 처방 후보는 전부 새 메커니즘이라 정하지 +않았다 — (a) `drive`가 `Parent` 키를 루프 뒤로 미룸, (b) 문서화만(사용자가 +`Parent`를 별도로 대입), (c) 무시. **사용자 판단.** + +**[2026-08-27 확정 — 셋 다 아님, 키 금지]** 사용자: *"Parent대입 자체가 오면 안 +돼. 그건 부모에서 할 일이거든."* props에 `Parent`는 올 수 없고(타입 제외 + +`PropertyHandler` 거부 → 기존 no-handler error), 순서 문제는 소멸. 소스는 +`-round9-followup.md`의 `H-142` 항목. + --- +### `H-143`~`H-146` — 2026-08-27 `/code-review high`가 Q4~Q10 반영분에서 잡은 것 (사용자 판단) + +반영 뒤 돌린 `/code-review high`(10건, 검증 12 CONFIRMED)가 낸 것 중 **처방이 새 +메커니즘·표면인 넷**. 나머지 여섯(`InstanceChildHandler` retractor의 `Parent = nil` +누락·순서·5번째 인자 / `H-17`에 빈 배열 파트 가드 / `Quad.Ref` 필드 마일스톤 / +Modifier 메소드 목록의 `Parent` 제외 / `EffectHandle` 산문 순서)은 기존 규칙 +적용이라 그 자리에서 반영했다(`-round9-followup.md`의 code-review 절). + +**`H-143` 🟡 — `fn` 안 `self:Unsubscribe()` 뒤 반환 cleanup이 고아.** +`effect-plan.md`가 *"`fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 핸들 +표면에 바로 닿는다"*고 허용하는데, `Rerun`은 `_consumeCleanup()` → `fn` → 반환값을 +`_cleanup`에 저장 + `_installed = true`다. `fn` 안에서 `Unsubscribe`가 통과하면 +(레지스트리 제거, `_consumeCleanup` no-op — 이미 nil) 그 뒤 `fn`이 돌려준 새 +cleanup이 저장되는데, 핸들은 이제 어느 레지스트리에도 없고 leaf도 아니라 재 +`Unsubscribe`는 error, `WeakUnsubscribe`는 소진 안 함, dep도 못 깨운다 → 영원히 +안 불린다(예: 타이머 정지 cleanup). **갈래**: (a) `Rerun`이 `fn` 반환 뒤 +`canExecute(self)`가 거짓이면 반환 cleanup을 **즉시 소진**(저장 안 함) / (b) `fn` +안 `Unsubscribe`를 금지하고 그 문장을 지움 / (c) 허용하되 "그 실행의 cleanup은 +사용자 책임"으로 문서화. **권고 (a)** — 계약(*"끝나는 시점에 마지막 cleanup 정확히 +1회"*)을 코드가 지키는 유일한 갈래. 다만 `Rerun` 꼬리에 분기 하나가 는다. + +**`H-144` 🟡 — `Unsubscribe` 후 재`Subscribe`에 재설치가 없다.** +`Observer.Subscribe`를 그대로 배정해서 `canBound` 게이트를 통과하면 +`.Subscribed = true`만 서고 `fn`은 안 돈다. deps 없는 Effect는 아무도 `Rerun`을 +못 불러 레지스트리가 살려두는 죽은 핸들(누수), deps 있으면 다음 emit까지 죽어 +있다가 조용히 재설치. leaf 재바인드는 `_bindDestroying`이 `not _installed`면 +`Rerun`한다(`H-65`)라 비대칭. **갈래**: (a) `EffectHandle:Subscribe` 래퍼 — +`Observer.Subscribe(self)` 뒤 `if not self._installed then self:Rerun() end` +(leaf 경로와 대칭) / (b) 소진된 핸들의 재구독을 error / (c) 허용하되 재설치 없음을 +문서화. **권고 (a)** — `H-65`가 leaf 쪽에 이미 세운 규칙과 같은 모양. + +**`H-145` 🟡 — 최상위 Slot 교체 시 `bk(inst).indexOfElement[oldSlot]` 잔존.** +`setLength(inst, k, 0)` 해제엔 요소가 없어 옛 키가 안 지워지고(`setLength`는 +`element ~= nil`일 때만 쓴다), `indexOfElement`는 강한 키 맵이라 `Relate(inst)` +부기가 옛 Slot을 부모가 죽을 때까지 붙든다 — Q3가 세운 맵의 부작용이고 +`rawReplace`(Slot 층)는 옛 키를 지우지만 inst 층 핸들러는 `bk`에 못 닿는다. +`InstanceChildHandler`는 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 +State라 요소가 필요하다. **갈래**: (a) `indexOfElement`를 **weak-key**로(마운트 +중엔 `_elements`/`Relate`가 강하게 잡으므로 사라질 일 없고, 해제 뒤엔 저절로 +빠진다) / (b) 해제 호출 `setLength(inst, k, 0, inst, oldSlot)`에도 요소를 넘기면 +`setLength`가 `len == 0`일 때 그 키를 지운다(시그니처 의미 확장) / (c) Slot 층처럼 +inst 층에도 명시 삭제 API. **권고 (a)** — 코드 한 단어이고 "다른 곳에서 안전하게 +유지되는 것은 weak로"(`lifecycle-pattern.md` (0)) 규칙 그대로. + +**`H-146` 🟡 — 루트를 붙이는 승인된 경로가 없다.** +`H-142`대로 `.Parent` 대입은 자식을 받는 쪽(`InstanceChildHandler`/Slot `native*`)만 +하는데, 부모가 quad 밖인 루트(`ScreenGui` → `PlayerGui`)는 그 어느 쪽도 아니다. +사용자가 `D.ScreenGui { … }`를 만든 뒤 props `Parent`는 error, `Mount`류 API는 +없음, 밖에서 `gui.Parent = PlayerGui`는 인용문(*"외부에서 직접 Parent 설정해주지 +말것"*)상 금지처럼 읽힌다. 부수로 거부 배선의 에러가 *"no handler … check +quad-roblox provider"*라 provider 설정을 의심하게 만든다. **갈래**: (a) **루트는 +예외** — quad가 만든 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 +`.Parent =`로 한다고 명문화(금지되는 건 *quad가 관리하는 자식 자리*에 끼우는 것) / +(b) `Mount(root, parent)`류 표면 신설 / (c) 루트 전용 props 키. 에러 메시지는 어느 +갈래든 `PropertyHandler`가 `"Parent"`를 거부할 때 전용 문구를 내는 게 낫다(그건 +배선 세부라 갈래와 무관). **권고 (a)** — 새 표면 없이 인용문의 범위만 정확히 +적는 것. + ## §5 이상 없다고 확인한 것 (다음 라운드가 다시 파지 않도록) **A각도 — 겹쳐 읽어 정합했던 조합**: diff --git a/.claude/question.md b/.claude/question.md index 25f58fc..f700016 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,13 +12,24 @@ --- -## ⭐ 최우선 — **비어 있음** (2026-08-27 갱신) +## ⭐ 최우선 — `/code-review`가 낸 새 메커니즘 넷 (2026-08-27 갱신) -**[2026-08-27] 9라운드 손 트레이싱의 결정 문항 Q4~Q10이 회신 대기입니다 — -M2 착수 게이트는 아닙니다**(🔴 둘은 Q1/Q2로 이미 닫혔고 남은 건 🟡/🟢). 문항과 -권고는 `qa-request/pre-implementation-handtrace-round9.md` §4 표, 진행 상태는 -`-round9-followup.md`의 진행 표가 소스(여기서 문항을 반복하지 않는다). 다음 -세션이 위에서부터 처리한다. +**[2026-08-27] 9라운드 손 트레이싱은 Q1~Q10·`H-138`·`H-139`·`H-142`까지 전량 +처리·반영됐고**, 그 반영분에 `/code-review high`를 돌린 결과 **처방이 새 +메커니즘·표면인 넷**이 나와 판단을 기다립니다 — 갈래·권고는 +`qa-request/pre-implementation-handtrace-round9.md`의 `H-143`~`H-146` 절이 +소스(여기서 반복하지 않음). 한 줄씩: +- **`H-143`** `fn` 안에서 `self:Unsubscribe()`하면 그 실행이 돌려준 cleanup이 + 영원히 안 불린다 — 권고 (a) `Rerun`이 `canExecute` 거짓이면 즉시 소진. **M2.** +- **`H-144`** `Unsubscribe`한 핸들을 다시 `Subscribe`해도 재설치가 없다 — 권고 + (a) `Subscribe` 래퍼가 `not _installed`면 `Rerun`(leaf 경로 `H-65`와 대칭). **M2.** +- **`H-145`** 최상위 Slot 교체 시 부모 `bk.indexOfElement`에 옛 Slot이 강참조로 + 남는다 — 권고 (a) 그 맵을 weak-key로. **M3.** +- **`H-146`** props `Parent` 금지 뒤 루트(`ScreenGui` → `PlayerGui`)를 붙이는 + 승인 경로가 없다 — 권고 (a) 루트는 예외로 명문화(금지는 quad가 관리하는 자식 + 자리에 한함). **M5.** +`H-143`/`H-144`는 M2 `Effect` 표면이라 **M2 착수 전 답이 있는 게 좋지만** 구현 +중 정해도 되는 크기입니다(게이트로 보지 않음). **[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어) 착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 780fae5..03c85dd 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1943,3 +1943,21 @@ ref 는 그 자체로 epoch임"*), `H-118`은 소유권 문제가 아니라 `gat high`는 Q4~Q10 반영 뒤로. 결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는 `session/2026-08-27-01-handtrace-round9-q1-q3.md`. **Q4~Q10은 다음 세션.** + +## 2026-08-27-02 — 9라운드 Q4~Q10 결정·반영 + `H-138`/`H-139`/`H-142` (전량 처리) + +Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubscribe`만 게이트 +통과 뒤 cleanup) / Q5(M2 공통 기반에 `Ref` 최소형) / Q6(`WeakUnsubscribe` 관대 — +약한 홀드를 강제하지 않으므로) / Q7(폐기 블록 `archive/effect-internal-observer-cascade-reversed.md`) +/ Q8(`InstanceChildHandler` 부기) / Q10(`reconcile` 재실행도 배치 Blocker, +네스팅 불가라 `ownsGate`). **Q9는 문항의 전제가 틀렸다** — Tween 절 스케치의 +`hint == nil` 줄이 `v == nil` 규칙의 복사 오류, 파괴 경로는 `process(nil)` 하나. +`H-138` 숏핸드 우선순위 > `PropertyHandler`(충돌 방지는 `UI` 접두어). **`H-139` +파이프라인 의사코드**(`New` ①~④ + `drive` (a)~(c), `bind-system-plan.md`)를 +쓰면서 배치 닫는 자리·빈 배열 파트 가드·`Parent` 순서가 드러났고, 마지막은 +**`H-142` — props에 `Parent` 금지**(*"그건 부모에서 할 일"*)로 순서 문제 자체가 +소멸. 감사 8라운드 뒤 `/code-review high` 10건 — 여섯 반영(그중 셋이 이 세션의 +`H-134` 반영분이 만든 것), **넷은 새 메커니즘이라 문항으로**(`H-143`~`H-146`, +`question.md` 최우선 절). 결정의 소스는 +`qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는 +`session/2026-08-27-02-handtrace-round9-q4-q10.md`. diff --git a/.claude/session/2026-08-27-02-handtrace-round9-q4-q10.md b/.claude/session/2026-08-27-02-handtrace-round9-q4-q10.md new file mode 100644 index 0000000..5bf6980 --- /dev/null +++ b/.claude/session/2026-08-27-02-handtrace-round9-q4-q10.md @@ -0,0 +1,73 @@ +# 2026-08-27 — 9라운드 Q4~Q10 결정·반영 + `H-138`/`H-139`/`H-142` + +**무엇을 했나**: 앞 세션(`session/2026-08-27-01-handtrace-round9-q1-q3.md`)이 +Q1~Q3까지 반영하고 남긴 Q4~Q10을 사용자와 두 턴으로 처리해 `base/`·`ROADMAP.md`에 +반영했다. 결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md` +(사용자 회신 원문 두 개도 거기 인용). 여기는 흐름과, 문서에 안 들어간 시행착오. + +## 흐름 + +1. 판단 불필요한 `H-129`(ROADMAP `:Callback` 잔재)·`H-131`(*"`for d in seen`은 + 죽는다"*는 거짓)을 먼저 닫고 Q4~Q10 + 🟢 둘(`H-138`/`H-139`)을 갈래·권고와 함께 + 한 번에 올렸다. +2. 사용자 1차 회신 — Q4/Q5/Q8/Q10 권고대로, Q6 (a)(약한 홀드를 강제하지 않으므로), + Q7 (b)(컨벤션대로 `archive/`), **Q9는 "뭔가 이상한데?"** — 문항의 전제를 + 정정. `H-138`은 숏핸드 우선순위가 높다(역방향 의존 논거 + `UI` 접두어의 역할), + `H-139`는 *"실 구현 전에 의사코드를 써보자 … 감추어졌던 설계 결함이나 폭탄이 + 발견된 경우가 많아서"*. +3. Q9 재고: 두 "확정"이 경쟁하는 게 아니라 Tween 절 스케치의 `hint == nil` 줄이 + `v == nil` 규칙의 **복사 오류**였고, 내가 (B) 분기의 "트윈이 끊긴다"를 피해로 든 + 것이 틀렸다(자식 파괴가 트윈을 지우는 건 당연). 파괴 경로는 `process(nil)` + 하나. 사용자 확인(*"9 맞음 … 우린 파괴에 대해서 retract 안하던게 맞아서"*). +4. `H-139` 파이프라인 의사코드(`New` ①~④ + `drive` (a)~(c))를 쓰면서 셋이 + 드러났다 — 배치 닫는 자리 미정(루프 뒤로), 자식 없는 Instance의 eager + Blocker/`bk`(`flattened[1] ~= nil` 가드), 해시 파트 `Parent` 순서(`H-142`). + 사용자 2차 회신: 1·2 확인, **`H-142`는 순서가 아니라 키 금지**(*"Parent대입 + 자체가 오면 안 돼. 그건 부모에서 할 일이거든 … '외부에서 직접 Parent 설정해주지 + 말것' 을 해치는 요인"*). 2에 대한 의심(*"bk없는게 Slot 안에 멀쩡히 들어가는데 + 문제 없을까"*)은 호출부 전수로 확인 — owner 키는 코퍼스 전체에서 Slot 자신 또는 + 최상위 `inst`뿐이고 요소 Frame은 앵커(gchold)/요소로만 나온다, `bk.base`는 + 이미 걷어낸 필드, `getBookkeeping`은 lazy. + +## 시행착오 (문서엔 결론만) + +- **Q9의 문항 자체가 잘못 세워져 있었다.** `H-135`는 "같은 문서의 두 확정이 + 갈린다"로 썼는데 실제론 한쪽이 그 절의 주제 밖 스케치 한 줄이었다. 그 줄을 + "확정"으로 격상해 갈래를 만든 건 나였다 — 스케치 안의 코드 한 줄을 산문의 + 확정과 같은 무게로 읽지 말 것. +- **`H-142`에서 제시한 세 갈래가 전부 틀린 축이었다.** 순서 (a)/(b)/(c)를 물었는데 + 답은 "그 키가 존재하면 안 된다"였다 — 증상(순서)에서 처방을 찾지 말고 그 키가 + 거기 있어야 하는지부터 물었어야 했다. 반영한 런타임 배선(`PropertyHandler`가 + `"Parent"` 거부 → 기존 no-handler error)은 내 선택이라 문서에 갈라 적었다. +- **Blocker 네스팅.** Q10을 반영하며 `reconcile`에 `blocker:On()`을 넣으려다 + `blocker-plan.md`의 "네스팅 미지원(불리언)"에 걸렸다 — 최초 population은 바깥 + 배치 안에서 `reconcile`에 오므로 무조건 `On()`이면 안쪽이 먼저 꺼서 바깥 등록이 + 게이팅을 잃는다. `ownsGate = not blocker:IsOn()`으로 갈랐다. +- **`H-17`을 못 보고 "배치 닫는 자리가 미정"이라고 써서 사용자 확인까지 받았다.** + 감사 1라운드가 잡음 — `dispatch-core-plan.md`가 `drive` 전체(post-pass 포함)를 + 감싸고 `PostRef` 콜백이 게이트 안에서 돈다고 이미 계약해뒀다. 의사코드를 + 그대로 고쳤고 사용자 확인 1번은 무효로 표기. "어디에도 없다"는 주장은 grep + 한 번이면 반증됐을 것 — 없다고 쓰기 전에 그 단어(`post-pass`)로 찾을 것. +- 절 인용이 Lua 주석 두 줄에 걸쳐 `--` 마커가 인용문 안으로 들어가 `doc-check` + ERROR — 한 줄로 합쳤다(`conventions.md`의 blockquote 주의와 같은 부류, 코드 + 주석도 마찬가지). + +## 감사 루프와 code-review + +- `quad-doc-auditor` 8라운드(5→3→4→5→3→1→3→1, 각도: `base/` 정합성 → 인덱스 + 레이어 → 주변 폴더·인용문 역방향 → 수정분·발견/결정 문서 내부 → 델타 밖 + `base/` → 신설 규칙 vs 기존 규정 → 세션 기록 사실성 → 7라운드 수정분). 단조 + 수렴은 아니었지만 1라운드(`H-17`) 이후 설계 내용 발견은 0이고 나머지는 기록 + 문서 표기라 8라운드에서 멈추고 code-review로 넘어갔다(처분 표는 followup 끝). +- `/code-review high` 10건 — 여섯 반영(`InstanceChildHandler` retractor의 + `Parent = nil`·순서·5번째 인자 / `H-17` 빈 배열 가드 / `Quad.Ref` 필드 M2 / + Modifier 메소드 목록 `Parent` 제외 / `effect-plan` 목록 재정렬), **넷은 새 + 메커니즘이라 문항으로**(`H-143`~`H-146` — `fn` 안 `Unsubscribe` 고아 cleanup / + 재`Subscribe` 재설치 / `indexOfElement` 잔존 / 루트 마운트 경로). 이번에도 + **반영분 자체가 새 결함을 만든 것**(`H-134`의 순서와 5번째 인자 둘 다 내가 넣은 + 것)이 반복됐다 — `conventions.md`의 2026-08-26 실측 그대로. + +## 남은 것 + +커밋, 그리고 `question.md` 최우선 절의 `H-143`~`H-146` 결정. M2 착수 게이트는 +여전히 0. diff --git a/.claude/todos.md b/.claude/todos.md index 5edba59..b9dd2fd 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -34,14 +34,21 @@ 검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동. - **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가 사본마다 분리된다는 사실(`research/documentation-content-map.md` §4). - - **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q3까지 확정·반영했다 — Q4~Q10 대기.** + - **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q10·`H-138`·`H-139`·`H-142` 전량 확정·반영했다.** 발견은 `qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`, 🔴 둘 다 실측 재현), **결정의 소스는 `-round9-followup.md`**(진행 표가 상태의 소스). 반영된 셋: `recompute` 되감기 판정을 `lengthList[i]` 읽기 앞으로 / `Offset`·`_baseObserver`를 Slot 생성자로 + `materializeSlotTree` 순서 + `_destroyed` / `element → index`를 `bk.indexOfElement` 하나로(사용자가 - 정한 적 없는 `token` 폐기). README 색인은 했고 `/code-review high`·커밋은 - Q4~Q10 뒤. 아래는 돌리기 전(2026-08-26) 서술: + 정한 적 없는 `token` 폐기). 같은 날 후속으로 Q4~Q8·Q10(`EffectHandle` 네 + 진입점 의사코드 / M2에 `Ref` 최소형 / `WeakUnsubscribe` 관대 / 폐기 블록 + `archive/` / `InstanceChildHandler` 부기 / `reconcile` 배치 Blocker)과 + `H-138`(숏핸드 우선순위)·`H-139`(`New`/`drive` 파이프라인 의사코드 — 거기서 + `H-142` `Parent` 순서 발견 → props에 `Parent` **금지**로 확정)까지 반영. + Q9는 문항 전제가 틀린 것(Tween 절 스케치 한 줄 복사 오류)으로 닫힘. + **[2026-08-27 기준] 감사 8라운드·`/code-review high`까지 돌렸다 — 리뷰 10건 중 + 여섯은 반영, 넷(`H-143`~`H-146`, 새 메커니즘)은 `question.md` 최우선 절에 + 판단 대기. 남은 액션: 커밋, 그리고 그 넷의 결정.** 아래는 돌리기 전(2026-08-26) 서술: 지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는 **커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤 `/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도 diff --git a/CLAUDE.md b/CLAUDE.md index 09204c0..11a77cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,9 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의 결정의 소스는 `.claude/qa-request/pre-implementation-handtrace-round8-followup.md` (7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은 -`-round9-followup.md` — Q1~Q3 반영 완료, Q4~Q10 대기**)이고 `.claude/question.md` 최우선 -절은 비어 있다. 같은 상태를 `.claude/project-context.md`도 +`-round9-followup.md` — Q1~Q10·`H-138`·`H-139`·`H-142` 전량 반영 완료**)이고 `.claude/question.md` 최우선 +절엔 **그 반영분에 `/code-review`가 낸 새 메커니즘 넷(`H-143`~`H-146`)이 판단 +대기**로 올라 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/ROADMAP.md b/ROADMAP.md index 03c295b..c5dc743 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,9 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 > 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다. > **[2026-08-27] 9라운드 몫은 `-round9-followup.md`** — Q1~Q3(`recompute` 되감기 > 순서 / Slot 생성자의 `Offset`·`_baseObserver`·`_destroyed` / `bk.indexOfElement`로 -> 토큰 폐기)까지 반영됐고 Q4~Q10은 대기 중입니다. +> 토큰 폐기)에 이어 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량** +> 반영됐습니다(`EffectHandle` 진입점 / M2에 `Ref` 최소형 / `InstanceChildHandler` +> 부기 / `reconcile` 배치 Blocker / `New`·`drive` 파이프라인 / props `Parent` 금지). > **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에 > 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 > 있습니다). M1까지의 산출물은 @@ -249,8 +251,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ### 공통 기반 — 반응형보다 먼저 (구 M2, 지금의 M3에서 이동) -> 셋 다 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. 이 절이 -> 끝나야 아래 반응형 본체를 짤 수 있습니다. +> 여기 있는 것 전부 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. +> 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다. - [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`가 브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`, @@ -325,6 +327,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 이 게이트를 그대로 탐 +- [ ] **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래 + `Effect(fn, ...deps)`의 `Ref` dep 분기(`isRef(d)` → + `d:WeakCallback(onRefFire)` → `self._epochs:Sync(d)`)가 **M2 안에서 + 실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/ + `:WeakCallback(fn)`/`:Callback(fn)`/`:Uncallback(fn)`/`isRef` + + `EpochBrand:register(self)`(`Epoch`를 만족하는 데 필요한 것 전부). + `PreRef`/`PostRef`/`:Wait`/디스패치 핸들러(`(v=Ref)` 매치, `Processed*`)는 + **M8 그대로**. 근거: `Ref`가 `Epoch`인 것 자체가 M2의 결정 + (`H-58`/`H-64`/`H-70`)이고, `Effect`의 `isEpoch` 분기를 M2의 mock + 테스트가 한 번은 실제로 태워야 한다 — 그 전엔 M8 머리가 *"M2가 이미 이 + 표면을 전제한다"*고 **인정만** 하고 M2 쪽엔 앞으로 참조가 없어, 구현자가 + `isRef` 스텁으로 비워두기와 M8 절반 앞당기기 중 임의로 고르게 돼 있었다. + 소스는 `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절. + **[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이 + 마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types`의 `Quad`에 `Ref` + 생성자 필드도 여기서** 추가한다 — M8의 `H-25` 체크박스는 이걸로 흡수. ### 반응형 본체 @@ -465,7 +483,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** - (State/Source는 `Observer`, `Ref`는 `:Callback`)을 걸어 + (State/Source는 `Observer`, `Ref`는 `:WeakCallback` — **[2026-08-27 + 9라운드 `H-129`]** 옛 `:Callback` 표기는 `H-58`이 정정한 것의 잔재로, + 강한 셋에 걸면 `Ref`가 `Effect`를 영원히 붙든다)을 걸어 재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`이 `EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간 억제 플래그가 그 `Update`보다 먼저 와야 함 @@ -572,7 +592,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** - [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** `quad-types`의 `Quad`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 — - `Source` / `Store` / `Effect` / `Blocker` / `Relate` / + `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Ref`**(최소형, + 2026-08-27 `H-128`) / `is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/ `isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`· `canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** — @@ -658,6 +679,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`에 `Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). + **[2026-08-27 9라운드 `H-139`]** `New` ①~④ + `drive` (a)~(c) 전체 + 파이프라인 의사코드는 `base/bind-system-plan.md`의 "`New(name)(props)` + 파이프라인 의사코드" 절 — 배치 Blocker를 여닫는 자리와 빈 배열 파트 + 가드도 거기. **[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라 단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히 base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau @@ -907,8 +932,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) — 진입점 `QuadRoblox(Quad): QuadRoblox`가 `QuadTypes.CheckedQuad`으로 주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고) -- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) -- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` +- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **[2026-08-27 9라운드 `H-142`] 생성되는 props 타입에서 `Parent`를 제외할 것** — props에 `Parent`는 올 수 없다(부모가 하는 일, 같은 문서의 파이프라인 절). `New` ①~④ 순서는 그 절의 의사코드가 소스 +- [ ] `Handlers/Property.luau`(**[2026-08-27 9라운드 `H-142`]** `isHandlable`이 `"Parent"` 키를 **거부**한다 — 매치 핸들러가 없어지면 `Dispatch.process`의 "매치 핸들러 없음 → 즉시 error"에 걸리는 것으로 런타임 가드가 공짜로 생긴다, 새 메커니즘 없음. **사용자 확정은 "props에 `Parent` 금지"라는 규칙이고, 이 거부 배선은 에이전트 선택** — `base/bind-system-plan.md`의 `H-142` 항목이 그렇게 갈라 적음), `Handlers/InstanceChild.luau` — + **⭐ [2026-08-27 9라운드 `H-134`] `InstanceChildHandler`도 말단이라 + 부기를 등록한다**: `process`에서 `setOffsetSource(inst, k, None)` → + `v.Parent = inst` → `setLength(inst, k, 1, inst)`(정적 단일 자식은 상수 + `1`, 5번째 인자 없음). 반환 클로저는 `v.Parent = nil`(내리기만, 파괴 + 아님) → `setOffsetSource(inst, k, None)` → `setLength(inst, k, 0)`. + **[2026-08-27 `/code-review` 정정]** 옛 순서(`setLength` → `Parent`)와 + "5번째 인자 `v`"는 틀렸었다 — 근거는 `base/dispatch-core-plan.md`의 그 + 문단. 빠뜨리면 + `Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 — + `base/dispatch-core-plan.md`의 `H-39` 블록(그 다섯째 항목)이 소스. - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 @@ -1262,6 +1297,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) ## M7 — Modifier +- [ ] **[2026-08-27 9라운드 `H-142` 후속, `/code-review`]** 생성기가 찍는 + `FrameModifier`류 메소드 목록에서도 **`Parent`를 제외**할 것 — props + 타입에서만 빼면 `Modifier():Parent(x)`가 타입을 통과하고 `flatten`이 + `Parent`를 해시 파트로 merge해 런타임에서야 죽는다. `PreRef`/`PostRef`가 + Modifier 타입으로 차단되는 것과 같은 자리(`base/bind-system-plan.md`의 + `H-142` 항목). + - [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 명시 — `Source(default)`/`Ref(default)`/`Store({defaults})`와 같은 `Type(args)` 팩토리 관습, `modifier-plan.md` 3번) @@ -1319,18 +1361,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M8 — Ref -- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Ref` 필드 추가 - (위 M3 항목의 "마일스톤마다" 규칙) -- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ - `:Wait(thread?)`, 전부 self 반환). - **⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-64`/`H-70`] `Ref`가 `Epoch`를 - 만족하게 됐다** — 공개 필드 **`.Revision`**(`:Set()`이 `Source`와 같은 - `bit32.bnot(-rev)`로 갱신) + **`EpochBrand:register(self)`**, 그리고 - 약하게 등록하는 **`:WeakCallback(fn)`**(weak-키 별도 테이블, `Weak` 쪽이 - 프리미티브이고 `:Callback`이 그 위에 "GC 킵"을 얹은 것). - **M2가 이미 이 표면을 전제한다** — `Effect` 생성자가 `Ref` dep을 - `self._epochs:Sync(d)`로 `EpochMap`에 태운다(`base/effect-plan.md`). - `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스 + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref +- [x] ~~**[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Ref` 필드 추가~~ + — **[2026-08-27 `H-128` 후속]** `Ref` 최소형과 함께 M2 공통 기반으로 + 이동(그 체크박스). `PreRef`/`PostRef` 필드는 아래 항목이 얹는다 +- [ ] `Ref.luau`의 **나머지** — `:Wait(thread?)`(self 반환). **[2026-08-27 + 9라운드 `H-128`] 최소형은 M2 "공통 기반" 절로 앞당겨졌다**(표면 목록은 + 그 체크박스가 소스 — 여기 반복하지 않는다) — `Ref`가 `Epoch`를 만족한다는 2026-08-25 확정 + (7라운드 `H-58`/`H-64`/`H-70`: `.Revision`은 `:Set()`이 `Source`와 같은 + `bit32.bnot(-rev)`로 갱신, `Weak` 쪽이 프리미티브이고 `:Callback`이 그 + 위에 "GC 킵"을 얹은 것, `Effect` 생성자가 `self._epochs:Sync(d)`로 태움)은 + 그 표면의 일부라 M2 몫이다 — 세부는 체크박스에 재서술하지 않고 + `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스. + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절)