From 5e96cd6258bac2a28f1d91cfd9905f458a5e0790 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 28 Aug 2026 15:52:15 +0900 Subject: [PATCH] =?UTF-8?q?qa:=2010=EB=9D=BC=EC=9A=B4=EB=93=9C=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20H-163/H-164=20+=20EmitReceive=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=20=EB=A3=A8=ED=94=84=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,=20Slot=20=EC=9E=AC=EB=A7=88=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=BA=90=EC=B9=98=EC=97=85,=20emitFrom=20nil=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmitReceive: State:_emitDown은 sub:_receive(from)만, Observer:_receive가 canExecute 판정·_rerunRequired 홀드 (사용자 지시 — 계층간 지식 분리), State:Observer(fn) 생성자 순서(플래그 참 → fn 1회 → 내림 → _subs) - H-163 (a′): _listObserver 재마운트 바인드는 materializeSlotTree 꼬리(트리 확정 뒤)에서 재마운트일 때만, 홀드가 있었으면 reconcile 1회 (감사가 잡은 첫 마운트 이중 bind·reconcile 미실행 결함 정정) - H-164 (c): emitFrom == nil = 출처 없음(설치 또는 캐치업), from 보관 기각 - 감사 4→3→2→2(각도 교체, 마지막 둘은 표현), /code-review 8 반영분 포함 Co-authored-by: qwreey Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb --- .claude/README.md | 4 +- .claude/base/dispatch-core-plan.md | 5 +- .claude/base/effect-plan.md | 5 +- .claude/base/lifecycle-pattern.md | 25 ++++--- .claude/base/slot-plan.md | 32 ++++++++- .claude/base/source-state-plan.md | 69 +++++++++++++------ ...plementation-handtrace-round10-followup.md | 35 +++++++++- .../pre-implementation-handtrace-round10.md | 4 +- .claude/question.md | 4 -- ROADMAP.md | 14 ++-- 10 files changed, 143 insertions(+), 54 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 732dd4e..b794219 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -48,12 +48,12 @@ | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` **[2026-08-24 6라운드 `H-24` — 실측]** 영향 범위 표에 **`tween:Mapped`** 추가 — `tween:Mapped(fn: (T) -> U): Tween`가 1번이 지목한 모양(`Foo` 안에서 `-> Foo`)과 **글자 그대로 같은데** 이 문서도 `tween-plan.md`도 그걸 모르고 있었다. 인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히 통과**시키고, ③(`typeof(named function)`)로 바꾸면 정상적으로 잡힌다 — **기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않고 있었다** | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 **[2026-08-24 6라운드 `H-11`]** `bindLifetime`/`unbindLifetime`이 **`isEffect`를 보고 `Destroying`을 걸고/끊는다** — `LP-2`가 *"`Effect`가 그 훅을 쓰는 유일한 소비자"*라 확정해뒀는데 **실제로 거는 코드가 어디에도 없었다.** `unbind`는 cleanup을 부르지 않는다(대칭이라 포탈이 자연히 성립) **⭐ [2026-08-26 정정, 8라운드 `H-111`]** `.Subscribed`는 *전역 `:Subscribe()` 전용*이 아니라 **구독 경로(강/약) 공용**이다 — `:WeakSubscribe()`도 세운다(갈라지는 건 레지스트리를 강하게 잡느냐뿐). 안 그러면 `WeakSubscribe`로만 등록되는 `Effect`의 내부 Observer가 `canExecute` 게이트를 영영 못 통과해 **State dep 전량이 조용히 침묵**한다. 같은 날 `Subscribe`/`WeakSubscribe` 분해 의사코드가 신설됐다("`bindLifetime`이 이 필드를 안 건드린다"는 요지는 그대로 유효). **[2026-08-27 9라운드 `H-133`]** `WeakUnsubscribe`는 관대(구독 안 한 값·이미 풀린 값에 조용히 통과), `Unsubscribe`는 엄격 — 대칭 가드는 *경로 교차*만 막는다는 것을 사용자 논거와 함께 명문화. | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `table.clone` 기반 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), **⭐⭐ [2026-08-25]** 두 가지가 바뀌었다 — (1) 생성이 **명시적 초기화**(타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접, 옛 lazy `__index` 폐기 → `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립), (2) 레코드 필드 타이핑이 **타입 함수를 안 쓰는 평범한 레코드**로(`WrapStore`/`ProcessStoreType` 폐기 — `H-75`/`H-76`이 그 한계를 실측). 동적 키는 `store:Of<>(name)` 하나로 옛 `GetDynamic`을 흡수했고, 예약 키(`Of`/`Names`) 충돌은 **조용히 타입 검사를 끄므로** `CheckReservedKeys` 타입 함수가 진단만 띄운다(**⭐ [2026-08-26 `H-112`]** 그 함수는 `T`가 아니라 **`keyof`**를 받는다 — `T`를 통째로 넘기는 옛 배선(`CheckReserved`)은 `T`에 실린 `Source`가 `*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 에러를 냈다, 실측). **같은 날 "`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`** — 거기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격됐다. `store.key = value` 폐기 → `store.key:Set(value)`는 **유지**, "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | -| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M2 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) **⭐⭐ [2026-08-26, 8라운드 `H-109`~`H-111`]** 전파 루프의 콜백 시그니처가 바뀌었다 — Observer `fn`은 **세 자리 `fn(targetState, self, emitFrom)`**이고 루프는 `sub.fn(sub._state, sub, from)`이다(그 전엔 `sub.fn(sub, from)`이라 *"self는 리시버 State의 lazy 핸들"* 계약과 정면 충돌했고, 무인자 `state:Observer()`의 내부 콜백이 즉사했다). 그 부수로 **`observer._state` 강참조**가 신설돼 `_hold` 불변식이 파생 노드뿐 아니라 **말단 핸들까지** 커버한다. `isModifier` 가드 적용 지점도 `Source` 생성자로 옮겼다(`H-122`). | +| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M2 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) **⭐⭐ [2026-08-26, 8라운드 `H-109`~`H-111`]** 전파 루프의 콜백 시그니처가 바뀌었다 — Observer `fn`은 **세 자리 `fn(targetState, self, emitFrom)`**이고 루프는 `sub.fn(sub._state, sub, from)`이다(**[2026-08-28 `EmitReceive`]** 지금은 `sub:_receive(from)` 한 줄 — Observer 자신의 `_receive`가 `canExecute` 판정과 `_rerunRequired` 홀드를 자기 안에 둔다)(그 전엔 `sub.fn(sub, from)`이라 *"self는 리시버 State의 lazy 핸들"* 계약과 정면 충돌했고, 무인자 `state:Observer()`의 내부 콜백이 즉사했다). 그 부수로 **`observer._state` 강참조**가 신설돼 `_hold` 불변식이 파생 노드뿐 아니라 **말단 핸들까지** 커버한다. `isModifier` 가드 적용 지점도 `Source` 생성자로 옮겼다(`H-122`). | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) **[2026-08-24 6라운드]** (1) **말단 핸들러 4종**(Tag/AttributeGroup/RefLeaf/ObserverEffectLeaf)이 `setOffsetSource`/`setLength`를 **아예 등록 안 하던 것**이 발견돼 계약대로 등록하게 됨 — 안 그러면 `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 `recompute`에서 명시적 error로 죽는다(`H-39`). (2) 접두합 캐시 무효화 3규칙이 **산문으로만 있고 코드 경로가 없던 것**을 `setLength`/`spliceArrays*`/`_baseObserver`에 실제 배치(`H-3`), `bk` 스펙에 `offsetCache`/`offsetSetUpTo` 추가(`H-4`). (3) **`recompute` 호출은 `setLength`의 단독 책임**(`H-19`). (4) Blocker 범위를 **`drive` 전체**로 정정하고 `PostRef` 콜백이 게이트 안에서 돈다는 걸 계약화(`H-17`). (5) *"잔여 부기는 인스턴스 GC로 정리된다"*는 **틀린 안전망 주장 삭제**(gcconn 불멸성과 양립 불가, `H-26`) **⭐ [2026-08-26, 8라운드 `H-113`/`H-119`]** `recompute`의 두 경계가 고쳐졌다 — splice의 접두합 무효화가 `j`가 아니라 **`j - 1`**이고(커서 위치 splice가 "변경 없음"과 구분이 안 돼 밀려 들어온 요소의 offset이 조용히 낡았다), **명시 `recompute` 호출부도 전부 재진입 게이트를 탄다**(`blocker:IsOn() or bk.recomputeBlocker:IsOn()` — 그 전엔 `Add`는 안전한데 `Remove`만 중첩 `recompute`가 완주해 바깥의 `Length`를 낡은 합으로 덮었다). **⭐⭐ [2026-08-26 `/code-review high` 4차] 부기 필드가 하나에서 둘로 갈라졌다** — `bk.offsetCacheValidUpTo`(`offsetCache`가 여기까지 정확, `getOffsetAt`이 올림)와 `bk.offsetSetUpTo`(offset `Source`에 여기까지 `:Set` 완료, **`recompute`만** 올림). 옛 단일 `invalidAfter`가 두 뜻을 겸했고, 그래서 `getOffsetAt`의 부수효과가 되감기 신호를 조용히 지웠다(`Remove` 뒤 같은 콜백에서 `Add`). `H-101`의 *"새 필드를 안 만든다"* 확정이 **역전**됐다 — 그 근거였던 "두 뜻이 실제로 같은 것"이 틀렸다. 무효화는 **둘 다** 내린다. **[2026-08-27, 9라운드 Q1/Q3]** `recompute`의 되감기 판정이 `lengthList[i]` 읽기 **앞**으로(`continue` — 옛 순서는 커서 뒤 자리 수가 줄면 `sum += nil`, `H-124`) / `setLength`가 5번째 인자로 그 자리의 요소를 받고 `gatedRecompute`는 요소를 캡처해 **`bk.indexOfElement`**를 조회 — `bk.tokens`/`indexOfToken`(사용자가 정한 적 없는 `token = {}`, `H-141`)은 폐기. | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 **[2026-08-27 9라운드 `H-139`]** `New(name)(props)` ①~④ + `Dispatch.drive` (a)~(c) 전체 파이프라인 의사코드 신설(네 문서에 흩어져 있던 순서를 한 자리에) — 쓰면서 빈 배열 파트 가드와 해시 파트 `Parent` 순서(`H-142`)가 드러남 — 후자는 **props에 `Parent` 금지**로 확정돼 순서 문제 자체가 소멸. 배치 닫는 자리는 `dispatch-core-plan.md` `H-17`이 이미 정해둔 것이라 의사코드를 그쪽에 맞춤(감사 1라운드 정정). | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | | `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 **[2026-08-24 6라운드 `H-25` — 실측]** `Quad`가 **5필드 닫힌 레코드**라 `RunInit`으로 붙인 서브시스템이 타입에 안 보인다(`quad.Dispatch`가 `luau-analyze`에서 타입에러 — `architecture.md` 결정 13번이 그 접근을 표준 사용법으로 확정해둔 것과 정면 충돌). **확정: `quad-types`의 `Quad`를 마일스톤마다 갱신한다** — 서브시스템을 붙이는 모든 마일스톤(M2/M3/M6/M7/M8/M10)에 체크박스가 뿌려졌다(규칙이 쓰인 계기는 `Dispatch`(M3)이지만 순서상 첫 적용은 M2다). 타입만 재수출하므로 "가벼운 타입 계약"이라는 존재 이유와 안 부딪힌다 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** **[2026-08-24 6라운드 손 트레이싱 — 이 문서가 가장 크게 바뀌었다]** (1) **상태가 셋이 됐다**(미실체화/실체화/마운트) — `_mounted`는 이제 **물리 인스턴스 유무**만 뜻하고 `slot._physicalTarget`이 신설됐다. `raw*`는 **부기를 실체화 시점부터 항상** 하고 `native*`만 `_mounted`로 가른다(`H-2`/`H-12`). (2) **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵; **[2026-08-27, 9라운드 Q3]** `bk.indexOfElement`로 Dispatch 부기에 통합됨) 신설 — `indexOfRaw`가 O(1) 기본 경로가 되고 `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등(`H-1`). (3) `updateFn`의 `index`를 **`Dispatch.getOffsetAt`에서** 구한다 — 옛 `result.Length:Get()` 가산은 마운트 전 Slot의 Length가 항상 0이라 아무것도 반영 못 했다(`H-2`). (4) 요소 타입 검증이 **블랙리스트→화이트리스트**(`isSlot`→`isState`→**주입 술어 `isInst`**), 관문은 `wrapElement` 하나(`H-40`). (5) `unwrapElement`에 `isSlot` 가드(Instance에서 항상 죽던 것, `H-21`), `identityUpdateFn`이 `KeyGone` 흡수(`H-22`), `dispose` 가드를 분기 밖으로(`H-28`/`H-43`), `collectLeaves` 신설 + 미작성 `raw*` 규약(`H-29`), 선행 검증 패스(`H-30`/`H-31`), `unmountSlotTree` 역순 순회(`H-6`), top-level `Slot.luau` 신설(`H-46`) **⭐ [2026-08-26, 8라운드 `H-119`/`H-121`/`H-123`]** `rawRemove`/`rawDetach`/`rawUnmount` 계열의 명시 `recompute` 호출과 `_baseObserver` 콜백이 **재진입 게이트를 먼저 본다**(`_baseObserver`엔 부기 두 필드를 `0`으로 내리는 것도 추가). 대표 `updateFn` 예시도 교정됐다 — `:With(offset):Compute(function(i, o))`의 `o`는 offset이 아니라 **`previous`**라 그대로 짜면 첫 사이클에 죽는다(`:With`로 모은 값은 클로저로 직접 읽는다). `:Single`은 3-인자 `(state, updateFn?, opts?)`. **[2026-08-27, 9라운드 Q2/Q3]** `Offset`·`_baseObserver`가 **Slot 생성자**에서 나고(마운트 시점 생성은 첫 마운트/재마운트를 갈라 재마운트 캐시가 낡았다, `H-125`), `materializeSlotTree`는 `blocker:On()` → `bindLifetime` → `setOffsetSource` 순서, 콜백 머리에 미실체화 가드; 파괴는 **`_destroyed`** 플래그 하나가 말하고 핸들은 unbind만(mutate CRUD·`:List`·마운트 진입 error, `Owned=false`는 안 섬, 이중 `dispose` no-op); `slot._elemIndex`는 `bk.indexOfElement`로 통합; splice가 비운 자리는 세 배열(`lengthList=0`/`sourceList=None`/`observers=nil`) 전부 처리(`H-126`). | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** **[2026-08-24 6라운드 손 트레이싱 — 이 문서가 가장 크게 바뀌었다]** (1) **상태가 셋이 됐다**(미실체화/실체화/마운트) — `_mounted`는 이제 **물리 인스턴스 유무**만 뜻하고 `slot._physicalTarget`이 신설됐다. `raw*`는 **부기를 실체화 시점부터 항상** 하고 `native*`만 `_mounted`로 가른다(`H-2`/`H-12`). (2) **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵; **[2026-08-27, 9라운드 Q3]** `bk.indexOfElement`로 Dispatch 부기에 통합됨) 신설 — `indexOfRaw`가 O(1) 기본 경로가 되고 `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등(`H-1`). (3) `updateFn`의 `index`를 **`Dispatch.getOffsetAt`에서** 구한다 — 옛 `result.Length:Get()` 가산은 마운트 전 Slot의 Length가 항상 0이라 아무것도 반영 못 했다(`H-2`). (4) 요소 타입 검증이 **블랙리스트→화이트리스트**(`isSlot`→`isState`→**주입 술어 `isInst`**), 관문은 `wrapElement` 하나(`H-40`). (5) `unwrapElement`에 `isSlot` 가드(Instance에서 항상 죽던 것, `H-21`), `identityUpdateFn`이 `KeyGone` 흡수(`H-22`), `dispose` 가드를 분기 밖으로(`H-28`/`H-43`), `collectLeaves` 신설 + 미작성 `raw*` 규약(`H-29`), 선행 검증 패스(`H-30`/`H-31`), `unmountSlotTree` 역순 순회(`H-6`), top-level `Slot.luau` 신설(`H-46`) **⭐ [2026-08-26, 8라운드 `H-119`/`H-121`/`H-123`]** `rawRemove`/`rawDetach`/`rawUnmount` 계열의 명시 `recompute` 호출과 `_baseObserver` 콜백이 **재진입 게이트를 먼저 본다**(`_baseObserver`엔 부기 두 필드를 `0`으로 내리는 것도 추가). 대표 `updateFn` 예시도 교정됐다 — `:With(offset):Compute(function(i, o))`의 `o`는 offset이 아니라 **`previous`**라 그대로 짜면 첫 사이클에 죽는다(`:With`로 모은 값은 클로저로 직접 읽는다). `:Single`은 3-인자 `(state, updateFn?, opts?)`. **[2026-08-27, 9라운드 Q2/Q3]** `Offset`·`_baseObserver`가 **Slot 생성자**에서 나고(마운트 시점 생성은 첫 마운트/재마운트를 갈라 재마운트 캐시가 낡았다, `H-125`), `materializeSlotTree`는 `blocker:On()` → `bindLifetime` → `setOffsetSource` 순서, 콜백 머리에 미실체화 가드; 파괴는 **`_destroyed`** 플래그 하나가 말하고 핸들은 unbind만(mutate CRUD·`:List`·마운트 진입 error, `Owned=false`는 안 섬, 이중 `dispose` no-op); `slot._elemIndex`는 `bk.indexOfElement`로 통합; splice가 비운 자리는 세 배열(`lengthList=0`/`sourceList=None`/`observers=nil`) 전부 처리(`H-126`). **[2026-08-28 10라운드 `H-163` (a′)]** 재마운트의 `_listObserver` 바인드는 `activateList` 분기가 아니라 `materializeSlotTree` 꼬리(트리 확정 뒤)에서 — 언마운트 사이 홀드(`_rerunRequired`)가 있었으면 reconcile 1회, `_baseObserver`는 끄고 묶음 | | `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` 포인터로 압축 | diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 5367534..934dfed 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -2601,8 +2601,9 @@ end 캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가 나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위 "핸들러 계약"/"핸들러 내부 상태 저장" 절 참고). -- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — State의 - 전파 루프가 발화 때마다 `canExecute(observer)`로 각 구독자를 게이팅하고, +- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — 발화 때마다 + Observer 자신의 `_receive`가 `canExecute(observer)`로 게이팅하고(**[2026-08-28 + `EmitReceive`]** 옛 표현 "State의 전파 루프가"), 그 판정 근거(`inst` 생존)는 `bindLifetime`이 `observer` 쪽에 복사해둔 gcconn 참조가 제공함(`base/lifecycle-pattern.md`의 "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime`" 절). diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 04d9864..5ff8192 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -539,8 +539,9 @@ end 있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다). **⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도 -발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가 -구독자마다 `canExecute(observer)`를 보고 죽은 것을 건너뛰는데(`base/source-state-plan.md`), +발화 시점에 `canExecute`를 확인한다.** State/Source dep은 Observer 자신의 `_receive`가 +`canExecute(observer)`를 보고 죽은 것을 건너뛰는데(**[2026-08-28 `EmitReceive`]** 옛 표현은 +"전파 루프가" — `base/source-state-plan.md`), `Ref` 경로엔 그 게이트가 아예 없었다. 해제가 늦거나 누락되는 창이 실재한다 — 예컨대 `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안 도는데도 `canExecute`가 거짓이다. **확정된 형태**: `Effect`가 거는 `Ref` diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index af954f0..fdac442 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -354,7 +354,8 @@ function bindLifetime(inst, value) -- `base/effect-plan.md`의 "확정 구조" 절이 소스. if isObserver(value) and value._rerunRequired then -- ⭐ [2026-08-28 `H-159`] Observer도 대칭 — 묶이기 전(생성~바인드 사이)에 온 emit은 - -- 전파 루프가 `_rerunRequired`로 홀드해 두고(`source-state-plan.md` 전파 루프), + -- Observer 자신의 `_receive`가 `_rerunRequired`로 홀드해 두고(`source-state-plan.md`의 + -- `Observer:_receive` — 전파 루프는 `EmitReceive`로만 본다), -- 묶이는 순간 1회 발화(출처 없음 — 설치 발화와 같은 모양). Observer엔 epoch가 -- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다. value._rerunRequired = false @@ -615,11 +616,11 @@ leaf냐"를 가르는 판별자. **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라운드가 -"초기화가 없어 한 번도 안 돈다"로 오독할 수 있음을 짚어 명시). 레지스트리 두 테이블은 인스턴스 필드가 아니라 +`H-159`]** 묶이기 전에 온 emit을 자기 `_receive`가 홀드 — `bindLifetime`/`Subscribe`/ +`WeakSubscribe`가 1회 발화. Effect와 같은 뜻("`fn`이 돌아야 하는데 아직 안 돌았다"): +생성 시 참 → `state:Observer(fn)` 생성자의 "등록 시점 즉시 1회 실행"이 돌면서 거짓 → +그 뒤 묶이기 전 사이에 온 변경이 다시 세운다), **`_receive(from)`**(`EmitReceive` — +`source-state-plan.md`의 `_emitDown` 아래). 레지스트리 두 테이블은 인스턴스 필드가 아니라 `Observer.luau`의 모듈 로컬. Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다. **여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`가 @@ -680,10 +681,12 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라 게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도. -#### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다 +#### (4) 실제 호출부 — Observer의 `_receive`가 `canExecute`로 게이팅한다 `canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위 -정정 배너 참고). 확정된 위치는 **State의 전파 루프**: +정정 배너 참고). 확정된 위치는 **`Observer:_receive(from)`**(**[2026-08-28]** State의 +전파 루프는 구독자를 `EmitReceive`로만 보고 `sub:_receive(from)`을 부른다 — +`base/source-state-plan.md`의 `_emitDown`; 판정·홀드는 Observer 자신의 몫): - State는 자기 구독자를 **weak-키로** 담는다 — 살려두는 책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있고, 어디에도 @@ -692,9 +695,9 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라 클로저"가 아니라 **Observer 값**이다.** `bindLifetime(inst, observer)`가 Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다. -- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`를 - 확인하고, 거짓이면 **그 구독자에게 `_rerunRequired`만 세우고 건너뜀**(**[2026-08-28 - `H-159`]** 옛 "조용히 건너뜀" — 이제 묶일 때 1회 따라잡는다) — 죽은 `inst`를 +- 발화 시 Observer 자신의 `_receive`가 `canExecute(self)`를 확인하고, 거짓이면 + **`_rerunRequired`만 세우고 건너뜀**(**[2026-08-28 `H-159`]** 옛 "조용히 건너뜀" — + 이제 묶일 때 1회 따라잡는다) — 죽은 `inst`를 건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점. **⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 51153b8..f094286 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1418,7 +1418,14 @@ function activateList(self, physicalTarget) -- 죽는다(`base/lifecycle-pattern.md`의 `bindLifetime` 계약). -- 언마운트 쪽(`unmountSlotTree`)은 이미 `if slot._listObserver then`로 -- 방어돼 있었는데 이 재마운트 분기만 빠져 있었다. - if self._listObserver then bindLifetime(physicalTarget, self._listObserver) end + -- ⭐ [2026-08-28 확정, 10라운드 `H-163` (a′)] `_listObserver`는 **여기서 묶지 않는다** — + -- `materializeSlotTree`의 꼬리(트리 확정 뒤)가 묶는다. 언마운트 사이의 emit은 + -- `_receive`가 `_rerunRequired`로 홀드해 뒀는데, 트리가 미확정인 이 시점에 묶으면 + -- `bindLifetime`의 홀드 발화가 reconcile을 동기 실행해 자리 이중 등록·중첩 Slot + -- `canBound` error가 난다. 사용자: *"슬롯트리가 미확정 상황에는 bind 안하고, + -- 확정될 때 bind 전에 rerun 끈다"*, 유실 대신 확정 뒤 1회 reconcile은 *"권고가 + -- 맞는듯. get 자체가 안 나니까"*(재마운트 분기 자체는 reconcile을 안 돌린다 — + -- 감사 2라운드 지적). plain table `data`면 `_listObserver`가 `nil`이라 꼬리도 건너뛴다. bindLifetime(physicalTarget, self._detachCleanup) return end @@ -2344,6 +2351,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- 완주하면, 그 시점 `bk`는 언마운트 전 옛 부기(`Relate(slot)` 위에 -- 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다. 이 Blocker는 이 Slot의 것이고 -- `setOffsetSource`는 **부모 owner의** blocker를 보므로 간섭이 없다. + slot._baseObserver._rerunRequired = false -- [2026-08-28 `H-163`] 위 `_listObserver`와 같은 이유 bindLifetime(physicalTarget, slot._baseObserver) -- offset — activateList가 updateFn에 이 값을 넘겨야 하므로(C1). 여기서 -- `slot.Offset`이 새 베이스로 `Set`되고, 위 관측자가 두 필드를 0으로 내린 뒤 @@ -2381,8 +2389,10 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- `_elements` 안의 중첩 Slot들이 **다시 실체화되지 않는다** — -- `_physicalTarget`이 `nil`인 채 `_mounted`만 켜지고, `_baseObserver`가 -- **죽은 옛 target**에 매달린 채 남는다(그 가드 자신이 경고하는 실패 모드). + local remountingList = slot._listed and slot._listActivated -- [2026-08-28 `H-163` (a′)] 꼬리가 본다 — + -- 아래 activateList가 플래그를 켜기 **전**에 잡는다 if slot._listed and not slot._listActivated then - activateList(slot, physicalTarget) -- 최초 population — rawAdd가 자리마다 등록 + activateList(slot, physicalTarget) -- 최초 population — rawAdd가 자리마다 등록(observer도 여기서 묶는다) else if slot._listed then activateList(slot, physicalTarget) -- 재마운트: 앵커만 새 target으로 @@ -2417,6 +2427,24 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) -- 여기서 slot.Length가 최종값으로 확정 end + -- ⭐ [2026-08-28 확정, 10라운드 `H-163` (a′)] 재마운트의 `_listObserver` 바인드는 + -- **트리가 확정된 여기서** — `activateList` 재마운트 분기가 넘겨둔 일. 언마운트 + -- 사이에 `data`가 바뀌었으면(`_receive`가 세운 `_rerunRequired`) 끄고 묶은 뒤 + -- reconcile을 **정확히 1회** — 상류 `data`는 epoch가 최신이라 `Get` 한 번이면 된다. + -- `bindLifetime` 자신의 홀드 발화에 맡기지 않는 이유: 그건 gcconn 연결 직후 + -- 묶이는 자리에서 돌아 순서를 이 꼬리에 못 맞춘다(`_baseObserver`는 위에서 끄고 + -- 묶었다 — 그쪽 캐치업은 `setOffsetSource` 경로가 한다). + -- ⚠️ **재마운트일 때만** — 최초 population은 위 `activateList`의 fresh 경로가 이미 + -- observer를 만들고 묶었다(감사 3라운드: 여기서 또 묶으면 `canBound` error). 늦은 + -- `:List()`(이미 마운트된 Slot에 설치, `Slot:List`)는 이 함수를 안 거치므로 fresh + -- 경로의 bind가 유일 — 그래서 fresh 경로의 bind를 여기로 옮길 수도 없다. + if remountingList and slot._listObserver then + local listObserver = slot._listObserver + local held = listObserver._rerunRequired + listObserver._rerunRequired = false + bindLifetime(physicalTarget, listObserver) + if held then listObserver.fn(slot._listData, listObserver, nil) end -- == reconcile(data:Get()) + end -- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에 -- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 6769608..adbb2ea 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -251,16 +251,39 @@ function State:_emitDown(from) local snap = {} for sub in self._subs do snap[#snap + 1] = sub end for _, sub in ipairs(snap) do - if isState(sub) then -- 자식 노드 - sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3 - elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다) - sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) - else - sub._rerunRequired = true -- ⭐ [2026-08-28 `H-159`] 묶이기 전의 변경은 버리지 않고 홀드 — - end -- `bindLifetime`/`Subscribe`가 1회 발화(`lifecycle-pattern.md`). - -- (Effect의 내부 Observer는 `WeakSubscribe`돼 있어 여기 안 옴 — - -- Effect 쪽 홀드는 `rawRerun`이 한다) - end + sub:_receive(from) -- ⭐ [2026-08-28 확정, `H-163` 대화] 구독자는 전부 **`EmitReceive`** + end -- (`:_receive(from)` 하나짜리 인터페이스 — `Epoch`과 같은 급). + -- State 노드(`ComputeNode`/`GateNode`)는 §4 규칙, Observer는 아래 + -- `Observer:_receive`. 한때 여기서 `isState`/`canExecute`로 갈라 + -- Observer의 `fn`을 직접 부르고 `_rerunRequired`까지 세웠는데 + -- **계층 간 지식이 섞여** 있었다 — 사용자: *"State:_emitDown 에 + -- … 계층간 확인 구조가 있거든? 그냥 Epoch 처럼 EmitReceive 를 + -- 만들고, 각 state 나 observer 측에서 해당 emit 을 처리하는 함수를 + -- 만들어주는게 맞는듯. _rerunRequired 를 여기서 설정하는게 + -- 문제가 되어보여(계층간 지식이 분리 안되어있음)."* +end + +-- Observer 쪽 `EmitReceive` 구현(`Observer.luau`). 판정과 홀드가 **여기** 산다. +function Observer:_receive(from) + if canExecute(self) then + self.fn(self._state, self, from) -- ⭐ (리시버 State, Observer 자신, 출처) + else + self._rerunRequired = true -- [2026-08-28 `H-159`] 묶이기 전의 변경은 홀드 — 묶일 때 1회 + end -- (Effect의 내부 Observer도 이 경로 — `fire`가 `fn`이다) +end + +-- `EmitReceive` — 구독자 집합 `_subs`의 원소가 만족하는 인터페이스(`Epoch`처럼 구조적). +type EmitReceive = { _receive: (self: any, from: Epoch | EpochSet) -> () } +-- 구현: `ComputeNode`/`GateNode`(state-epoch-plan.md §4 규칙 1~3 / gate-plan.md 조립 절), `Observer`(위). + +-- `state:Observer(fn)` 생성자 — 순서가 계약이다(**[2026-08-28 `H-159`/`H-164`]**). +function State:Observer(fn) + local o = setmetatable({ fn = fn, _state = self, _rerunRequired = true }, Observer) + ObserverBrand:register(o) + o.fn(self, o, nil) -- (1) 등록 시점 즉시 1회 — 출처 없음(`nil`) + o._rerunRequired = false -- (2) 설치 발화가 플래그를 내린다(사용자 확인) + self._subs[o] = true -- (3) 그다음 구독자 집합에 — 순서를 뒤집으면 (1)이 자기 + return o -- State를 Set할 때 그 emit이 자신의 _receive에 와 플래그가 선다 end ``` @@ -1143,10 +1166,11 @@ retract/Destroy되면 자동으로 정리됨. - **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, 사용자 확정 — 이전까지 미명시였던 항목).** (**[2026-08-28 `H-159`]** 이 1회는 - `state:Observer(fn)` 생성자가 무조건 하는 것이고, 같은 날 신설된 `_rerunRequired` - 홀드는 그 **뒤** ~ 묶이기 전 사이의 변경만 다룬다 — 별개다. **생성자 순서는 `fn` - 1회 실행 → `_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기 - State를 `Set`할 때 그 emit이 아직 안 묶인 자신에게 와 `_rerunRequired`가 서고 첫 + `state:Observer(fn)` 생성자가 무조건 하는 것(Effect 생성자와 같은 모양 — + `_rerunRequired = true`로 시작해 이 1회가 내린다)이고, 같은 날 신설된 홀드는 + 그 **뒤** ~ 묶이기 전 사이의 변경만 다룬다. **생성자 순서는 `fn` 1회 실행 → + `_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기 State를 + `Set`할 때 그 emit이 아직 안 묶인 자신의 `_receive`에 와 플래그가 서고 첫 바인드에서 `fn`이 한 번 더 돈다. Effect의 내부 Observer가 이 설치 발화를 `from == nil`로 거르는 이유이기도 하다.) 근거: (1) 이미 채워진 State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 @@ -1179,9 +1203,13 @@ retract/Destroy되면 자동으로 정리됨. 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom`은 **옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high` 발견 — 한때 non-optional로 적혀 있었다). `fn`이 `emitFrom`을 실제로 쓰는 - 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. **⚠️ [2026-08-28 판단 대기, - `H-164`]** `H-159`의 Observer 홀드 발화(묶일 때 1회)도 지금 `nil`을 넘겨 이 - 분기와 구분이 안 된다 — 갈래는 `-round10.md` §4. + 소비자라면 `nil`을 **"출처 없음 — 값을 읽어라"**로 다뤄야 한다. **[2026-08-28 + `H-164` 확정]** `nil`은 설치 발화 **또는** 묶일 때의 캐치업(`H-159` 홀드 발화) + 둘 다다 — 둘 다 "특정 출처의 통지"가 아니라 "지금 값을 반영하라"라 같은 종류. + 홀드된 출처를 보관해 넘기는 안은 기각(사용자: *"_rerunRequired 를 from 으로 + 저장하면 여러 홀드 변경이 오면 from 이 날아가지 않아? 애초에 from 을 저장할 + 이유가 왜 있어?"*). `nil`을 "초기화 전용"으로 분기하는 소비자 패턴은 계약이 + 아니다. - **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라 **핸들과 메타데이터**뿐이다. - 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄 @@ -1262,8 +1290,9 @@ override할 자리를 구조적으로 열어두는 것.) 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. **[명시화, 2026-08-14 다섯 번째 세션] 이 게이팅이 일어나는 자리는 State의 - 전파 루프**다 — State는 구독자를 **weak로** 담고, 발화 시 각 구독자마다 - `canExecute(observer)`를 확인해 거짓이면 그 구독자만 건너뜀. 여기에 + 전파 루프**다 — State는 구독자를 **weak로** 담고 `sub:_receive(from)`을 부르며, 발화 + 시 Observer 자신의 `_receive`가 `canExecute(observer)`를 확인해 거짓이면 홀드 + (**[2026-08-28 `EmitReceive`]**). 여기에 `inst`가 없다는 사실이 `canExecute`가 `value` 하나만 받아야 하는 이유(`base/lifecycle-pattern.md`의 "실제 호출부" 절, 옛 2-인자 시그니처의 역전 경위는 `archive/canexecute-inst-arg-reversed.md`). @@ -1357,7 +1386,7 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고, `.Subscribed` 플래그는 **강·약 구독 경로 공용**이다. 이게 정해져 있지 않아서 두 해석이 각자 다른 확정 문장에 뿌리를 두고 있었고, 안 세우는 - 쪽으로 읽으면 전파 루프의 `canExecute(sub)` 게이트가 항상 거짓이 되어 + 쪽으로 읽으면 `Observer:_receive`의 `canExecute(sub)` 게이트(**[2026-08-28 `EmitReceive`]** 옛 표현 "전파 루프의")가 항상 거짓이 되어 **`Effect`의 State dep 전량이 조용히 침묵**한다(`Effect`의 내부 Observer는 `WeakSubscribe`로만 등록되고, gcconn 경로는 핸들에만 있으므로 남는 판정 근거가 `.Subscribed`뿐이다). 사용자 원문 *"구현이 한 벌"*과도 이쪽이 diff --git a/.claude/qa-request/pre-implementation-handtrace-round10-followup.md b/.claude/qa-request/pre-implementation-handtrace-round10-followup.md index c3c1afe..57fcf7f 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round10-followup.md +++ b/.claude/qa-request/pre-implementation-handtrace-round10-followup.md @@ -17,7 +17,8 @@ | `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-163` | Slot 내부 Observer × 홀드 발화 | ✅ **확정 (a′)** `_listObserver`는 트리 확정 뒤(`materializeSlotTree` 꼬리)에 끄고 묶고, 홀드가 있었으면 reconcile 1회; `_baseObserver`는 끄고 묶음 + **`EmitReceive` 인터페이스**(전파 루프 계층 분리) | +| `H-164` | 홀드 발화의 `emitFrom == nil` | ✅ **전제 정정 (c)** — `nil` = 출처 없음(설치 또는 캐치업), `from` 보관 기각 | | `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) | @@ -365,3 +366,35 @@ session 후속 절) · 2 의미론(**`_rerunRequired` 상태 기계 자체는 stale 셋(`Blocker` 억제·"`fire` 첫 줄"·"죽은 핸들은 no-op") / `ROADMAP.md` M2 `Rerun` no-op → 홀드 / `lifecycle-pattern.md`의 *조용히 건너뜀* → 홀드 / "M5 이후" 잔존 셋 / followup 절 제목 포인터·개수. + +## `H-163` — Slot은 재바인드 전에 내부 Observer의 홀드를 끈다 (a) + `EmitReceive` 인터페이스 + +**사용자 확정**: *"slot 의 canExecute 를 따라 이것도 쌓아두는 등의 작업을 할 필요는 안 +보이는듯. 이건 단일 대상에 대해 observe 하는거라서, 상류 state 의 온전한 epochmap +처리를 받거든. _rerunRequired 를 직접 false 로 바꿔두고 bind 하는건 확실히 맞아보여. +… 그냥 슬롯트리가 미확정 상황에는 bind 안하고, 확정될 때 bind 전에 rerun 끈다는 +괜찮은 생각."* — 처음엔 `activateList` 재마운트 분기에서 끄고 묶었는데 **감사 +2라운드가 그 분기는 reconcile을 다시 돌리지 않는다**(앵커만 옮기고 return)고 잡아, +그대로면 언마운트 중 `data` 변경이 다음 `Set`까지 유실됐다. 갈래 (a′) 트리 확정 뒤 +(`materializeSlotTree` 꼬리, `OffWithoutEmit`·`recompute` 뒤) 끄고 묶고 홀드가 있었으면 +`reconcile(data:Get())` 1회 / (b′) 유실을 계약으로 → **사용자 확정 (a′)**: *"권고가 +맞는듯. get 자체가 안 나니까"*. `_baseObserver`는 `materializeSlotTree` 머리에서 끄고 +묶는다(그쪽 캐치업은 `setOffsetSource` 경로). + +**같은 발언에서 나온 구조 결정 — `EmitReceive`**: *"State:_emitDown 에 관한 이야기인데, +여기 계층간 확인 구조가 있거든? 그냥 Epoch 처럼 EmitReceive 를 만들고, 각 state 나 +observer 측에서 해당 emit 을 처리하는 함수를 만들어주는게 맞는듯. _rerunRequired 를 +여기서 설정하는게 문제가 되어보여(계층간 지식이 분리 안되어있음)."* → 전파 루프는 +`sub:_receive(from)` 한 줄, `Observer:_receive`가 `canExecute` 판정과 홀드를 자기 안에 +둔다(`source-state-plan.md` `_emitDown` 아래, `lifecycle-pattern.md` (4)절, `ROADMAP.md`). + +## `H-164` — 문항 전제 정정: `emitFrom == nil` = 출처 없음(설치 또는 캐치업), `from` 보관 기각 (c) + +**사용자**: *"H164는 뭔가 이상함. 설치 발화 후 홀드된 변경이 무슨말인지 모르겠음. 아에 +별도의 이야기일텐데? … _rerunRequired 를 from 으로 저장하면 여러 홀드 변경이 오면 from 이 +날아가지 않아? 애초에 from 을 저장할 이유가 왜 있어?"* — 맞다. 홀드 발화는 출처 있는 +통지가 아니라 "묶였으니 값을 읽어라"라 설치 발화와 같은 종류이고, `nil`을 "초기화 +전용"으로 분기하는 소비자 패턴은 계약이 아니다. Observer의 `_rerunRequired`는 Effect와 +같은 뜻으로 **생성 시 참 → 설치 발화가 내림 → 묶이기 전 변경이 다시 세움**(사용자 +확인: *"설치 발화 이후엔 rerunRequired = false 되긴 해."*). 반영: `source-state-plan.md` +1171행 계약 문구. diff --git a/.claude/qa-request/pre-implementation-handtrace-round10.md b/.claude/qa-request/pre-implementation-handtrace-round10.md index 19dd19a..6923244 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round10.md +++ b/.claude/qa-request/pre-implementation-handtrace-round10.md @@ -350,8 +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-163`** ✅ (a) → **(a′)** | **[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) → (a′)** — (a)의 전제("재마운트 캐치업은 `activateList`가 이미 한다")는 감사 2라운드가 반증(그 분기는 앵커만 옮긴다) → 트리 확정 뒤 끄고 묶고 홀드가 있었으면 reconcile 1회. 소스는 `-round10-followup.md` | +| **`H-164`** ✅ (c) — 문항 전제 정정 | Observer 홀드 발화가 `emitFrom = nil`로 오면 계약("`nil` = 설치 발화")과 구분 불가 — `if emitFrom == nil then initOnly()`로 짠 소비자가 변경을 놓침 | (a) 홀드 시 **마지막 `from`을 보관**(`_rerunRequired = from`, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(`HeldEmit`) / (c) 계약 문구만 "`nil` = 설치 **또는** 묶일 때 캐치업" | **(c) — 문항 전제 정정**: 홀드 발화는 출처 있는 통지가 아니라 "묶였으니 값을 읽어라"라 설치 발화와 같은 종류 — `nil` = 출처 없음(설치 또는 캐치업). (a)의 `from` 보관은 사용자 기각(여러 홀드가 오면 앞 것이 날아감, 보관할 이유 없음). 소스는 `-round10-followup.md` | 갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), `H-156`(`H-32` 문단), `H-157`(실측 완료 표기). diff --git a/.claude/question.md b/.claude/question.md index 7d67922..4e3c27f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -18,10 +18,6 @@ 사용자와 대화형으로 전량 결정·반영됐습니다**(소스는 `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 diff --git a/ROADMAP.md b/ROADMAP.md index 7ff07ea..dccd630 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -410,8 +410,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`가 곧 선언 키 집합이고 `Names()`가 성립한다 - [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** - (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 - 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) — + (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부" 절) — State는 구독자를 **weak-키로만** 담고, 살려두는 책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음 (어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). @@ -427,12 +426,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold` 불변식**(하류 → 상류 강함)이 책임진다. ```lua - 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 + sub:_receive(from) -- [2026-08-28 `EmitReceive`] 구독자 전부 같은 인터페이스 — State 노드는 §4 규칙, + -- Observer:_receive가 canExecute 판정·홀드(`_rerunRequired`)를 자기 안에서 ``` + (한때 여기서 `isState`/`canExecute`로 갈라 Observer의 `fn`을 직접 불렀다 — + 계층 지식이 섞여 사용자 지시로 인터페이스화, `base/source-state-plan.md`) `canExecute`가 `inst`를 인자로 받을 수 없는 이유는 그대로다(State는 자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로 @@ -1526,7 +1524,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는 `.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/ `Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit - 전파 루프만 `canExecute`. + `Observer:_receive`만 `canExecute`(**[2026-08-28 `EmitReceive`]** 옛 표현 "전파 루프만"). **저장은 전부 `SetWeak`**(`SetStrong` 아님 — gchold/gcconn은 아래 M5 클로저↔`gchold[1]` 상호 참조로 이미 안전하게 살아있고, "다른 곳에서 안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙).