diff --git a/.claude/README.md b/.claude/README.md index dc70460..732dd4e 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -57,9 +57,9 @@ | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 **[2026-08-24 6라운드 `H-35`]** `flatten`이 만드는 `ProcessedModifier` 센티널을 받는 **`ProcessedModifierHandler`의 의사코드가 없고 색인 두 곳에서도 빠져 있던 것**을 보강 — `Modifier`가 하나라도 든 리터럴은 **전부** 이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를 놓친다. 소속 파일은 `quad-base/Dispatch/Modifier.luau`(`architecture.md` 소스 트리와 `ROADMAP.md` M7에도 등재) **⭐ [2026-08-26 자리 정정, 8라운드 `H-122`]** `isModifier` 가드의 적용 지점에서 *"Store 생성 시 각 `defaults` 키를 `Source(v)`로 만드는 시점"*이 빠졌다 — 명시적 초기화 이후 **Store는 `Source`를 안 만든다**(코드상 없는 자리였다). 가드는 **`Source` 생성자**로 옮겨 defaults 경로를 자동 커버하고, Store 생성자는 대신 `defaults`를 `isSource` 화이트리스트로 런타임 검증한다(error level 2). | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | -| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-24 재확정]** 구현은 State 코어와 같은 **M2**이고(2026-08-22에 디스패치 쪽으로 앞당겼다가 마일스톤 순서 교체로 되돌아옴), 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) **[2026-08-24 6라운드 `H-33`/`H-49`]** **`blocker:Policy(emit) -> onUpstreamEmit`** 신설 — 자기 게이트 정책을 값으로 내주고 `state:Block(b)`가 그 위의 얇은 래퍼가 된다. `Debounce`/`Throttle`이 이걸로 자기 Blocker를 조종한다 | +| `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:Apply()` 없이 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:Apply(b)`가 그 위의 얇은 래퍼가 된다. `Debounce`/`Throttle`이 이걸로 자기 Blocker를 조종한다 **[2026-08-28 `H-158`]** `state:Block(blocker)` 동사 폐기 — 배선은 `state:Apply(blocker)`(`Blocker.__apply`) | | `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`였으나 **[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`로 이전. | +| `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 self._rerunRequired 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`는 등록 끝에 `_rerunRequired → Rerun`(재구독 재설치 + 홀드된 변경, **[2026-08-28 `H-159`]** `_installed`는 이 플래그로 통합). **[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 계약 전체에 예외가 생겨 오버엔지니어링) | @@ -71,7 +71,7 @@ | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) **[2026-08-24 6라운드 `H-24`]** `:Mapped` 절에 타입 단서 추가 — 그 시그니처는 재귀 제네릭 누수 패턴이라 **인라인이 아니라 `typeof(named function)`으로 선언해야** 체커가 지켜준다(`base/typing-limits.md`). "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는 뜻이 아니었다 | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 **⚠️ [2026-08-24 6라운드 `H-26`] 미해결 항목이 하나 신설됐다** — **실패 이전에 생성된 부분 트리는 회수되지 않는다.** quad Instance는 gcconn 때문에 `Destroy`로만 회수되는데, 컴포넌트가 리터럴을 만들다 던지면 그때까지 완성된 형제/자손이 트리에 붙지도 파괴되지도 않은 채 사라지고 `Fallback`은 그 존재를 알 방법이 없다. **`Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는 대표 사용처**라 층위가 다르다 — **백로그**(그 둘이 슈가라 구현 시점에 같이 다룬다, 그 문서의 ⚠️ 절이 소스) | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. | -| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 | +| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 | | `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) @@ -99,7 +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 게이트 아님 | 중 — 다음 배치 문항 | +| `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 스코프**(`H-161`), 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/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index b0fe8d4..ad701ea 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -278,20 +278,21 @@ quad/ │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) -│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive`의 `None` 스킵 분기 폐기) +│ │ ├── None.luau # (**[2026-08-28 `H-162`]** 센티널 `None`과 같은 급으로 quad-base가 export하는 단일 no-op 함수 **`Void`**는 의존 없는 잎 모듈 `Void.luau`(아래)에 정의하고 최상위 `init.luau`가 재export한다 — `Dispatch/init.luau`가 파일 스코프 `local NOOP = Void`로 쓰므로 최상위에 두면 순환 require(`/code-review` 지적); 핸들러 retractor·cleanup 자리의 `function() end`를 전부 대체) NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive`의 `None` 스킵 분기 폐기) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) │ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) │ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**) │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈 │ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`) │ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`) +│ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정) │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 -│ └── init.luau +│ └── init.luau # 패키지 최상위 export — `Quad` 값 테이블(`New`/`Source`/…/`None`/**`Void`**(재export — 정의는 위 `Void.luau`, `H-162`)) └── quad-roblox/ ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── src/ diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1746e18..5a57ee2 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -342,7 +342,7 @@ end 상위에 있고 거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 될것 같은 느낌이 듦. 이건 Parent 이상의 문제인것 같아."* 루트는 밖에서 `Parent`를 만지는 게 아니라 **quad가 `Claim`으로 소유**한다(PlayerGui·`Clone()` - 사본·Studio GUI — 그 research 문서가 소스, M5 이후). 그러면 `.Parent =`를 + 사본·Studio GUI — 그 research 문서가 소스, **M5 스코프**(`H-161`)). 그러면 `.Parent =`를 사용자가 쓸 자리 자체가 없어진다. 아래는 폐기 전 서술: **[2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 — quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`로 diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 3abfe43..1550dc6 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -16,7 +16,20 @@ 문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이 위험을 구조적으로 우회한다. -**store 개발(M2)과 밀접하게 연관됨** — `state:Block(blocker)`가 State +**⭐ [2026-08-28 확정, 10라운드 `H-158`] `state:Block(blocker)` 동사는 폐기 — 배선은 +`state:Apply(blocker)`다.** 사용자: *"이제 Compute 와 유사하게 Gate 만 놓일 뿐, Apply 로 +Blocker 처리가 가능함. 왜냐면 펑터와 적용성펑터를 전부 허용하니까 ( map|{...map} ) 키는 +`__apply` 로 하기로 했던거로 기억중임."* — `source-state-plan.md`의 "`state:Apply(factory)`" +절이 애플리커티브 팩토리를 **지정된 필드**로 받기로 확정해뒀고(필드 이름은 "구현 시"로 +열려 있었다 — 이 발언으로 **`__apply`**), `Blocker`가 그 필드를 **메소드**로 노출한다 — `function Blocker:__apply(state) +return state:Gate(function(emit) return self:Policy(emit) end) end`(`self` = 이 blocker +인스턴스; 호출 규약은 `source-state-plan.md`의 "`state:Apply(factory)`" 절). 아래 본문의 +`state:Apply(blocker)`는 전부 옛 `state:Block(blocker)`를 기계 치환한 것 — 의미는 같다 +(2026-08-21에 *"`Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다"*고 +예고한 그것). 이 문서는 **`Blocker` = `Policy`를 주는 프리미티브 + `__apply` 슈가**를 +서술한다. + +**store 개발(M2)과 밀접하게 연관됨** — `state:Apply(blocker)`가 State 위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. **[2026-08-24 재확정] 구현 마일스톤은 다시 M2다 — "State와 같은 @@ -57,14 +70,15 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 -- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래 -- "이름 확정" 참고), 호출부 가독성만을 위한 추가. -state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 +state:Apply(blocker) -> state -- [2026-08-28 `H-158`] `Blocker.__apply`를 통한 `state:Apply` — + -- 옛 `state:Block(blocker)`. 새 gated state 반환. **호출되는 즉시**(나중에 -- 처음 블록될 때가 아니라) onunblock 핸들을 -- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관"). blocker:Policy(emit) -> onUpstreamEmit -- [2026-08-24 신설] 이 blocker의 게이트 정책을 - -- **값으로** 돌려준다. `state:Block(b)`가 + -- **값으로** 돌려준다. `state:Apply(b)`가 -- 내부에서 쓰는 바로 그것: - -- state:Block(b) + -- state:Apply(b) -- == state:Gate(function(emit) return b:Policy(emit) end) -- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)`라 -- 적었었는데 그건 언바운드 메소드라 `emit`이 `self` @@ -91,10 +105,10 @@ gated state의 동작: 안 한다"**로 일반화된다(`base/gate-plan.md`의 8번). 구현 시 두 개를 따로 들지 말 것. -**⭐ [2026-08-21 신설] `state:Block(blocker)`는 `state:Gate(setup)` 위에 +**⭐ [2026-08-21 신설] `state:Apply(blocker)`는 `state:Gate(setup)` 위에 얹힌다.** 위 "gated state의 동작"은 `Blocker`만의 특수 노드가 아니라 `base/gate-plan.md`가 확정한 **`GateNode`**(`ComputeNode`와 같은 층위)의 -정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy`가 +정책 하나다 — `Blocker.__apply`(옛 `Block`)가 내부에서 `state:Gate(policy)`를 부르고, 그 `policy`가 `blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지 정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다. @@ -118,20 +132,20 @@ gated state의 동작: 걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List` 항목마다 게이트를 무는 패턴에서 직행 누수). 3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중 - 새 등록(핸들 → flush → 하류 Observer가 `state:Block(b)`를 새로 만듦)이 + 새 등록(핸들 → flush → 하류 Observer가 `state:Apply(b)`를 새로 만듦)이 `pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은 처방(`base/source-state-plan.md`의 `H-23` 확정). **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로 꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고, -`Blocker()` 생성자와 `state:Block`은 그대로다. +`Blocker()` 생성자와 배선(`state:Apply(blocker)` — 옛 `state:Block`)은 그대로다. - **왜 필요한가**: `Debounce`/`Throttle`이 "언제 통과시킬지"를 정하면서 실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수 있으면 그게 그냥 함수 합성이 된다 — `setup`이 곧 `(emit) -> onUpstreamEmit`이라 타입이 이미 맞는다. - **정책이 하는 일은 안 바뀐다** — `Policy(emit)`을 부르는 시점에 onunblock - 핸들이 등록되므로(지금 `state:Block`이 하던 것과 같은 자리), `Off()`가 + 핸들이 등록되므로(지금 `state:Apply(blocker)`가 하던 것과 같은 자리), `Off()`가 풀 때 그 `emit`이 정확히 1회 불린다. - **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당 하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성). @@ -160,7 +174,7 @@ gated state의 동작: ```lua local blocker = Blocker() -local gated3 = state3:Block(blocker) -- 소비자는 gated3를 구독 +local gated3 = state3:Apply(blocker) -- 소비자는 gated3를 구독 blocker:On() state1:Set(1) -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true @@ -173,9 +187,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. -## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설) +## `state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설) -지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를 +지금까지 위 예시는 전부 `state:Apply(blocker)`로 만든 **gated state**를 경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`의 "Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 @@ -186,7 +200,7 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` -형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 +형태로 자기 자신을 게이팅). 이 용례는 `state:Apply(blocker)`를 전혀 호출하지 않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — `blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두 메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기 @@ -204,13 +218,12 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 - 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자 네이밍 관례와 일치. - Blocker 자신의 토글: **`On()`/`Off()` -> self** (`Block()`/`Unblock()` - 아님) — `state:Block(blocker)`가 이미 "배선(wiring)" 동작의 동사로 - "Block"을 쓰고 있어서, Blocker 자신의 토글까지 같은 단어를 쓰면 - `blocker:Block()`(블로커를 켠다)과 `state:Block(blocker)`(state를 이 - 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨. + 아님) — 원래 근거는 배선 동사 `state:Block(blocker)`와의 충돌이었고, + **[2026-08-28 `H-158`]** 그 동사가 `state:Apply(blocker)`로 바뀐 뒤에도 이름은 + 유지한다(`On`/`Off`가 `IsOn`/`IsBlocked`와 이미 짝이고, 바꿀 이유가 없다). - 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). -- 메소드: `state:Block(blocker) -> state`. +- 메소드: `state:Apply(blocker) -> state`(**[2026-08-28 `H-158`]** 옛 `state:Block(blocker)` — 별도 메소드가 아니라 `Blocker.__apply`). - **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는 얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들" 참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함. @@ -253,7 +266,7 @@ Blocker 자신은 여전히 단순 불리언이고 두 번 켜지지 않는다 아니라 소유권 판정이다. **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — -위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset +위 "`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset 배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을 재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의 `materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다) @@ -267,7 +280,7 @@ Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식 ## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐 **[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과 -`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API +`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API 확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과 메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신. diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index 4f7649b..106e218 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -932,7 +932,8 @@ local function makeGate(reset: boolean, opts) -- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/ -- `Blocker`가 전부 같은 계약을 만족한다 — -- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스 - -- (필드 이름과 정확한 시그니처는 구현 시 정한다). + -- (필드 이름은 `__apply`, **메소드형** `obj:__apply(state) -> State` — [2026-08-28 `H-158`], + -- 호출 규약은 그 절이 소스; 아래 `__call = function(_, self)` 자리 수는 자리표시자일 뿐). -- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만 -- 볼 것 — 위 7절 배너와 같은 취급이다. local factory = setmetatable({}, { @@ -1138,7 +1139,7 @@ Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름 평소처럼 무효화를 받아 `:Get()`할 뿐. - **`Tween`**: 직교. `debounced:Apply(Animate{...})`처럼 겹쳐 쓸 수 있고, 둘 다 시간을 다루지만 층이 다름(하나는 값 보간, 하나는 전파 타이밍). -- **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`). +- **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Apply(b)`). 실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음. - **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된 빈도로 재실행됨 — 별도 장치 불필요. **[2026-08-28 `H-151`]** 단 게이트는 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index bcb1a27..5367534 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -66,7 +66,11 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 전반에서 이 인자를 `hintValue`라고 부르는데 이는 타입이 보장되지 않던 옛 모델에서 온 이름이고, 지금은 "힌트"가 아니라 보장된 값임에 유의 (이름 자체는 `question.md` 용어 정리 대기열). **반환값 생략 불가 — - 정리할 게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** — + 정리할 게 없는 핸들러도 항상 no-op 클로저를 반환할 것 — [2026-08-28 `H-162`] + 새 클로저 `function() end`가 아니라 quad-base가 export하는 단일 `Void`를 + 반환한다**(사용자: *"의도적으로 클린업이 없는 Void 함수를 많이 만들게 될 것 … + quad-base 에서 Void = function()end 를 제공하는게 편해보임"* — 할당 없음, 신원 + 비교 가능) — `Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가 나중에 정확히 이 클로저 하나만 호출해서 정리하므로(예전 "retract 필드 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴). **생략했을 때 실제로 @@ -278,8 +282,8 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의 "quad는 자신이 만든 Instance의 라이프사이클" 절의 원칙 참고). - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 - 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만 - 반환하면 됨. + 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`Void`, **[2026-08-28 + `H-162`]**)만 반환하면 됨. - **[정정 이력, 2026-08-12 열한 번째 → 2026-08-13 다섯 번째 → 열네 번째 세션] 이 클로저는 "핸들러 타입이 바뀔 때만" 불리는 게 아니라, store 바인드가 재발행될 때마다(값이 뭐로 바뀌든) 항상 불림** — 다만 **누가 @@ -478,7 +482,7 @@ NoneHandler.priority = <매우 높음> NoneHandler.isHandlable(inst, k, v) = (v == None) function NoneHandler.process(inst, k, v, index) Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스 - return function() end -- 자기 자신은 아무 상태도 없어 no-op + return Void -- 자기 자신은 아무 상태도 없어 no-op ([2026-08-28 `H-162`] 단일 `Void`) end ``` @@ -638,7 +642,7 @@ end - **반환하는 retractor는 여기서 할 일이 없음** — `NoneHandler`는 `v==None`을 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`을 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 - 전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티 + 전혀 안 씀) `Void`(no-op, **[2026-08-28 `H-162`]**)만 반환하면 됨 — 일반 프로퍼티 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에 쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위 "핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음. @@ -669,7 +673,7 @@ function NilHandler.process(inst, k, v, index) -- [2026-08-18 감사에서 순서 정정] Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0) - return function() end + return Void -- [2026-08-28 `H-162`] no-op은 단일 `Void` end ``` @@ -949,7 +953,7 @@ Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 ** ```lua -- Dispatch/init.luau local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = {handler, retractor}}(strong)}} -local NOOP = function() end +local NOOP = Void -- [2026-08-28 `H-162`] 새 클로저가 아니라 export된 단일 no-op function Dispatch.process(inst, k, v, index) -- [순서 주의] list 확보 + chains 등록은 반드시 h.process 호출 *전에* 끝나야 함 — @@ -1110,7 +1114,7 @@ end Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함 end -- v.enabled가 false면 아무것도 안 함 ← 여기가 문제 - return function() end + return Void -- [2026-08-28 `H-162`] no-op은 단일 `Void` end ``` @@ -1306,7 +1310,7 @@ end 이 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를 `index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음. -**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil`을 +**7. 반환 생략 금지.** 정리할 게 없어도 `Void`(**[2026-08-28 `H-162`]** 단일 no-op export). `nil`을 반환하면 그 자리 슬롯이 완성되지 못해 `#list`가 정의되지 않게 되고 (`retractFrom` 순회 시작점이 어긋남) 체인 추적 자체가 깨짐 — `Dispatch.process`가 (A)/(B) 양쪽에서 즉시 error를 냄. **[정정, 2026-08-13 @@ -2275,8 +2279,8 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser 자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그 owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작 전에 `:On()`한다**(`Dispatch.drive`는 배열 파트가 있을 때만 — 위 `H-17` - 절의 2026-08-27 가드). 이 Blocker는 `state:Block()`을 거치지 않고 - **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 + 절의 2026-08-27 가드). 이 Blocker는 `state:Apply(blocker)`를 거치지 않고 + **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례" 절 참고. 2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 `gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 8be5244..04d9864 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -153,9 +153,9 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그 `H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는 **dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다**는 것 — 재마운트는 `Destroying` 연결만 다시 건다(`_bindDestroying`). 포탈 사이에 놓친 emit의 - 캐치업은 없다(**[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()` 폐기 — `_installed`가 - 참인 채 재마운트되므로 `not _installed → Rerun`도 안 걸린다; 다음 emit이 - 리비전 차이로 잡는다). + 캐치업은 **홀드 플래그로**(**[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()` + 폐기 — 대신 언마운트~재마운트 사이에 온 emit은 `rawRerun`이 `_rerunRequired`로 + 잡아 두고 재마운트의 `_bindDestroying`이 1회 돌린다). 3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 `Rerun`이 같은 자리를 읽게 된다. @@ -220,7 +220,8 @@ function Effect(fn, ...) if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님) end - -- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다. + -- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출(설치 발화)은 `fire`의 `from == nil` + -- 가드가 거른다(**[2026-08-28]** 옛 `Blocker`/`canExecute` 억제 서술은 폐기). -- ⭐⭐ [2026-08-26 재확정, 8라운드 `H-107`] dep 종류마다 **클로저를 -- 따로** 단다. 여기 한때 "클로저는 하나로 통일한다"고 적혀 있었으나, -- 그 통일을 시도할 근거 자체가 없었다 — **사용자 확정**: @@ -235,18 +236,21 @@ function Effect(fn, ...) -- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다 -- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 -- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). - -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 등록 즉시 1회 발화(내부 Observer·`Ref` - -- 콜백의 설치 발화는 **그대로 일어난다**)는 아래 `fire`의 첫 줄 — **Effect - -- 핸들의** `canExecute` — 이 흡수한다: 생성자 안에선 아직 어디에도 안 묶여 - -- 있어 항상 거짓이다. 한때 여기 사적 `Blocker`(`_blocker:On()` … `OffWithoutEmit()`) - -- 가 같은 억제를 한 번 더 하려고 있었는데, 실측(10라운드 `t18`)상 어떤 경로에서도 - -- 판정에 닿지 않는 죽은 부품이라 **제거**(사용자 확정: *"Effect 의 canExecute 를 - -- 보겠다는거지? 그럼 그건 맞는것 같아"*). `H-147`로 `fn`이 생성자 안에서 자기를 - -- 묶을 수도 없으므로 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식이다. + -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 사적 `Blocker`(`_blocker:On()` … + -- `OffWithoutEmit()`)는 **제거** — 실측(10라운드 `t18`)상 어떤 경로에서도 판정에 + -- 닿지 않는 죽은 부품이었다(사용자 확정: *"Effect 의 canExecute 를 보겠다는거지? + -- 그럼 그건 맞는것 같아"*). 설치 발화의 억제는 아래 `from == nil` 가드가, 실행 + -- 가능 여부는 `rawRerun`이 한 곳에서 본다(**[같은 날 `H-159`]** `fire` 자신은 + -- 상태를 판정하지 않는다 — 사용자: *"fire 는 그냥 rerun 을 호출해도 될것"*). local function fire(from) -- 공통 본문 - if not canExecute(self) then return end -- 발화 게이트 — `Update`보다 먼저(아래 ⚠️) - if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 - self:Rerun() + if from == nil then return end -- 내부 Observer의 **설치 발화**(등록 즉시 1회, + -- `emitFrom == nil` — `source-state-plan.md`)는 + -- 출처가 없어 `Update(nil)`을 못 한다. ⚠️ `Ref` + -- 값이 `nil`인 것과 무관 — `Ref` 경로의 `from`은 + -- 항상 그 `ref` 객체다(`onRefFire`). + if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 — + self:Rerun() -- 묶여 있든 아니든 항상. 실행 불가면 `rawRerun`이 + -- `_rerunRequired`로 홀드한다(`H-159`). end end local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처 @@ -275,9 +279,12 @@ function Effect(fn, ...) end -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇). - -- ⭐ [2026-08-28 `H-147`] 공개 `Rerun()`이 아니라 본체를 `force`로 부른다 — - -- 초기 설치는 "re"-run이 아니고, 아직 안 묶여 있어 공개 진입의 - -- `canExecute` 게이트를 통과하지 못한다. + -- ⭐ [2026-08-28 `H-147`/`H-159`] "한 번도 안 돌았다"는 `_rerunRequired`로 + -- 표시하고(초기 실행과 "실행 못 하던 중의 변경"은 같은 요구 — 사용자), + -- 본체를 `force`로 부른다. `force`의 뜻은 **하나** — *"canExecute 를 무시하고도 + -- 호출할 수 있냐. 오직 그게 전부야."*(사용자). 초기 실행을 바인드로 미룰 수 + -- 없다는 결정(순차 처리) 때문에 이 시점 예외 하나가 남는다. + self._rerunRequired = true rawRerun(self, true) return self end @@ -285,8 +292,8 @@ end - **`_installing` 플래그도, 그 뒤를 이은 사적 `_blocker`도 폐기됐다** — `_installing`은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 `H-58`), - `_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `fire`의 - 첫 줄 `canExecute`가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다 + `_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `canExecute`(당시 + `fire` 첫 줄, `H-159` 뒤엔 `rawRerun` 진입)가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다 (실측 `t18`: `drop:canExecute` 3 / `drop:blocker` 0). `H-58`의 사용자 지시 (*"해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*)는 그 전제("등록 즉시 1회가 `Rerun`에 닿는다")가 @@ -313,17 +320,17 @@ function EffectHandle:_bindDestroying(inst) self:_consumeCleanup() end) - -- (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 + -- (2) 캐치업 — **`_rerunRequired`가 서 있으면 1회**. dep 등록은 이미 생성자에서 + -- 끝났다. ⭐ [2026-08-28 `H-151`→`H-159`] `_epochs:Refresh()`는 폐기(`_epochs`는 + -- `fire`의 `Update(from)`에서만 갱신 — 사용자: *"우린 애초에 Refersh 를 할 + -- 필요가 없는거야"*), 대신 **실행 불가 상태(안 묶임·죽음·cleanup 중)에 온 + -- 변경은 `rawRerun`이 `_rerunRequired`로 홀드**해 두고 여기서 한 번 돌린다 + -- (Gate의 유보와 같은 그림 — Effect는 "한 번 다시 돌면 된다"라 불리언 하나). + -- 소진된 뒤의 재바인드도 같은 플래그(`_consumeCleanup`이 세운다). 사용자: + -- *"'초기실행' 과 '실행 안하던 중에 바뀐것' 이 사실 같은 요소"* — 옛 + -- `_installed`는 이 플래그의 부정형이라 통합했다. gcconn 연결 **뒤**라 공개 + -- `Rerun`의 게이트를 통과한다. + if self._rerunRequired then self:Rerun() end end @@ -350,7 +357,7 @@ end function EffectHandle:_consumeCleanup() local c = self._cleanup self._cleanup = nil - self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다 + self._rerunRequired = true -- ⭐ 소진됐다 = 다음 기회에 다시 설치해야 한다(아래 캐비엇) if c then -- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 — -- `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은 @@ -366,16 +373,17 @@ function EffectHandle:_consumeCleanup() end ``` -**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup`의 -유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때 -`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은 +**⚠️ [2026-08-25 `/code-review high` 정정; 2026-08-28 `H-159`로 플래그 통합] "설치돼 +있는가"를 `_cleanup`의 유무로 판정하면 안 된다 — 별도 플래그가 필요하다.** 여기 +한때 `if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은 선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는 게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면 바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가 -없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** -`_installed`는 `rawRerun`이 `fn`을 돌리고 끝날 때 참, `_consumeCleanup`에서 -거짓이 된다(**[2026-08-28 `H-147`]** `fn` 실행 중에 핸들이 죽는 경로는 더 이상 -없다 — 아래 `Rerun` 정의). +없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** 그 플래그는 +2026-08-25~28엔 `_installed`(설치됨)였고, **[2026-08-28 `H-159`]** 지금은 +**`_rerunRequired`**("`fn`이 돌아야 하는데 아직 안 돌았다") 하나다 — 세워지는 곳은 +생성자·`_consumeCleanup`·`rawRerun`의 홀드 셋, 내려가는 곳은 `rawRerun`이 `fn`을 +실제로 돌리는 자리 하나. `_installed`는 이 플래그의 부정형이라 통합했다. **⭐ [2026-08-25 신설, 7라운드 `H-60`; 2026-08-28 10라운드 `H-147`로 재정의] `rawRerun(self, force)` 본체 + 공개 `EffectHandle:Rerun()`.** @@ -396,18 +404,22 @@ local function rawRerun(self, force: boolean) self._pending = true -- 실행 중 재진입 → 지연 return end - if not force and not canExecute(self) then - return -- ⭐ 죽은 핸들·안 묶인 핸들의 재실행 요청은 **정의된 - end -- no-op** — `fire`가 죽은 핸들의 emit을 버리는 것과 - -- 같은 규칙(`H-147`: `Unsubscribe` 뒤 늦게 오는 - -- 타이머의 `Rerun()`, 해제 뒤 cleanup의 재요청 등). + if self._cleanupRunning or (not force and not canExecute(self)) then + self._rerunRequired = true -- ⭐ [2026-08-28 `H-159`/`H-160`] 실행 불가 상태(cleanup 중 / + return -- 안 묶임 / 죽음)에 온 요청은 **버리지 않고 홀드** — 다음에 + end -- 묶이는 순간 1회 돈다. 버리면 "변경을 아예 보고 안 함" 경로가 + -- 생긴다(사용자: `State` 포탈의 언마운트 cleanup 도중 + -- dep이 바뀌면 재마운트가 최신값을 못 본다). leaf `Destroying` + -- cleanup 안의 `self:Rerun()`/`dep:Set()`도 여기 — gcconn이 아직 + -- 연결돼 `canExecute`만으론 못 막는다(`H-160`). `Unsubscribe` 뒤 + -- 늦게 오는 타이머의 `Rerun()`도 홀드(재구독하면 돈다). self._running = true repeat self._pending = false - self:_consumeCleanup() + self:_consumeCleanup() -- 안에서 `_rerunRequired = true` + self._rerunRequired = false -- ⭐ 실제로 돈다 — 이 플래그가 내려가는 **유일한** 자리 self._cleanup = self.fn(self) - self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" - until not self._pending -- 재요청이 또 오면 또 돈다 + until not self._pending -- 재요청이 또 오면 또 돈다(`_pending` = 실행 **중**에 온 요청) self._running = false end @@ -437,18 +449,21 @@ end 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함."* -- **`canExecute` 확인은 진입에서 한 번** — `fire`(`Ref` 콜백·전파 루프 경유)가 - 첫 줄에서 보고, 공개 `Rerun()`도 **[2026-08-28 `H-147`]** 진입에서 본다(죽은·안 - 묶인 핸들은 no-op). 그래서 `rawRerun` 루프 안엔 판정이 없다. (한때 "사용자가 - `fn` 안에서 직접 부르는 경로는 게이트하지 않는다"였는데, 그 문장은 `Rerun`에 - 게이트가 없던 시절 것.) -- **error 시 UB** — 전파되고 복구하지 않는다(`_running`/`_cleanupRunning`이 참으로 - 남는 것 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 +- **`canExecute` 확인은 `rawRerun` 진입에서 한 번** — **[2026-08-28 `H-159`]** `fire`는 + 판정하지 않고 `Update → Rerun`만 하며, 실행 불가(안 묶임·죽음·cleanup 중)면 + `rawRerun`이 `_rerunRequired`로 **홀드**한다(no-op이 아니다 — 다음 바인드에서 1회). + 그래서 루프 안엔 판정이 없다. (한때 "`fire` 첫 줄에서 보고 죽은 핸들은 no-op" + 이었는데 그 문장은 `H-159` 이전 것.) +- **error 시 UB — 그 Effect는 죽는다.** 전파되고 복구하지 않는다: `fn`이 error하면 + `_running`이, cleanup이 error하면 `_cleanupRunning`이 참으로 남아 **이후 모든 + 재진입(`Rerun`·네 진입점·재바인드)이 막힌다**. **[2026-08-28 `H-160` 사용자 확정]** + *"한번 죽는게 나오면 Effect 가 전부 죽는다가 계약으로 상향되어도 문제는 없는듯. + 이미 _running 도 그러한 제약을 받으니까."* — 계약으로 명문화. *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다. **⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리 패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의 -`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는 +`_rerunRequired`가 참이라(**[2026-08-28 `H-159`]** 옛 `not _installed`) **재설치**된다. 죽음을 표시하는 별도 부기는 만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에 이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 @@ -484,9 +499,11 @@ end `base/architecture.md`의 `EngineOps.luau` 줄이다. - **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건 `fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref`도 `Epoch`라 균일), - `_cleanup`, **`_installed`**(설치 여부 — - cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다), - `_running`/`_pending`(재진입), **`_cleanupRunning`**(cleanup 실행 중 — `_running`과 + `_cleanup`, **`_rerunRequired`**(`fn`이 돌아야 하는데 아직 안 돌았다 — 생성 직후 / + 소진 뒤 / 실행 불가 상태에 온 변경. cleanup 반환이 선택이라 `_cleanup`으로는 판정 + 못 한다; **[2026-08-28 `H-159`]** 옛 `_installed`의 부정형을 흡수), + `_running`/`_pending`(재진입 — `_pending`은 실행 **중**에 온 요청, `_rerunRequired`는 + 실행 **불가 상태**에 온 요청), **`_cleanupRunning`**(cleanup 실행 중 — `_running`과 별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute`가 읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절). **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로 @@ -589,7 +606,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 쓴 것이고, **순서는 이 블록이 정본**이다. 산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에 `:Unsubscribe()`를 불렀을 때 **cleanup을 소진한 뒤에야 error**가 나서 `E-11`이 -막으려던 피해(cleanup 앞당김, `_installed = false`)가 이미 일어난 뒤다 — +막으려던 피해(cleanup 앞당김, `_rerunRequired = true`)가 이미 일어난 뒤다 — Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. ```lua @@ -624,8 +641,8 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. -- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시 -- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다. local function resubscribeTail(self) - if not self._installed then -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다 - self:Rerun() + if self._rerunRequired then -- 소진됐거나 죽어 있는 동안 변경이 홀드됐으면 1회(`H-159`). + self:Rerun() -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다 end end @@ -685,7 +702,7 @@ function EffectHandle:Unsubscribe() Subscribed[self] = nil -- 안 댄다(`E-11`). WeakSubscribed[self] = nil self.Subscribed = false -- 향후 재실행 차단 - self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_installed = false` + self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_rerunRequired = true` return self end ``` @@ -720,11 +737,10 @@ end - **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref` 콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절). `Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그 - 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `not _installed → - Rerun`이 붙는다(위 의사코드) — 첫 구독은 설치돼 있으니 no-op, **소진된 뒤의 - 재구독**만 재설치. **[2026-08-28 `H-151`]** 생성과 `Subscribe()` 사이에 온 - emit은 `fire`가 버렸고 여기서 따라잡지 않는다 — 다음 emit의 리비전 차이로 - 잡힌다(Observer와 같은 정도의 캐치업 없음). + 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `_rerunRequired → + Rerun`이 붙는다(위 의사코드) — 소진된 뒤의 재구독은 재설치, **[2026-08-28 + `H-159`]** 생성과 `Subscribe()` 사이에 온 변경도 `rawRerun`이 홀드해 뒀다가 + 여기서 1회 따라잡는다(첫 구독이라도 그 사이 변경이 없었으면 no-op). - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 @@ -900,7 +916,7 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 핸들의 `canExecute`가 한다.** 여기 한때 *"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을 놓쳤다(`H-58`). 그 자리에 `self._blocker:On()` … `:OffWithoutEmit()`이 들어왔는데, - 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `fire`의 첫 줄 `canExecute`가 + 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `canExecute`(**[`H-159`]** 지금은 `fire`가 아니라 `rawRerun` 진입에서 본다)가 설치 발화를 전부 떨어뜨리므로 `_blocker`는 **한 번도 판정에 닿지 않았다** (실측 `t18`). 위 생성자 의사코드가 소스다. **`_installing`도 `_blocker`도 폐기된 필드다.** @@ -943,8 +959,8 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 (2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가 `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로였고, **[2026-08-28 - `H-150`]** 그 억제 주체가 `canExecute`가 된 지금도 같다 — `canExecute`가 - `fire`의 첫 줄, `Update`는 그 뒤. + `H-150`→`H-159`]** 지금은 `fire`가 `Update`를 **먼저** 하므로 설치 발화는 + 명시적 `from == nil` 가드가 `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 fa95d82..a71376d 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -128,7 +128,7 @@ end) `pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는 여전히 안 닫힌다). - `Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가 + `Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Apply(blocker)`가 내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.** `:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로 *"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로, @@ -149,7 +149,9 @@ end) - **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미 **`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로 확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는 - 것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다. + 것으로 끝난다 — **[2026-08-28 `H-158` 정정]** 그 메소드는 폐기됐고 지금은 + 정확히 반대로 `blocker` 객체를 `state:Apply(blocker)`에 넘긴다(`blocker:__apply(state)`가 + `state:Gate(...)`를 감싼다 — 메소드형, `self`는 blocker). 결과 노드가 `GateNode`인 건 같다. - **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고, 타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State) -> U` 함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력). @@ -281,10 +283,10 @@ end) 했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. - **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가 틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md`가 - *"이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도 … + *"이 용례는 `state:Apply(blocker)`를 전혀 호출하지 않으므로 gated state도 … 생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.** 결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다** — - `state:Block(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해 + `state:Apply(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해 거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가 실제로 집합이 단조 증가하는 자리다. - **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의 @@ -375,14 +377,14 @@ end 방향이 반대로 잡혔다: - **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.** - `state:Block(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다. + `state:Apply(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다. **⚠️ [2026-08-24 표기 정정, `/code-review high` 지적]** 여기 한때 `state:Gate(b:Policy)`라고 적었는데 **그건 문법 오류다**(인자 목록 없는 `b:Policy`). `b.Policy`로 고쳐도 **언바운드 메소드**라 `Gate`가 `setup(emit)`으로 부르면 `emit`이 `self` 자리에 들어가 정책의 `emit`이 `nil`이 된다. 정확한 형태는 클로저로 묶는 것이다: ```lua - state:Block(b) == state:Gate(function(emit) return b:Policy(emit) end) + state:Apply(b) == state:Gate(function(emit) return b:Policy(emit) end) ``` - **`Debounce`/`Throttle`은 보류 판정·`pending` 부기를 직접 구현하지 않는다 — `Blocker`에 위임한다.** @@ -503,7 +505,7 @@ end ## 계약 — 게이트는 emit 경로만 미룬다 (2026-08-28 확정, 10라운드 `H-151`) 게이트가 하는 일은 **다운스트림 통지의 유보**뿐이고, 값은 안 가린다 -(`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**: +(`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**(**[같은 날 `H-159`]** 그 캐치업의 메커니즘은 `_epochs:Refresh()`가 아니라 `rawRerun`이 세우는 `_rerunRequired` 홀드 플래그 — `base/effect-plan.md`. 재구독 시 소진돼 있었으면(`_consumeCleanup`이 플래그를 세운다) 게이트 상태와 무관하게 재설치로 한 번 돌고, 유보분은 flush 때 `Update`로 또 한 번 — 둘 다 정상): - **`Effect`의 재바인드/재구독 캐치업** — 소진된 핸들이 다시 묶이면 초기 설치와 같은 뜻으로 `fn`이 돈다(`base/effect-plan.md` `_bindDestroying`). 유보 중이어도 돈다. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 51e3a10..af954f0 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -352,10 +352,19 @@ function bindLifetime(inst, value) -- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`이 -- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 — -- `base/effect-plan.md`의 "확정 구조" 절이 소스. + if isObserver(value) and value._rerunRequired then + -- ⭐ [2026-08-28 `H-159`] Observer도 대칭 — 묶이기 전(생성~바인드 사이)에 온 emit은 + -- 전파 루프가 `_rerunRequired`로 홀드해 두고(`source-state-plan.md` 전파 루프), + -- 묶이는 순간 1회 발화(출처 없음 — 설치 발화와 같은 모양). Observer엔 epoch가 + -- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다. + value._rerunRequired = false + value.fn(value._state, value, nil) + end if isEffect(value) then - value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회** - -- (`if not self._installed then self:Rerun() end` — - -- [2026-08-28 `H-151`] 옛 `_epochs:Refresh()` 캐치업은 폐기) + value:_bindDestroying(inst) -- Destroying 연결 + **홀드된 변경 캐치업 1회** + -- (`if self._rerunRequired then self:Rerun() end` — + -- [2026-08-28 `H-151`/`H-159`] 옛 `_epochs:Refresh()`는 폐기, + -- 실행 불가 상태에 온 변경은 홀드됐다가 여기서 1회) -- 의사코드는 `base/effect-plan.md`가 소스. -- 그 안에서 주입 op `onDestroying(inst, fn)`을 -- 부른다(base는 Instance를 모른다). @@ -454,7 +463,7 @@ end 확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두 일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect` 쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는 -**[2026-08-27 `H-144`]** 등록 끝에 `not _installed → Rerun`, 넷 다 첫 줄에 +**[2026-08-27 `H-144`]** 등록 끝에 `_rerunRequired → Rerun`, 넷 다 첫 줄에 **[2026-08-28 `H-147`]** `_running`/`_cleanupRunning` 가드 — `fn`/cleanup은 자기 구독을 못 바꾼다)은 `base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스: @@ -473,6 +482,10 @@ function Observer:WeakSubscribe() end self.Subscribed = true -- ⭐ [H-111] 약한 쪽도 세운다 — 구독 경로 공용 플래그 WeakSubscribed[self] = true + if self._rerunRequired then -- [2026-08-28 `H-159`] 구독 전에 홀드된 변경 1회(바인드와 대칭) + self._rerunRequired = false + self.fn(self._state, self, nil) + end return self end @@ -510,6 +523,10 @@ function Observer:Subscribe() self.Subscribed = true WeakSubscribed[self] = true Subscribed[self] = true -- 강한 킵 하나만 더 + if self._rerunRequired then -- [2026-08-28 `H-159`] 위 `WeakSubscribe`와 같은 꼬리 + self._rerunRequired = false + self.fn(self._state, self, nil) + end return self end @@ -595,6 +612,16 @@ leaf냐"를 가르는 판별자. | `:WeakUnsubscribe()` | `false` | — | 제거 | | `:Unsubscribe()` | `false` | 제거 | 제거 | +**Observer 인스턴스 필드 목록 (2026-08-28 명문화)** — `fn`(콜백, `fn(targetState, self, +emitFrom)`), `_state`(리시버 State — `_hold`로 강참조, `source-state-plan.md`), +**`.Subscribed`**(공개 플래그, 위 표), **`_rerunRequired`**(**[2026-08-28 10라운드 +`H-159`]** 묶이기 전에 온 emit을 전파 루프가 홀드 — `bindLifetime`/`Subscribe`/ +`WeakSubscribe`가 1회 발화. **거짓으로 시작**한다: `state:Observer(fn)`의 "등록 시점 +즉시 1회 실행"(`source-state-plan.md`)은 생성자가 **무조건** 하는 것이라 이 플래그와 +무관하고, 플래그는 그 뒤 ~ 묶이기 전 사이에 온 변경만 기록한다 — 감사 3라운드가 +"초기화가 없어 한 번도 안 돈다"로 오독할 수 있음을 짚어 명시). 레지스트리 두 테이블은 인스턴스 필드가 아니라 +`Observer.luau`의 모듈 로컬. Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다. + **여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`가 강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만 내리면) 그게 반쪽짜리 해제다. @@ -666,7 +693,8 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라 Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다. - 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`를 - 확인하고, 거짓이면 **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 + 확인하고, 거짓이면 **그 구독자에게 `_rerunRequired`만 세우고 건너뜀**(**[2026-08-28 + `H-159`]** 옛 "조용히 건너뜀" — 이제 묶일 때 1회 따라잡는다) — 죽은 `inst`를 건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점. **⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index b0f7f7a..1396da2 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -118,7 +118,7 @@ end -- 순서는 늘 offsetSource 먼저(setLength가 끝에서 recompute를 태우므로) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0, inst) - return function() end -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음 + return Void -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음 ([2026-08-28 `H-162`] 단일 `Void`) end ``` diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 5719859..481d820 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -288,7 +288,7 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 `priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13 다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종 계약이었으나, `process`가 자기 retract 클로저 `(nextValue: any?) -> ()`를 - 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end` + 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `Void`(**[2026-08-28 `H-162`]** 단일 no-op) 반환 생략 불가까지 확정. `base/dispatch-core-plan.md` "핸들러 계약" 절. - ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 2895eff..6642f37 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -803,7 +803,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store -- (`base/dispatch-core-plan.md`의 해제 순서 계약) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0, inst) - return function() end -- no-op retract, 이 자리는 fire가 끝나 + return Void -- no-op retract([2026-08-28 `H-162`]), 이 자리는 fire가 끝나 -- 되돌릴 상태 자체가 없음 end ``` @@ -1080,7 +1080,7 @@ function ProcessedPostRefHandler.process(inst, k, v, index) -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0, inst) - return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음) + return Void -- no-op retract([2026-08-28 `H-162`]), PreRef와 같은 이유(되돌릴 상태가 없음) end ``` diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 114a9db..51153b8 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -2148,7 +2148,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-28 10라운드 `H-148`]** 루트(`PlayerGui` 등 quad 밖 부모)는 사용자가 `.Parent =`로 붙이는 게 아니라 **quad가 `Claim`으로 소유**하는 쪽으로 방향이 확정됐다 -(`research/existing-mount-plan.md`, M5 이후) — 그래서 이 금지에 예외가 없어진다. +(`research/existing-mount-plan.md`, M5 스코프 — `H-161`) — 그래서 이 금지에 예외가 없어진다. (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 2e5374c..6769608 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -255,7 +255,11 @@ function State:_emitDown(from) sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3 elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다) sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) - end -- 거짓이면 조용히 건너뜀 + else + sub._rerunRequired = true -- ⭐ [2026-08-28 `H-159`] 묶이기 전의 변경은 버리지 않고 홀드 — + end -- `bindLifetime`/`Subscribe`가 1회 발화(`lifecycle-pattern.md`). + -- (Effect의 내부 Observer는 `WeakSubscribe`돼 있어 여기 안 옴 — + -- Effect 쪽 홀드는 `rawRerun`이 한다) end end ``` @@ -1029,7 +1033,13 @@ Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processo State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — `state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 -정의: `function(self, factory) return factory(self) end`). +정의: `function(self, factory) return factory(self) end`). **[2026-08-28 `H-158` +호출 규약 — `/code-review` 지적으로 명시, 에이전트 배선]** 애플리커티브 팩토리는 +**메소드형** `__apply`다: `function(self, factory) if type(factory) == "function" +then return factory(self) else return factory:__apply(self) end end` — 즉 +`Blocker`/`Debounce`는 `function Blocker:__apply(state) … end`로 정의하고 `self`는 +그 인스턴스다(클래스 테이블의 `__apply`를 `self` 없이 부르면 첫 emit에서 `nil` +인덱스로 죽는다). 세 소비자(`Blocker`·`Debounce`·`Throttle`)가 같은 자리 수. - **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, @@ -1064,8 +1074,9 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 - **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 — `Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다. - **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면 - 유니온도 캐스트도 필요 없다. 필드 이름과 정확한 시그니처는 구현 시 - 정한다. + 유니온도 캐스트도 필요 없다. 필드 이름은 **`__apply`**(**[2026-08-28 10라운드 + `H-158` 사용자 확정]** *"키는 __apply 로 하기로 했던거로 기억중임"* — + `base/blocker-plan.md` 배너), 정확한 시그니처는 구현 시 정한다. - **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 @@ -1075,7 +1086,7 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 **함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체" 형태를 **거부한다** — `state:Apply(Debounce{...})`가 그대로 타입에러다. 파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다 - (정확한 필드 이름과 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에 + (필드 이름은 `__apply` — **[2026-08-28 `H-158`]**; 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에 대한 것이라 그대로 유효하다 — Modifier의 `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 @@ -1131,7 +1142,13 @@ Frame { retract/Destroy되면 자동으로 정리됨. - **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, - 사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 + 사용자 확정 — 이전까지 미명시였던 항목).** (**[2026-08-28 `H-159`]** 이 1회는 + `state:Observer(fn)` 생성자가 무조건 하는 것이고, 같은 날 신설된 `_rerunRequired` + 홀드는 그 **뒤** ~ 묶이기 전 사이의 변경만 다룬다 — 별개다. **생성자 순서는 `fn` + 1회 실행 → `_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기 + State를 `Set`할 때 그 emit이 아직 안 묶인 자신에게 와 `_rerunRequired`가 서고 첫 + 바인드에서 `fn`이 한 번 더 돈다. Effect의 내부 + Observer가 이 설치 발화를 `from == nil`로 거르는 이유이기도 하다.) 근거: (1) 이미 채워진 State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 @@ -1162,7 +1179,9 @@ retract/Destroy되면 자동으로 정리됨. 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom`은 **옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high` 발견 — 한때 non-optional로 적혀 있었다). `fn`이 `emitFrom`을 실제로 쓰는 - 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. + 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. **⚠️ [2026-08-28 판단 대기, + `H-164`]** `H-159`의 Observer 홀드 발화(묶일 때 1회)도 지금 `nil`을 넘겨 이 + 분기와 구분이 안 된다 — 갈래는 `-round10.md` §4. - **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라 **핸들과 메타데이터**뿐이다. - 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄 diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 8515747..56a393c 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -69,7 +69,7 @@ Lua 문법상 `Tween{Value=target, Time=0.3}`처럼 괄호를 생략해 호출. **PropertyHandler.process(inst,k,realv,index)의 새 로직** — `realv`는 이미 StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값(그리고 이 함수는 -계약대로 마지막에 no-op 클로저 `function() end`을 반환 — 아래 "왜 +계약대로 마지막에 no-op 클로저 `Void`(**[2026-08-28 `H-162`]**)를 반환 — 아래 "왜 `retract`가 더 이상 필요 없는가" 절): 1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고, diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 02f8c0a..69a24ac 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -150,7 +150,7 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 바뀌어도, `dispatch-core-plan.md` 일반 retract 계약 절 정정분 참고), 이 Handler는 `process(inst,k,v,index)` 자체가 `v`가 `nil`이든 숫자든 전부 완결적으로 처리하므로(있으면 지우거나 만들거나) 반환 클로저가 할 일이 - 없어 `function() end`이면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를 + 없어 `Void`(**[2026-08-28 `H-162`]**)면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`가 다시 자식을 만들면 그만이라 클로저 쪽에 별도로 구현할 게 없음. @@ -178,15 +178,15 @@ function UICornerHandler.process(inst, k, v, index) if v == nil then -- 기존 규칙 그대로: 만들어둔 자식이 있으면 지움(아래 "v가 nil인 경우" 절) destroyManagedChild(inst, k) - return function() end + return Void -- [2026-08-28 `H-162`] end local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용 Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1) - return function() end -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고 + return Void -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고. [2026-08-28 `H-162`] `Void` export end ``` -- **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 `function() end`다 — +- **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 no-op(`Void`, **[2026-08-28 `H-162`]** — 옛 표기 `function() end`)다 — 여기 한때 `function(hint) if hint == nil then destroyManagedChild(inst, k) end end`가 적혀 있었는데, 그건 위 "`v`가 `nil`인 경우" 절의 `v == nil`(값) 규칙을 `hint == nil`(retractor 인자)로 잘못 옮긴 **복사 오류**였다.** 이 절의 주제는 diff --git a/.claude/project-context.md b/.claude/project-context.md index 3c0f79f..675d32a 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)로 확정·반영**돼 **[2026-08-28] 10라운드**(`-round10.md`, 광범위 탐사 `H-150`~`H-157` 포함)도 같은 날 전량 결정·반영(소스 `-round10-followup.md`) — 둘이 뒤집혔다(`fn`은 자기 구독을 못 바꿈 / 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절 둘(게이트 아님). 저장소 루트에 +반영됐고**, 같은 날 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`). 같은 날 후속 `H-158`~`H-162`까지 반영. 남은 미결은 `question.md` 최우선 절의 `Claim` 갈래 하나(게이트 아님). 저장소 루트에 `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 index 86d369e..c3c1afe 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round10-followup.md +++ b/.claude/qa-request/pre-implementation-handtrace-round10-followup.md @@ -14,13 +14,17 @@ | `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-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ✅ **확정 폐기** → `state:Apply(blocker)`, 필드 `__apply` | +| `H-159` | 바인드 전 emit 캐치업 | ✅ **확정 — 사용자 제안 `_rerunRequired`(Gate식 홀드)**, `_installed` 흡수, Observer 대칭, `fire`는 `Update → Rerun`만 | +| `H-162` | `Void` no-op export (이 대화에서 나옴) | ✅ **확정** — quad-base export(잎 모듈 `Void.luau`), no-op 클로저 자리는 전부 `Void` | +| `H-163`/`H-164` | `H-159` 반영분에 `/code-review high`가 낸 둘 | ⏳ 판단 대기 — Slot 내부 Observer × 홀드 발화 / 홀드 발화의 `emitFrom == nil` | +| `H-160` | `Destroying` 경로 cleanup `Rerun` | ✅ **확정 (a) → `H-159`로 정정**: `rawRerun`이 `_cleanupRunning`이면 **버리지 않고 `_rerunRequired`로 홀드** + "error 나면 그 Effect는 죽는다" 계약 | +| `H-161` | M5 루트 부착·다중 스크립트 `Claim` | ✅ **확정 (a)** `Claim`을 M5 스코프로; §5-7 다중 스크립트는 미결 | | `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` 소멸 +## `H-147` — 전제 정정: `fn`/cleanup은 자기 구독을 바꿀 수 없다 (A) — `H-143` 소멸 (`Rerun` 모양은 이후 `H-159`로 다시 바뀜 — 아래) 문항은 "(a) UB / (b) `_everAlive` / (c) `wasAlive` 위치"였는데, 대화 중에 **문제의 뿌리가 `H-143`의 허용 자체**라는 것이 드러났다. @@ -93,10 +97,10 @@ / `Claim` DFS(내려가며 해석 → 자식부터 `drive`)는 derive 위의 한 겹 / 부기 대상 자식은 전부 매핑, 숏핸드(`UI*`)는 부기 밖 — quad가 직접 쓰거나 실제 객체로 매핑하거나 / 이름 중복·부재는 UB + debug 모드 `seen` 검사 / `nativeFindChild` -프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 이후. +프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 스코프(`H-161`로 당김). **따름**: 전용 문구 **철회**(일반 매치 실패 그대로), `H-146` (a)의 "루트는 밖에서 `.Parent =`" **폐기**(루트가 quad 소유). `H-142` 키 금지는 그대로. -**미결 6개**는 그 research 문서 §5 — 다음 배치 문항. +**미결**(개수는 그 research 문서 §5가 소스)은 다음 배치 문항. ## `H-149` — Observer `Subscribe`/`Unsubscribe`도 인라인 (a) @@ -134,7 +138,7 @@ 체크박스의 "선행: `Blocker` 기본 메커니즘" 문구(Effect 자체는 이제 Blocker 불필요 — `Blocker.luau` 선행 요구는 `GateNode`/Slot 쪽만). -## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기) +## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기 — "캐치업 없음"은 이후 `H-159`의 홀드로 대체, 아래) **사용자 확정**: *"해당 우회는 더 크게 보면, 처음부터 Effect 가 Rerun 되는 경로라서 그건 맞아. 그리고 Observer 에서 받은 emit 의 epoch|{epoch:boolean} 를 @@ -216,7 +220,7 @@ then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil `-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개 — 다음 배치. +`question.md`에; `research/existing-mount-plan.md` §5 갈래 — 다음 배치. (이후 `H-158`은 확정, 아래 절.) ## 감사 루프 (2026-08-28, 10라운드 반영분) @@ -255,3 +259,109 @@ then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil 7. stale 문장 — `effect-plan.md`의 *게이트가 아니라 `Blocker`를 쓰는 이유* / 포탈 근거를 *`H-64` 캐치업*으로 적은 것(재마운트는 `_installed` 참이라 캐치업이 아예 없다) / `source-state-plan.md`의 *구현이 한 벌*(위임 아님으로 정정). + +## `H-158` — `state:Block(blocker)` 폐기 → `state:Apply(blocker)` (확정) + +**사용자 확정**: *"H158은 폐기로 하자. 내가 이미 그렇게 정했었는데, 전파가 안 된 +부분이라서. 이제 Compute 와 유사하게 Gate 만 놓일 뿐, Apply 로 Blocker 처리가 가능함. +왜냐면 펑터와 적용성펑터를 전부 허용하니까 ( map|{...map} ) 키는 __apply 로 하기로 +했던거로 기억중임."* — `source-state-plan.md`의 "`state:Apply(factory)`" 절은 +애플리커티브 팩토리를 "지정된 필드"로 받되 이름은 "구현 시"로 열어뒀었다 → 이 +발언으로 **`__apply`**. `Blocker.__apply = function(state) return state:Gate(function(emit) +return self:Policy(emit) end) end`. 반영: `blocker-plan.md` 배너·API·이름 절, +`gate-plan.md`·`dispatch-core-plan.md`·`debounce-throttle-plan.md`·`ROADMAP.md`· +`README.md`의 `state:Block` 전부 치환, `qa-round2.md`의 절 인용. + +## `H-160` — `rawRerun`은 `_cleanupRunning`이면 버린다 (a) + error 계약 상향 (→ `H-159`로 "홀드"로 정정. 문별 동작: `Rerun`/`fire` 경로는 조용히 홀드, 네 진입점·`_bindDestroying`은 error) + +**사용자 확정**: *"H-160 는 a 동의. 그런데 이로 인해 cleanup 이 error 를 내면 +cleanupRunning 플래그가 풀리지 않는 문제가 날듯. 모든 재 진입이 막히는건데, 문제 될 +것은 없어보이나 문서화가 필요한것 같아. 한번 죽는게 나오면 Effect 가 전부 죽는다가 +계약으로 상향되어도 문제는 없는듯. 이미 _running 도 그러한 제약을 받으니까."* — +`effect-plan.md` "error 시 UB" bullet을 **"그 Effect는 죽는다"** 계약으로. + +## `H-161` — `Claim`을 M5 스코프로 (a); §5-7은 미결 + +**사용자 확정**: *"H-161 는 확인. M5 스코프로 올라가도 될것으로 보임."* — `ROADMAP.md` +M5 체크박스·`research/existing-mount-plan.md` 헤더·§5-6. 다중 스크립트/루트 컨테이너 +(§5-7)는 답이 없어 미결 유지. + +## `H-159` — `_rerunRequired` 홀드 플래그 (사용자 제안, 확정) — `_installed` 흡수, `H-160` 정정 + +`/code-review`의 권고 (a)("묶이는 시점 1회 `Refresh` 복원")를 올렸더니 사용자가 +다른 모양을 냈다: *"observrer 는 생성과 동시에 바운딩 처리가 되는게 일반적임. 그리고 +이건 또한 observer 에도 유사한 문제가 있는 부분으로 보임 - 바운딩 전에 바뀌다가, +바운딩 되면 그대로 다 씹힘. 따라서 Gate 와 유사하게 _rerunRequired 정도가 필요한듯. +언바운딩 상태에서 이것을 true 로 만들지 관리. canExecute 가 거짓이면 모두 홀드하는게 +맞아보임. 각 둘의 정의는 '초기에 한번은 불러주고, 각 변경에 불러주겠다' 인데(바인드 +빼고 보면) 바인드가 들어오면서 각 변경에 아에 스킵하는 경우가 생겨났다는 의미."* + +**메인 세션 판단**: `Refresh` 복원보다 낫다 — 갱신 경로는 `fire`의 `Update` 하나로 +유지(`H-151` 계약 그대로)하고, 실행 불가일 때 *버리는 대신 홀드*한다. Gate의 유보와 +같은 그림이되 Effect는 "한 번 다시 돌면 된다"라 불리언 하나. + +**대화로 다듬어진 것 넷**: +1. **`_cleanupRunning` 중의 변경도 홀드** — 처음 메인 세션이 "cleanup 중 `dep:Set()`은 + `_epochs`에 반영되니 버려도 된다"고 했는데 사용자가 정정: *"변경을 '아에 보고 + 안함' 이라는 경로가 생김. State 형태로 포탈을 만들었다고 가정하면, cleanup + 도중에도 여전히 _rerunRequired 는 셋업되어야함. 안 그러면 다음 바운딩에 최신값 + 측정이라는 목표를 잃거든."* → `H-160`의 "버림"도 홀드로 정정. 세 상황이 갈린다: + `fn` 실행 중 → `_pending`(같은 루프) / 실행 불가(안 묶임·죽음·cleanup 중) → + `_rerunRequired`(다음 바인드) / 그 외 → 즉시. `_pending`과 `_rerunRequired`의 분리는 + 사용자 *"완전 동의"*. +2. **`fire`는 `Update → Rerun`만** — 사용자: *"self._cleanupRunning 확인이 아래에 + 있다면 … fire 는 그냥 rerun 을 호출해도 될것"*. 상태 판정은 `rawRerun` 한 곳. +3. **`_installed` 폐기 → `_rerunRequired`로 통합** — 사용자: *"rerun 의 force 가 + self._rerunRequired = true 하는것과 같은 동작을 낼것으로 보임. … '초기실행' 과 + '실행 안하던 중에 바뀐것' 이 사실 같은 요소"*. `_installed`는 그 플래그의 + 부정형이었다. 세워지는 곳: 생성자·`_consumeCleanup`·`rawRerun` 홀드 / 내려가는 곳: + `rawRerun`이 `fn`을 실제로 돌리는 자리 하나. **`force`는 시점 예외 하나만** — + 사용자: *"force 는 딱 하나의 역할을 해. canExecute 를 무시하고도 호출할 수 있냐. + 오직 그게 전부야."* 초기 실행을 바인드로 미룰 수 없다는 결정(순차 처리)은 유지. +4. **`fire`의 `from == nil` 가드는 유지** — 사용자가 *"ref<...> 가 Frame->nil 가는 + 경로를 막을 이유가 없지 않나?"*라 물었는데 그건 오해: `from`은 값이 아니라 출처 + `Epoch`라 `Ref` 경로에선 항상 `ref` 객체이고, `nil`은 내부 Observer의 설치 발화 + (`emitFrom == nil`)뿐 — `Update(nil)`이 정의돼 있지 않아 걸러야 한다(2026-08-21 + `/code-review`가 잡았던 자리). 주석에 그 구분을 명시. + +**Observer 대칭**: 전파 루프가 `canExecute(observer)` 거짓이면 `_rerunRequired`를 세우고, +`bindLifetime`·`Subscribe`·`WeakSubscribe`가 그 플래그를 보면 1회 발화(`emitFrom = nil`, +설치 발화와 같은 모양). 실효 범위는 "구독자 집합엔 있지만 아직 안 묶인" 창. +**[반영 뒤 감사 3라운드]** Observer의 "등록 시점 즉시 1회 실행"(2026-08-07 확정)은 +생성자가 무조건 하는 것이라 이 플래그와 **별개** — Observer의 `_rerunRequired`는 +거짓으로 시작한다(Effect는 생성자가 참으로 세우고 `force`로 즉시 돌리는 것과 대비). +감사자가 "초기화가 없어 한 번도 안 돈다"로 읽을 수 있음을 짚어 두 문서에 명시. + +**반영**: `effect-plan.md`(`fire`·생성자·`rawRerun`·`_consumeCleanup`·`_bindDestroying`· +`resubscribeTail`·필드 목록·캐비엇·포탈 근거·`H-65` 문단), `lifecycle-pattern.md` +(bindLifetime Observer 분기·Observer `Subscribe`/`WeakSubscribe` 꼬리·주석), +`source-state-plan.md` 전파 루프, `ROADMAP.md` M2 넷·M6, `README.md`. + +## `H-162` — `Void` no-op export (확정) + +사용자: *"지금 상황에서 의도적으로 클린업이 없는 Void 함수를 많이 만들게 될 것으로 +보이는데, 이걸 quad-base 에서 Void = function()end 를 제공하는게 편해보임."* — +quad-base가 단일 no-op 함수 `Void`를 export(quad-roblox 핸들러도 쓰므로 공개), +no-op 클로저를 돌려주는 자리(숏핸드 retractor 등)는 새 클로저 대신 `Void`. 사용자 +Effect `fn`이 `return Void`로 "cleanup 없음"을 명시하는 것도 자연히 허용(반환 안 하는 +것과 동일 취급). 반영: `dispatch-core-plan.md` 반환값 규칙, `ui-shorthand-plan.md` 둘, +`ROADMAP.md` M2 공통 기반. + +## 감사 루프 (2026-08-28, `H-158`~`H-162` 반영분) + +새 발견 7→6→1→**0**(4라운드에서 수렴). 1 문구 잔존(`Block` 현재형 / `__apply` 전파 / +`Void` 9곳 / "`fire`의 첫 줄 `canExecute`" stale 4곳 / `architecture.md` `Void` 자리 / +session 후속 절) · 2 의미론(**`_rerunRequired` 상태 기계 자체는 모순 없음**; debounce +`__apply` / `Void` 5곳 더 / session-summary 후속 / `init.luau` 모호 / gate 계약 절 +포인터 / Observer 필드 목록 신설) · 3 수정분 재검토(Observer 생성자 1회 실행이 +플래그와 별개임을 명시 — 감사자의 오독 가능성) · 4 수렴 **0건**. + +## `/code-review high` (2026-08-28, `H-158`~`H-162` 반영분 + 감사 4라운드 뒤) + +10건. **여덟 반영**, **둘 문항**(`H-163`/`H-164`, `-round10.md` §4). +반영: `__apply` 호출 규약(메소드형 `factory:__apply(state)`, 에이전트 배선) / +`Void`는 잎 모듈 `Void.luau`(최상위 `init.luau`에 두면 순환 require) / Observer 생성자 +순서 `fn` 1회 → `_subs` 삽입 / `gate-plan.md` 계약 절 괄호 정정 / `effect-plan.md` +stale 셋(`Blocker` 억제·"`fire` 첫 줄"·"죽은 핸들은 no-op") / `ROADMAP.md` M2 `Rerun` +no-op → 홀드 / `lifecycle-pattern.md`의 *조용히 건너뜀* → 홀드 / "M5 이후" 잔존 셋 / +followup 절 제목 포인터·개수. diff --git a/.claude/qa-request/pre-implementation-handtrace-round10.md b/.claude/qa-request/pre-implementation-handtrace-round10.md index 1260a93..19dd19a 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) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 미결로 남은 것은 거기서 새로 생긴 `H-158`(`:Block` 슈가)과 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는 +> 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 후속 `H-158`~`H-162`도 같은 날 확정(`H-159`는 `/code-review` 권고 (a) `Refresh` 복원이 아니라 사용자 제안 **`_rerunRequired` 홀드**로). 미결은 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는 > `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존 > 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`의 > 마지막 code-review 절). @@ -350,6 +350,8 @@ spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18` | **`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-163`** | **[2026-08-28 `/code-review`, `H-159` 반영 뒤]** Slot 내부 Observer(`_listObserver`·`_baseObserver`)에도 홀드 발화가 걸려 재마운트의 `bindLifetime`이 `materializeSlotTree` **도중** `reconcile`을 동기 실행 → 자리 이중 등록, 중첩 Slot이면 `canBound` error | (a) Slot이 자기 내부 Observer를 다시 묶기 전에 `_rerunRequired`를 **지운다**(재마운트 캐치업은 `activateList`가 이미 명시적으로 한다 — 이중) / (b) 홀드 발화를 사용자 Observer에만(내부 Observer는 브랜드로 구분 — 새 구분) / (c) 홀드 발화를 `bindLifetime` 안이 아니라 `materializeSlotTree` 끝(`blocker:OffWithoutEmit()` 뒤)으로 미룸 | **(a)** — 새 구분 없이 한 줄, "재마운트 캐치업의 주체는 Slot"이라는 기존 계약 그대로 | +| **`H-164`** | Observer 홀드 발화가 `emitFrom = nil`로 오면 계약("`nil` = 설치 발화")과 구분 불가 — `if emitFrom == nil then initOnly()`로 짠 소비자가 변경을 놓침 | (a) 홀드 시 **마지막 `from`을 보관**(`_rerunRequired = from`, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(`HeldEmit`) / (c) 계약 문구만 "`nil` = 설치 **또는** 묶일 때 캐치업" | **(a)** — Observer는 dedup이 없어 "마지막 출처"가 곧 홀드의 내용; 단 필드가 불리언과 출처를 겸하는 게 원칙(한 필드 두 뜻)에 걸리면 (b) | 갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), `H-156`(`H-32` 문단), `H-157`(실측 완료 표기). diff --git a/.claude/qa-request/pre-implementation-qa-round2.md b/.claude/qa-request/pre-implementation-qa-round2.md index d81499e..46aa1b1 100644 --- a/.claude/qa-request/pre-implementation-qa-round2.md +++ b/.claude/qa-request/pre-implementation-qa-round2.md @@ -24,7 +24,7 @@ 설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제 반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, -`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" +`base/blocker-plan.md`의 "`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례"(2026-08-28 `H-158`로 개명 — 당시 이름은 `state:Block()`) 절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다. ### 발견한 크래시 경로 diff --git a/.claude/question.md b/.claude/question.md index fac704b..7d67922 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,23 +12,22 @@ --- -## ⭐ 최우선 — 10라운드 후속 둘 (2026-08-28 갱신) +## ⭐ 최우선 — `Claim` 갈래 (2026-08-28 갱신) -**[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로, 다중 - 스크립트는 권고 없음). +**[2026-08-28] 10라운드 §4 문항 7건과 그 반영분의 후속(`H-158`~`H-162`)까지 +사용자와 대화형으로 전량 결정·반영됐습니다**(소스는 +`qa-request/pre-implementation-handtrace-round10-followup.md`). 남은 건 하나 — +**M2 게이트 아님**: +- **`H-163`/`H-164`** (`-round10.md` §4 표 마지막 둘, `H-159` 반영분에 + `/code-review high`가 낸 것) — Slot 내부 Observer × 홀드 발화(권고 (a) Slot이 + 재바인드 전에 플래그를 지움) / Observer 홀드 발화의 `emitFrom == nil` 모호(권고 + (a) 마지막 `from`을 넘김, 한 필드 두 뜻이 걸리면 (b) 센티널). - **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는 `Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트 - 사용·debug 검사 범위·표면 이름·마일스톤 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5 이후. + 사용·debug 검사 범위·표면 이름 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5 + 스코프(`H-161`). **특히 §5-7**(여러 스크립트/여러 quad가 같은 `PlayerGui`를 + 쓰는 경우 — "전부 매핑" 계약이 루트 컨테이너엔 안 맞는다는 신호, 권고 없음)이 + `Claim` 설계의 핵심 미결입니다. **[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 index 95812bd..044c0be 100644 --- a/.claude/research/existing-mount-plan.md +++ b/.claude/research/existing-mount-plan.md @@ -2,8 +2,10 @@ > **[2026-08-28 신설, 사용자 발의]** 10라운드 `H-148`(`Parent` 거부 문구)을 > 논의하다 **더 큰 표면의 공백**이 드러나 만든 문서. 상태: **설계 논의 중 — -> 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 이후** -> (프로바이더 op가 필요). 결정이 나면 `base/`로 승격한다. +> 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 스코프** +> (**[2026-08-28 `H-161`]** "M5 이후"에서 당김 — 루트 예외 폐기 뒤 M5의 유일한 루트 +> 부착 경로; 프로바이더 op가 필요하니 M5가 자연스러운 자리). 결정이 나면 `base/`로 +> 승격한다. > > **`archive/existing-instance-bind-rejected.md`(2026-08-14 기각)와의 관계**: > 그 기각은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*였고 사유는 @@ -131,7 +133,8 @@ local cloned = Claim(template:Clone(), M.Frame "root" { -- ← 루트 이름 백로그에 포인터만. **[2026-08-28 `/code-review`, `H-161`]** 단 `H-146` 루트 예외를 폐기한 지금 **M5에 승인된 루트 부착 경로가 없다** — (a) `Claim`을 M5 스코프로 당김 / (b) `Claim` 전까지 M5 한정 임시 예외 / (c) 루트 컨테이너용 - 얇은 표면. 권고 (a). + 얇은 표면. **사용자 확정 (a)**(*"M5 스코프로 올라가도 될것으로 보임"*) — 헤더와 + `ROADMAP.md` M5 체크박스에 반영. §5-7(다중 스크립트)은 여전히 미결. 7. **[2026-08-28 `/code-review`, `H-161`] 여러 스크립트/여러 quad가 같은 루트 컨테이너를 쓰는 경우** — 위 "이중 claim error / 다중 quad UB / 부기 대상 자식 전부 매핑"을 그대로 두면 `Shop.client.luau`와 `Inventory.client.luau`가 각각 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 2211d7e..0e234ac 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1988,5 +1988,8 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs `_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` 슈가). 소스는 + stale(`H-155`~`H-157`). **같은 세션 후속**으로 `/code-review` 3건 + `H-158`~`H-162`도 + 확정 — `:Block` 폐기(`__apply`) / **`_rerunRequired` 홀드 플래그**(사용자 제안, + `_installed` 흡수, Observer 대칭) / `Claim` M5 스코프 / `Void` export. 미결은 + `Claim` 갈래(특히 다중 스크립트)뿐. 소스는 `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 index 607b5f2..db23fd7 100644 --- a/.claude/session/2026-08-28-01-handtrace-round10-resolution.md +++ b/.claude/session/2026-08-28-01-handtrace-round10-resolution.md @@ -34,3 +34,23 @@ - 사용자가 결정 직전에 전제를 묻는 패턴(*"그게 진짜 날 수 있어?"*, *"canExecute 가 막는다가 말이 맞아?"*)이 두 번 다 유효한 정정으로 이어졌다 — 그때 "맞다"로 넘기지 말고 층을 갈라 답할 것. + +## 후속 (같은 세션) — `/code-review` 3건과 `H-158`~`H-162` + +반영 뒤 감사 6라운드 수렴 → `/code-review high` 10건 중 셋이 판단 필요라 커밋 +`ae34cfa` 뒤 사용자와 이어서 처리: +- **`H-158`** `:Block` 폐기 확정(*"내가 이미 그렇게 정했었는데, 전파가 안 된 + 부분"*) + `__apply` 필드명(코퍼스는 "구현 시"로 열어뒀었다). +- **`H-160`** (a) + *"한번 죽는게 나오면 Effect 가 전부 죽는다"* 계약 상향. +- **`H-161`** `Claim` M5 스코프. +- **`H-159`** — 리뷰 권고((a) `Refresh` 복원)를 사용자가 **다른 모양**으로 뒤집음: + `_rerunRequired` 홀드. 대화 네 턴: (1) 홀드 제안 → 메인이 `fire` 분기 모양 제시 + → (2) 사용자: cleanup 중 변경도 홀드해야(포탈), `fire`는 그냥 `Rerun`, `force` ≈ + `_rerunRequired` → (3) 메인: `_installed`를 `_rerunRequired`로 통합 제안 → (4) + 사용자: `from == nil` 가드 의문(→ 출처 `Epoch`와 값 `nil`의 혼동을 갈라 답함), + *"force 는 딱 하나의 역할"*. 결정의 소스는 followup `H-159` 절. +- **`H-162`** `Void` export(사용자 발의). +교훈: **리뷰 권고 (a)가 "옛 메커니즘 복원"일 때는 사용자가 더 단순한 새 모양을 +갖고 있을 가능성이 높다** — `Refresh`(두 번째 갱신 경로) 대신 홀드 플래그(갱신 +경로는 하나, 실행만 미룸). 이번 세션에서 `_installed`가 사라진 것처럼 통합 기회도 +같이 온다. diff --git a/.claude/todos.md b/.claude/todos.md index 47d77d6..116d716 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -56,8 +56,10 @@ 와 함께 **같은 날 대화형으로 전량 결정·반영**(소스 `-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 착수.** + 아니라 quad가 `Claim`으로 소유(`H-148`, `research/existing-mount-plan.md`). **같은 날 후속** `H-158`~`H-162`(`:Block` 폐기 → `state:Apply(blocker)` / + `_rerunRequired` 홀드 플래그가 `_installed`를 흡수 / `Claim` M5 스코프 / `Void` export)도 + 확정·반영. 남은 미결은 `question.md` 최우선 절의 `Claim` 갈래(특히 §5-7 다중 + 스크립트) 하나 — 게이트 아님. 남은 액션: M2 착수.** 아래는 돌리기 전(2026-08-26) 서술: 지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는 **커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤 diff --git a/CLAUDE.md b/CLAUDE.md index ee8b95d..552c5d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의 낸 `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`도 +절엔 `Claim` 갈래(`research/existing-mount-plan.md` §5, 특히 다중 스크립트)만 +남아 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/ROADMAP.md b/ROADMAP.md index c37d231..7ff07ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 > (`-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 때만 갱신 +> `research/existing-mount-plan.md`, **M5 스코프** — `H-161`). 그 밖에 `_epochs`는 emit 때만 갱신 > (`Refresh` 캐치업 폐기, `H-151`) / `Effect._blocker` 제거(`H-150`) / Observer > 진입점 인라인(`H-149`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 > 런타임 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`). @@ -430,6 +430,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다) sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처) + else sub._rerunRequired = true -- [2026-08-28 `H-159`] 묶이기 전의 변경은 홀드 → 바인드/구독 시 1회 end ``` `canExecute`가 `inst`를 인자로 받을 수 없는 이유는 그대로다(State는 @@ -489,7 +490,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). - [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~ (**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적 - `Blocker`는 `fire` 첫 줄의 `canExecute`가 이미 같은 억제를 해서 한 번도 + `Blocker`는 `canExecute`(지금은 `rawRerun` 진입, `H-159`)가 이미 같은 억제를 해서 한 번도 판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/ Slot 쪽 요구뿐.) (`base/effect-plan.md`, **[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 @@ -505,14 +506,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). **[2026-08-28 10라운드 `H-147`]** `rawRerun(self, force)` 본체 + 공개 - `Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들은 정의된 no-op), + `Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들의 요청은 **[`H-159`]** `_rerunRequired`로 홀드), 생성자는 `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`. + `Subscribe`/`WeakSubscribe`는 등록 끝에 `_rerunRequired → Rerun`(재구독 + 재설치 + 홀드된 변경; **[`H-151`/`H-159`]** `_epochs:Refresh()` 캐치업은 폐기 — + `_epochs`는 `fire`의 `Update`에서만 갱신하고, 실행 불가 상태에 온 변경은 + `rawRerun`이 `_rerunRequired`로 홀드) — 의사코드는 `base/effect-plan.md`. **동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md` "동적 경로 가드" 절, 2026-08-14 열한 번째 세션) **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 @@ -531,11 +533,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 · ~~**`handle._blocker`**~~(**[2026-08-28 `H-150`]** 제거 — 등록 구간의 - 즉시-1회 호출은 별도 필드 없이 `fire` 첫 줄의 `canExecute`가 억제한다; 옛 + 즉시-1회 호출은 별도 필드 없이 `fire`의 `from == nil` 가드와 `rawRerun`의 `canExecute`가 억제한다(`H-159`); 옛 `_installing` 플래그도 생성자 구간만 덮어 폐기됐었다) · **`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가 - 같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup - 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · + 같은 자리를 읽는다) · **`handle._rerunRequired`**(`fn`이 돌아야 하는데 아직 안 돌았다 — 생성 직후 / + 소진 뒤 / 실행 불가 상태에 온 변경; **[2026-08-28 `H-159`]** 옛 `_installed`를 흡수. + `fn`의 cleanup 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · **`handle._running`/`_pending`**(`Rerun` 재진입 지연) · **`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`** @@ -549,9 +552,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/ `:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 (`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다 - (`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`가 소스 + (`H-7`). 캐치업은 바인드 직후 **`_rerunRequired`면 1회**(`if self._rerunRequired then + self:Rerun() end` — **[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`는 폐기, + `_epochs`는 emit 받을 때만 갱신하되 실행 불가 상태의 변경은 홀드 — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 - [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 @@ -616,7 +619,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** - [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** `quad-types`의 `Quad`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 — - `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Ref`**(최소형, + `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형, 2026-08-27 `H-128`) / `is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/ `isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`· @@ -643,7 +646,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정) -- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` / +- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` / `Source:Emit()` — `:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 있는데 이 셋만 빠져 있었다 - [ ] **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]** @@ -844,7 +847,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `base/gate-plan.md`가 소스 — 여기서 반복하지 않는다. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 - `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 + `Void`(**[2026-08-28 `H-162`]** 단일 no-op)를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션 / **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process` 반환값으로 합쳐지며 대상만 바뀜**) @@ -970,7 +973,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가 소스(개수도), 다음 배치 문항. +- [ ] **[2026-08-28 M5 스코프, `H-161`]** `Claim(inst, D.Mapper. "Name" {…})` — 이미 있는 트리(PlayerGui·`Clone()` 사본)를 quad가 소유. `H-146` 루트 예외를 폐기한 뒤 M5에 승인된 루트 부착 경로가 이것뿐이라 **백로그가 아니라 M5 안**(사용자 확정). 프로바이더 op `nativeFindChild` 필요. 갈래 미결(특히 §5-7 다중 스크립트/루트 컨테이너) — `research/existing-mount-plan.md` §5가 소스(개수도), 다음 배치 문항. - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 @@ -1514,8 +1517,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고 `_bindDestroying`은 **`Ref` dep 콜백을 (재)등록하지 않는다** — dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과 - **재설치 캐치업 한 줄**(`if not self._installed then self:Rerun() end` — - **[2026-08-28 `H-151`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 + **홀드 캐치업 한 줄**(`if self._rerunRequired then self:Rerun() end` — + **[2026-08-28 `H-151`/`H-159`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`와 `base/effect-plan.md`가 소스. **[2026-08-14