From ae34cfa316fd81288ffd3560293bfa776dec19a4 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 28 Aug 2026 13:00:49 +0900 Subject: [PATCH] =?UTF-8?q?qa:=2010=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20=EB=B0=98=EC=98=81=20(H-147~H-158)=20=E2=80=94=20fn?= =?UTF-8?q?/cleanup=20=EC=9E=90=EA=B8=B0=20=EA=B5=AC=EB=8F=85=20=EA=B8=88?= =?UTF-8?q?=EC=A7=80,=20Refresh=20=EC=BA=90=EC=B9=98=EC=97=85=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0,=20=EB=A3=A8=ED=8A=B8=EB=8A=94=20Claim=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H-147 (A): fn/cleanup은 자기 구독을 못 바꾼다 — rawRerun(force)/Rerun 분리, 진입 canExecute 게이트, 네 진입점+_bindDestroying에 _running/_cleanupRunning 가드, H-143(원샷) 소멸, 자기 leaf 파괴 UB - H-148: 루트는 밖에서 .Parent=가 아니라 quad가 Claim으로 소유 → research/existing-mount-plan.md 신설, H-146 예외·전용 문구 폐기, archive 부활 배너 - H-149 Observer 진입점 인라인 / H-150 Effect._blocker 제거 / H-151 _epochs는 emit 때만(게이트는 emit 경로만 미룬다 계약) / H-152 GateNode StateBrand:register / H-153 Store 예약 이름 런타임 가드 + 그림자=store 자신 / H-154 InstanceChildHandler dedup / H-155~H-157 stale - 감사 3→5→2→3→1→0, /code-review high 10건 중 7 반영, 셋(H-159~H-161)은 -round10.md §4 문항으로 Co-authored-by: qwreey Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb --- .claude/README.md | 7 +- .../existing-instance-bind-rejected.md | 7 + .claude/base/bind-system-plan.md | 20 +- .claude/base/debounce-throttle-plan.md | 17 +- .claude/base/dispatch-core-plan.md | 11 +- .claude/base/effect-plan.md | 308 +++++++++++------- .claude/base/gate-plan.md | 32 +- .claude/base/lifecycle-pattern.md | 49 ++- .claude/base/ref-plan.md | 14 +- .claude/base/slot-plan.md | 14 +- .claude/base/source-state-plan.md | 5 +- .claude/base/store-plan.md | 37 ++- .claude/project-context.md | 2 +- ...plementation-handtrace-round10-followup.md | 257 +++++++++++++++ .../pre-implementation-handtrace-round10.md | 45 ++- ...mplementation-handtrace-round9-followup.md | 16 +- .../pre-implementation-handtrace-round9.md | 6 +- .claude/question.md | 25 +- .claude/research/existing-mount-plan.md | 159 +++++++++ .claude/session-summary.md | 13 +- ...6-08-28-01-handtrace-round10-resolution.md | 36 ++ .claude/todos.md | 11 +- CLAUDE.md | 8 +- ROADMAP.md | 85 +++-- 24 files changed, 943 insertions(+), 241 deletions(-) create mode 100644 .claude/qa-request/pre-implementation-handtrace-round10-followup.md create mode 100644 .claude/research/existing-mount-plan.md create mode 100644 .claude/session/2026-08-28-01-handtrace-round10-resolution.md diff --git a/.claude/README.md b/.claude/README.md index 0a6d7a3..dc70460 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+ 값 단위 트레이싱)과 우선순위 근거, `-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` 금지 — 순서 문제 소멸)** / **`H-143`~`H-146`**(`/code-review`가 낸 새 메커니즘 넷 — 전부 권고 (a), `H-144`는 사용자 요청으로 재구독 뒤 epoch·Blocker 상호작용을 재트레이싱한 기록 포함). 진행 표가 상태의 소스, 사용자 회신 원문 셋 인용; **[2026-08-28]** `H-143`~`H-146` 반영분의 감사 8라운드 표와 `/code-review high` 기록도 여기), `pre-implementation-handtrace-round10-brief.md`(**[2026-08-28 신설]** 10라운드 지시서 — 델타가 아니라 **광범위** 스코프(M2~M8, 레인 A 반응형 코어 전체 / C 참조 구현 갱신·실행 / B 디스패치·Slot / D 타입·M1 코드), 신선한 탐사자가 한 번에 문항지를 만들어 사용자가 배치로 결정하게 하는 것이 목적), `pre-implementation-handtrace-round10.md`(**[2026-08-28 신설, 회신 대기]** 10라운드 발견 보고 + §4 배치 문항지 — 씨앗은 `/code-review`가 낸 `H-147`~`H-149`(죽은 핸들에서 `Rerun` / `Parent` 거부 전용 문구는 새 메커니즘 / Observer `Subscribe` 위임과 `level 2`), 탐사자 발견은 `H-150`부터. 레인 C 실행 기록은 `audit/handtrace-round10-reference-impl/`. **[2026-08-28 탐사 완료]** 🔴 0 / 🟡 5 / 🟢 3, §4 문항 7건(기존 3 + `H-150` `Effect._blocker` 죽은 부품 / `H-151` 게이트 우회 계약 / `H-153` Store 예약 이름 런타임 가드 / `H-154` `InstanceChildHandler` dedup) — 전부 권고 (a). 레인 A·C 완료, B 부분, D `./scripts/test.sh` ALL PASS). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-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` 금지 — 순서 문제 소멸)** / **`H-143`~`H-146`**(`/code-review`가 낸 새 메커니즘 넷 — 전부 권고 (a), `H-144`는 사용자 요청으로 재구독 뒤 epoch·Blocker 상호작용을 재트레이싱한 기록 포함). 진행 표가 상태의 소스, 사용자 회신 원문 셋 인용; **[2026-08-28]** `H-143`~`H-146` 반영분의 감사 8라운드 표와 `/code-review high` 기록도 여기), `pre-implementation-handtrace-round10-brief.md`(**[2026-08-28 신설]** 10라운드 지시서 — 델타가 아니라 **광범위** 스코프(M2~M8, 레인 A 반응형 코어 전체 / C 참조 구현 갱신·실행 / B 디스패치·Slot / D 타입·M1 코드), 신선한 탐사자가 한 번에 문항지를 만들어 사용자가 배치로 결정하게 하는 것이 목적), `pre-implementation-handtrace-round10.md`(**[2026-08-28 신설, 회신 대기]** 10라운드 발견 보고 + §4 배치 문항지 — 씨앗은 `/code-review`가 낸 `H-147`~`H-149`(죽은 핸들에서 `Rerun` / `Parent` 거부 전용 문구는 새 메커니즘 / Observer `Subscribe` 위임과 `level 2`), 탐사자 발견은 `H-150`부터. 레인 C 실행 기록은 `audit/handtrace-round10-reference-impl/`. **[2026-08-28 탐사 완료]** 🔴 0 / 🟡 5 / 🟢 3, §4 문항 7건(기존 3 + `H-150` `Effect._blocker` 죽은 부품 / `H-151` 게이트 우회 계약 / `H-153` Store 예약 이름 런타임 가드 / `H-154` `InstanceChildHandler` dedup) — 전부 권고 (a). 레인 A·C 완료, B 부분, D `./scripts/test.sh` ALL PASS), `pre-implementation-handtrace-round10-followup.md`(**[2026-08-28] 10라운드 결정의 소스 — 전량 처리 완료.** 사용자가 *"하나하나 같이 보자"*라 대화형으로 처리. **문항의 전제를 뒤집은 것 둘**: `H-147`은 "죽은 핸들에서 `Rerun`"이 아니라 **`fn`/cleanup이 자기 구독을 바꿀 수 있다는 허용 자체가 모순**(→ (A) 금지, `H-143` 소멸, `rawRerun(force)`/`Rerun` 분리), `H-148`은 "문구"가 아니라 **루트 마운트 표면의 부재**(→ `Claim` + `D.Mapper`, `research/existing-mount-plan.md`). 나머지: `H-149` Observer 진입점 인라인 / `H-150` `_blocker` 제거 / `H-151` `Refresh` 캐치업 폐기 — 게이트는 emit 경로만 미룬다 / `H-153` Store 예약 이름 가드 / `H-154` dedup. 새로 생긴 `H-158`(`:Block` 슈가)은 미결). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-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` | @@ -59,7 +59,7 @@ | `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`면 `: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` 네 진입점 의사코드 신설 — **[같은 날 (b)로 정정, `H-144` 후속]** 네 진입점은 `EffectHandle` 자기 것(공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐 — Observer 함수를 배정하면 콜론 위임이 오버라이드를 타 재구독 꼬리가 두 번 돈다), `Unsubscribe`는 **게이트 통과 뒤** cleanup 소진(산문 순서대로 짜면 leaf 바인딩된 핸들의 cleanup이 error보다 먼저 소진됐다), `Subscribe`/`WeakSubscribe`는 등록 끝에 `_epochs:Refresh()` + 조건부 `Rerun`(재구독 재설치), `Rerun` 꼬리는 실행 중 죽었으면 cleanup 즉시 소진(`H-143`). 옛 `_observers` cascade 블록은 `archive/effect-internal-observer-cascade-reversed.md`로 이전. | +| `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`였으나 **[2026-08-28 `H-150`]** 그것도 제거 — Effect 핸들의 `canExecute`가 한다). `bindLifetime`/`unbindLifetime`은 **핸들 하나에만** 적용되고 내부 Observer로 cascade하지 않으며, `Ref` 콜백을 떼지도 않는다 — 발화 게이팅은 `canExecute(handle)` 하나(`H-58`/`H-59`). `Ref`가 `Epoch`로 승격돼 `_epochs`가 dep 종류를 균일하게 담고, 포탈 캐치업이 재설치 한 줄(`if not self._installed then self:Rerun() end`)이 됐다(`H-64`/`H-65`; **[2026-08-28 `H-151`]** 한때 있던 `_epochs:Refresh()` 판정은 폐기 — `_epochs`는 emit 때만 갱신 — 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` 네 진입점 의사코드 신설 — **[같은 날 (b)로 정정, `H-144` 후속]** 네 진입점은 `EffectHandle` 자기 것(공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐 — Observer 함수를 배정하면 콜론 위임이 오버라이드를 타 재구독 꼬리가 두 번 돈다), `Unsubscribe`는 **게이트 통과 뒤** cleanup 소진(산문 순서대로 짜면 leaf 바인딩된 핸들의 cleanup이 error보다 먼저 소진됐다), `Subscribe`/`WeakSubscribe`는 등록 끝에 `not _installed → Rerun`(재구독 재설치). **[2026-08-28 10라운드 `H-147`]** `fn`/cleanup은 자기 구독을 못 바꾼다(네 진입점 첫 줄 `_running` 가드, `H-143`의 원샷 지원은 하루 만에 소멸) — `Rerun`은 `rawRerun(self, force)` 본체 + 공개 `Rerun()`(진입 `canExecute` 게이트)로 분리. 옛 `_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 계약 전체에 예외가 생겨 오버엔지니어링) | @@ -99,6 +99,7 @@ | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md` ← `.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | | `fastscroll-plan.md` | **[2026-08-18 신설]** 사용자 아이디어 메모 — 완전 외부 패키지 `quad-roblox-fastscroll`(리스트/그리드 내 상대 위치 계산으로 움직일 요소만 갱신, 배경의 빈 공간만 스크롤). 가상 레이아웃 유틸이 선행 요구사항으로 보임. 설계 논의 전, 아직 아이디어 단계 | 최하 — 사용자가 "quad가 잘 작동하게 될 때" 직접 검토하겠다고 후순위 지정. 선행 확인 필요 사항(`Visible=false`일 때 `AbsoluteSize`/`AbsolutePosition` 갱신 여부)은 Roblox Studio 실측 필요 | +| `existing-mount-plan.md` | **[2026-08-28 신설, 사용자 발의]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper. "Name" {…})` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 위에서 닫는다. `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님, claim-once·own-all). 방향 확정, 갈래 미결(개수는 §5가 소스 — 루트 이름·물리 순서·debug 검사 범위 등). **M5 이후**, M2 게이트 아님 | 중 — 다음 배치 문항 | | `spring-plan.md` | **[2026-08-18 신설]** 사용자 아이디어 메모 — 스프링 물리 기반 지속 업데이트 프리미티브(`quad-spring`), 이전 상태와 비교해 스프링 연산을 수행하는 중간 핸들러. 참고 구현 [qwreey/spring.lua](https://github.com/qwreey/spring.lua) 사용 가능 여부 확인 필요. 확정 `Tween` 모델과는 별개 트랙 — `quad-base`의 `onStep`류 후킹 인터페이스로 얹을지, 엔진별 `quad-roblox-spring`으로 각자 구현할지, `Source` 확장 primitive로 둘지 미정 | 최하 — "모든게 완성된 후, 별도 모듈로 분화"라고 사용자가 직접 명시 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 @@ -127,7 +128,7 @@ | `invalidate-dedup-propagation-reversed.md` | **[역전됨, 2026-08-14 신설]** "신호를 받은 State는 이미 `invalid`였다면 그 아래로 더 전파하지 않는다"(다이아몬드 중복 워크 방지) — 실제로는 **emit이 자기 `invalid` 상태와 무관하게 항상 전파**되고, 중복 재계산은 pull-recompute+캐시가 막음. 옛 서술은 확정된 `Observer` 계약(`fn`이 `:Get()`을 안 불러도 됨)과 정면 충돌해 **`:Get()` 안 하는 Observer가 한 번 울고 영구 침묵**하게 만들었고, `architecture.md`와도 어긋나 있었음. Debounce 설계 중 사용자 지적으로 발견 — 그 위에 쌓였던 `debounce-throttle-plan.md` 3절 발견도 같이 철회됨 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | -| `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 | +| `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 **[2026-08-28] 좁은 형태로 부활** — 재바인드가 아니라 claim-once·own-all(`research/existing-mount-plan.md`), 기각 사유 자체는 유효 | | `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | diff --git a/.claude/archive/existing-instance-bind-rejected.md b/.claude/archive/existing-instance-bind-rejected.md index 2981ee7..5385490 100644 --- a/.claude/archive/existing-instance-bind-rejected.md +++ b/.claude/archive/existing-instance-bind-rejected.md @@ -3,6 +3,13 @@ > **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서 > `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.** > +> **⭐ [2026-08-28] 좁은 형태로 부활 — `research/existing-mount-plan.md`.** 여기서 +> 기각된 것은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*이고 그 +> 사유(바깥이 자식 구성을 밀고 당기면 부기가 깨진다)는 그대로 유효하다. 부활한 +> 것은 그 반대 방향 — **한 번 `Claim`하면 quad가 소유하고 직계 자식은 사용자가 +> 전부 매핑한다**(claim-once · own-all, 재바인드는 여전히 미지원). 루트(PlayerGui)와 +> `Clone()` 템플릿이 그 용도다. +> > **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/ > `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은, > quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 46ddc8b..1746e18 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -331,12 +331,20 @@ end 배선은 사용자 확정이 아니라 **규칙을 기존 계약에 얹은 제 선택**이다 — `H-142` 처방 후보 (a)/(b)/(c)가 전부 새 메커니즘이라 정하지 않았던 것을 "키 금지"로 바꾸니 필요한 코드가 이 거부 한 줄뿐이다. 다른 모양이 낫다면 - 갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다. **[2026-08-27 `H-146`]** - 그 거부는 일반 매치 실패 문구(*"no handler … check quad-roblox provider"*)가 - 아니라 **전용 문구**를 낸다 — provider 설정을 의심하게 만들지 않도록 - ("`Parent` is not a prop: the parent attaches its children; a quad root is - parented by the caller" 취지, 정확한 문장은 구현 시). - - **⭐ [2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 — + 갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다. **[2026-08-28 10라운드 + `H-148` 철회]** 2026-08-27에 여기 "그 거부는 **전용 문구**를 낸다"를 붙였는데 + 그건 새 메커니즘이었다(`isHandlable` 거부는 `Dispatch.process`의 일반 매치 + 실패 문구로 떨어지고 그 자리에 특수 분기는 두지 않기로 확정돼 있다) — + **철회**, 일반 문구 그대로. 오해는 사용자 문서가 맡는다. + - **⛔ [2026-08-28 폐기, 10라운드 `H-148` → `research/existing-mount-plan.md`] + 아래 "루트는 사용자가 밖에서 `.Parent =`" 예외는 하루 만에 뒤집혔다** — 사용자: + *"slot 은 물리 장치에 mount 할 방법이 거의 존재하지 않음 … PlayerGui 가 + 상위에 있고 거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 + 될것 같은 느낌이 듦. 이건 Parent 이상의 문제인것 같아."* 루트는 밖에서 + `Parent`를 만지는 게 아니라 **quad가 `Claim`으로 소유**한다(PlayerGui·`Clone()` + 사본·Studio GUI — 그 research 문서가 소스, M5 이후). 그러면 `.Parent =`를 + 사용자가 쓸 자리 자체가 없어진다. 아래는 폐기 전 서술: + **[2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 — quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`로 한다.** 위 인용문의 *"외부에서 직접 Parent 설정해주지 말것"*이 막는 것은 **quad가 관리하는 자식 자리**(Slot 요소·정적 자식)에 밖에서 끼우는 것이고 diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index 335b624..4f7649b 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -887,11 +887,13 @@ function clearTimeout(timeout: Timeout) timeout._native() end (사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이 `pending = false`를 무조건 하므로 유일한 탈출구다. -**해소는 위 재작성에 흡수된다** — `pending`을 없애고 Blocker의 -`HasBlockedEmit`을 쓰면 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어 -이 결함이 구조적으로 성립하지 않는다(`Trailing = false`는 `OffWithoutEmit()` -으로 표현되고, 그건 보류분을 **버리면서** 상태도 같이 비운다). 아래 코드를 -참고할 때 이 결함을 그대로 옮기지 말 것. +**해소는 위 재작성에 흡수된다** — `pending`을 없애고 **`emit()`의 반환값**으로 +"보류분이 있는가"를 읽으면(**[2026-08-28 정정, 10라운드 `H-156`]** 여기 한때 +"`HasBlockedEmit`을 쓰면"이라 적혀 있었는데 그건 7라운드 `H-86`이 뒤집은 통로다) +"보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어 이 결함이 구조적으로 +성립하지 않는다(`Trailing = false`는 **`emit(false)`**로 버린다 — `H-55`: +`OffWithoutEmit()`만으로는 흡수 집합이 안 빈다). 아래 코드를 참고할 때 이 결함을 +그대로 옮기지 말 것. ```lua -- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절). @@ -1139,7 +1141,10 @@ Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름 - **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`). 실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음. - **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된 - 빈도로 재실행됨 — 별도 장치 불필요. `Effect`가 deps 배열을 안 만들고 + 빈도로 재실행됨 — 별도 장치 불필요. **[2026-08-28 `H-151`]** 단 게이트는 + emit 경로만 미룬다 — `Effect`의 재바인드/재구독 캐치업과 게이트 없는 형제 + dep의 emit은 창을 무시하고 `fn`이 돌며 그때 `:Get()`은 최신값(계약, + `base/gate-plan.md`의 "계약 — 게이트는 emit 경로만 미룬다" 절). `Effect`가 deps 배열을 안 만들고 `:With`를 재사용한 것과 같은 결로, "debounce된 Effect"라는 별도 API를 만들 필요가 없다는 뜻. - **테스트/`quad-mock`**: 주입 op 2개 덕분에 **가상 시계로 결정론적 테스트가 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 91a6a55..bcb1a27 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -1490,7 +1490,16 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 확정(사용자, 갈래 없음 — `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)` → + 철거에서 **`if nextValue == v then return end`**(**[2026-08-28 확정, 10라운드 + `H-154`]** 같은 값 재발행 dedup — `SlotHandler`의 retractor 쪽 `slotValue == + nextValue` 얼리리턴과 동형. 없으면 `store.child:Set(store.child:Get())` 한 줄에 + `Parent = nil → inst`와 `recompute`가 두 번 돌아 `ChildRemoved`/`ChildAdded`가 + 실제로 나간다, 실측 `d23`. **정확히 무엇이 줄어드나**: 물리 detach/attach와 + `recompute` 한 번 — (A) 분기는 retractor 뒤 `process`를 다시 부르므로 + `setOffsetSource(None)`·`Parent = inst`(같은 값, 엔진 no-op)·`setLength(1)`의 + `recompute` 1회는 남는다(2→1이지 0이 아님; `SlotHandler`는 `claimOwnerAt`이 + process 쪽도 접지만 이 핸들러는 옛 값을 process에서 모른다 — 그 skip은 안 둔다); 사용자: *"말단 핸들러가 v 를 정확히 알아서 retract + 가 정확히 해소되는 부분"*) → **`v.Parent = nil`** → `setOffsetSource(inst, k, None)` → `setLength(inst, k, 0)`(`SlotHandler`의 retractor가 `unmountSlotTree`를 먼저 부르는 것과 같은 모양, 부기 둘의 순서 근거는 아래 "해제" 문단). **[2026-08-27 `/code-review high` 정정 셋]** — (1) 옛 자식은 **내린다**(파괴 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 6fe467e..8be5244 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -151,7 +151,11 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그 - ~~**bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을 떼고 재마운트의 `bindLifetime`이 다시 건다.~~ **[2026-08-26 폐기, `H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는 - `H-64`의 **조건부 캐치업**(`_epochs:Refresh()`)이다. + **dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다**는 것 — 재마운트는 + `Destroying` 연결만 다시 건다(`_bindDestroying`). 포탈 사이에 놓친 emit의 + 캐치업은 없다(**[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()` 폐기 — `_installed`가 + 참인 채 재마운트되므로 `not _installed → Rerun`도 안 걸린다; 다음 emit이 + 리비전 차이로 잡는다). 3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 `Rerun`이 같은 자리를 읽게 된다. @@ -203,7 +207,6 @@ callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 -- quad-base, Effect.luau function Effect(fn, ...) local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle) - self._blocker = Blocker() -- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다. -- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다. @@ -232,11 +235,17 @@ function Effect(fn, ...) -- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다 -- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 -- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). - self._blocker:On() + -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 등록 즉시 1회 발화(내부 Observer·`Ref` + -- 콜백의 설치 발화는 **그대로 일어난다**)는 아래 `fire`의 첫 줄 — **Effect + -- 핸들의** `canExecute` — 이 흡수한다: 생성자 안에선 아직 어디에도 안 묶여 + -- 있어 항상 거짓이다. 한때 여기 사적 `Blocker`(`_blocker:On()` … `OffWithoutEmit()`) + -- 가 같은 억제를 한 번 더 하려고 있었는데, 실측(10라운드 `t18`)상 어떤 경로에서도 + -- 판정에 닿지 않는 죽은 부품이라 **제거**(사용자 확정: *"Effect 의 canExecute 를 + -- 보겠다는거지? 그럼 그건 맞는것 같아"*). `H-147`로 `fn`이 생성자 안에서 자기를 + -- 묶을 수도 없으므로 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식이다. local function fire(from) -- 공통 본문 - if not canExecute(self) then return end -- 발화 게이트 - if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저) - if self._epochs:Update(from) then + if not canExecute(self) then return end -- 발화 게이트 — `Update`보다 먼저(아래 ⚠️) + if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 self:Rerun() end end @@ -257,25 +266,31 @@ function Effect(fn, ...) -- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙 -- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) — -- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가 - -- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다. + -- `Update(from)`이 그 원천의 리비전을 영영 못 본다. if isEpoch(d) then self._epochs:Sync(d) else self._epochs:TrackFrom(d.valueEpochMap) end end - self._blocker:OffWithoutEmit() -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇). - self:Rerun() + -- ⭐ [2026-08-28 `H-147`] 공개 `Rerun()`이 아니라 본체를 `force`로 부른다 — + -- 초기 설치는 "re"-run이 아니고, 아직 안 묶여 있어 공개 진입의 + -- `canExecute` 게이트를 통과하지 못한다. + rawRerun(self, true) return self end ``` -- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드 - 구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**: - *"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든 - 옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*). +- **`_installing` 플래그도, 그 뒤를 이은 사적 `_blocker`도 폐기됐다** — + `_installing`은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 `H-58`), + `_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `fire`의 + 첫 줄 `canExecute`가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다 + (실측 `t18`: `drop:canExecute` 3 / `drop:blocker` 0). `H-58`의 사용자 지시 + (*"해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서 + 이를 수행해야할 것임."*)는 그 전제("등록 즉시 1회가 `Rerun`에 닿는다")가 + 성립하지 않았던 것으로 정정 — 억제 주체는 Effect 핸들의 `canExecute`다. - **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**: *"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행 결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안 @@ -285,6 +300,11 @@ end ```lua function EffectHandle:_bindDestroying(inst) + if isRunning(self) then -- ⭐ [2026-08-28 `/code-review`] (A)의 강제 — `fn` 안에서 + error("cannot bind an Effect from inside its own fn or cleanup", 2) + end -- + -- `New "Frame" { self }`로 자기를 leaf에 묶는 경로도 막는다 + -- (`bindLifetime`은 범용이라 Effect 훅인 여기서 건다) self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등 -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점. @@ -293,19 +313,29 @@ function EffectHandle:_bindDestroying(inst) self:_consumeCleanup() end) - -- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다. - -- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행. - -- ⚠️ `Refresh()`를 **먼저 부른다** — 그건 비교만 하는 게 아니라 자기 - -- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면 - -- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도 - -- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번 - -- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다). - local depsChanged = self._epochs:Refresh() - if not self._installed or depsChanged then + -- (2) 캐치업 — **재설치 1회뿐**. dep 등록은 이미 생성자에서 끝났다. + -- ⭐ [2026-08-28 확정, 10라운드 `H-151`] 여기 한때 `_epochs:Refresh()`로 + -- "dep이 변했으면 재실행"까지 했는데 **폐기** — `_epochs`는 `fire`의 + -- `Update(from)`에서만, 즉 **emit을 받을 때만** 갱신한다(Observer·중간 + -- State와 같다). 재바인드는 초기 설치와 같은 뜻이라 소진돼 있으면 다시 + -- 설치할 뿐이고, 죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로 + -- 잡힌다(Observer와 같은 정도의 캐치업 없음 — 계약). 사용자: *"우린 애초에 + -- Refersh 를 할 필요가 없는거야. 재진입은 초기 설정해주는 요소이고, 그건 + -- 처음 생성할때랑 같은거야."* gcconn 연결 **뒤**라 공개 `Rerun`의 게이트를 + -- 통과한다. + if not self._installed then self:Rerun() end end +-- ⚠️ [2026-08-28 확정, 10라운드 감사 2라운드] **`fn` 안에서 자기 leaf `inst`를 +-- 파괴하는 것은 UB.** `Workspace.SignalBehavior`가 `Deferred`(현재 기본)면 +-- `Destroying` 콜백이 다음 리줌으로 늦춰져 경합이 없지만, `Immediate`(레거시)면 +-- `fn` 실행 도중 위 콜백이 동기 발화해 `_consumeCleanup`(이미 비어 있음 — no-op)과 +-- `_destroyConn` 해제만 일어나고, `fn`이 돌려준 cleanup은 저장되지만 소진할 연결이 +-- 사라져 **영구 미소진**이다. `fn`이 자기 생명주기를 못 바꾼다(`H-147` (A))는 +-- 계약의 물리판 — 사용자: *"bind/unbind 에 간접 영향을 주는건데, UB 인게 맞다는 생각"*. +-- `SignalBehavior` 구분 자체는 `base/ref-plan.md`가 소스. function EffectHandle:_unbindDestroying() if self._destroyConn then self._destroyConn:Disconnect() @@ -321,7 +351,18 @@ function EffectHandle:_consumeCleanup() local c = self._cleanup self._cleanup = nil self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다 - if c then c() end + if c then + -- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 — + -- `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은 + -- `_running` 밖이라 cleanup 안의 `self:Subscribe()`가 가드를 지나 + -- `Unsubscribe()`가 끝나기도 전에 `fn`이 재진입했다. `_running`에 뜻을 + -- 얹지 않고 **별도 플래그**로 잡는다(사용자: *"_running 으로 묶어 보는건 + -- 여전히 별로 괜찮은 이유가 없음. _cleanupRunning 같은걸 넣지 말아야할 + -- 이유가 없는것"*). + self._cleanupRunning = true + c() + self._cleanupRunning = false + end end ``` @@ -332,65 +373,77 @@ end 게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면 바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가 없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** -`_installed`는 `Rerun`이 끝날 때 참(**[2026-08-27 `H-143`]** 단 그 실행 중에 -핸들이 죽었으면 — `wasAlive and not canExecute` — 세우지 않는다, -`_consumeCleanup`이 걸어둔 거짓이 그대로 남는다), `_consumeCleanup`에서 거짓이 -된다. +`_installed`는 `rawRerun`이 `fn`을 돌리고 끝날 때 참, `_consumeCleanup`에서 +거짓이 된다(**[2026-08-28 `H-147`]** `fn` 실행 중에 핸들이 죽는 경로는 더 이상 +없다 — 아래 `Rerun` 정의). -**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.** -지금까지 호출부만 다섯 곳이고 정의가 없었다. +**⭐ [2026-08-25 신설, 7라운드 `H-60`; 2026-08-28 10라운드 `H-147`로 재정의] +`rawRerun(self, force)` 본체 + 공개 `EffectHandle:Rerun()`.** +지금까지 호출부만 다섯 곳이고 정의가 없었다. **[2026-08-28]** 사용자 지적으로 +둘로 갈랐다 — *"처음부터 rerun 이 're'-run 인데도 초기 실행까지 담당하고 있잖아 +… rawRerun(force: boolean) 을 만들어 생성 시점과 실행 시점에서 이를 명시하는게 +맞지 않아?"* — 초기 설치는 아직 안 묶인 상태라 공개 진입의 게이트를 못 지나므로 +호출자가 그 사실을 인자로 말한다(`raw*` = 검사 없는 내부 본체, Slot의 `raw*` +관용구와 같다). ```lua -function EffectHandle:Rerun() -- 공개 메소드, 무인자 +-- 본체. force = 초기 설치(생성자) — 아직 안 묶였으니 게이트를 안 본다. +-- (이 문서의 절 순서는 개념 순서다 — 실제 파일에선 `rawRerun`·`isRunning`· +-- `resubscribeTail` 같은 `local function`이 사용처(생성자·`_bindDestroying`·네 +-- 진입점)보다 **앞에** 선언돼야 한다. Luau의 `local function`은 앞선 호출에서 안 보인다.) +local function rawRerun(self, force: boolean) if self._running then self._pending = true -- 실행 중 재진입 → 지연 return end + if not force and not canExecute(self) then + return -- ⭐ 죽은 핸들·안 묶인 핸들의 재실행 요청은 **정의된 + end -- no-op** — `fire`가 죽은 핸들의 emit을 버리는 것과 + -- 같은 규칙(`H-147`: `Unsubscribe` 뒤 늦게 오는 + -- 타이머의 `Rerun()`, 해제 뒤 cleanup의 재요청 등). self._running = true repeat self._pending = false self:_consumeCleanup() - local wasAlive = canExecute(self) -- `fn` 진입 전 상태 - local c = self.fn(self) - -- ⭐ [2026-08-27 확정, 9라운드 `H-143`] **이 실행 중에 죽었으면 cleanup만 - -- 한다.** `fn` 안에서 `self:Unsubscribe()`(또는 `WeakUnsubscribe`)가 - -- 통과했으면(강/약 구독 핸들 — leaf 바인딩 핸들은 자기 해제로 죽지 - -- 않으므로 이 분기에 오지 않는다) 이 핸들은 더 이상 어느 레지스트리에도 - -- 없고 leaf도 아니라 **아무도 이 cleanup을 소진할 수 없다** — 저장하지 - -- 말고 즉시 소진한다. - -- 판정은 "살아 있다가 → 죽었다"(`wasAlive and not canExecute`)다. - -- `not canExecute` 하나로 하면 안 된다 — **생성자의 최초 `Rerun()`은 - -- 어떤 바인드·구독보다 먼저** 돌아 `canExecute`가 항상 거짓이라, 첫 - -- 실행의 cleanup을 그 자리에서 소진하고 `_installed`를 거짓으로 남겨 - -- 첫 바인드에서 `fn`이 또 돈다(감사 2라운드가 잡은 회귀 — `H-58`의 - -- 재현). 약한 해제도 같은 경로로 소진된다(죽은 핸들에 매달린 cleanup은 - -- "영원히 안 불림"보다 "한 번 불림"이 계약에 가깝다). 사용자 확정: - -- *"특정 state 에 변경을 딱 한번만 처리하고 cleanup 되는걸 만드는 요구가 - -- 존재하지 않을 이유가 딱히 없다"*, *"실행중 죽으면 클린업만 하기"* — - -- `fn` 안 `Unsubscribe`는 지원 대상이다. - if wasAlive and not canExecute(self) then - if c then c() end -- `_installed`는 이미 `_consumeCleanup`이 거짓으로 - self._pending = false -- 죽은 핸들의 재요청(`fn` 안 `self:Rerun()`)은 버린다 - -- — `fire`가 죽은 핸들의 emit을 버리는 것과 같은 규칙. - -- 안 버리면 아래 `until`이 한 바퀴 더 돌아 죽은 - -- 핸들에서 `fn`이 또 돌고 그 cleanup이 다시 고아가 된다. - else - self._cleanup = c - self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" - end + self._cleanup = self.fn(self) + self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" until not self._pending -- 재요청이 또 오면 또 돈다 self._running = false end + +function EffectHandle:Rerun() -- 공개 메소드, 무인자 — 항상 게이트 + rawRerun(self, false) + return self +end ``` +- **⭐⭐ [2026-08-28 확정, 10라운드 `H-147`] `fn`도 cleanup도 자기 생명주기를 바꿀 + 수 없다 — 그래서 이 루프 안에는 사망 판정이 없다.** 2026-08-27에 `H-143`으로 + "`fn` 안 `self:Unsubscribe()`(원샷 Effect)"를 지원하기로 하고 `Rerun` 꼬리에 + `wasAlive and not canExecute` 판정을 넣었는데, 하루 만에 그 허용 하나에서 + 파생된 결함이 넷(감사 2·4라운드, `H-147`) 나왔고 사용자가 뿌리를 짚었다: + *"유저 함수가 본인을 죽이고 살린다는점 자체가 모순이였다는 문제가 나와. 처음 + 실행해 unsub 했는데, 아래에서 sub 해버릴 수도 있지. 이건 의도 동작일까? 게다가 + unbind/bind 는 본인이 못 해. 같은 계층으로 sub/unsub 가 본인이 할 수 있어야할 + 이유 제공 자체가 큰 그림에서 무언가 잘못된거 아닐까?"* → **(A) 확정**: Effect의 + 생애는 **묶은 쪽**(leaf면 Instance, `:Subscribe()`면 그 호출자)이 소유하고, `fn`은 + dep을 읽고 부작용을 내고 cleanup을 돌려주는 것까지다. leaf가 `fn` 안에서 + unbind/bind를 못 하는 것과 **대칭**. (*"나는 지원 안 할 이유가 안 보였었는데, + 지금 보면 엄청난 모순이네."*) 강제는 네 진입점의 `_running` 가드(아래 + `EffectHandle:Subscribe()` 절). **원샷**은 소유자가 밖에서 `Unsubscribe`하거나 + 나중에 `Once`류 슈가로 — 코어엔 없다. `H-143`은 소멸. + - **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함."* -- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미 - 그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다. -- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것 - 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 +- **`canExecute` 확인은 진입에서 한 번** — `fire`(`Ref` 콜백·전파 루프 경유)가 + 첫 줄에서 보고, 공개 `Rerun()`도 **[2026-08-28 `H-147`]** 진입에서 본다(죽은·안 + 묶인 핸들은 no-op). 그래서 `rawRerun` 루프 안엔 판정이 없다. (한때 "사용자가 + `fn` 안에서 직접 부르는 경로는 게이트하지 않는다"였는데, 그 문장은 `Rerun`에 + 게이트가 없던 시절 것.) +- **error 시 UB** — 전파되고 복구하지 않는다(`_running`/`_cleanupRunning`이 참으로 + 남는 것 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다. **⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리 @@ -431,12 +484,14 @@ end `base/architecture.md`의 `EngineOps.luau` 줄이다. - **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건 `fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref`도 `Epoch`라 균일), - `_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 — + `_cleanup`, **`_installed`**(설치 여부 — cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다), - `_running`/`_pending`(재진입), **`.Subscribed`**(공개 플래그 — `canExecute`가 + `_running`/`_pending`(재진입), **`_cleanupRunning`**(cleanup 실행 중 — `_running`과 + 별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute`가 읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절). - **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와 - `_blocker`로 대체됐다.** + **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로 + 대체됐고, `_installing` 자리에 잠깐 있던 `_blocker`도 [2026-08-28 `H-150`] + 제거됐다 — 억제는 `canExecute`.** ### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`) @@ -463,7 +518,7 @@ end `:Unsubscribe()`에서 같이 해제한다 — State/Source dep 쪽과 대칭이다 (**[2026-08-26 표기 정정, `H-114`]** 옛 `_observers` 표기를 지웠다 — 지금은 `_deps` 하나다. **⚠️ 다만 이 문단의 "`unbindLifetime`에서 해제"는 `H-58`이 -뒤집었다** — 언바인드는 아무것도 안 떼고, 억제는 `_blocker`가 한다. 살아 +뒤집었다** — 언바인드는 아무것도 안 떼고, 억제는 `canExecute`가 한다(`H-150`). 살아 있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다). **⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도 @@ -546,9 +601,9 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. -- `EffectHandle.Subscribe = Observer.Subscribe`처럼 **함수 객체를 그대로 배정**해 -- 뒀는데, `Observer:Subscribe`의 본문이 `self:WeakSubscribe()`로 **콜론 위임**하는 -- 탓에 `self`가 `EffectHandle`이면 그 조회가 `EffectHandle`의 오버라이드로 가서 --- 재구독 꼬리가 두 번 돌고, 첫 번째는 강한 킵이 서기 **전에** `Rerun`해 `fn` 안 --- `self:Unsubscribe()`(`H-143`이 지원하는 패턴)가 *"not subscribed strongly"*로 --- error했다(로컬 `luau`로 재현). **사용자 확정**: *"b가 맞아. 내 머리에서 나왔던 +-- 재구독 꼬리가 두 번 돌고, 첫 번째는 강한 킵이 서기 **전에** `Rerun`했다 +-- (로컬 `luau`로 재현; 당시엔 `fn` 안 `self:Unsubscribe()`가 허용돼 그 자리에서 +-- error까지 났다 — 그 허용은 `H-147`로 폐기). **사용자 확정**: *"b가 맞아. 내 머리에서 나왔던 -- 처음 구조는 그것이였어. … '하나의 무언가가 두 일을 동작하지 않는가에 -- 유의하자' — 이것도 마찬가지야. 버그를 유발하기 좋은 포인트였고"* — -- Observer와 Effect는 이질적 타입이라(생성 방법부터 다르다) 본문을 섞지 않는다 @@ -561,26 +616,36 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. -- (`_bindDestroying`, `H-65`)와 **정확히 같은 꼬리**를 붙인다. `Unsubscribe`로 -- cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 `fn`이 -- 안 돌아, deps 없는 Effect는 레지스트리가 살려두는 죽은 핸들(누수)이 되고 --- deps 있는 것은 다음 emit까지 죽어 있었다. `Refresh()`를 **먼저** 부르는 이유는 --- 287행 캐비엇과 같다 — 해제 중 도착한 emit은 `fire`의 `canExecute` 가드가 --- `_epochs:Update` 앞에서 버리므로 `_epochs`가 해제 전 리비전에 멈춰 있고, 안 --- 맞추면 재구독 뒤 첫 emit(예: 게이트 유보가 풀리며 오는 배치)에 같은 값으로 --- `fn`이 한 번 더 돈다. 사용자 확정: *"초기에 epoch 를 전부 잘 설정해주는게 --- 나을수도 … 처음 연산 기준에 있어서도 전부 값은 최신 값이거든. 따라서 다음 --- emit 을 받아야할 이유가 없을수도 있어."* Blocker는 안 쓴다 — dep을 다시 +-- deps 있는 것은 다음 emit까지 죽어 있었다. **[2026-08-28 `H-151`]** 한때 여기 +-- `_epochs:Refresh()`를 먼저 불러 "dep이 변했으면"까지 재실행했는데 폐기 — +-- `_epochs`는 emit을 받을 때만 갱신한다(`_bindDestroying`의 같은 주석). 재구독 뒤 +-- 게이트 유보가 풀리며 같은 값으로 `fn`이 한 번 더 도는 것은 **정상 재실행** +-- (사용자: *"처음 생성할 때에도 Block 되어있던게 나중에 다시 들어오는 경로가 +-- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시 -- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다. local function resubscribeTail(self) - local depsChanged = self._epochs:Refresh() - if not self._installed or depsChanged then + if not self._installed then -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다 self:Rerun() end end +-- ⭐⭐ [2026-08-28 확정, 10라운드 `H-147`] **네 진입점(과 `_bindDestroying`) 첫 줄에 +-- 가드** — `fn`/cleanup은 자기 구독을 바꿀 수 없다(위 `Rerun` 정의의 (A)). 보는 +-- 플래그는 둘: `_running`(`fn` 실행 중)과 **`_cleanupRunning`**(cleanup 실행 중 — +-- 감사 2라운드에서 신설, `_consumeCleanup` 참고). **`error`는 헬퍼가 아니라 각 +-- 본문에서 던진다** — 헬퍼 안의 `error(…, 2)`는 헬퍼의 호출 줄(quad 내부)을 +-- 가리켜 `H-104` level 계약을 어긴다(`/code-review` 지적, `H-149`와 같은 이유). +local function isRunning(self) -- 술어만 헬퍼로 — `error`는 각 본문에서(`level 2`) + return self._running or self._cleanupRunning +end + -- 꼬리는 항상 **등록이 전부 끝난 뒤** 한 번 — `Subscribe`는 `WeakSubscribe`를 -- 부르지 않고 등록 세 줄을 자기 안에 펼쳐 쓴다. 위임하면(콜론이든 dot이든) -- 꼬리가 강한 킵 **앞**에서 돌거나 두 번 돈다 — 감사 4라운드가 잡은 바로 그 --- 모양이다. 게이트·메시지 분기는 Observer의 것과 같다(`lifecycle-pattern.md` (2)). +-- 모양이다. 게이트·메시지 분기는 Observer의 것과 같다(`lifecycle-pattern.md` (2) — +-- **[2026-08-28 `H-149`]** Observer 쪽도 같은 이유로 위임을 풀고 인라인했다). function EffectHandle:WeakSubscribe() + if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147` if not canBound(self) then error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) end @@ -591,17 +656,19 @@ function EffectHandle:WeakSubscribe() end function EffectHandle:Subscribe() + if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147` if not canBound(self) then error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) end self.Subscribed = true WeakSubscribed[self] = true - Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 — `Rerun` 안의 - resubscribeTail(self) -- `fn`이 `self:Unsubscribe()`해도 가드 통과 + Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 한 번 + resubscribeTail(self) return self end function EffectHandle:WeakUnsubscribe() -- 관대(`H-133`) — cleanup 안 건드림 + if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147` if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end @@ -611,6 +678,7 @@ function EffectHandle:WeakUnsubscribe() -- 관대(`H-133`) — cleanup end function EffectHandle:Unsubscribe() + if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147` if Subscribed[self] == nil then -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면 error("not subscribed strongly; use :WeakUnsubscribe()", 2) -- (leaf 바인딩·약한 end -- 구독·미구독) 여기서 error, cleanup엔 손도 @@ -622,24 +690,16 @@ function EffectHandle:Unsubscribe() end ``` -- **`WeakUnsubscribe` 자체는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에 - 맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 **[2026-08-27 `H-143` - 으로 셋]** — 강한 구독의 `Unsubscribe`, leaf 사망(`unbindLifetime`의 훅), 그리고 - **`fn`이 실행 중에 자기 핸들을 죽인 경우**(`fn` 안 `self:Unsubscribe()` / - `self:WeakUnsubscribe()` — `WeakUnsubscribe` 본문은 여전히 cleanup에 손대지 - 않지만, 그걸 감싼 `Rerun`의 죽음 판정 `wasAlive and not canExecute`가 그 - 실행이 돌려준 cleanup을 즉시 소진한다, 위 `Rerun` 의사코드). 여기 한때 - "둘뿐"이라 적혀 있었다(감사 7라운드 정정). -- **`fn` 안에서 허용되는 핸들 호출은 `self:Rerun()`과 자기 해제다 — 단 자기 - 해제는 *건 경로로*: 강구독 핸들은 `self:Unsubscribe()`, 약구독 핸들은 - `self:WeakUnsubscribe()`. leaf 바인딩된 핸들엔 자기 해제 경로가 없다**(종료는 - leaf 사망뿐 — `Unsubscribe()`는 가드에서 error하고 그 error는 `Rerun`을 뚫고 - 나가 `_running`이 참으로 남는 UB, `WeakUnsubscribe()`는 관대 통과하지만 - `isBoundAlive`가 그대로라 죽지 않는다; **[2026-08-28 `/code-review`]**). `fn` 안 - 재구독(`self:Subscribe()`/ - `self:WeakSubscribe()`)은 **[2026-08-27 기준] 서술하지 않는다**(트레이싱상 - `resubscribeTail`의 `Rerun`이 `_running` 재진입으로 `_pending`만 세워 크래시는 - 안 하지만 지원 목록이 아니다 — 필요가 관측되면 그때 정한다). +- **`WeakUnsubscribe`는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에 + 맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 강한 구독의 + `Unsubscribe`와 leaf 사망(`unbindLifetime`의 훅) **둘뿐**(**[2026-08-28 + `H-147`]** 2026-08-27에 잠깐 "`fn` 안 자기 해제"가 셋째로 있었으나 그 허용 + 자체가 폐기됐다). +- **`fn` 안에서 허용되는 핸들 호출은 `self:Rerun()`뿐이다** — 자기 구독을 바꾸는 + 넷(`Subscribe`/`WeakSubscribe`/`Unsubscribe`/`WeakUnsubscribe`)은 `_running` + 가드가 error로 막는다(**[2026-08-28 `H-147`]** 위 `Rerun` 정의 (A)). cleanup + 안에서도 같다 — 어느 자리(`Rerun` 루프·`Unsubscribe`·leaf `Destroying`)에서 돌든 + `_cleanupRunning`이 서 있어 같은 가드가 먼저 걸린다. - 실측(9라운드 `core9.luau`, `t12` 매트릭스 — **당시 함수 배정 형태에서의 실측**이고 (b) 재작성 뒤 재실행하지는 않았다[2026-08-27 기준]; 게이트 순서는 그대로라 같은 결과가 나올 것으로 추정할 뿐, 확정 근거는 M2 구현 테스트가 될 것): leaf 바인딩된 @@ -660,12 +720,11 @@ end - **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref` 콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절). `Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그 - 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `_epochs:Refresh()` - + 조건부 `Rerun`이 붙는다(위 의사코드) — **그 사이 dep이 안 변했으면** - no-op이다. 첫 구독이라도 생성과 `Subscribe()` 사이에 emit이 있었으면 - (`.Subscribed`가 거짓이라 `fire`가 첫 가드에서 버린다) `Refresh()`가 참을 - 돌려 캐치업으로 `fn`이 다시 돈다 — 바람직한 동작이고, "첫 구독은 재실행 - 안 한다"를 테스트에 인코딩하지 말 것(**[2026-08-28 `/code-review`]**). + 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `not _installed → + Rerun`이 붙는다(위 의사코드) — 첫 구독은 설치돼 있으니 no-op, **소진된 뒤의 + 재구독**만 재설치. **[2026-08-28 `H-151`]** 생성과 `Subscribe()` 사이에 온 + emit은 `fire`가 버렸고 여기서 따라잡지 않는다 — 다음 emit의 리비전 차이로 + 잡힌다(Observer와 같은 정도의 캐치업 없음). - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 @@ -824,13 +883,10 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect `:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가 인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문 자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다. - - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 - 핸들 표면에 바로 닿는다. **[2026-08-27 확정, 9라운드 `H-143`]** `fn` 안 - `Unsubscribe`는 **지원 대상**이다("이 dep의 변경을 딱 한 번만 처리하고 - 끝나는 Effect") — 그 실행이 돌려준 cleanup을 `Rerun`이 그대로 저장하면 - 아무도 소진 못 하므로, `Rerun` 꼬리가 "이 실행 중에 죽었다"(`wasAlive and - not canExecute`)를 보면 **즉시 소진**하고 재요청도 버린다(위 `Rerun` - 의사코드). + - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`에 바로 닿는다. **[2026-08-28 + `H-147`]** 단 **구독 표면 넷은 `fn` 안에서 error** — 2026-08-27에 `H-143`으로 + "`fn` 안 `Unsubscribe`(원샷)"를 잠깐 지원 대상으로 뒀으나 하루 만에 뒤집었다 + (위 `Rerun` 정의의 (A)). - **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. @@ -839,18 +895,18 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect - **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에 한 번만 실행한다. - **⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래그 - (`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때 - *"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker`도 - 안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을 - 놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼 - 돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는 - `self._blocker:On()` … `:OffWithoutEmit()` 구간이 맡는다 — - `materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과 - 생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.** + **⭐⭐ [2026-08-25 정정, 7라운드 `H-58`; 2026-08-28 10라운드 `H-150` 재정정] 그 + 억제는 `Effect` 내부 플래그(`_installing`)도 사적 `Blocker`도 아니라 Effect + 핸들의 `canExecute`가 한다.** 여기 한때 *"[2026-08-21 확정] 이건 `Effect` 내부 + 플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을 + 놓쳤다(`H-58`). 그 자리에 `self._blocker:On()` … `:OffWithoutEmit()`이 들어왔는데, + 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `fire`의 첫 줄 `canExecute`가 + 설치 발화를 전부 떨어뜨리므로 `_blocker`는 **한 번도 판정에 닿지 않았다** + (실측 `t18`). 위 생성자 의사코드가 소스다. **`_installing`도 `_blocker`도 폐기된 + 필드다.** (2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안 일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로 - 유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.) + 유효하다 — 그래서 게이트도, 결국은 `Blocker`도 아닌 `canExecute` 하나로 족하다.) - **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만 돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를, @@ -875,7 +931,7 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect -- ⛔ 옛 모델(2026-08-21). 지금은 dep 종류별로 클로저가 둘이다. function(self, from) if not canExecute(handle) then return end -- 발화 게이트 - if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정) + if handle._blocker:IsOn() then return end -- 등록 구간 억제 (⛔ `_blocker`는 `H-150`으로 제거) if handle._epochs:Update(from) then handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행 end @@ -886,7 +942,9 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect "`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 (2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가 - `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다. + `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로였고, **[2026-08-28 + `H-150`]** 그 억제 주체가 `canExecute`가 된 지금도 같다 — `canExecute`가 + `fire`의 첫 줄, `Update`는 그 뒤. - **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** — 여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`이 없다"*고 적혀 있었는데, **`Ref`가 `Epoch`로 승격**되며(`base/ref-plan.md`) diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 6714640..fa95d82 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -309,6 +309,12 @@ end) ```lua -- 필드 (ComputeNode와 같은 층위) +-- ⭐ [2026-08-28 확정, 10라운드 `H-152`] 조립의 **첫 줄은 `StateBrand:register(node)`**다 — +-- `_emitDown`은 자식을 `isState(sub)`로만 가르므로(`source-state-plan.md`) 등록이 +-- 빠지면 상류 emit이 `_receive`로 안 오고 `canExecute(gate)`도 거짓이라 **통지만 +-- 조용히 죽는다**(`Get()`은 `_hold`로 최신값을 주니 값 검사로는 안 잡힌다 — 실측 +-- `t24`: 하류 발화 2 → 0). `GateNode`는 State 생성자를 안 지나고 이 절이 곧 +-- 생성자라 여기 없으면 어디에도 없다. GateNode = { _hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`) _subs = , -- 원소는 Observer 값 / 자식 State @@ -445,8 +451,11 @@ end 소비자가 **아니다**.** 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나, 위 8번(빈 배치는 통지 안 함)으로 **성립하지 않는 게 확인됐다** — 설치 구간엔 어떤 `Set`도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다. - `Effect`가 **자기 내부 플래그로** 설치 중 발화를 누르고 마지막에 한 번 - 직접 실행하면 되고, 새 메커니즘이 필요 없다. `base/effect-plan.md`의 그 + `Effect`가 설치 중 발화를 누르고 마지막에 한 번 직접 실행하면 되고, 새 + 메커니즘이 필요 없다(**[2026-08-28 10라운드 `H-150`]** 그 억제 주체는 "자기 + 내부 플래그"도 그 뒤의 사적 `Blocker`도 아니라 Effect 핸들의 `canExecute`다 — + 생성자 안에선 아직 안 묶여 있어 설치 발화가 첫 가드에서 떨어진다, + `base/effect-plan.md` 생성자 의사코드). `base/effect-plan.md`의 그 항목에 달려 있던 "⚠️ `Gate` 설계에 딸려 있다"도 같이 해소됐다. 아래는 원 서술: 2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을 @@ -491,6 +500,25 @@ end "게이팅 먼저"는 그대로 지켜진다 — 게이팅이 디스패치보다 먼저 지어진다. `Blocker`는 `GateNode`를 다시 만들지 말고 그 위의 정책으로 얹을 것. +## 계약 — 게이트는 emit 경로만 미룬다 (2026-08-28 확정, 10라운드 `H-151`) + +게이트가 하는 일은 **다운스트림 통지의 유보**뿐이고, 값은 안 가린다 +(`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**: +- **`Effect`의 재바인드/재구독 캐치업** — 소진된 핸들이 다시 묶이면 초기 설치와 + 같은 뜻으로 `fn`이 돈다(`base/effect-plan.md` `_bindDestroying`). 유보 중이어도 + 돈다. +- **게이트 없는 형제 dep** — `Effect(fn, gated, plain)`에서 `plain`이 깨우면 `fn` + 안의 `gated:Get()`은 최신값이다. +- 유보됐던 emit이 나중에 풀려 들어오면 그냥 재실행 — 생성 직후 유보분이 들어오는 + 것과 같은 경로. `Effect`의 `_epochs`는 emit을 받을 때만 갱신된다(Observer·중간 + State와 같다). + +**사용자 원문**: *"block 은 단지 유보만 해줄뿐이라서. - Effect 도 observer 랑 +똑같게, 중간 state 랑 똑같게, emit 받을때에만 epoch 맵을 업데이트 하면 돼. 계약 +추가로 끝나는 일로 보여"*. 막는 갈래(캐치업이 dep 노드의 `emitEpochMap`을 보게 +하기 / value-hold 재개방)는 둘 다 기각 — 전자는 `EpochMap` 계약 변경, 후자는 +`base/debounce-throttle-plan.md` 4절이 철회한 (B). 발견 원문은 `qa-request/pre-implementation-handtrace-round10.md` `H-151`. + ## 관련 문서 - `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상). diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index acdee20..51e3a10 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -354,8 +354,8 @@ function bindLifetime(inst, value) -- `base/effect-plan.md`의 "확정 구조" 절이 소스. if isEffect(value) then value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회** - -- (`local depsChanged = self._epochs:Refresh()` 먼저, - -- `if not self._installed or depsChanged then self:Rerun() end`) + -- (`if not self._installed then self:Rerun() end` — + -- [2026-08-28 `H-151`] 옛 `_epochs:Refresh()` 캐치업은 폐기) -- 의사코드는 `base/effect-plan.md`가 소스. -- 그 안에서 주입 op `onDestroying(inst, fn)`을 -- 부른다(base는 Instance를 모른다). @@ -446,14 +446,17 @@ end 진입점 전량이고 소스다(사용자 원문 *"구현이 한 벌"*). **[2026-08-27 9라운드 `H-127`, 같은 날 (b)로 정정]** `EffectHandle`은 **같은 레지스트리 둘과 같은 `canBound` 게이트를 쓰되 네 진입점 본문은 자기 것**이다 — 한때 "같은 넷을 -그대로 재사용(함수 배정)"으로 적었는데, 아래 `Subscribe`/`Unsubscribe`가 -`self:WeakSubscribe()`/`self:WeakUnsubscribe()`로 **콜론 위임**하므로 그 함수를 +그대로 재사용(함수 배정)"으로 적었는데, 당시 `Subscribe`/`Unsubscribe`가 +`self:WeakSubscribe()`/`self:WeakUnsubscribe()`로 **콜론 위임**하고 있어서 그 함수를 `EffectHandle`에 배정하면 위임이 `EffectHandle`의 오버라이드로 가서(재구독 꼬리 -두 번, 첫 번째는 강한 킵 전) 깨진다(감사 4라운드, `luau` 재현). **사용자 +두 번, 첫 번째는 강한 킵 전) 깨졌다(감사 4라운드, `luau` 재현; **[2026-08-28 +`H-149`]** 그 위임 자체도 이제 없다 — 아래 코드는 인라인). **사용자 확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두 일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect` 쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는 -**[2026-08-27 `H-144`]** 등록 끝에 `_epochs:Refresh()` + 조건부 `Rerun`)은 +**[2026-08-27 `H-144`]** 등록 끝에 `not _installed → Rerun`, 넷 다 첫 줄에 +**[2026-08-28 `H-147`]** `_running`/`_cleanupRunning` 가드 — `fn`/cleanup은 자기 구독을 +못 바꾼다)은 `base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스: ```lua @@ -479,8 +482,8 @@ function Observer:WeakUnsubscribe() -- `.Subscribed = false`로 **조용히 죽이면서** 강한 레지스트리엔 항목을 -- 남겨 **영원히 GC 안 되는** 반쪽짜리 해제가 된다(바로 아래에서 금지하는 -- 그것). 사용자 확정: fail-fast — `Subscribe()`로 건 건 `Unsubscribe()`로 - -- 푼다. 아래 `Unsubscribe`가 강한 킵을 **먼저** 지우고 위임하므로 자기 - -- 가드에 걸리지 않는다(순서가 계약이다). + -- 푼다. 아래 `Unsubscribe`는 이 함수에 위임하지 않고 양쪽을 직접 지우므로 + -- (**[2026-08-28 `H-149`]**) 이 가드와는 무관하다. if Subscribed[self] ~= nil then error("...: subscribed strongly; use :Unsubscribe()", 2) end @@ -494,7 +497,18 @@ end -- ── 그 위의 "GC 안 되게 킵" 한 겹 ─────────────────────────── function Observer:Subscribe() - self:WeakSubscribe() -- 게이트·플래그·약한 등록을 전부 여기서 + -- ⭐ [2026-08-28 확정, 10라운드 `H-149`] `self:WeakSubscribe()`에 **위임하지 않고 + -- 펼쳐 쓴다.** 위임하면 (1) `error(…, 2)`가 사용자 호출부가 아니라 이 본문을 + -- 가리키고(`H-104` level 계약 위반), (2) 콜론 위임은 서브 테이블의 오버라이드를 + -- 탄다(`H-144` (b)의 교훈). 사용자: *"weak 나 아닌거나 줄 차이가 그리 안 커서, + -- 분리할 큰 이유가 없음."* + if not canBound(self) then + error(if self.Subscribed + then "이미 구독된 값" + else "이미 Instance에 바인딩된 값", 2) + end + self.Subscribed = true + WeakSubscribed[self] = true Subscribed[self] = true -- 강한 킵 하나만 더 return self end @@ -508,16 +522,21 @@ function Observer:Unsubscribe() if Subscribed[self] == nil then error("...: not subscribed strongly; use :WeakUnsubscribe()", 2) end - Subscribed[self] = nil -- 강한 킵을 먼저 놓고 - return self:WeakUnsubscribe() -- 나머지는 프리미티브에 위임(양쪽 테이블 대칭) + Subscribed[self] = nil -- 강한 킵을 놓고 + WeakSubscribed[self] = nil -- 약한 쪽도 직접(양쪽 테이블 대칭) — [`H-149`] 위임 없음 + self.Subscribed = false + return self end ``` -- **게이트는 한 번만 돈다** — `Subscribe`가 `WeakSubscribe`에 위임하므로 - `canBound` 검사가 중복되지 않는다. +- **각 진입점이 자기 게이트를 정확히 한 번 돈다** — **[2026-08-28 `H-149`]** + 한때 "`Subscribe`가 `WeakSubscribe`에 위임하므로 검사가 중복되지 않는다"였는데 + 위임을 풀었다(위 주석). 중복되는 세 줄은 같은 타입 안이라 dot 호출 로컬 + 헬퍼로 빼도 되지만 **`error(…, 2)` 줄만은 본문에 남길 것** — 헬퍼 안에서 + 던지면 level 2가 헬퍼의 호출 줄(quad 내부)을 가리킨다(`H-104`). - **해제는 반드시 양쪽을 지운다.** `Unsubscribe`가 `WeakSubscribed`를 안 - 지우면 항목이 약한 테이블에 남아 반쪽짜리 해제가 된다 — 그래서 - `WeakUnsubscribe`에 위임하는 모양이 정본이다. + 지우면 항목이 약한 테이블에 남아 반쪽짜리 해제가 된다 — 위임 대신 두 줄을 + 직접 쓴다. - **⭐ 해제는 *건 경로로* 푼다 — 양방향 대칭 가드**(사용자 확정 2026-08-26). 강하게 구독된 값에 `WeakUnsubscribe`를 부르면 error, 약하게만 구독된 값에 `Unsubscribe`를 부르면 error. 후자가 없으면 **조용히 성공**해서 범용 정리 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index da6ab31..2895eff 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -241,8 +241,8 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 **⭐⭐ [2026-08-26 갱신, 8라운드 `H-107`/`H-108`] 아래 블록이 소스다.** 원래 이 블록은 2026-08-24에 쓰였고 하루 뒤(2026-08-25) 확정된 두 가지 — `.Revision` 갱신과 약한 콜백 테이블 — 를 **소급으로 못 받았다.** 그 - 블록대로 짜면 `Effect`의 캐치업(`_epochs:Refresh()`)과 `Update(ref)` - 판정이 전부 죽고, `Effect`가 건 `:WeakCallback`은 한 번도 발화하지 + 블록대로 짜면 `Effect`의 `Update(ref)` 판정이 전부 죽고(**[2026-08-28 `H-151`]** + 옛 `_epochs:Refresh()` 캐치업은 폐기됐다), `Effect`가 건 `:WeakCallback`은 한 번도 발화하지 않는다. 확정된 순서는 **값 → 리비전 → 콜백**이다: ```lua function Ref:Set(value) @@ -281,7 +281,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 (`function(inst) ... end`)은 두 번째 인자를 무시하면 그대로다. - **왜 리비전이 콜백보다 앞인가(`H-108`)** — 뒤면 콜백 안의 `Update(ref)`가 **옛 리비전**을 읽어 `false`를 돌려주고, 그 `Set`의 - `Rerun`이 접힌 채 다음 `Refresh()` 때에야 뒤늦게 돈다(간헐 지연). + `Rerun`이 접힌 채 **다음 `Set`의 리비전 차이**로나 돈다(간헐 지연 — + **[2026-08-28 `H-151`]** 옛 표기 "다음 `Refresh()` 때"는 캐치업이 폐기돼 + 더 이상 없는 경로). - **두 테이블을 다 훑는다** — `.Callbacks`(강한 셋)와 `.WeakCallbacks`(weak-키)를 **각각 스냅샷**해 한 배열로 잇되, **양쪽에 다 있는 키는 한 번만 싣는다**(*"중복 등록은 dedup이 계약"*이 @@ -440,9 +442,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`, `base/state-epoch-plan.md` §4·§8. 균일해지는 건 **판정 쪽**이다). 그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이 - **대칭**이 되고, 판정이 `local depsChanged = self._epochs:Refresh()` + - `if not self._installed or depsChanged then self:Rerun() end` 두 줄이 된다 - (`Refresh()`는 항상 먼저 — `base/effect-plan.md`의 `_bindDestroying` 캐비엇). + **대칭**이 되고, 판정이 `fire`의 `_epochs:Update(from)` 하나로 균일해진다 + (**[2026-08-28 `H-151`]** 재바인드 캐치업의 `Refresh()`는 폐기 — `_epochs`는 + emit을 받을 때만 갱신, `base/effect-plan.md`의 `_bindDestroying`). - 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로 **공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다. diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 1fe2cd7..114a9db 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -233,8 +233,9 @@ Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운 절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`는 별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 — 실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 **Slot의 -마운트 경로 자체**(`attachSlot` 분해분 — 별도 `Mount` 함수가 아니다, **[2026-08-27 -`H-146`]** v2에 v1식 `Mount(parent, tree)` 표면은 없다)가 이 강제를 담당. +마운트 경로 자체**(`attachSlot` 분해분 — 별도 `Mount` 함수가 아니다; **[2026-08-28]** +이미 있는 트리를 quad가 소유하는 `Claim`은 `research/existing-mount-plan.md`에서 +논의 중이고 그것도 이 단일 마운트 불변식을 그대로 진다)가 이 강제를 담당. Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만 구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서 @@ -2144,10 +2145,11 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워 넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라 -조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-27 확정, -9라운드 `H-146`] 이 금지의 범위는 *quad가 관리하는 자식 자리*다 — quad 트리의 -루트를 quad 밖 부모(`PlayerGui` 등)에 붙이는 `.Parent =`는 여기 해당하지 않고 -사용자가 밖에서 한다**(`base/bind-system-plan.md`의 `H-142` 항목). +조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-28 10라운드 +`H-148`]** 루트(`PlayerGui` 등 quad 밖 부모)는 사용자가 `.Parent =`로 붙이는 게 +아니라 **quad가 `Claim`으로 소유**하는 쪽으로 방향이 확정됐다 +(`research/existing-mount-plan.md`, M5 이후) — 그래서 이 금지에 예외가 없어진다. +(2026-08-27에 하루 있었던 "루트는 밖에서" 예외는 폐기.) ## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar) diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 78a643d..2e5374c 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -1329,7 +1329,10 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다 (**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉 -`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다. +`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이다(**[2026-08-28 `H-149`]** +"구현이 한 벌"은 이제 **의미**가 한 벌이라는 뜻이지 위임이 아니다 — `Subscribe`가 +`self:WeakSubscribe()`를 부르면 `error(…, 2)`가 quad 내부 줄을 가리키고 콜론 +위임이 서브 테이블 오버라이드를 타므로 인라인했다, `lifecycle-pattern.md` (2)). - **⭐⭐ [2026-08-26 확정, 8라운드 `H-111`] `WeakSubscribe`도 `.Subscribed = true`를 세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고, diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 4c3606a..b1de4df 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -97,7 +97,9 @@ store.hp:Compute(function(s) ... end) 실제로 지우는 것도 `Source → None → nil`로 핸들러 계열을 타고 말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다. - **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을 - 만든다(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에 + 만든다(**[2026-08-28 확정, 10라운드 `H-153`]** 그림자 = **store 자신** — `store.key`가 + 평범한 레코드 필드라는 계약과 맞고, 메소드는 `__index`에 있어 `Names()`가 안 + 센다; 별도 테이블 + 프록시 안은 기각. `table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에 키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미 `Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데 `table.clone(nil)`은 @@ -109,7 +111,11 @@ store.hp:Compute(function(s) ... end) 에러로 죽는다. `H-40`이 `:List` 요소 검증을 블랙리스트에서 화이트리스트로 뒤집은 것과 같은 성격의 자리다 — **사용자 확정**으로 여기도 화이트리스트를 둔다. `defaults`를 한 번 순회하며 `isSource(v)`가 거짓이면 - **`error(..., 2)`**(사용자 입력 검증이므로 `level 2`, 메시지는 영어 — + **`error(..., 2)`**(**[2026-08-28 10라운드 `H-153`]** 같은 순회에서 **예약 이름** + (`RESERVED` 테이블 — 아래 `Of` 절의 리터럴이 런타임 단일 소스; `__reservedCheck`는 + 팬텀이라 `__index`에서 도출할 수 없다)도 `error(..., 2)` — `Store({ Of = Source(1) })`가 통과하면 + 첫 `s:Of(…)`가 *attempt to call a table value*로 엉뚱한 자리에서 죽는다, 실측 + `t23`; 사용자 확정 *"나도 a 동의"*)(사용자 입력 검증이므로 `level 2`, 메시지는 영어 — `base/architecture.md`의 error 계약). 생성 시 1회라 hot path가 아니다. `isModifier` 쪽 가드는 여기가 아니라 **`Source` 생성자**가 맡는다 (`base/modifier-plan.md` 7번). @@ -192,11 +198,20 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 조회하면 `nil`을 `Source` 타입으로 돌려주고 호출부가 `:Get()`에서 타입 에러 없이 nil 역참조한다. ```lua + -- ⭐ [2026-08-28 `H-153`] 예약 이름의 **런타임 단일 소스**. 세 이름을 리터럴로 — + -- `__reservedCheck`는 팬텀이라 `__index`에서 도출할 수 없다(아래 주석). + -- 타입 함수 `CheckReservedKeys`의 목록은 이 테이블의 사본이라 이름을 바꿀 땐 + -- 둘을 같이(코퍼스의 다른 산문 나열은 전부 이 테이블을 가리키기만 할 것). + local RESERVED = { Of = true, Names = true, __reservedCheck = true } + function Store:Of(name) -- 동적 키 전용 - local src = shadow[name] + if RESERVED[name] then -- 동적 키는 타입이 못 막는다 + error("reserved store key: " .. name, 2) + end + local src = rawget(self, name) -- 그림자 = store 자신(`H-153`) — `__index`를 안 타게 raw if src == nil then src = Source() -- == Source(nil) - shadow[name] = src + self[name] = src end return src end @@ -280,9 +295,9 @@ type Store = T & { -- 감수하되, 두 가지를 문서화한다: -- (1) **읽지 말 것** — 사용자 표면이 아니다. -- (2) `store:Names()`(그림자 테이블의 키)에는 **안 들어간다**. 그래서 --- `store:Of("__reservedCheck")`는 런타임에선 그냥 통과해 충돌하는 --- `Source`를 만든다 — 타입 쪽은 `CheckReservedKeys`가 막지만 --- **동적 키는 이름이 타입에 안 실리므로** 못 막는다. +-- `store:Of("__reservedCheck")`는 타입 쪽 `CheckReservedKeys`가 못 막는다 +-- (**동적 키는 이름이 타입에 안 실린다**) — 그래서 **[2026-08-28 `H-153`]** +-- `Of(name)`과 생성자가 런타임 예약 이름 가드로 `error(…, 2)`한다(위). ``` **⭐⭐ [2026-08-26 확정, 8라운드 `H-112`] 예약 키 진단 타입 함수는 `T`가 아니라 @@ -327,8 +342,12 @@ end 같은 §0이 경고하는 내장 `index<>`/`keyof<>`도 **형제 필드의 `*error-type*`에 오염되지 않는다**는 것이 별도 실측(`CheckedQuad`)으로 재확인됐다. -**⚠️ [2026-08-26, `/code-review high`] 빈 Store(`Store<<{}>>()`)는 아직 실측 -안 됐다.** `H-83`은 무인자 생성이 유효해야 한다고 확정했는데(`or {}` 방어의 +**✅ [2026-08-28 실측 완료, 10라운드 `H-157`] 빈 Store(`Store<<{}>>()`)는 클린이다** — +최종형(`StateData`/`State` 쪼개기, `Source` 필드, `CheckReservedKeys>`) +에 `Store({} :: {})`를 넣으면 진단 0(`keyof<{}>`는 에러가 아니라 빈 유니온이라 +검사가 그냥 통과), `Names()`/`Of("dyn")` 정상(`audit/handtrace-round10-reference-impl/` +`ty11`). 아래는 실측 전 서술: **[2026-08-26, `/code-review high`] 빈 Store는 아직 +실측 안 됐다.** `H-83`은 무인자 생성이 유효해야 한다고 확정했는데(`or {}` 방어의 존재 이유), 위 실측은 **키가 있는 `T`로만** 돌았다. `keyof<{}>`가 Luau에서 빈 유니온이 되는지 에러가 되는지에 따라 **무인자 Store 전체가 스퓨리어스 타입 에러를 뒤집어쓸 수 있다** — `H-112`가 `CheckReserved`에서 찾은 실패 diff --git a/.claude/project-context.md b/.claude/project-context.md index fe57d1b..3c0f79f 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·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 그 반영분의 `/code-review`가 낸 셋(`H-147`~`H-149`)은 **[2026-08-28] 10라운드 문항지**(`-round10.md`, 광범위 탐사 결과와 함께 배치 회신 대기)로. 게이트 0. 저장소 루트에 +반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 **[2026-08-28] 10라운드**(`-round10.md`, 광범위 탐사 `H-150`~`H-157` 포함)도 같은 날 전량 결정·반영(소스 `-round10-followup.md`) — 둘이 뒤집혔다(`fn`은 자기 구독을 못 바꿈 / 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절 둘(게이트 아님). 저장소 루트에 `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가 diff --git a/.claude/qa-request/pre-implementation-handtrace-round10-followup.md b/.claude/qa-request/pre-implementation-handtrace-round10-followup.md new file mode 100644 index 0000000..86d369e --- /dev/null +++ b/.claude/qa-request/pre-implementation-handtrace-round10-followup.md @@ -0,0 +1,257 @@ +# 구현 전 손 트레이싱 **10라운드** — 결정과 반영 (2026-08-28) + +> **이 파일이 무엇인가**: `-round10.md` §4 문항 7건(+ 갈래 없는 넷)에 대한 +> 사용자 결정과 근거, 그리고 `base/` 반영 결과. **결정의 소스는 이 파일**이고 +> 발견 원문은 `-round10.md`. 사용자가 *"하나하나 같이 보자"*라고 해 이번 라운드는 +> 대화형으로 처리한다(배치 회신 대신) — 반영은 전부 정한 뒤 한 번에. + +## 진행 표 (상태의 소스) + +| 문항 | 무엇 | 상태 | +|---|---|---| +| `H-147` | 죽은 핸들에서 `Rerun()` | ✅ **확정 — 문항의 전제를 뒤집음**: `fn`/cleanup은 자기 생명주기를 못 바꾼다(A). `H-143`도 함께 소멸 | +| `H-148` | `Parent` 거부 문구 | ✅ **전제 정정** — 문구가 아니라 루트 마운트 표면의 부재. `research/existing-mount-plan.md` 신설(`Claim` + `D.Mapper`), `H-146` 루트 예외 폐기, 전용 문구 철회 | +| `H-149` | Observer `Subscribe` 위임과 `level 2` | ✅ **확정 (a)** — `Subscribe`/`Unsubscribe`도 게이트·등록을 인라인, 위임 없음 | +| `H-150` | `Effect._blocker` 죽은 부품 | ✅ **확정 (a)** — 제거, 억제는 Effect 핸들의 `canExecute` | +| `H-151` | 게이트 우회 계약 | ✅ **확정 — (a) 문서화 + `Refresh` 캐치업 폐기**: Effect의 `_epochs`는 emit 수신 때만 갱신, 재바인드/재구독은 초기 설치와 같다 | +| `H-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ⏳ 권고: 폐기 → `state:Apply(blocker)` | +| `H-159`~`H-161` | 반영 뒤 `/code-review high`가 낸 새 메커니즘 셋 (`-round10.md` §4 하단) | ⏳ 판단 대기 — 바인드 전 emit 캐치업 / `Destroying` 경로 cleanup `Rerun` / M5 루트 부착·다중 스크립트 `Claim` | +| `H-153` | Store 예약 이름 런타임 가드 | ✅ **확정 (a)** — 생성자·`Of(name)`에 예약 이름 검사(level 2), 그림자 = store 자신 (I) | +| `H-154` | `InstanceChildHandler` dedup | ✅ **확정 (a)** — retractor 첫 줄 `if nextValue == v then return end` | +| `H-152`/`H-155`~`H-157` | 갈래 없음 | ✅ 반영(`gate-plan.md` 조립 첫 줄 `StateBrand:register` / `ROADMAP.md` M6×3·M11 / `debounce-throttle-plan.md` 7절 `H-32` 문단 / `store-plan.md` 빈 Store 실측 완료) | + +## `H-147` — 전제 정정: `fn`/cleanup은 자기 구독을 바꿀 수 없다 (A) — `H-143` 소멸 + +문항은 "(a) UB / (b) `_everAlive` / (c) `wasAlive` 위치"였는데, 대화 중에 **문제의 +뿌리가 `H-143`의 허용 자체**라는 것이 드러났다. + +**경위**: +1. 사용자가 *"canExecute 자체가 유저함수인 cleanup 아래 있으면"*을 제안 → 그러면 + 생성자 최초 실행이 죽는 문제(감사 2라운드와 같은 함정)를 짚었고, `wasAlive`를 + cleanup 앞에서 잡는 절충을 냈다. +2. 사용자: *"처음부터 rerun 이 're'-run 인데도 초기 실행까지 담당하고 있잖아 … + rerun 자체에 인자로써 force: boolean? 처럼 주거나, rawRerun(force: boolean) 을 + 만들어 생성 시점과 실행 시점에서 이를 명시하는게 맞지 않아?"* → `wasAlive`는 + 호출자가 아는 사실("초기 설치냐")을 상태로 추론하던 편법이었음을 인정, + `rawRerun(self, force)` 분리 + `not force and not canExecute` 게이트 제안. +3. 사용자가 그것도 기각: *"not force 가지고 확인하면 안 될 부분같음. 초기 + 설치에서도 본인을 직접 죽이거나 바운딩을 걸거나 할 수 있잖아. … 유저 함수가 + 본인을 죽이고 살린다는점 자체가 모순이였다는 문제가 나와. 처음 실행해 unsub + 했는데, 아래에서 sub 해버릴 수도 있지. 이건 의도 동작일까? 게다가 unbind/bind + 는 본인이 못 해. 같은 계층으로 sub/unsub 가 본인이 할 수 있어야할 이유 제공 + 자체가 큰 그림에서 무언가 잘못된거 아닐까?"* +4. 갈래 (A) `fn`/cleanup의 자기 구독 변경 금지(leaf와 대칭) / (B) 자기 해제만 허용 + (비대칭 감수)을 올렸고 **사용자 확정 (A)**: *"그런것 같아. 나는 지원 안 할 이유가 + 안 보였었는데, 지금 보면 엄청난 모순이네. 나는 너의 권고처럼 A가 맞아보여."* + +**확정된 것**: +- **Effect의 생애는 묶은 쪽이 소유한다** — leaf면 Instance, `:Subscribe()`면 그 + 호출자. `fn`은 dep을 읽고 부작용을 내고 cleanup을 돌려주는 것까지. leaf가 + `fn` 안에서 unbind/bind를 못 하는 것과 **대칭**. +- **`Subscribe`/`Unsubscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 `self._running` + 또는 `self._cleanupRunning`이면 error(level 2, "cannot change subscription from + inside fn or cleanup")**. **[반영 뒤 감사 2라운드 정정]** 처음엔 `_running` + 하나로 적었는데 cleanup은 `rawRerun` 밖(`Unsubscribe()`·leaf `Destroying`)에서도 + 돌아 그 안의 `self:Subscribe()`가 가드를 지났다(재`Unsubscribe`만 레지스트리 + 가드에 걸렸다). 갈래 (a) `_consumeCleanup`이 `_running`을 세움 / (b) UB 문서화 / + (c) 별도 플래그 → **사용자 확정 (c)** `_cleanupRunning`: *"_running 으로 묶어 + 보는건 여전히 별로 괜찮은 이유가 없음. _cleanupRunning 같은걸 넣지 말아야할 + 이유가 없는것"* — 한 플래그에 두 뜻을 얹지 않는다. +- **`fn` 안에서 자기 leaf `inst`를 파괴하는 것은 UB**(같은 감사 2라운드 미서술 + 항목): `SignalBehavior = Immediate`면 `Destroying` 콜백이 `fn` 도중 동기 발화해 + cleanup이 영구 미소진. 사용자 확정: *"bind/unbind 에 간접 영향을 주는건데, UB + 인게 맞다는 생각."* — `effect-plan.md` `_bindDestroying` 아래 주석. +- **`H-143` 소멸** — `fn` 안 자기 해제 지원 철회. `Rerun` 본체는 원래 모양 + (`_consumeCleanup → fn → 저장`), `wasAlive`도 사후 판정도 없다. 종료 신호는 + 다시 **둘**(강한 `Unsubscribe` / leaf 사망). +- **`rawRerun(self, force)` / 공개 `Rerun()` 분리** — "re"-run과 초기 설치는 다른 + 일. 공개 `Rerun`은 진입에서 `canExecute` 게이트(죽은 핸들·안 묶인 핸들은 + **정의된 no-op**, `fire`와 같은 규칙), 생성자는 `rawRerun(self, true)`로 게이트만 + 건너뛴다. (A)에서는 `fn`이 전이를 못 일으키므로 `force`가 건너뛰는 것은 정확히 + "아직 안 묶임" 하나. 사용자 확인: *"unsub 뒤에 오는 rerun 은 그냥 실행 안 + 되는게 원래 정상적 형태"* — 맞다. +- **원샷("딱 한 번 처리하고 끝")은 지원 목록에서 빠진다** — 소유자가 밖에서 + `Unsubscribe`하거나, 나중에 `Once`류 슈가로 별도 결정. 코어에 넣지 않는다. + +**반영 대상**: `base/effect-plan.md`(`Rerun` 의사코드 → `rawRerun`/`Rerun`, `H-143` +관련 서술 전부 — 꼬리 분기·"종료 신호 셋"·"`fn` 안 허용 호출" bullet·`self`를 주는 +덕에 bullet, 네 진입점에 `_running` 가드, 실측 bullet), `base/lifecycle-pattern.md` +(포인터), `ROADMAP.md` M2 `Effect` 체크박스, `conventions.md`(원칙 사례로 추가 여부는 +반영 시 판단), `-round9-followup.md` `H-143` 절에 소멸 배너, `-round9.md` 요약 표 +`H-143` 행. + +## `H-148` — 전제 정정: 문구 문제가 아니라 루트 마운트 표면의 부재 → `Claim` + `D.Mapper` (research 신설) + +권고 (a)(전용 문구 철회)를 올렸더니 사용자가 더 큰 공백을 짚었다: *"slot 은 +물리 장치에 mount 할 방법이 거의 존재하지 않음. … PlayerGui 가 상위에 있고 +거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 될것 같은 +느낌이 듦. 이건 Parent 이상의 문제인것 같아."* → 이미 있는 트리를 quad가 +소유하는 `Claim(inst, D.Mapper. "Name" {…})` 제안(원문·확정·갈래 전량은 +`research/existing-mount-plan.md`). + +**확정된 것**: 방향 자체 / 디스크립터는 `D.Mapper`에(`D.Frame`에 직접 얹지 않음) +/ `Claim` DFS(내려가며 해석 → 자식부터 `drive`)는 derive 위의 한 겹 / 부기 대상 +자식은 전부 매핑, 숏핸드(`UI*`)는 부기 밖 — quad가 직접 쓰거나 실제 객체로 +매핑하거나 / 이름 중복·부재는 UB + debug 모드 `seen` 검사 / `nativeFindChild` +프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 이후. +**따름**: 전용 문구 **철회**(일반 매치 실패 그대로), `H-146` (a)의 "루트는 밖에서 +`.Parent =`" **폐기**(루트가 quad 소유). `H-142` 키 금지는 그대로. +**미결 6개**는 그 research 문서 §5 — 다음 배치 문항. + +## `H-149` — Observer `Subscribe`/`Unsubscribe`도 인라인 (a) + +**사용자 확정**: *"a 로 가는게 맞는듯. weak 나 아닌거나 줄 차이가 그리 안 커서, +분리할 큰 이유가 없음."* — `Observer:Subscribe`는 `self:WeakSubscribe()`에 +위임하지 않고 게이트(`canBound`, `error(…, 2)`)·플래그·약한 등록·강한 킵을 자기 +안에 펼친다. `Unsubscribe`도 같은 이유로 `self:WeakUnsubscribe()` 위임을 풀어 +양쪽 레지스트리 삭제를 직접 한다(콜론 위임 자체를 남기지 않는다 — `H-144` (b)의 +교훈). `lifecycle-pattern.md`의 *"게이트는 한 번만 돈다 — `Subscribe`가 +`WeakSubscribe`에 위임하므로"* 문장은 "각 진입점이 자기 게이트를 한 번 돈다"로. +`EffectHandle`의 넷과 모양이 같아진다(거기에 `H-147` (A)의 `_running` 가드가 +하나 더 붙는 것만 다름 — Observer엔 `_running`이 없다). + +**반영 대상**: `base/lifecycle-pattern.md` Observer 네 진입점 의사코드 + "게이트는 +한 번만 돈다" bullet, `base/effect-plan.md`의 `EffectHandle` 블록 머리 주석(Observer +위임 서술 참조 부분). + +## `H-150` — `Effect._blocker` 제거 (a) + +사용자가 확인한 전제: *"canExecute 와 별개로 처음 observer 생성에는 callback 이 +실행되어야하는게 맞잖아. … 그러니까, Effect 의 canExecute 를 보겠다는거지? 그럼 +그건 맞는것 같아."* — 두 층이 다르다: 내부 Observer/`Ref` 콜백의 **설치 발화는 +그대로 일어나고**(그 계약 불변), 그 콜백이 부르는 `fire`의 첫 줄 +`canExecute(self)`의 `self`가 **Effect 핸들**이라 생성자 안(아직 안 묶임)에선 +거기서 흡수된다. `_blocker`는 같은 흡수를 한 번 더 하려던 장치라 어떤 경로에서도 +판정에 닿지 않는다(실측 `t18`). `H-147` (A)로 `fn`이 생성자 안에서 자기를 묶을 +수도 없어졌으니 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식. + +**확정**: `_blocker` 필드·`On()`/`OffWithoutEmit()` 제거. 생성자 주석은 *"등록 +즉시 1회는 Effect의 `canExecute`가 막는다"*로. 7라운드 `H-58`의 지시(*"모든 +옵저버와 callback 등록에 있어서 이를 수행해야할 것임"*)는 그 전제("등록 즉시 +1회가 `Rerun`에 닿는다")가 성립하지 않았던 것으로 정정 배너. +**반영 대상**: `base/effect-plan.md` 생성자 의사코드·"확정 구조" 절·`H-58` 문단· +필드 목록, `base/gate-plan.md` 7번(`_installing` 잔재), `ROADMAP.md` M2 `Effect` +체크박스의 "선행: `Blocker` 기본 메커니즘" 문구(Effect 자체는 이제 Blocker 불필요 +— `Blocker.luau` 선행 요구는 `GateNode`/Slot 쪽만). + +## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기) + +**사용자 확정**: *"해당 우회는 더 크게 보면, 처음부터 Effect 가 Rerun 되는 +경로라서 그건 맞아. 그리고 Observer 에서 받은 emit 의 epoch|{epoch:boolean} 를 +받는 시점은 emit 되어야하는 시점이 맞기도 하지. 우린 애초에 Refersh 를 할 필요가 +없는거야. 재진입은 초기 설정해주는 요소이고, 그건 처음 생성할때랑 같은거야. 사실 +처음 생성할 때에도 Block 되어있던게 나중에 다시 들어오는 경로가 있어. 그 경우도 +그냥 재실행 해주지. 또 observer 도 마찬가지야. block 은 단지 유보만 해줄뿐이라서. +- Effect 도 observer 랑 똑같게, 중간 state 랑 똑같게, emit 받을때에만 epoch 맵을 +업데이트 하면 돼. 계약 추가로 끝나는 일로 보여"* + +**확정된 것**: +- **계약(문항의 (a))**: 게이트는 emit 경로만 미룬다. 재바인드/재구독 캐치업과 + 게이트 없는 형제 dep의 emit은 게이트를 거치지 않고, 그때 `:Get()`은 최신값. + 유보됐던 emit이 나중에 풀려 들어오면 그냥 재실행 — 생성 직후 유보분이 들어오는 + 것과 같은 경로. +- **`_epochs`는 `fire`의 `Update(from)`에서만 갱신** — Observer·중간 State와 + 같다. `_bindDestroying`·`resubscribeTail`의 `_epochs:Refresh()`와 `depsChanged`는 + **폐기**. 캐치업은 `if not self._installed then self:Rerun() end` 하나(초기 설치와 + 같은 뜻 — 소진돼 있으면 다시 설치). `H-144`의 "`Refresh` 먼저" 하위 결정과 + 그 단축평가 캐비엇(`ROADMAP.md`·`lifecycle-pattern.md`·`ref-plan.md`에 오늘 넣은 + 두 줄 형태)은 전부 이 결정으로 **소멸**. +- 따름: 죽어 있는 동안 떨어뜨린 emit은 다음 emit 때 리비전 차이로 잡힌다 — + Observer와 같은 정도의 "캐치업 없음"이고 계약으로 적는다. 재구독 뒤 게이트 + flush가 같은 값으로 한 번 더 도는 것(`H-144` 재트레이싱의 케이스)은 **허용** + (유보가 풀리는 정상 재실행). +- `EpochMap:Refresh`는 State의 `rawInvalid == false` 경로(`state-epoch-plan.md` + §4)에만 남는다 — Effect 소비자 삭제. + +**반영 대상**: `base/effect-plan.md`(`_bindDestroying`·`resubscribeTail`·`H-65` +캐치업 서술·`H-144` 블록 주석), `base/gate-plan.md`(계약 문장), +`base/debounce-throttle-plan.md` 11절, `base/lifecycle-pattern.md` 357행 주석, +`base/ref-plan.md` 244·284·443행(`Refresh` 전제 서술 — 284행의 "다음 `Refresh()` +때에야" 추론은 재검토), `ROADMAP.md` M2·M6 캐치업 문구, `-round9-followup.md` +`H-144` 절에 소멸 배너. + +## `H-158` — `state:Block(blocker)` 슈가 (이 대화에서 나옴, 미결) + +사용자: *"`:Block` 은 이제 없는거 아냐? Apply(Blocker) 이긴 할꺼야 (표면은 +:Gate 만 남아 Blocker 는 Apply 슈거와 Policy 를 주는 프리미티브)"* — 확인 결과 +`base/blocker-plan.md`에 `state:Block(blocker)`가 `state:Gate(function(emit) +return b:Policy(emit) end)` 위의 슈가로 **아직 있다**(60·94·163·178·207행). +갈래: (a) `:Block` 폐기, Blocker가 `state:Apply(factory)` 프로토콜을 만족해 +`state:Apply(blocker)`로 / (b) `:Block` 유지. **권고 (a)** — 동사 하나가 줄고 +"Blocker = Policy를 주는 프리미티브 + Apply 슈가"로 뜻이 하나. 반영 시 +`blocker-plan.md`·`gate-plan.md`·`debounce-throttle-plan.md`의 `:Block` 예시 전부. + +## `H-153` — Store 예약 이름 런타임 가드 (a) + +**사용자 확정**: *"나도 a 동의."* — 생성자의 `isSource` 순회에 `if RESERVED[k] +then error(…, 2) end`, `store:Of(name)`에 같은 검사. `H-122` 화이트리스트와 같은 +자리·같은 논거(조용히 받고 엉뚱한 자리에서 죽는 것을 fail-fast로). 부수로 +스케치의 "그림자 테이블"은 **store 자신**(I)으로 못박는다 — `store.key`가 평범한 +레코드 필드라는 계약과 맞고, 메소드는 `__index`에 있어 `Names()`가 안 센다. +`RESERVED`는 메소드 이름 집합(`Of`/`Names`/… — 구현 시 `__index` 테이블의 키에서 +자동 도출하면 두 곳에 안 적어도 된다, 권고). + +**반영 대상**: `base/store-plan.md` 구현 스케치·`Of` 절·`__reservedCheck` +주석(동적 키는 런타임 가드가 맡는다고 명시), `ROADMAP.md` M2 Store 체크박스. + +## `H-154` — `InstanceChildHandler` retractor에 같은 값 dedup (a) + +**사용자 확정**: *"a 동의. … 간단한 dedup 이고 말단 핸들러가 v 를 정확히 알아서 +retract 가 정확히 해소되는 부분이 맞네."* — retractor 첫 줄에 `if nextValue == v +then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil → inst`와 +`recompute` 2회가 사라진다. **반영 대상**: `base/dispatch-core-plan.md` `H-134` +문단, `ROADMAP.md` M5 `InstanceChild.luau` 체크박스. + +## 반영 기록 (2026-08-28) + +`base/`: `effect-plan.md`(`rawRerun`/`Rerun` 분리, `_blocker` 제거, `Refresh` 캐치업 +폐기, 네 진입점 `_running` 가드, `H-143` 관련 서술 전부) / `lifecycle-pattern.md` +(Observer `Subscribe`/`Unsubscribe` 인라인, 캐치업 주석) / `gate-plan.md`(7번 정정, +조립 첫 줄 브랜드, "계약 — 게이트는 emit 경로만 미룬다" 절 신설) / +`debounce-throttle-plan.md`(7절 `H-32` 문단, 11절 `Effect`) / `ref-plan.md`(`Refresh` +전제 셋) / `store-plan.md`(그림자 = store 자신, 예약 이름 가드 둘, 빈 Store 실측) / +`dispatch-core-plan.md`(`InstanceChildHandler` dedup) / `bind-system-plan.md`(전용 문구 +철회, 루트 예외 폐기 배너) / `slot-plan.md`(각주 둘). `archive/existing-instance-bind-rejected.md` +부활 배너. `ROADMAP.md` 배너·M2 `Effect`/`GateNode`/Store·M5·M6×3·M8·M11·백로그. +`-round9-followup.md`/`-round9.md`의 `H-143`/`H-144`/`H-146` 소멸·정정 배너. + +**남은 미결**: `H-158`(`state:Block` 슈가 폐기 → `state:Apply(blocker)`, 권고만) — +`question.md`에; `research/existing-mount-plan.md` §5 갈래 6개 — 다음 배치. + +## 감사 루프 (2026-08-28, 10라운드 반영분) + +`quad-doc-auditor` 한 턴에 하나, diff 범위, 각도 교체. 새 발견 3→5→2→3→1→**0** +(6라운드에서 수렴). 라운드별: 1 문구 잔존(ROADMAP 필드 목록 `_blocker` / 콜론 위임 +현재형 둘 / `rawRerun` 선언 순서) · 2 의미론(README `effect-plan.md` 행 / +`StateBrand:add`→`register` / 예약 이름 도출 권고와 팬텀 필드 모순 / +**`_running` 가드가 `Unsubscribe`·`Destroying`의 cleanup을 안 덮음 → 사용자 결정 +`_cleanupRunning`** / `fn` 안 자기 leaf 파괴 미서술 → UB) · 3 수정분 재검토(`Store:Of` +스니펫의 `shadow` 업밸류 / UB 괄호) · 4 전체 diff(`gate-plan.md` 새 절의 "4절" +인용에 문서명 / 인용문 한 구절 / README archive 행 부활 포인터) · 5 수렴 +확인(`CLAUDE.md` 볼드 짝) · 6 형식·라벨 정합 **0건**. + +## `/code-review high` (2026-08-28, 10라운드 반영분 + 감사 6라운드 뒤) + +10건. **일곱 반영**, **셋은 새 메커니즘·기존 결정 변경이라 문항**(`-round10.md` +`H-159`~`H-161`, `question.md`). + +반영한 일곱: +1. `bindLifetime` 경로에 (A)의 강제가 없었다 — `fn` 안 `New "Frame" { self }`로 + 자기를 leaf에 묶을 수 있었다. `_bindDestroying` 첫 줄에 `isRunning` 가드. +2. `guardNotRunning` 헬퍼 안의 `error(…, 2)`가 헬퍼 호출 줄(quad 내부)을 가리킴 + (`H-104`, `H-149`와 같은 이유) — 술어 `isRunning`만 헬퍼로, `error`는 다섯 본문에 + 인라인(`level 3` 선례를 만들지 않음). `lifecycle-pattern.md`의 *헬퍼로 빼도 된다* 문장에 + 단서. +3. `RESERVED`가 어디에도 정의되지 않았고 예약 이름 셋이 네 곳에 리터럴 — `Of` 절에 + `local RESERVED = {…}` 단일 소스, 나머지는 가리키기만. +4. `H-154` 서술 정확화 — dedup이 없애는 건 물리 detach/attach와 `recompute` **1회** + (2→1); process 쪽 skip은 옛 값을 몰라 안 둔다(`SlotHandler`의 `claimOwnerAt`과 + 다른 점 명시). +5. 산문 ↔ 의사코드 모순 — *기존 플래그 재사용, 새 상태 없음* / *재`Unsubscribe`는 + 레지스트리 가드* / *`fn` 안 직접 호출은 게이트하지 않는다* / `lifecycle-pattern.md`의 + *`_running` 가드*만 — 전부 `_cleanupRunning`·진입 게이트 반영. +6. `-round9-followup.md` 진행 표 행·`H-146` 절에 2026-08-28 소멸 표시, `todos.md`의 + *`Refresh` 먼저*·*뒤집힌 것 둘*→셋, *갈래 6개* 다섯 곳 → 개수는 §5가 소스. +7. stale 문장 — `effect-plan.md`의 *게이트가 아니라 `Blocker`를 쓰는 이유* / 포탈 + 근거를 *`H-64` 캐치업*으로 적은 것(재마운트는 `_installed` 참이라 캐치업이 아예 + 없다) / `source-state-plan.md`의 *구현이 한 벌*(위임 아님으로 정정). diff --git a/.claude/qa-request/pre-implementation-handtrace-round10.md b/.claude/qa-request/pre-implementation-handtrace-round10.md index 33229ab..1260a93 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round10.md +++ b/.claude/qa-request/pre-implementation-handtrace-round10.md @@ -8,7 +8,7 @@ > 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의 > "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것. > -> 상태: **[2026-08-28 기준] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) / 회신 대기.** `H-147`~`H-149`는 +> 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 미결로 남은 것은 거기서 새로 생긴 `H-158`(`:Block` 슈가)과 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는 > `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존 > 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`의 > 마지막 code-review 절). @@ -347,10 +347,53 @@ spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18` | **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 | | **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 | | **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 | +| **`H-159`** | **[2026-08-28 `/code-review`, 반영 뒤]** `H-151`이 잃은 캐치업 — 바인드 **전**에 온 emit(특히 `Ref`)은 다시 안 온다 | (a) `_bindDestroying`/`resubscribeTail`에 **"묶이는 시점 1회 `Refresh`"**만 되살림(emit 경로의 `_epochs` 갱신은 `H-151`대로 `Update`만) / (b) `Ref` dep만 바인드 시 `.Revision` 대조 / (c) 계약으로 두고 사용자에게 "`Ref`를 dep으로 쓰는 Effect는 그 leaf 뒤에 두라" 문서화 | **(a)** — `H-151`의 근거("다음 emit이 잡는다")가 `Ref`엔 성립하지 않는다; (a)는 `H-151`을 되돌리는 게 아니라 "emit 경로만 미룬다"는 계약과 양립(바인드는 emit 경로가 아님) | +| **`H-160`** | leaf `Destroying` 콜백이 도는 cleanup 안의 `self:Rerun()`/`dep:Set()` — `canExecute`가 아직 참이라 죽는 inst에서 `fn`이 돌고 새 cleanup이 영구 고아 | (a) `rawRerun` 진입에서 `_cleanupRunning`이면 **버린다**(no-op) — "cleanup은 자기 생명주기를 못 바꾼다"의 `Rerun`판 / (b) `Destroying` 콜백이 `_consumeCleanup` **전에** `.Subscribed`류 표식으로 죽음을 먼저 세움(새 상태) / (c) UB 문서화 | **(a)** — 새 상태 없이 기존 플래그 하나로, `Unsubscribe` 경로와 같은 결과 | +| **`H-161`** | `H-148` 이후 **M5에 승인된 루트 부착 경로가 없다** + 여러 스크립트가 같은 `PlayerGui`를 `Claim`하면 이중 claim error / 다중 quad UB라 `Claim`이 자기 동기 사례를 막는다 | (a) `Claim`을 **M5 스코프**로 당기고(프로바이더 마일스톤이라 자연스러움) `research/existing-mount-plan.md` §5-7·8 갈래를 같이 정한다 / (b) `Claim` 전까지 임시로 `H-146` 루트 예외(밖에서 `.Parent =`)를 M5 한정으로 되살림 / (c) 루트 컨테이너(부기 대상 아님)는 claim 없이 자식만 붙이는 얇은 표면 신설 | **(a)** — 임시 예외는 하루 만에 뒤집힌 것을 되살리는 것이고, (c)는 `Mount` 기각의 재개방. §5-7(다중 스크립트)은 `Claim`의 "전부 매핑" 계약이 **루트 컨테이너에는 안 맞는다**는 신호라 갈래를 그 문서에 적었다 | 갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), `H-156`(`H-32` 문단), `H-157`(실측 완료 표기). +**[2026-08-28 추가] `H-159`~`H-161`은 §4 문항 7건을 반영한 뒤 `/code-review high`가 +낸 10건 중 새 메커니즘·기존 결정 변경인 셋**(나머지 일곱은 반영 — `-round10-followup.md` +마지막 code-review 절). 상세는 아래. + +### `H-159` 🟡 — `H-151`이 잃은 캐치업: 바인드 전에 온 emit은 다시 안 온다 + +`D.Frame { Effect(function(self) if ref.Value then … end end, ref), D.TextButton { ref } }`. +Lua는 배열 원소를 순서대로 평가한다 — `Effect(...)`가 먼저 생성돼 `fn`이 1회 돌고 +(`ref.Value == nil`), 그다음 `D.TextButton { ref }`가 `ref:Set(button)` → `fire` → +`canExecute(E)` 거짓(아직 안 묶임) → **`_epochs`를 안 건드리고 버림**. 그 뒤 Frame의 +`drive`가 E를 leaf에 묶음 → `_bindDestroying` → `_installed == true`라 `Rerun` 없음. +`Ref`는 `Set`될 때만 발화하므로 **`fn`은 영원히 버튼을 못 본다**(배열 순서를 바꾸면 +된다 — 순서 의존 버그). 같은 구멍이 생성자 안에도 있다: `fn`이 자기 dep을 `Set`하면 +그 emit은 `fire` 첫 가드에서 버려지고(`self:Rerun()`처럼 지연되지 않는다) `_installed`가 +참이 돼 바인드가 재실행하지 않는다. 옛 `_epochs:Refresh()`는 둘 다 잡았다. `H-151`의 +근거 *"죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로 잡힌다"*는 **다음 +emit이 오는 dep**에만 성립한다. 갈래·권고는 §4 표. + +### `H-160` 🟡 — leaf `Destroying` 경로의 cleanup은 `canExecute`가 아직 참인 채 돈다 + +`SignalBehavior = Immediate`: `inst:Destroy()` → `Destroying` → `_unbindDestroying()`( +`_destroyConn`만 해제, gcconn은 아직 `.Connected`) → `_consumeCleanup()` → cleanup이 +`self:Rerun()`(또는 `dep:Set()` → `fire`) → `rawRerun(false)`: `_running` 거짓, +`canExecute` **참** → 죽는 inst에서 `fn`이 돌고 `_cleanup = c2`, `_installed = true`; +`_destroyConn`은 이미 nil이라 c2를 소진할 연결이 없고, 나중 포탈 재바인드는 +`_installed` 참을 보고 재설치를 건너뛴다(조용히 죽은 Effect). `Unsubscribe()` 경로가 +안전한 건 `.Subscribed = false`를 소진 **전에** 세우기 때문 — `Destroying` 경로엔 +그 대응물이 없다. `rawRerun` 주석의 *"해제 뒤 cleanup의 재요청 … 정의된 no-op"*은 이 +경로에서 거짓. 갈래·권고는 §4 표. + +### `H-161` 🟡 — M5에 승인된 루트 부착 경로가 없다 / `Claim`이 자기 동기 사례를 막는다 + +`H-148`이 `H-146`의 루트 예외를 폐기하고 `Claim`은 "M5 이후" 백로그라, M5(프로바이더· +`D`·`InstanceChildHandler`)가 끝나도 quad가 만든 트리를 `PlayerGui`에 붙이는 승인된 +경로가 코퍼스에 없다. 그리고 `research/existing-mount-plan.md`의 *이중 claim은 error / +여러 quad가 한 트리를 claim은 UB / 부기 대상 자식은 전부 매핑* 계약을 그대로 두면 +`Shop.client.luau`와 `Inventory.client.luau`가 각각 `Claim(PlayerGui, …)`하는 **가장 +흔한 사례가 error**다 — 그 문서 §5엔 이 문항이 없었다(추가: §5-7·§5-8). 갈래·권고는 +§4 표. + ## §5 이상 없다고 확인한 것 전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/ diff --git a/.claude/qa-request/pre-implementation-handtrace-round9-followup.md b/.claude/qa-request/pre-implementation-handtrace-round9-followup.md index 2915099..4c40d5d 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round9-followup.md +++ b/.claude/qa-request/pre-implementation-handtrace-round9-followup.md @@ -31,7 +31,7 @@ | — | `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` 발견 중 새 메커니즘 넷) | ✅ **확정·반영 (전부 권고 (a))** — `Rerun` 꼬리 실행 중 사망이면 즉시 소진(`wasAlive`) / 재구독 꼬리(`Refresh` 먼저; 감사 4라운드로 진입점 소유권 (b) — `EffectHandle` 자기 것 — 추가 확정) / `indexOfElement` weak-key / 루트는 금지 범위 밖 + 전용 문구 | +| — | `H-143`~`H-146` (`/code-review high` 발견 중 새 메커니즘 넷) | ✅ 2026-08-27 확정·반영 → **⚠️ [2026-08-28] 10라운드가 셋을 뒤집음**(`-round10-followup.md`가 소스): `H-143` 소멸(`fn` 안 자기 해제 지원 폐기, `H-147`) / `H-144` 꼬리는 유지하되 `Refresh` 먼저는 폐기(`H-151`), 진입점은 `EffectHandle` 자기 것 (b) / `H-145` weak-key **유지** / `H-146` 루트 예외·전용 문구 폐기(`H-148` → `Claim`) | **[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과 열어둔 확인은 아래 "반영 기록" 절. **같은 날 이어서 Q4~Q8·Q10·`H-138`·`H-139`를 @@ -664,7 +664,11 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음 아니거든) 해당 부분을 해결하기 위해서 다른 API 를 제공 할 이유가 없기도 해. 해당 부분은 각 엔진을 사용하는 최종 사용자의 몫."* -### `H-143` — `Rerun` 꼬리에서 실행 중 사망(`wasAlive and not canExecute`)이면 반환 cleanup 즉시 소진 (a) +### `H-143` — ⛔ [2026-08-28 소멸, 10라운드 `H-147`] `fn` 안 자기 해제 지원 자체가 폐기됨 — 아래는 하루 살았던 결정 + +**`-round10-followup.md` `H-147`이 소스.** `fn`/cleanup은 자기 구독을 못 바꾼다(leaf와 대칭), `Rerun` 꼬리의 사망 판정도 없다. + +#### (폐기) `Rerun` 꼬리에서 실행 중 사망(`wasAlive and not canExecute`)이면 반환 cleanup 즉시 소진 (a) - 하위 결정: 판정은 **"이 실행 중에 죽었는가"(`wasAlive and not canExecute`)** — `fn` 안 `WeakUnsubscribe`도 같은 경로로 소진된다. @@ -683,7 +687,7 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음 - 반영: `base/effect-plan.md` `Rerun` 의사코드 + "`self`를 주는 덕에" bullet (허용 문장이 확정으로), `ROADMAP.md` M2 `Effect` 체크박스. -### `H-144` — `Subscribe`/`WeakSubscribe` 등록 끝에 `Refresh()` 먼저, `not _installed or depsChanged → Rerun` (a) + 진입점은 `EffectHandle` 자기 것 (b, 감사 4라운드) +### `H-144` — `Subscribe`/`WeakSubscribe` 등록 끝에 재설치 꼬리 (a) + 진입점은 `EffectHandle` 자기 것 (b, 감사 4라운드) — **[2026-08-28 10라운드 `H-151`] `Refresh()` 먼저는 폐기**(`_epochs`는 emit 때만 갱신, 꼬리는 `not _installed → Rerun` 하나) 사용자가 요청한 **재트레이싱** 결과(회신의 두 질문): 1. *재구독 뒤 "다음 emit"이 오면 꼬여도 괜찮은가* — 괜찮다. `fire`의 가드 @@ -738,7 +742,11 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음 5번째 인자 근거 정리(weak가 되며 "옛 키 잔존" 근거는 사라지고 "지속 클로저 없음"만 남음), `ROADMAP.md` M3 `getBookkeeping` 체크박스. -### `H-146` — 루트는 금지 범위 밖, `Mount` 표면 없음, 거부는 전용 문구 (a) +### `H-146` — ⛔ [2026-08-28 폐기, 10라운드 `H-148`] 루트는 밖에서 `.Parent =`가 아니라 quad가 `Claim`으로 소유 — 아래는 하루 살았던 결정 + +**`research/existing-mount-plan.md`와 `-round10-followup.md` `H-148`이 소스.** 전용 문구도 철회. + +#### (폐기) 루트는 금지 범위 밖, `Mount` 표면 없음, 거부는 전용 문구 (a) - 인용문 *"외부에서 직접 Parent 설정해주지 말것"*의 범위를 **quad가 관리하는 자식 자리**로 명문화. 루트 부착은 엔진마다 다른 최종 사용자 코드. (b) diff --git a/.claude/qa-request/pre-implementation-handtrace-round9.md b/.claude/qa-request/pre-implementation-handtrace-round9.md index 96666c3..9799144 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round9.md +++ b/.claude/qa-request/pre-implementation-handtrace-round9.md @@ -57,10 +57,10 @@ high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견 | `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` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a)** | — | -| `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` | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) — `Refresh` 먼저, 두 구독 진입점 모두 꼬리; 진입점은 (b) `EffectHandle` 자기 것** | — | +| `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun`이 `fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) → [2026-08-28] 소멸**(10라운드 `H-147`: `fn` 안 자기 해제 자체가 폐기) | — | +| `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` | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) 두 구독 진입점 모두 꼬리; 진입점은 (b) `EffectHandle` 자기 것 — [2026-08-28] `Refresh` 먼저는 폐기(10라운드 `H-151`)** | — | | `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 | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) weak-key** | — | -| `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` | 리뷰 발견, 처방은 새 표면. **[2026-08-27] 확정 (a) 루트 예외 + 전용 문구** | — | +| `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` | 리뷰 발견, 처방은 새 표면. **[2026-08-27] 확정 (a) 루트 예외 + 전용 문구 → [2026-08-28] 둘 다 폐기**(10라운드 `H-148`: 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`) | — | --- diff --git a/.claude/question.md b/.claude/question.md index 214136a..fac704b 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,16 +12,23 @@ --- -## ⭐ 최우선 — 10라운드 문항지 (2026-08-28 갱신, 배치 회신 대기) +## ⭐ 최우선 — 10라운드 후속 둘 (2026-08-28 갱신) -**[2026-08-28] 10라운드 문항지가 `qa-request/pre-implementation-handtrace-round10.md`에 -있습니다** — §4 표를 위에서 아래로 읽고 갈래만 회신하시면 됩니다. 씨앗은 -`H-143`~`H-146` 반영분에 `/code-review high`가 낸 판단 대기 셋(`H-147` 죽은 -핸들에서 `Rerun` / `H-148` `Parent` 거부 전용 문구는 새 메커니즘 / `H-149` -Observer `Subscribe` 위임과 `level 2`), 그 아래에 신선한 탐사자의 **광범위 -탐사**(M2~M8, 레인 A/C/B/D — 지시서 `-round10-brief.md`) 결과가 이어집니다. -**M2 착수 게이트는 아닙니다**(셋 다 구현 중 정해도 되는 크기). 사용자 판단: -*"인간을 기다리는거 엄청 비효율이라서 … batch 로 처리될 필요가 있는듯"*. +**[2026-08-28] 10라운드 §4 문항 7건은 사용자와 대화형으로 전량 결정·반영됐습니다** +(소스는 `qa-request/pre-implementation-handtrace-round10-followup.md`). 그 대화에서 +새로 생긴 것만 남아 있습니다 — **둘 다 M2 게이트 아님**: +- **`H-158`** `state:Block(blocker)` 슈가가 `blocker-plan.md`에 아직 있다 — 사용자 + 언급(*"Apply(Blocker) 이긴 할꺼야 (표면은 :Gate 만 남아 …)"*)대로 **폐기하고 + `state:Apply(blocker)`로** 갈지 확인(권고 (a) 폐기). +- **`H-159`~`H-161`** (`-round10.md` §4 표 아래 셋, 10라운드 반영분에 + `/code-review high`가 낸 것) — `H-151`이 잃은 "바인드 전 emit(특히 `Ref`)" + 캐치업(권고 (a) 묶이는 시점 1회 `Refresh`만 복원) / leaf `Destroying` 경로의 + cleanup 안 `Rerun`(권고 (a) `_cleanupRunning`이면 `rawRerun` no-op) / **M5 + 루트 부착 경로 부재 + 다중 스크립트 `Claim`**(권고 (a) `Claim`을 M5로, 다중 + 스크립트는 권고 없음). +- **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는 + `Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트 + 사용·debug 검사 범위·표면 이름·마일스톤 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5 이후. **[2026-08-27] 9라운드**(Q1~Q10·`H-138`·`H-139`·`H-142`·`H-143`~`H-146`)는 전량 처리·반영됐습니다 — 소스는 `-round9-followup.md`. diff --git a/.claude/research/existing-mount-plan.md b/.claude/research/existing-mount-plan.md new file mode 100644 index 0000000..95812bd --- /dev/null +++ b/.claude/research/existing-mount-plan.md @@ -0,0 +1,159 @@ +# 이미 있는 트리를 quad가 소유하기 — `Claim` + `D.Mapper` (가칭) + +> **[2026-08-28 신설, 사용자 발의]** 10라운드 `H-148`(`Parent` 거부 문구)을 +> 논의하다 **더 큰 표면의 공백**이 드러나 만든 문서. 상태: **설계 논의 중 — +> 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 이후** +> (프로바이더 op가 필요). 결정이 나면 `base/`로 승격한다. +> +> **`archive/existing-instance-bind-rejected.md`(2026-08-14 기각)와의 관계**: +> 그 기각은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*였고 사유는 +> "quad가 만들지 않은 트리의 자식 구성을 바깥이 밀고 당기면 `setLength`/ +> `setOffsetSource` 부기가 깨진다"였다. 이 문서는 그 반대 방향 — **한 번 +> claim하면 quad가 소유하고 직계 자식은 사용자가 전부 매핑한다**는 계약이라 +> claim 뒤엔 quad가 만든 트리와 같은 불변식이 성립한다. **재바인드는 여전히 +> 미지원**(claim은 1회, 디스크립터는 `Processed`로 소진). 그 archive엔 "좁은 +> 형태로 부활"이라는 배너를 달 것(반영 시). + +## 1. 왜 필요한가 (사용자 원문) + +`H-146`이 "루트는 사용자가 밖에서 `.Parent =`"로 닫혔는데, 사용자가 이어서 +지적했다: *"slot 은 물리 장치에 mount 할 방법이 거의 존재하지 않음. Parent = +처럼 마운트 할 방법이 없는데? 그럼 PlayerGui 가 상위에 있고 거기에 GUI 를 +여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 될것 같은 느낌이 듦. 이건 +Parent 이상의 문제인것 같아."* — 즉 **루트가 Slot일 수 없다**(Slot은 quad가 +부기를 가진 부모 `inst` 아래에만 산다). 그리고: *"생성할 요소들 자체가 너무 +많은 경우 Clone 이 엇청 더 싸서, 그 Clone 된 것 아래 quad 를 바인딩 할 방법이 +있으면 좋은것도 사실인듯. … web 에서도 템플릿에 의해 유효한 요소일꺼고, +roblox 에서도 보면서 만들어낸 GUI를 바인딩하는건 흔한 요구라서 이 역시 흔한 +필요일꺼야."* + +결론(사용자): *"이런 방식으로, 이미 있는 PlayerGui 아래 마운트를 거는거지. +… 이것도 똑같이 Quad 가 소유하게 될 요소가 되는거지. … 이렇게 끝내면 parent +를 설정할 문제 자체가 사라져"* — **`H-146`의 루트 예외와 `H-148`의 문구 문제가 +같이 소멸**한다. + +## 2. 모양 (사용자 스케치 + 확정된 것) + +```lua +local M = D.Mapper -- 정의는 D 안에 산다. 유저가 필요하면 꺼낸다 +local cloned = Claim(template:Clone(), M.Frame "root" { -- ← 루트 이름은 미결(§5-1) + M.TextLabel "Title" { Text = title }, + M.Frame "List" { + Slot { … }, -- 기존 부모 아래 Slot — 이제 가능 + }, + BackgroundColor3 = color, -- props는 New와 같은 derive 테이블 +}) -- -> cloned (claim한 루트 Instance) +``` + +- **`D.Mapper. "Name" { … }`는 Instance를 만들지 않고 디스크립터만 + 만든다**(브랜드 `Mapper`류) — derive 테이블 + 매칭 키. `D.Frame`에 직접 얹지 + 않는다(사용자: *"D.Frame 에 바로 바인딩은 위험한듯. 의미가 겹쳐버려"*). +- **`Claim(inst, descriptor) -> inst`가 최상위**. `inst`는 quad 밖에서 온 것 + (PlayerGui, `Clone()` 결과, Studio에서 만든 GUI). 이 호출로 `inst`와 매핑된 + 하위 전부가 **quad 소유**가 된다 — `New`가 만든 것과 같은 gcconn/gchold·부기. +- **처리 순서는 `New`와 반대 방향에서 시작한다.** `New`는 안쪽 생성자가 먼저 + 평가돼 자연히 bottom-up이지만, 매퍼의 안쪽은 평가 시점에 자기 Instance를 + 모른다(부모가 아직 없다). 그래서 `Claim`이 **DFS로 내려가며 이름으로 해석 → + 자식부터 `drive` → 올라오며 부모 `drive`**. 사용자: *"핸들러가 된다면 위험해. + 일반 생성과 다르게, 상위 부터 처리하거든 … DFS 로써, 내려가는게 먼저고 그 + 뒤에서 derive 를 걸어야해. 이건 derive 에선 구현하지 않고, 그 위의 무언가로써 + 구현되어야할듯."* — `drive`/핸들러 층은 안 바뀌고 그 **위의 한 겹**이다. +- **매핑된 정적 자식은 `InstanceChildHandler`와 같은 부기(`setLength(inst,k,1)`)만 + 하고 `.Parent =`는 안 한다**(이미 거기 있다). Slot은 평소처럼 `native*`로 + 기존 부모 아래 끼운다. +- **디스크립터는 1회용** — claim 뒤 `Processed`로 소진(`PreRef`와 같은 관용구). + 재사용·이중 claim은 error. +- **매칭은 프로바이더 주입 op** `nativeFindChild(inst, key)`(가칭) — Roblox는 + `Name`, web은 id/selector. **quad-base가 순회·부기 전반을 구현하고 + 프로바이더는 이 핸들만 낸다**(사용자: *"quad-base 에서 전반을 구현해주고 + 필요 핸들을 구현하라고 남기는건 괜찮은 생각"*). +- **여러 quad 인스턴스가 한 트리를 claim — UB**(사용자 확정). +- **`H-142`(props에 `Parent` 금지)는 그대로.** 루트가 quad 소유가 되므로 + `H-146`의 "루트는 밖에서 `.Parent =`" 예외는 **폐기**. + +## 3. 계약 — 자식은 전부 매핑한다 + +사용자: *"모든 개체를 유저가 직접 네임을 매핑해서 derive 테이블 안에서 내부 +요소를 전부 매핑해준다를 계약으로 잡으면 문제가 없다고 생각해."* + +- **부기 대상(그려지는 자식)은 전부 매핑해야 한다.** 안 된 자식이 남으면 + `nativeInsert`의 삽입 위치(web은 곧 DOM 순서)와 Length/Offset이 어긋난다. +- **숏핸드(`UICorner` 등 `UI*`)는 부기 대상이 아니다** — 그려지지 않고 Roblox에만 + 있으며 단순 `Parent` 대입 요소. 사용자 확정: *"숏핸드를 quad 에서만 직접 + 쓰거나 … 아니면 실제 UI 객체를 바인딩해서 숏핸드를 안 쓰거나"* — 둘 중 하나: + (i) 템플릿엔 `UI*`가 없고 quad가 숏핸드 키로 만든다, (ii) 템플릿의 `UI*`를 + `M.UICorner "UICorner" {…}`처럼 **실제 객체로 매핑**하고 그 부모에 숏핸드 + 키는 안 쓴다. 섞으면(템플릿에 `UICorner`가 있는데 숏핸드 키도 씀) 둘이 + 생기는 것은 UB. +- **이름 중복·부재는 UB**(사용자 확정). **debug 모드**에선 `seen` 맵으로 중복을 + 잡아 error(사용자 제안) — 부재·클래스 불일치도 같은 자리에서 검사. + +## 4. 이 문서가 여는 것 + +- **루트**: `Claim(PlayerGui, M.ScreenGui "…" { Slot {…} })` — PlayerGui가 + quad 소유 부모가 되어 Slot이 그 아래 산다. `ScreenGui`를 `New`로 만들어 + 붙이는 경우도 `Claim(PlayerGui, M.PlayerGui(...) { New "ScreenGui" {…} })`처럼 + 정적 자식으로 들어간다(→ §5-3 루트 이름 문제). +- **템플릿 대량 생성**: `template:Clone()` → `Claim` — 각 사본이 독립 소유. + Claim이 Instance를 돌려주므로 **Slot 요소로도 그대로 쓸 수 있다**(요소는 + `inst`) — "요소가 너무 많은 경우"의 답. +- **비루트 사용**: `New "Frame" { Claim(clone, …) }` — 반환된 `inst`가 정적 + 자식으로 들어가면 `InstanceChildHandler`가 `Parent =`와 부기를 한다. 평가 + 순서상 `Claim`이 먼저 끝나므로 bottom-up이 유지된다. + +## 5. 미결 — 다음 배치 문항 + +1. **루트 디스크립터의 이름** — 루트는 `Claim`이 `inst`를 직접 받으니 매칭 키가 + 필요 없다. 사용자: *"최상위는 이름을 뭐로 둬야할지 아직 모르겠음. 비워두는걸 + D.Mapper.Frame{} 으로 제공하는건 더 나빠보이는데. 아니면 테이블로써 + MapperRoot = {} Mapper.Frame (MapperRoot) {} 모양이 되어도 될것같음."* + 갈래: (a) 센티널 `MapperRoot`(`M.Frame(MapperRoot) {…}`) / (b) 루트는 + `Claim(inst, { … })`처럼 클래스 없는 맨 테이블(클래스는 `inst`가 이미 안다) + / (c) 이름을 받되 무시. **권고 (b)** — 루트 클래스를 두 번 말하지 않고, + `M.`는 "찾아야 하는 자식"에만 쓰여 뜻이 하나가 된다. 단 타입(`D` + 생성기가 만든 props 타입)을 잃으므로 `Claim<<"Frame">>(inst, {…})`처럼 + 타입 인자로 보완 — `New<>`와 같은 관용구. +2. **물리 순서 계약** — 디스크립터 배열 순서와 기존 트리의 실제 순서가 다를 때. + Roblox는 물리 순서가 의미 없어 무관, web은 DOM 순서라 `nativeInsert` 위치가 + 어긋난다. 갈래: (a) 디스크립터 순서가 정본이고 일치는 사용자 책임(UB, + debug 검사) / (b) claim 시 quad가 `nativeMove`로 실제 순서를 디스크립터에 + 맞춘다. **권고 (a)** — "이미 있는 걸 그대로"의 취지, Roblox에선 비용 0. +3. **`New`로 만든 자식을 claim된 부모에 넣는 것** — 위 §4 첫 항목. 정적 자식이니 + `InstanceChildHandler`가 `Parent =`를 하면 되고 새 결정은 없어 보이나, + "매핑(이미 있음)"과 "생성(새로 붙임)"이 한 배열에 섞이는 것이 계약상 + 괜찮은지 확인 문항. +4. **debug 검사의 범위** — 이름 중복 / 부재 / 클래스 불일치 / 미매핑 부기 + 대상 자식 / 같은 quad의 이중 claim 중 어디까지. 권고: 전부(debug에선 싸다). +5. **표면 이름** — `Claim`/`Mount`/`Adopt`, `D.Mapper`/`D.Existing`. + `Mount(root, parent)`를 `H-146`에서 기각한 사유("부기 없는 대상에 quad 객체 + 주입")는 여기 반대로 적용된다 — claim은 부기를 *세우는* 행위. 권고 `Claim`. +6. **마일스톤** — M5 이후(`nativeFindChild`가 프로바이더 표면). `ROADMAP.md` + 백로그에 포인터만. **[2026-08-28 `/code-review`, `H-161`]** 단 `H-146` 루트 + 예외를 폐기한 지금 **M5에 승인된 루트 부착 경로가 없다** — (a) `Claim`을 M5 + 스코프로 당김 / (b) `Claim` 전까지 M5 한정 임시 예외 / (c) 루트 컨테이너용 + 얇은 표면. 권고 (a). +7. **[2026-08-28 `/code-review`, `H-161`] 여러 스크립트/여러 quad가 같은 루트 + 컨테이너를 쓰는 경우** — 위 "이중 claim error / 다중 quad UB / 부기 대상 자식 + 전부 매핑"을 그대로 두면 `Shop.client.luau`와 `Inventory.client.luau`가 각각 + `Claim(PlayerGui, …)`하는 **가장 흔한 사례가 막힌다**. 이건 "전부 매핑" 계약이 + **루트 컨테이너**(부기 대상이 아닌 `PlayerGui`·`CoreGui`류 — 자식 순서가 + 의미 없고 quad가 그 형제들을 관리하지 않는다)에는 안 맞는다는 신호다. 갈래: + (a) `Claim`은 **부기를 갖는 노드**에만, 루트 컨테이너엔 "quad가 만든 자식 + 하나를 붙이는" 별개 표면(부기 없음, 여러 스크립트 공존, 이름은 §5-5와 같이) / + (b) `Claim`에 "이 노드의 다른 자식은 관리하지 않는다"(부분 매핑) 모드 — 단 + web처럼 물리 순서가 의미 있는 엔진에선 위험 / (c) 다중 claim을 허용하되 각 + claim이 자기가 매핑한 자식만 소유(UB 대신 정의) — 부기 충돌 없음이 조건. + **권고 없음** — (a)는 `H-146`에서 기각한 `Mount(root, parent)`의 재개방과 + 경계가 얇고, (b)(c)는 계약을 약화시킨다. 사용자 판단. +8. **매핑된 정적 자식의 `Parent` 대입** — §2는 "부기만, `.Parent =`는 안 한다"인데 + `InstanceChildHandler`는 `v.Parent = inst`가 계약(`dispatch-core-plan.md` + `H-134`). 같은 핸들러를 쓰면 이미 거기 있는 자식에 같은 값을 재대입(엔진 + no-op)하는 것뿐이라 별도 핸들러가 필요 없어 보인다 — 확인 문항(권고: 같은 + 핸들러, 재대입 감수). + +## 6. 반영 시 고칠 자리 (승격 때 체크리스트) + +`archive/existing-instance-bind-rejected.md` 배너 / `base/bind-system-plan.md` +`H-142` 항목의 `H-146` 루트 bullet(폐기 → 이 문서 포인터) / `base/slot-plan.md` +"동적 자식은 반드시" 절 각주 / `ROADMAP.md` M5 `Property.luau` 전용 문구 삭제 + +백로그 항목 / `research/documentation-content-map.md` / `question.md`. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index ab6d314..2211d7e 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1978,4 +1978,15 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs 결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`. **[2026-08-28]** 감사 8라운드 수렴 → `/code-review high` 10건(일곱 반영, 셋은 판단 필요) → 사용자 판단으로 **10라운드 문항지**(`-round10.md`, `H-147`~`H-149` - 씨앗 + 광범위 탐사)로 이관, 배치 회신 대기. + 씨앗 + 광범위 탐사)로 이관. +- **`session/2026-08-28-01-handtrace-round10-resolution.md`** — 10라운드 7문항 대화형 + 결정·반영. **뒤집힌 것 둘**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147` (A) — + 어제 `H-143`의 원샷 지원 소멸, `rawRerun(force)`/`Rerun` 분리, 네 진입점 `_running` + 가드) / 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148` → + `research/existing-mount-plan.md`, 2026-08-14 기각과 다른 claim-once·own-all). + 나머지: Observer 진입점 인라인(`H-149`) / `Effect._blocker` 제거(`H-150`) / + `_epochs`는 emit 때만 갱신, `Refresh` 캐치업 폐기 + "게이트는 emit 경로만 미룬다" + 계약(`H-151`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 런타임 + 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`) / ROADMAP·debounce·store + stale(`H-155`~`H-157`). 미결 `H-158`(`:Block` 슈가). 소스는 + `qa-request/pre-implementation-handtrace-round10-followup.md`. diff --git a/.claude/session/2026-08-28-01-handtrace-round10-resolution.md b/.claude/session/2026-08-28-01-handtrace-round10-resolution.md new file mode 100644 index 0000000..607b5f2 --- /dev/null +++ b/.claude/session/2026-08-28-01-handtrace-round10-resolution.md @@ -0,0 +1,36 @@ +# 2026-08-28 — 10라운드 결정·반영 (대화형) + `Claim` 방향 + +**무엇을 했나**: 어제 밤 탐사자가 만든 10라운드 문항지(`-round10.md` §4, 7건)를 +사용자가 *"하나하나 같이 보자"*라 해 대화형으로 처리하고 `base/`·`ROADMAP.md`에 +반영했다. 결정의 소스는 `qa-request/pre-implementation-handtrace-round10-followup.md` +(사용자 발언 원문 전부 거기). 여기는 흐름과 문서에 안 들어간 것. + +## 흐름 + +1. `H-147`부터. 사용자의 첫 제안("`canExecute`를 cleanup 아래에")에 생성자 함정을 + 짚었더니 *"rerun 이 're'-run 인데 초기 실행까지 담당"*이라는 더 정확한 지적 → + `rawRerun(force)` 분리. 그 다음 턴에 *"not force 로 확인하면 안 될 부분"*과 + 함께 **뿌리를 뒤집었다**: `fn`이 자기를 sub/unsub할 수 있다는 것 자체가 leaf + (unbind/bind 불가)와 비대칭이고, 어제 `H-143`부터 오늘까지의 결함 넷이 전부 그 + 허용의 파생물. (A) 금지 확정. 어제 사용자가 *"지원 안 할 이유가 딱히 + 없다"*고 한 것을 스스로 *"엄청난 모순이네"*로 뒤집은 자리. +2. `H-148`에서 사용자가 더 큰 공백을 짚음 — 루트가 Slot일 수 없다(`PlayerGui` + 아래 `Slot { Shop{} }` 불가). 2026-08-14 기각(재바인드)과 다른 방향(claim-once· + own-all)임을 archive와 대조해 확인하고 `research/existing-mount-plan.md` 신설. + `H-146`의 "루트는 밖에서 `.Parent =`" 예외는 하루 만에 폐기. +3. `H-149`~`H-154`는 권고대로. `H-151`에서 사용자가 *"우린 애초에 Refresh 를 할 + 필요가 없는거야"* — 어제 `H-144`에서 세운 "`Refresh` 먼저" 하위 결정이 소멸. + `H-150`은 사용자가 "Observer 설치 발화는 일어나는 게 맞지 않나"를 확인한 뒤 + (Effect 핸들의 `canExecute`라는 것을 갈라 답함) 확정. +4. 그 대화에서 `:Block` 슈가 잔존(`H-158`)이 드러남 — 미결로 남김. + +## 시행착오 / 다음 세션이 알아야 할 것 + +- **어제 결정 셋이 하루 만에 뒤집혔다**(`H-143` 지원, `H-144` `Refresh` 먼저, + `H-146` 루트 예외). 셋 다 "권고 (a)를 사용자가 승인"한 것이었고, 문제는 갈래 + 자체가 **더 위의 질문**(소유권 / 캐치업이 필요한가 / 루트를 누가 소유하나)을 + 안 묻고 증상 층위에서 만들어졌다는 것. 다음 라운드 문항지는 "이 갈래들이 + 공유하는 전제가 뭔가"를 한 줄 적는 습관이 필요하다. +- 사용자가 결정 직전에 전제를 묻는 패턴(*"그게 진짜 날 수 있어?"*, *"canExecute 가 + 막는다가 말이 맞아?"*)이 두 번 다 유효한 정정으로 이어졌다 — 그때 "맞다"로 + 넘기지 말고 층을 갈라 답할 것. diff --git a/.claude/todos.md b/.claude/todos.md index f3cb651..47d77d6 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -48,12 +48,17 @@ Q9는 문항 전제가 틀린 것(Tween 절 스케치 한 줄 복사 오류)으로 닫힘. **[2026-08-27 기준] 감사 8라운드·`/code-review high`까지 돌렸다 — 리뷰 10건 중 여섯은 반영, 넷(`H-143`~`H-146`, 새 메커니즘)은 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 사용자 결정으로 - 전부 권고 (a) 확정·반영(`Rerun` 꼬리 즉시 소진 / 재구독 꼬리 `Refresh` 먼저(진입점은 `EffectHandle` 자기 것) / + 전부 권고 (a) 확정·반영(`Rerun` 꼬리 즉시 소진 / 재구독 꼬리(진입점은 `EffectHandle` 자기 것) / `bk.indexOfElement` weak-key / 루트 부착은 금지 범위 밖). **[2026-08-28]** 그 반영분에 `/code-review high`가 또 셋(`H-147`~`H-149`)을 냈고, 사용자 판단으로 **10라운드 문항지**(`qa-request/pre-implementation-handtrace-round10.md`)로 - 올려 신선한 탐사자의 광범위 탐사(지시서 `-round10-brief.md`)와 함께 **배치 - 회신 대기**. 남은 액션: 그 회신 처리 → M2 착수.** 아래는 돌리기 전(2026-08-26) 서술: + 올려 신선한 탐사자의 광범위 탐사(지시서 `-round10-brief.md`, 발견 `H-150`~`H-157`) + 와 함께 **같은 날 대화형으로 전량 결정·반영**(소스 `-round10-followup.md`). + **어제 결정 중 뒤집힌 것 셋**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸) / + 재구독·재바인드의 `Refresh` 캐치업 폐기(`H-151`) / 루트는 밖에서 `.Parent =`가 + 아니라 quad가 `Claim`으로 소유(`H-148`, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절의 둘 + (`H-158` `:Block` 슈가, `Claim` 갈래 — 개수는 `research/existing-mount-plan.md` §5가 소스) — 게이트 아님. 남은 액션: M2 착수.** + 아래는 돌리기 전(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 fdc676c..ee8b95d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,11 @@ 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~Q10·`H-138`·`H-139`·`H-142`, 그리고 `/code-review`가 -낸 `H-143`~`H-146`까지 전량 반영 완료**)이고, **[2026-08-28] `.claude/question.md` -최우선 절엔 10라운드 문항지(`-round10.md` §4 — `H-147`~`H-149` + 광범위 탐사 -결과)가 배치 회신 대기**로 올라 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`도 +낸 `H-143`~`H-146`까지 전량 반영 완료**; **[2026-08-28] 10라운드 몫은 +`-round10-followup.md`** — 광범위 탐사 `H-150`~`H-157`까지 전량 결정·반영, 그중 +`H-143`과 `H-146` 루트 예외는 하루 만에 뒤집힘)이고, `.claude/question.md` 최우선 +절엔 그 대화에서 새로 생긴 둘(`H-158` `:Block` 슈가 / `Claim` 갈래 — +`research/existing-mount-plan.md` §5)만 남아 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/ROADMAP.md b/ROADMAP.md index c4babea..c37d231 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,9 +28,14 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 > 부기 / `reconcile` 배치 Blocker / `New`·`drive` 파이프라인 / props `Parent` 금지), > 그 반영분에 `/code-review`가 낸 `H-143`~`H-146`(`Rerun` 꼬리 실행 중 사망이면 즉시 > 소진 / 재구독 꼬리 + 진입점은 `EffectHandle` 자기 것 / `bk.indexOfElement` -> weak-key / 루트 부착은 사용자 몫)도 같은 날 확정·반영. **[2026-08-28]** 그 반영분의 -> `/code-review`가 낸 셋(`H-147`~`H-149`)은 **10라운드 문항지**(`-round10.md`)에서 -> 광범위 탐사 결과와 함께 배치 회신 대기(게이트 아님). +> weak-key / 루트 부착은 사용자 몫)도 같은 날 확정·반영. **[2026-08-28] 10라운드** +> (`-round10-followup.md`가 소스) — 그중 둘은 하루 만에 다시 뒤집혔다: `fn`/cleanup은 +> 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸 · `rawRerun(force)`/`Rerun` 분리) / +> 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148`, +> `research/existing-mount-plan.md`, M5 이후). 그 밖에 `_epochs`는 emit 때만 갱신 +> (`Refresh` 캐치업 폐기, `H-151`) / `Effect._blocker` 제거(`H-150`) / Observer +> 진입점 인라인(`H-149`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 +> 런타임 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`). > **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에 > 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 > 있습니다). M1까지의 산출물은 @@ -374,6 +379,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 (`luau-test/STATUS.md`). - [ ] `Source.luau`/`State.luau`/`Store.luau` +- [ ] **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)`에 + **예약 이름 런타임 가드**(`error(…, 2)`) — 동적 키는 타입이 못 막는다; + 그림자 = store 자신(`base/store-plan.md`). - [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<>(name): Source` — 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 기각). **콜론 메소드로 확정**했고, 예약 키 @@ -479,13 +487,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). -- [ ] `Effect(fn, ...deps)` — **⚠️ 선행: `Blocker`의 기본 메커니즘** - (`On`/`Off`/`IsOn`/`OffWithoutEmit`). 생성자가 등록 구간 억제에 사적 - `Blocker` 하나를 쓴다(`base/effect-plan.md`). 아래 `Blocker.luau` - 체크박스가 이 항목보다 뒤에 있지만 **그 기본 넷은 `GateNode`/`:Policy`와 - 무관하게 독립 완결**이라(`base/blocker-plan.md`의 "메커니즘" 절) 그 - 부분만 먼저 만들면 된다 — `:Policy`/`state:Block` 배선은 `GateNode` - 뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드 +- [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~ + (**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적 + `Blocker`는 `fire` 첫 줄의 `canExecute`가 이미 같은 억제를 해서 한 번도 + 판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/ + Slot 쪽 요구뿐.) (`base/effect-plan.md`, **[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** (State/Source는 `Observer`, `Ref`는 `:WeakCallback` — **[2026-08-27 @@ -498,16 +504,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). - **[2026-08-27 9라운드 `H-143`/`H-144`]** `Rerun` 꼬리는 `fn` 실행 중에 - 핸들이 죽었으면(`wasAlive and not canExecute`) 반환 cleanup을 **즉시 - 소진**하고 `_pending`을 버린다(`fn` 안 `self:Unsubscribe()` 지원 — - `not canExecute` 하나로 판정하면 생성자 최초 설치가 죽는다), 네 진입점은 - **`EffectHandle` 자기 것**(Observer 함수 본문을 배정하지 않는다 — 공유는 - `Observer.luau`의 레지스트리 둘과 `canBound`뿐; 콜론 위임이 오버라이드를 - 타 꼬리가 두 번 도는 걸 감사가 잡아 사용자가 (b)로 확정), `Subscribe`/ - `WeakSubscribe`는 등록 끝에 `_epochs:Refresh()` + `not _installed or - depsChanged → Rerun` 꼬리(재구독 재설치, leaf `_bindDestroying`과 동형) — - 의사코드는 `base/effect-plan.md`. + **[2026-08-28 10라운드 `H-147`]** `rawRerun(self, force)` 본체 + 공개 + `Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들은 정의된 no-op), + 생성자는 `rawRerun(self, true)`. **`fn`/cleanup은 자기 구독을 못 바꾼다** — + 네 진입점 첫 줄에 `_running` 가드(2026-08-27의 "`fn` 안 `Unsubscribe` 지원" + `H-143`과 `wasAlive` 꼬리는 소멸). 네 진입점은 **`EffectHandle` 자기 것** + (`H-144` (b) — 공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐), + `Subscribe`/`WeakSubscribe`는 등록 끝에 `not _installed → Rerun`(재구독 + 재설치; **[`H-151`]** `_epochs:Refresh()` 캐치업은 폐기 — `_epochs`는 + `fire`의 `Update`에서만 갱신) — 의사코드는 `base/effect-plan.md`. **동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md` "동적 경로 가드" 절, 2026-08-14 열한 번째 세션) **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 @@ -525,8 +530,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 있었으나 근거 없는 서술이라 삭제됐다(사용자 확정: 두 콜백은 이질적이고, Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 · - **`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing` - 플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) · + ~~**`handle._blocker`**~~(**[2026-08-28 `H-150`]** 제거 — 등록 구간의 + 즉시-1회 호출은 별도 필드 없이 `fire` 첫 줄의 `canExecute`가 억제한다; 옛 + `_installing` 플래그도 생성자 구간만 덮어 폐기됐었다) · **`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가 같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · @@ -543,9 +549,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/ `:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 (`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다 - (`H-7`). 캐치업은 바인드 직후 **조건부 최대 1회** - (`local depsChanged = self._epochs:Refresh()` **먼저**, 그다음 `if not self._installed or depsChanged then self:Rerun() end` — `or` 한 줄로 단축평가에 걸면 재설치 경로에서 `Refresh()`가 건너뛰어져 다음 emit이 헛돈다, `base/effect-plan.md` 캐비엇 - — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 + (`H-7`). 캐치업은 바인드 직후 **재설치 1회뿐**(`if not self._installed then + self:Rerun() end` — **[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()`는 폐기, + `_epochs`는 emit 받을 때만 갱신 — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 - [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 @@ -575,6 +581,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) — emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드 (`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦). + **[2026-08-28 10라운드 `H-152`] 조립 첫 줄은 `StateBrand:register(node)`** — + 빠지면 `_emitDown`의 `isState`가 거짓이라 통지만 조용히 죽는다(`base/gate-plan.md` + 조립 절). **[`H-151`]** 게이트는 emit 경로만 미룬다는 계약(같은 문서). 유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을 통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet` 스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를 @@ -950,7 +959,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `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`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **[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` 항목이 그렇게 갈라 적음; **[2026-08-27 `H-146`]** 그 거부는 **전용 에러 문구** — "`Parent` is not a prop" 취지 — 를 내고, 루트를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`로 한다(`Mount` 표면 없음, 같은 항목)), `Handlers/InstanceChild.luau` — +- [ ] `Handlers/Property.luau`(**[2026-08-27 9라운드 `H-142`]** `isHandlable`이 `"Parent"` 키를 **거부**한다 — 매치 핸들러가 없어지면 `Dispatch.process`의 "매치 핸들러 없음 → 즉시 error"에 걸리는 것으로 런타임 가드가 공짜로 생긴다, 새 메커니즘 없음. **사용자 확정은 "props에 `Parent` 금지"라는 규칙이고, 이 거부 배선은 에이전트 선택** — `base/bind-system-plan.md`의 `H-142` 항목이 그렇게 갈라 적음. **[2026-08-28 10라운드 `H-148`]** 전용 문구는 **철회**(일반 매치 실패 그대로) — 루트는 밖에서 `.Parent =`가 아니라 quad가 `Claim`으로 소유하는 쪽으로(`research/existing-mount-plan.md`, 아래 백로그)), `Handlers/InstanceChild.luau`(**[2026-08-28 `H-154`]** retractor 첫 줄 `if nextValue == v then return end` — 같은 값 재발행 dedup, `SlotHandler` 동형) — **⭐ [2026-08-27 9라운드 `H-134`] `InstanceChildHandler`도 말단이라 부기를 등록한다**: `process`에서 `setOffsetSource(inst, k, None)` → `v.Parent = inst` → `setLength(inst, k, 1, inst)`(정적 단일 자식은 상수 @@ -961,6 +970,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 문단. 빠뜨리면 `Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 — `base/dispatch-core-plan.md`의 `H-39` 블록(그 다섯째 항목)이 소스. +- [ ] **[2026-08-28 백로그, M5 이후]** `Claim(inst, D.Mapper. "Name" {…})` — 이미 있는 트리(PlayerGui·`Clone()` 사본)를 quad가 소유. 프로바이더 op `nativeFindChild` 필요. 갈래 미결 — `research/existing-mount-plan.md` §5가 소스(개수도), 다음 배치 문항. - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 @@ -1190,8 +1200,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - **해제 시 owner 등록 되돌리는 순서 고정** — `setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**. 반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset - `Source`에 헛된 `:Set()`을 날림. `recompute`는 `sourceList[i]`가 `nil` - 이어도 `None`처럼 skip(방어). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]** + `Source`에 헛된 `:Set()`을 날림. `recompute`는 `sourceList[i]`가 `nil`이면 + **즉시 `error`**(부기가 깨졌다는 신호 — 4라운드 `C-6`; **[2026-08-28 + `H-155`]** 여기 한때 "`None`처럼 skip(방어)"라 적혀 있었다, `base/slot-plan.md`가 소스). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]** 여기 한때 *"해제 시 `slot.Offset = nil`"*이 붙어 있었는데 그건 4라운드 `SL-75`/`D-60`이 **폐기**한 문장이다 — `nil`로 갈아치우면 그 Source를 구독 중인 다운스트림이 끊겨 포탈이 깨진다. `slot.Offset`은 생성자에서 @@ -1202,8 +1213,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 spurious 재발행만 `false`, `Frame{slot,slot}`은 error). `releaseOwner`는 불일치 시 즉시 error. - **`rawRemove`가 `releaseOwner`를 부를 것**(옛 의사코드에서 누락돼 있었음), - **`destroySlotTree`가 자식 소유권 반납 + `_mounted`/`_mountedInst` 복원** - (GC에 맡기면 재사용이 GC 타이밍 의존으로 비결정적 실패). + ~~**`destroySlotTree`가 자식 소유권 반납 + `_mounted`/`_mountedInst` 복원**~~ + (**[2026-08-28 `H-155`]** 이 수정은 5라운드 `C-4`로 **되돌려졌다** — + `base/slot-plan.md`의 `State` 재설정 표 정정 문단이 소스). - **`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함** — no-op을 반환하면 다음 진짜 교체 때 정리 주체가 사라짐(`retractFrom`은 클로저가 early-return해도 체인에서 항상 소비하므로). @@ -1300,7 +1312,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **`data:Observer(fn)` 구독은 `:List()` 호출 시점이 아니라 Slot 마운트 시점까지 lazy — `Dispatch.setLength`와 같은 패턴으로 `bindLifetime(inst,observer)`(마운트 이후 `:List()`가 불리면 - `self._mounted` 확인 후 즉시 활성화)** (2026-08-09 일곱 번째 세션, + `self._physicalTarget` 확인 후 즉시 활성화 — **[2026-08-28 `H-155`]** 옛 + `_mounted` 기준은 6라운드 `H-2`로 바뀌었다)** (2026-08-09 일곱 번째 세션, `base/slot-plan.md` "`Slot:List(data, updateFn, keyFn?)`"의 "구독 시점" 절) **`Slot.Offset: Source`도 `Slot.Length`처럼 공개 필드로 노출 — `Length`와 같은 자리, 즉 생성자에서 `Source(0)`으로 만들고 마운트 @@ -1501,9 +1514,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고 `_bindDestroying`은 **`Ref` dep 콜백을 (재)등록하지 않는다** — dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과 - **조건부 캐치업 두 줄**(`local depsChanged = self._epochs:Refresh()` 뒤 - `if not self._installed or depsChanged then self:Rerun() end` — `Refresh()`를 - `or` 뒤에 두면 단축평가로 건너뛰어진다)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 + **재설치 캐치업 한 줄**(`if not self._installed then self:Rerun() end` — + **[2026-08-28 `H-151`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`와 `base/effect-plan.md`가 소스. **[2026-08-14 @@ -1675,8 +1687,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음) - [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 - (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 - 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 + (**[2026-08-28 `H-155`]** `base/tween-plan.md`의 "3-상태 저장" 절이 소스 — + 활성 트윈은 엔진 객체가 아니라 `{Tween, Value}` **테이블**, `Tween.Finish`가 + 목표값을 알아야 해서; 옛 표기 `RobloxTween | true | nil`) + 첫 세팅은 무조건 애니메이션 없이 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) - [ ] `quad-roblox/Animate.luau` — **시그니처도 이미 확정 완료**(2026-08-12