qa: 10라운드 결정 반영 (H-147~H-158) — fn/cleanup 자기 구독 금지, Refresh 캐치업 폐기, 루트는 Claim으로

- H-147 (A): fn/cleanup은 자기 구독을 못 바꾼다 — rawRerun(force)/Rerun 분리, 진입 canExecute 게이트,
  네 진입점+_bindDestroying에 _running/_cleanupRunning 가드, H-143(원샷) 소멸, 자기 leaf 파괴 UB
- H-148: 루트는 밖에서 .Parent=가 아니라 quad가 Claim으로 소유 → research/existing-mount-plan.md 신설,
  H-146 예외·전용 문구 폐기, archive 부활 배너
- H-149 Observer 진입점 인라인 / H-150 Effect._blocker 제거 / H-151 _epochs는 emit 때만(게이트는 emit 경로만
  미룬다 계약) / H-152 GateNode StateBrand:register / H-153 Store 예약 이름 런타임 가드 + 그림자=store 자신 /
  H-154 InstanceChildHandler dedup / H-155~H-157 stale
- 감사 3→5→2→3→1→0, /code-review high 10건 중 7 반영, 셋(H-159~H-161)은 -round10.md §4 문항으로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
This commit is contained in:
qwreey 2026-08-28 13:00:49 +09:00
parent d2d67c7aeb
commit ae34cfa316
Signed by: qwreey
GPG key ID: D28DB79297A214BD
24 changed files with 943 additions and 241 deletions

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,13 @@
> **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서 > **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서
> `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.** > `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.**
> >
> **⭐ [2026-08-28] 좁은 형태로 부활 — `research/existing-mount-plan.md`.** 여기서
> 기각된 것은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*이고 그
> 사유(바깥이 자식 구성을 밀고 당기면 부기가 깨진다)는 그대로 유효하다. 부활한
> 것은 그 반대 방향 — **한 번 `Claim`하면 quad가 소유하고 직계 자식은 사용자가
> 전부 매핑한다**(claim-once · own-all, 재바인드는 여전히 미지원). 루트(PlayerGui)와
> `Clone()` 템플릿이 그 용도다.
>
> **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/ > **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/
> `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은, > `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은,
> quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를 > quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를

View file

@ -331,12 +331,20 @@ end
배선은 사용자 확정이 아니라 **규칙을 기존 계약에 얹은 제 선택**이다 — 배선은 사용자 확정이 아니라 **규칙을 기존 계약에 얹은 제 선택**이다 —
`H-142` 처방 후보 (a)/(b)/(c)가 전부 새 메커니즘이라 정하지 않았던 것을 `H-142` 처방 후보 (a)/(b)/(c)가 전부 새 메커니즘이라 정하지 않았던 것을
"키 금지"로 바꾸니 필요한 코드가 이 거부 한 줄뿐이다. 다른 모양이 낫다면 "키 금지"로 바꾸니 필요한 코드가 이 거부 한 줄뿐이다. 다른 모양이 낫다면
갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다. **[2026-08-27 `H-146`]** 갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다. **[2026-08-28 10라운드
그 거부는 일반 매치 실패 문구(*"no handler … check quad-roblox provider"*)가 `H-148` 철회]** 2026-08-27에 여기 "그 거부는 **전용 문구**를 낸다"를 붙였는데
아니라 **전용 문구**를 낸다 — provider 설정을 의심하게 만들지 않도록 그건 새 메커니즘이었다(`isHandlable` 거부는 `Dispatch.process`의 일반 매치
("`Parent` is not a prop: the parent attaches its children; a quad root is 실패 문구로 떨어지고 그 자리에 특수 분기는 두지 않기로 확정돼 있다) —
parented by the caller" 취지, 정확한 문장은 구현 시). **철회**, 일반 문구 그대로. 오해는 사용자 문서가 맡는다.
- **⭐ [2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 — - **⛔ [2026-08-28 폐기, 10라운드 `H-148``research/existing-mount-plan.md`]
아래 "루트는 사용자가 밖에서 `.Parent =`" 예외는 하루 만에 뒤집혔다** — 사용자:
*"slot 은 물리 장치에 mount 할 방법이 거의 존재하지 않음 … PlayerGui 가
상위에 있고 거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안
될것 같은 느낌이 듦. 이건 Parent 이상의 문제인것 같아."* 루트는 밖에서
`Parent`를 만지는 게 아니라 **quad가 `Claim`으로 소유**한다(PlayerGui·`Clone()`
사본·Studio GUI — 그 research 문서가 소스, M5 이후). 그러면 `.Parent =`
사용자가 쓸 자리 자체가 없어진다. 아래는 폐기 전 서술:
**[2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 —
quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =` quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`
한다.** 위 인용문의 *"외부에서 직접 Parent 설정해주지 말것"*이 막는 것은 한다.** 위 인용문의 *"외부에서 직접 Parent 설정해주지 말것"*이 막는 것은
**quad가 관리하는 자식 자리**(Slot 요소·정적 자식)에 밖에서 끼우는 것이고 **quad가 관리하는 자식 자리**(Slot 요소·정적 자식)에 밖에서 끼우는 것이고

View file

@ -887,11 +887,13 @@ function clearTimeout(timeout: Timeout) timeout._native() end
(사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이 (사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이
`pending = false`를 무조건 하므로 유일한 탈출구다. `pending = false`를 무조건 하므로 유일한 탈출구다.
**해소는 위 재작성에 흡수된다** — `pending`을 없애고 Blocker의 **해소는 위 재작성에 흡수된다** — `pending`을 없애고 **`emit()`의 반환값**으로
`HasBlockedEmit`을 쓰면 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어 "보류분이 있는가"를 읽으면(**[2026-08-28 정정, 10라운드 `H-156`]** 여기 한때
이 결함이 구조적으로 성립하지 않는다(`Trailing = false`는 `OffWithoutEmit()` "`HasBlockedEmit`을 쓰면"이라 적혀 있었는데 그건 7라운드 `H-86`이 뒤집은 통로다)
으로 표현되고, 그건 보류분을 **버리면서** 상태도 같이 비운다). 아래 코드를 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어 이 결함이 구조적으로
참고할 때 이 결함을 그대로 옮기지 말 것. 성립하지 않는다(`Trailing = false`는 **`emit(false)`**로 버린다 — `H-55`:
`OffWithoutEmit()`만으로는 흡수 집합이 안 빈다). 아래 코드를 참고할 때 이 결함을
그대로 옮기지 말 것.
```lua ```lua
-- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절). -- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절).
@ -1139,7 +1141,10 @@ Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름
- **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`). - **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`).
실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음. 실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음.
- **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된 - **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된
빈도로 재실행됨 — 별도 장치 불필요. `Effect`가 deps 배열을 안 만들고 빈도로 재실행됨 — 별도 장치 불필요. **[2026-08-28 `H-151`]** 단 게이트는
emit 경로만 미룬다 — `Effect`의 재바인드/재구독 캐치업과 게이트 없는 형제
dep의 emit은 창을 무시하고 `fn`이 돌며 그때 `:Get()`은 최신값(계약,
`base/gate-plan.md`의 "계약 — 게이트는 emit 경로만 미룬다" 절). `Effect`가 deps 배열을 안 만들고
`:With`를 재사용한 것과 같은 결로, "debounce된 Effect"라는 별도 API를 `:With`를 재사용한 것과 같은 결로, "debounce된 Effect"라는 별도 API를
만들 필요가 없다는 뜻. 만들 필요가 없다는 뜻.
- **테스트/`quad-mock`**: 주입 op 2개 덕분에 **가상 시계로 결정론적 테스트가 - **테스트/`quad-mock`**: 주입 op 2개 덕분에 **가상 시계로 결정론적 테스트가

View file

@ -1490,7 +1490,16 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
확정(사용자, 갈래 없음 — `H-39`의 "예외 없이"에 이 핸들러를 넣는 것뿐): 확정(사용자, 갈래 없음 — `H-39`의 "예외 없이"에 이 핸들러를 넣는 것뿐):
`process`에서 `setOffsetSource(inst, k, None)`**`v.Parent = inst`** → `process`에서 `setOffsetSource(inst, k, None)`**`v.Parent = inst`** →
**`setLength(inst, k, 1, inst)`**(상수 `1`). 반환 클로저는 (A)/(B) 분기·단순 **`setLength(inst, k, 1, inst)`**(상수 `1`). 반환 클로저는 (A)/(B) 분기·단순
철거에서 **`v.Parent = nil`** → `setOffsetSource(inst, k, None)` 철거에서 **`if nextValue == v then return end`**(**[2026-08-28 확정, 10라운드
`H-154`]** 같은 값 재발행 dedup — `SlotHandler`의 retractor 쪽 `slotValue ==
nextValue` 얼리리턴과 동형. 없으면 `store.child:Set(store.child:Get())` 한 줄에
`Parent = nil → inst``recompute`가 두 번 돌아 `ChildRemoved`/`ChildAdded`가
실제로 나간다, 실측 `d23`. **정확히 무엇이 줄어드나**: 물리 detach/attach와
`recompute` 한 번 — (A) 분기는 retractor 뒤 `process`를 다시 부르므로
`setOffsetSource(None)`·`Parent = inst`(같은 값, 엔진 no-op)·`setLength(1)`의
`recompute` 1회는 남는다(2→1이지 0이 아님; `SlotHandler``claimOwnerAt`
process 쪽도 접지만 이 핸들러는 옛 값을 process에서 모른다 — 그 skip은 안 둔다); 사용자: *"말단 핸들러가 v 를 정확히 알아서 retract
가 정확히 해소되는 부분"*) → **`v.Parent = nil`** → `setOffsetSource(inst, k, None)`
`setLength(inst, k, 0)`(`SlotHandler`의 retractor가 `unmountSlotTree`를 먼저 `setLength(inst, k, 0)`(`SlotHandler`의 retractor가 `unmountSlotTree`를 먼저
부르는 것과 같은 모양, 부기 둘의 순서 근거는 아래 "해제" 문단). 부르는 것과 같은 모양, 부기 둘의 순서 근거는 아래 "해제" 문단).
**[2026-08-27 `/code-review high` 정정 셋]** — (1) 옛 자식은 **내린다**(파괴 **[2026-08-27 `/code-review high` 정정 셋]** — (1) 옛 자식은 **내린다**(파괴

View file

@ -151,7 +151,11 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그
- ~~**bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을 - ~~**bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을
떼고 재마운트의 `bindLifetime`이 다시 건다.~~ **[2026-08-26 폐기, 떼고 재마운트의 `bindLifetime`이 다시 건다.~~ **[2026-08-26 폐기,
`H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는 `H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는
`H-64`**조건부 캐치업**(`_epochs:Refresh()`)이다. **dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다**는 것 — 재마운트는
`Destroying` 연결만 다시 건다(`_bindDestroying`). 포탈 사이에 놓친 emit의
캐치업은 없다(**[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()` 폐기 — `_installed`
참인 채 재마운트되므로 `not _installed → Rerun`도 안 걸린다; 다음 emit이
리비전 차이로 잡는다).
3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
`Rerun`이 같은 자리를 읽게 된다. `Rerun`이 같은 자리를 읽게 된다.
@ -203,7 +207,6 @@ callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지
-- quad-base, Effect.luau -- quad-base, Effect.luau
function Effect(fn, ...) function Effect(fn, ...)
local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle) local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle)
self._blocker = Blocker()
-- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다. -- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다.
-- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다. -- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다.
@ -232,11 +235,17 @@ function Effect(fn, ...)
-- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다 -- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다
-- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 -- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서
-- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). -- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
self._blocker:On() -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 등록 즉시 1회 발화(내부 Observer·`Ref`
-- 콜백의 설치 발화는 **그대로 일어난다**)는 아래 `fire`의 첫 줄 — **Effect
-- 핸들의** `canExecute` — 이 흡수한다: 생성자 안에선 아직 어디에도 안 묶여
-- 있어 항상 거짓이다. 한때 여기 사적 `Blocker`(`_blocker:On()` … `OffWithoutEmit()`)
-- 가 같은 억제를 한 번 더 하려고 있었는데, 실측(10라운드 `t18`)상 어떤 경로에서도
-- 판정에 닿지 않는 죽은 부품이라 **제거**(사용자 확정: *"Effect 의 canExecute 를
-- 보겠다는거지? 그럼 그건 맞는것 같아"*). `H-147``fn`이 생성자 안에서 자기를
-- 묶을 수도 없으므로 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식이다.
local function fire(from) -- 공통 본문 local function fire(from) -- 공통 본문
if not canExecute(self) then return end -- 발화 게이트 if not canExecute(self) then return end -- 발화 게이트 — `Update`보다 먼저(아래 ⚠️)
if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저) if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리
if self._epochs:Update(from) then
self:Rerun() self:Rerun()
end end
end end
@ -257,25 +266,31 @@ function Effect(fn, ...)
-- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙 -- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙
-- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) — -- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) —
-- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가 -- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가
-- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다. -- `Update(from)`이 그 원천의 리비전을 영영 못 본다.
if isEpoch(d) then if isEpoch(d) then
self._epochs:Sync(d) self._epochs:Sync(d)
else else
self._epochs:TrackFrom(d.valueEpochMap) self._epochs:TrackFrom(d.valueEpochMap)
end end
end end
self._blocker:OffWithoutEmit()
-- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇). -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
self:Rerun() -- ⭐ [2026-08-28 `H-147`] 공개 `Rerun()`이 아니라 본체를 `force`로 부른다 —
-- 초기 설치는 "re"-run이 아니고, 아직 안 묶여 있어 공개 진입의
-- `canExecute` 게이트를 통과하지 못한다.
rawRerun(self, true)
return self return self
end end
``` ```
- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드 - **`_installing` 플래그도, 그 뒤를 이은 사적 `_blocker`도 폐기됐다** —
구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**: `_installing`은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 `H-58`),
*"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든 `_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `fire`
옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*). 첫 줄 `canExecute`가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다
(실측 `t18`: `drop:canExecute` 3 / `drop:blocker` 0). `H-58`의 사용자 지시
(*"해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서
이를 수행해야할 것임."*)는 그 전제("등록 즉시 1회가 `Rerun`에 닿는다")가
성립하지 않았던 것으로 정정 — 억제 주체는 Effect 핸들의 `canExecute`다.
- **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**: - **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**:
*"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행 *"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행
결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안 결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안
@ -285,6 +300,11 @@ end
```lua ```lua
function EffectHandle:_bindDestroying(inst) function EffectHandle:_bindDestroying(inst)
if isRunning(self) then -- ⭐ [2026-08-28 `/code-review`] (A)의 강제 — `fn` 안에서
error("cannot bind an Effect from inside its own fn or cleanup", 2)
end --
-- `New "Frame" { self }`로 자기를 leaf에 묶는 경로도 막는다
-- (`bindLifetime`은 범용이라 Effect 훅인 여기서 건다)
self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등 self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점. -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
@ -293,19 +313,29 @@ function EffectHandle:_bindDestroying(inst)
self:_consumeCleanup() self:_consumeCleanup()
end) end)
-- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다. -- (2) 캐치업 — **재설치 1회뿐**. dep 등록은 이미 생성자에서 끝났다.
-- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행. -- ⭐ [2026-08-28 확정, 10라운드 `H-151`] 여기 한때 `_epochs:Refresh()`
-- ⚠️ `Refresh()`**먼저 부른다** — 그건 비교만 하는 게 아니라 자기 -- "dep이 변했으면 재실행"까지 했는데 **폐기**`_epochs``fire`
-- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면 -- `Update(from)`에서만, 즉 **emit을 받을 때만** 갱신한다(Observer·중간
-- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도 -- State와 같다). 재바인드는 초기 설치와 같은 뜻이라 소진돼 있으면 다시
-- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번 -- 설치할 뿐이고, 죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로
-- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다). -- 잡힌다(Observer와 같은 정도의 캐치업 없음 — 계약). 사용자: *"우린 애초에
local depsChanged = self._epochs:Refresh() -- Refersh 를 할 필요가 없는거야. 재진입은 초기 설정해주는 요소이고, 그건
if not self._installed or depsChanged then -- 처음 생성할때랑 같은거야."* gcconn 연결 **뒤**라 공개 `Rerun`의 게이트를
-- 통과한다.
if not self._installed then
self:Rerun() self:Rerun()
end end
end end
-- ⚠️ [2026-08-28 확정, 10라운드 감사 2라운드] **`fn` 안에서 자기 leaf `inst`
-- 파괴하는 것은 UB.** `Workspace.SignalBehavior``Deferred`(현재 기본)면
-- `Destroying` 콜백이 다음 리줌으로 늦춰져 경합이 없지만, `Immediate`(레거시)면
-- `fn` 실행 도중 위 콜백이 동기 발화해 `_consumeCleanup`(이미 비어 있음 — no-op)과
-- `_destroyConn` 해제만 일어나고, `fn`이 돌려준 cleanup은 저장되지만 소진할 연결이
-- 사라져 **영구 미소진**이다. `fn`이 자기 생명주기를 못 바꾼다(`H-147` (A))는
-- 계약의 물리판 — 사용자: *"bind/unbind 에 간접 영향을 주는건데, UB 인게 맞다는 생각"*.
-- `SignalBehavior` 구분 자체는 `base/ref-plan.md`가 소스.
function EffectHandle:_unbindDestroying() function EffectHandle:_unbindDestroying()
if self._destroyConn then if self._destroyConn then
self._destroyConn:Disconnect() self._destroyConn:Disconnect()
@ -321,7 +351,18 @@ function EffectHandle:_consumeCleanup()
local c = self._cleanup local c = self._cleanup
self._cleanup = nil self._cleanup = nil
self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다 self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다
if c then c() end if c then
-- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 —
-- `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은
-- `_running` 밖이라 cleanup 안의 `self:Subscribe()`가 가드를 지나
-- `Unsubscribe()`가 끝나기도 전에 `fn`이 재진입했다. `_running`에 뜻을
-- 얹지 않고 **별도 플래그**로 잡는다(사용자: *"_running 으로 묶어 보는건
-- 여전히 별로 괜찮은 이유가 없음. _cleanupRunning 같은걸 넣지 말아야할
-- 이유가 없는것"*).
self._cleanupRunning = true
c()
self._cleanupRunning = false
end
end end
``` ```
@ -332,65 +373,77 @@ end
게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면 게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면
바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가 바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가
없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** 없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.**
`_installed``Rerun`이 끝날 때 참(**[2026-08-27 `H-143`]** 단 그 실행 중에 `_installed``rawRerun``fn`을 돌리고 끝날 때 참, `_consumeCleanup`에서
핸들이 죽었으면 — `wasAlive and not canExecute` — 세우지 않는다, 거짓이 된다(**[2026-08-28 `H-147`]** `fn` 실행 중에 핸들이 죽는 경로는 더 이상
`_consumeCleanup`이 걸어둔 거짓이 그대로 남는다), `_consumeCleanup`에서 거짓이 없다 — 아래 `Rerun` 정의).
된다.
**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.** **⭐ [2026-08-25 신설, 7라운드 `H-60`; 2026-08-28 10라운드 `H-147`로 재정의]
지금까지 호출부만 다섯 곳이고 정의가 없었다. `rawRerun(self, force)` 본체 + 공개 `EffectHandle:Rerun()`.**
지금까지 호출부만 다섯 곳이고 정의가 없었다. **[2026-08-28]** 사용자 지적으로
둘로 갈랐다 — *"처음부터 rerun 이 're'-run 인데도 초기 실행까지 담당하고 있잖아
… rawRerun(force: boolean) 을 만들어 생성 시점과 실행 시점에서 이를 명시하는게
맞지 않아?"* — 초기 설치는 아직 안 묶인 상태라 공개 진입의 게이트를 못 지나므로
호출자가 그 사실을 인자로 말한다(`raw*` = 검사 없는 내부 본체, Slot의 `raw*`
관용구와 같다).
```lua ```lua
function EffectHandle:Rerun() -- 공개 메소드, 무인자 -- 본체. force = 초기 설치(생성자) — 아직 안 묶였으니 게이트를 안 본다.
-- (이 문서의 절 순서는 개념 순서다 — 실제 파일에선 `rawRerun`·`isRunning`·
-- `resubscribeTail` 같은 `local function`이 사용처(생성자·`_bindDestroying`·네
-- 진입점)보다 **앞에** 선언돼야 한다. Luau의 `local function`은 앞선 호출에서 안 보인다.)
local function rawRerun(self, force: boolean)
if self._running then if self._running then
self._pending = true -- 실행 중 재진입 → 지연 self._pending = true -- 실행 중 재진입 → 지연
return return
end end
if not force and not canExecute(self) then
return -- ⭐ 죽은 핸들·안 묶인 핸들의 재실행 요청은 **정의된
end -- no-op** — `fire`가 죽은 핸들의 emit을 버리는 것과
-- 같은 규칙(`H-147`: `Unsubscribe` 뒤 늦게 오는
-- 타이머의 `Rerun()`, 해제 뒤 cleanup의 재요청 등).
self._running = true self._running = true
repeat repeat
self._pending = false self._pending = false
self:_consumeCleanup() self:_consumeCleanup()
local wasAlive = canExecute(self) -- `fn` 진입 전 상태 self._cleanup = self.fn(self)
local c = self.fn(self)
-- ⭐ [2026-08-27 확정, 9라운드 `H-143`] **이 실행 중에 죽었으면 cleanup만
-- 한다.** `fn` 안에서 `self:Unsubscribe()`(또는 `WeakUnsubscribe`)가
-- 통과했으면(강/약 구독 핸들 — leaf 바인딩 핸들은 자기 해제로 죽지
-- 않으므로 이 분기에 오지 않는다) 이 핸들은 더 이상 어느 레지스트리에도
-- 없고 leaf도 아니라 **아무도 이 cleanup을 소진할 수 없다** — 저장하지
-- 말고 즉시 소진한다.
-- 판정은 "살아 있다가 → 죽었다"(`wasAlive and not canExecute`)다.
-- `not canExecute` 하나로 하면 안 된다 — **생성자의 최초 `Rerun()`
-- 어떤 바인드·구독보다 먼저** 돌아 `canExecute`가 항상 거짓이라, 첫
-- 실행의 cleanup을 그 자리에서 소진하고 `_installed`를 거짓으로 남겨
-- 첫 바인드에서 `fn`이 또 돈다(감사 2라운드가 잡은 회귀 — `H-58`
-- 재현). 약한 해제도 같은 경로로 소진된다(죽은 핸들에 매달린 cleanup은
-- "영원히 안 불림"보다 "한 번 불림"이 계약에 가깝다). 사용자 확정:
-- *"특정 state 에 변경을 딱 한번만 처리하고 cleanup 되는걸 만드는 요구가
-- 존재하지 않을 이유가 딱히 없다"*, *"실행중 죽으면 클린업만 하기"*
-- `fn``Unsubscribe`는 지원 대상이다.
if wasAlive and not canExecute(self) then
if c then c() end -- `_installed`는 이미 `_consumeCleanup`이 거짓으로
self._pending = false -- 죽은 핸들의 재요청(`fn` 안 `self:Rerun()`)은 버린다
-- — `fire`가 죽은 핸들의 emit을 버리는 것과 같은 규칙.
-- 안 버리면 아래 `until`이 한 바퀴 더 돌아 죽은
-- 핸들에서 `fn`이 또 돌고 그 cleanup이 다시 고아가 된다.
else
self._cleanup = c
self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨"
end
until not self._pending -- 재요청이 또 오면 또 돈다 until not self._pending -- 재요청이 또 오면 또 돈다
self._running = false self._running = false
end end
function EffectHandle:Rerun() -- 공개 메소드, 무인자 — 항상 게이트
rawRerun(self, false)
return self
end
``` ```
- **⭐⭐ [2026-08-28 확정, 10라운드 `H-147`] `fn`도 cleanup도 자기 생명주기를 바꿀
수 없다 — 그래서 이 루프 안에는 사망 판정이 없다.** 2026-08-27에 `H-143`으로
"`fn` 안 `self:Unsubscribe()`(원샷 Effect)"를 지원하기로 하고 `Rerun` 꼬리에
`wasAlive and not canExecute` 판정을 넣었는데, 하루 만에 그 허용 하나에서
파생된 결함이 넷(감사 2·4라운드, `H-147`) 나왔고 사용자가 뿌리를 짚었다:
*"유저 함수가 본인을 죽이고 살린다는점 자체가 모순이였다는 문제가 나와. 처음
실행해 unsub 했는데, 아래에서 sub 해버릴 수도 있지. 이건 의도 동작일까? 게다가
unbind/bind 는 본인이 못 해. 같은 계층으로 sub/unsub 가 본인이 할 수 있어야할
이유 제공 자체가 큰 그림에서 무언가 잘못된거 아닐까?"* → **(A) 확정**: Effect의
생애는 **묶은 쪽**(leaf면 Instance, `:Subscribe()`면 그 호출자)이 소유하고, `fn`
dep을 읽고 부작용을 내고 cleanup을 돌려주는 것까지다. leaf가 `fn` 안에서
unbind/bind를 못 하는 것과 **대칭**. (*"나는 지원 안 할 이유가 안 보였었는데,
지금 보면 엄청난 모순이네."*) 강제는 네 진입점의 `_running` 가드(아래
`EffectHandle:Subscribe()` 절). **원샷**은 소유자가 밖에서 `Unsubscribe`하거나
나중에 `Once`류 슈가로 — 코어엔 없다. `H-143`은 소멸.
- **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서 - **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서
뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행
하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는
상황은 react 등지에서 흔함."* 상황은 react 등지에서 흔함."*
- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미 - **`canExecute` 확인은 진입에서 한 번** — `fire`(`Ref` 콜백·전파 루프 경유)가
그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다. 첫 줄에서 보고, 공개 `Rerun()`**[2026-08-28 `H-147`]** 진입에서 본다(죽은·안
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것 묶인 핸들은 no-op). 그래서 `rawRerun` 루프 안엔 판정이 없다. (한때 "사용자가
포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다"였는데, 그 문장은 `Rerun`
게이트가 없던 시절 것.)
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`/`_cleanupRunning`이 참으로
남는 것 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의
일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다. 일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다.
**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리 **⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리
@ -431,12 +484,14 @@ end
`base/architecture.md``EngineOps.luau` 줄이다. `base/architecture.md``EngineOps.luau` 줄이다.
- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건 - **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건
`fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref``Epoch`라 균일), `fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref``Epoch`라 균일),
`_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 — `_cleanup`, **`_installed`**(설치 여부 —
cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다), cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다),
`_running`/`_pending`(재진입), **`.Subscribed`**(공개 플래그 — `canExecute` `_running`/`_pending`(재진입), **`_cleanupRunning`**(cleanup 실행 중 — `_running`
별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute`
읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절). 읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절).
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와 **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로
`_blocker`로 대체됐다.** 대체됐고, `_installing` 자리에 잠깐 있던 `_blocker`도 [2026-08-28 `H-150`]
제거됐다 — 억제는 `canExecute`.**
### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`) ### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`)
@ -463,7 +518,7 @@ end
`:Unsubscribe()`에서 같이 해제한다 — State/Source dep 쪽과 대칭이다 `:Unsubscribe()`에서 같이 해제한다 — State/Source dep 쪽과 대칭이다
(**[2026-08-26 표기 정정, `H-114`]** 옛 `_observers` 표기를 지웠다 — 지금은 (**[2026-08-26 표기 정정, `H-114`]** 옛 `_observers` 표기를 지웠다 — 지금은
`_deps` 하나다. **⚠️ 다만 이 문단의 "`unbindLifetime`에서 해제"는 `H-58` `_deps` 하나다. **⚠️ 다만 이 문단의 "`unbindLifetime`에서 해제"는 `H-58`
뒤집었다** — 언바인드는 아무것도 안 떼고, 억제는 `_blocker`가 한다. 살아 뒤집었다** — 언바인드는 아무것도 안 떼고, 억제는 `canExecute`가 한다(`H-150`). 살아
있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다). 있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다).
**⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도 **⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도
@ -546,9 +601,9 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.
-- `EffectHandle.Subscribe = Observer.Subscribe`처럼 **함수 객체를 그대로 배정**해 -- `EffectHandle.Subscribe = Observer.Subscribe`처럼 **함수 객체를 그대로 배정**해
-- 뒀는데, `Observer:Subscribe`의 본문이 `self:WeakSubscribe()`로 **콜론 위임**하는 -- 뒀는데, `Observer:Subscribe`의 본문이 `self:WeakSubscribe()`로 **콜론 위임**하는
-- 탓에 `self``EffectHandle`이면 그 조회가 `EffectHandle`의 오버라이드로 가서 -- 탓에 `self``EffectHandle`이면 그 조회가 `EffectHandle`의 오버라이드로 가서
-- 재구독 꼬리가 두 번 돌고, 첫 번째는 강한 킵이 서기 **전에** `Rerun``fn` -- 재구독 꼬리가 두 번 돌고, 첫 번째는 강한 킵이 서기 **전에** `Rerun`했다
-- `self:Unsubscribe()`(`H-143`이 지원하는 패턴)가 *"not subscribed strongly"*로 -- (로컬 `luau`로 재현; 당시엔 `fn``self:Unsubscribe()`가 허용돼 그 자리에서
-- error했다(로컬 `luau`로 재현). **사용자 확정**: *"b가 맞아. 내 머리에서 나왔던 -- error까지 났다 — 그 허용은 `H-147`로 폐기). **사용자 확정**: *"b가 맞아. 내 머리에서 나왔던
-- 처음 구조는 그것이였어. … '하나의 무언가가 두 일을 동작하지 않는가에 -- 처음 구조는 그것이였어. … '하나의 무언가가 두 일을 동작하지 않는가에
-- 유의하자' — 이것도 마찬가지야. 버그를 유발하기 좋은 포인트였고"* — -- 유의하자' — 이것도 마찬가지야. 버그를 유발하기 좋은 포인트였고"* —
-- Observer와 Effect는 이질적 타입이라(생성 방법부터 다르다) 본문을 섞지 않는다 -- Observer와 Effect는 이질적 타입이라(생성 방법부터 다르다) 본문을 섞지 않는다
@ -561,26 +616,36 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.
-- (`_bindDestroying`, `H-65`)와 **정확히 같은 꼬리**를 붙인다. `Unsubscribe` -- (`_bindDestroying`, `H-65`)와 **정확히 같은 꼬리**를 붙인다. `Unsubscribe`
-- cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 `fn` -- cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 `fn`
-- 안 돌아, deps 없는 Effect는 레지스트리가 살려두는 죽은 핸들(누수)이 되고 -- 안 돌아, deps 없는 Effect는 레지스트리가 살려두는 죽은 핸들(누수)이 되고
-- deps 있는 것은 다음 emit까지 죽어 있었다. `Refresh()`**먼저** 부르는 이유는 -- deps 있는 것은 다음 emit까지 죽어 있었다. **[2026-08-28 `H-151`]** 한때 여기
-- 287행 캐비엇과 같다 — 해제 중 도착한 emit은 `fire``canExecute` 가드가 -- `_epochs:Refresh()`를 먼저 불러 "dep이 변했으면"까지 재실행했는데 폐기 —
-- `_epochs:Update` 앞에서 버리므로 `_epochs`가 해제 전 리비전에 멈춰 있고, 안 -- `_epochs`는 emit을 받을 때만 갱신한다(`_bindDestroying`의 같은 주석). 재구독 뒤
-- 맞추면 재구독 뒤 첫 emit(예: 게이트 유보가 풀리며 오는 배치)에 같은 값으로 -- 게이트 유보가 풀리며 같은 값으로 `fn`이 한 번 더 도는 것은 **정상 재실행**
-- `fn`이 한 번 더 돈다. 사용자 확정: *"초기에 epoch 를 전부 잘 설정해주는게 -- (사용자: *"처음 생성할 때에도 Block 되어있던게 나중에 다시 들어오는 경로가
-- 나을수도 … 처음 연산 기준에 있어서도 전부 값은 최신 값이거든. 따라서 다음 -- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시
-- emit 을 받아야할 이유가 없을수도 있어."* Blocker는 안 쓴다 — dep을 다시
-- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다. -- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다.
local function resubscribeTail(self) local function resubscribeTail(self)
local depsChanged = self._epochs:Refresh() if not self._installed then -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다
if not self._installed or depsChanged then
self:Rerun() self:Rerun()
end end
end end
-- ⭐⭐ [2026-08-28 확정, 10라운드 `H-147`] **네 진입점(과 `_bindDestroying`) 첫 줄에
-- 가드** — `fn`/cleanup은 자기 구독을 바꿀 수 없다(위 `Rerun` 정의의 (A)). 보는
-- 플래그는 둘: `_running`(`fn` 실행 중)과 **`_cleanupRunning`**(cleanup 실행 중 —
-- 감사 2라운드에서 신설, `_consumeCleanup` 참고). **`error`는 헬퍼가 아니라 각
-- 본문에서 던진다** — 헬퍼 안의 `error(…, 2)`는 헬퍼의 호출 줄(quad 내부)을
-- 가리켜 `H-104` level 계약을 어긴다(`/code-review` 지적, `H-149`와 같은 이유).
local function isRunning(self) -- 술어만 헬퍼로 — `error`는 각 본문에서(`level 2`)
return self._running or self._cleanupRunning
end
-- 꼬리는 항상 **등록이 전부 끝난 뒤** 한 번 — `Subscribe``WeakSubscribe` -- 꼬리는 항상 **등록이 전부 끝난 뒤** 한 번 — `Subscribe``WeakSubscribe`
-- 부르지 않고 등록 세 줄을 자기 안에 펼쳐 쓴다. 위임하면(콜론이든 dot이든) -- 부르지 않고 등록 세 줄을 자기 안에 펼쳐 쓴다. 위임하면(콜론이든 dot이든)
-- 꼬리가 강한 킵 **앞**에서 돌거나 두 번 돈다 — 감사 4라운드가 잡은 바로 그 -- 꼬리가 강한 킵 **앞**에서 돌거나 두 번 돈다 — 감사 4라운드가 잡은 바로 그
-- 모양이다. 게이트·메시지 분기는 Observer의 것과 같다(`lifecycle-pattern.md` (2)). -- 모양이다. 게이트·메시지 분기는 Observer의 것과 같다(`lifecycle-pattern.md` (2) —
-- **[2026-08-28 `H-149`]** Observer 쪽도 같은 이유로 위임을 풀고 인라인했다).
function EffectHandle:WeakSubscribe() function EffectHandle:WeakSubscribe()
if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147`
if not canBound(self) then if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end end
@ -591,17 +656,19 @@ function EffectHandle:WeakSubscribe()
end end
function EffectHandle:Subscribe() function EffectHandle:Subscribe()
if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147`
if not canBound(self) then if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end end
self.Subscribed = true self.Subscribed = true
WeakSubscribed[self] = true WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 `Rerun` 안의 Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 한 번
resubscribeTail(self) -- `fn``self:Unsubscribe()`해도 가드 통과 resubscribeTail(self)
return self return self
end end
function EffectHandle:WeakUnsubscribe() -- 관대(`H-133`) — cleanup 안 건드림 function EffectHandle:WeakUnsubscribe() -- 관대(`H-133`) — cleanup 안 건드림
if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147`
if Subscribed[self] ~= nil then if Subscribed[self] ~= nil then
error("subscribed strongly; use :Unsubscribe()", 2) error("subscribed strongly; use :Unsubscribe()", 2)
end end
@ -611,6 +678,7 @@ function EffectHandle:WeakUnsubscribe() -- 관대(`H-133`) — cleanup
end end
function EffectHandle:Unsubscribe() function EffectHandle:Unsubscribe()
if isRunning(self) then error("cannot change subscription from inside fn or cleanup", 2) end -- `H-147`
if Subscribed[self] == nil then -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면 if Subscribed[self] == nil then -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면
error("not subscribed strongly; use :WeakUnsubscribe()", 2) -- (leaf 바인딩·약한 error("not subscribed strongly; use :WeakUnsubscribe()", 2) -- (leaf 바인딩·약한
end -- 구독·미구독) 여기서 error, cleanup엔 손도 end -- 구독·미구독) 여기서 error, cleanup엔 손도
@ -622,24 +690,16 @@ function EffectHandle:Unsubscribe()
end end
``` ```
- **`WeakUnsubscribe` 자체는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에 - **`WeakUnsubscribe`는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에
맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 **[2026-08-27 `H-143` 맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 강한 구독의
으로 셋]** — 강한 구독의 `Unsubscribe`, leaf 사망(`unbindLifetime`의 훅), 그리고 `Unsubscribe`와 leaf 사망(`unbindLifetime`의 훅) **둘뿐**(**[2026-08-28
**`fn`이 실행 중에 자기 핸들을 죽인 경우**(`fn` 안 `self:Unsubscribe()` / `H-147`]** 2026-08-27에 잠깐 "`fn` 안 자기 해제"가 셋째로 있었으나 그 허용
`self:WeakUnsubscribe()``WeakUnsubscribe` 본문은 여전히 cleanup에 손대지 자체가 폐기됐다).
않지만, 그걸 감싼 `Rerun`의 죽음 판정 `wasAlive and not canExecute`가 그 - **`fn` 안에서 허용되는 핸들 호출은 `self:Rerun()`뿐이다** — 자기 구독을 바꾸는
실행이 돌려준 cleanup을 즉시 소진한다, 위 `Rerun` 의사코드). 여기 한때 넷(`Subscribe`/`WeakSubscribe`/`Unsubscribe`/`WeakUnsubscribe`)은 `_running`
"둘뿐"이라 적혀 있었다(감사 7라운드 정정). 가드가 error로 막는다(**[2026-08-28 `H-147`]** 위 `Rerun` 정의 (A)). cleanup
- **`fn` 안에서 허용되는 핸들 호출은 `self:Rerun()`과 자기 해제다 — 단 자기 안에서도 같다 — 어느 자리(`Rerun` 루프·`Unsubscribe`·leaf `Destroying`)에서 돌든
해제는 *건 경로로*: 강구독 핸들은 `self:Unsubscribe()`, 약구독 핸들은 `_cleanupRunning`이 서 있어 같은 가드가 먼저 걸린다.
`self:WeakUnsubscribe()`. leaf 바인딩된 핸들엔 자기 해제 경로가 없다**(종료는
leaf 사망뿐 — `Unsubscribe()`는 가드에서 error하고 그 error는 `Rerun`을 뚫고
나가 `_running`이 참으로 남는 UB, `WeakUnsubscribe()`는 관대 통과하지만
`isBoundAlive`가 그대로라 죽지 않는다; **[2026-08-28 `/code-review`]**). `fn`
재구독(`self:Subscribe()`/
`self:WeakSubscribe()`)은 **[2026-08-27 기준] 서술하지 않는다**(트레이싱상
`resubscribeTail``Rerun``_running` 재진입으로 `_pending`만 세워 크래시는
안 하지만 지원 목록이 아니다 — 필요가 관측되면 그때 정한다).
- 실측(9라운드 `core9.luau`, `t12` 매트릭스 — **당시 함수 배정 형태에서의 실측**이고 - 실측(9라운드 `core9.luau`, `t12` 매트릭스 — **당시 함수 배정 형태에서의 실측**이고
(b) 재작성 뒤 재실행하지는 않았다[2026-08-27 기준]; 게이트 순서는 그대로라 (b) 재작성 뒤 재실행하지는 않았다[2026-08-27 기준]; 게이트 순서는 그대로라
같은 결과가 나올 것으로 추정할 뿐, 확정 근거는 M2 구현 테스트가 될 것): leaf 바인딩된 같은 결과가 나올 것으로 추정할 뿐, 확정 근거는 M2 구현 테스트가 될 것): leaf 바인딩된
@ -660,12 +720,11 @@ end
- **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref` - **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref`
콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절). 콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절).
`Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그 `Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그
경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `_epochs:Refresh()` 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `not _installed →
+ 조건부 `Rerun`이 붙는다(위 의사코드) — **그 사이 dep이 안 변했으면** Rerun`이 붙는다(위 의사코드) — 첫 구독은 설치돼 있으니 no-op, **소진된 뒤의
no-op이다. 첫 구독이라도 생성과 `Subscribe()` 사이에 emit이 있었으면 재구독**만 재설치. **[2026-08-28 `H-151`]** 생성과 `Subscribe()` 사이에 온
(`.Subscribed`가 거짓이라 `fire`가 첫 가드에서 버린다) `Refresh()`가 참을 emit은 `fire`가 버렸고 여기서 따라잡지 않는다 — 다음 emit의 리비전 차이로
돌려 캐치업으로 `fn`이 다시 돈다 — 바람직한 동작이고, "첫 구독은 재실행 잡힌다(Observer와 같은 정도의 캐치업 없음).
안 한다"를 테스트에 인코딩하지 말 것(**[2026-08-28 `/code-review`]**).
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst` 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
@ -824,13 +883,10 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
`:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가 `:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가
인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문 인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문
자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다. 자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다.
- `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`에 바로 닿는다. **[2026-08-28
핸들 표면에 바로 닿는다. **[2026-08-27 확정, 9라운드 `H-143`]** `fn` `H-147`]** 단 **구독 표면 넷은 `fn` 안에서 error** — 2026-08-27에 `H-143`으로
`Unsubscribe`는 **지원 대상**이다("이 dep의 변경을 딱 한 번만 처리하고 "`fn` 안 `Unsubscribe`(원샷)"를 잠깐 지원 대상으로 뒀으나 하루 만에 뒤집었다
끝나는 Effect") — 그 실행이 돌려준 cleanup을 `Rerun`이 그대로 저장하면 (위 `Rerun` 정의의 (A)).
아무도 소진 못 하므로, `Rerun` 꼬리가 "이 실행 중에 죽었다"(`wasAlive and
not canExecute`)를 보면 **즉시 소진**하고 재요청도 버린다(위 `Rerun`
의사코드).
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref` - **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.
@ -839,18 +895,18 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록 - **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에 즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에
한 번만 실행한다. 한 번만 실행한다.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래 **⭐⭐ [2026-08-25 정정, 7라운드 `H-58`; 2026-08-28 10라운드 `H-150` 재정정]
(`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때 억제는 `Effect` 내부 플래그(`_installing`)도 사적 `Blocker`도 아니라 Effect
*"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker` 핸들의 `canExecute`가 한다.** 여기 한때 *"[2026-08-21 확정] 이건 `Effect` 내부
안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을 플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을
놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼 놓쳤다(`H-58`). 그 자리에 `self._blocker:On()``:OffWithoutEmit()`이 들어왔는데,
돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `fire`의 첫 줄 `canExecute`
`self._blocker:On()``:OffWithoutEmit()` 구간이 맡는다 — 설치 발화를 전부 떨어뜨리므로 `_blocker`는 **한 번도 판정에 닿지 않았다**
`materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과 (실측 `t18`). 위 생성자 의사코드가 소스다. **`_installing``_blocker`도 폐기된
생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.** 필드다.**
(2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안 (2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안
일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로 일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로
유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.) 유효하다 — 그래서 게이트도, 결국은 `Blocker`도 아닌 `canExecute` 하나로 족하다.)
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만 - **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`, 돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
@ -875,7 +931,7 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
-- ⛔ 옛 모델(2026-08-21). 지금은 dep 종류별로 클로저가 둘이다. -- ⛔ 옛 모델(2026-08-21). 지금은 dep 종류별로 클로저가 둘이다.
function(self, from) function(self, from)
if not canExecute(handle) then return end -- 발화 게이트 if not canExecute(handle) then return end -- 발화 게이트
if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정) if handle._blocker:IsOn() then return end -- 등록 구간 억제 (`_blocker``H-150`으로 제거)
if handle._epochs:Update(from) then if handle._epochs:Update(from) then
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행 handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
end end
@ -886,7 +942,9 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면 "`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
(2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가 (2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가
`_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다. `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로였고, **[2026-08-28
`H-150`]** 그 억제 주체가 `canExecute`가 된 지금도 같다 — `canExecute`
`fire`의 첫 줄, `Update`는 그 뒤.
- **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** — - **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** —
여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from` 여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`
없다"*고 적혀 있었는데, **`Ref``Epoch`로 승격**되며(`base/ref-plan.md`) 없다"*고 적혀 있었는데, **`Ref``Epoch`로 승격**되며(`base/ref-plan.md`)

View file

@ -309,6 +309,12 @@ end)
```lua ```lua
-- 필드 (ComputeNode와 같은 층위) -- 필드 (ComputeNode와 같은 층위)
-- ⭐ [2026-08-28 확정, 10라운드 `H-152`] 조립의 **첫 줄은 `StateBrand:register(node)`**다 —
-- `_emitDown`은 자식을 `isState(sub)`로만 가르므로(`source-state-plan.md`) 등록이
-- 빠지면 상류 emit이 `_receive`로 안 오고 `canExecute(gate)`도 거짓이라 **통지만
-- 조용히 죽는다**(`Get()`은 `_hold`로 최신값을 주니 값 검사로는 안 잡힌다 — 실측
-- `t24`: 하류 발화 2 → 0). `GateNode`는 State 생성자를 안 지나고 이 절이 곧
-- 생성자라 여기 없으면 어디에도 없다.
GateNode = { GateNode = {
_hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`) _hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`)
_subs = <weak-키 구독자 집합>, -- 원소는 Observer 값 / 자식 State _subs = <weak-키 구독자 집합>, -- 원소는 Observer 값 / 자식 State
@ -445,8 +451,11 @@ end
소비자가 **아니다**.** 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나, 소비자가 **아니다**.** 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나,
위 8번(빈 배치는 통지 안 함)으로 **성립하지 않는 게 확인됐다** — 설치 구간엔 위 8번(빈 배치는 통지 안 함)으로 **성립하지 않는 게 확인됐다** — 설치 구간엔
어떤 `Set`도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다. 어떤 `Set`도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다.
`Effect`**자기 내부 플래그로** 설치 중 발화를 누르고 마지막에 한 번 `Effect`가 설치 중 발화를 누르고 마지막에 한 번 직접 실행하면 되고, 새
직접 실행하면 되고, 새 메커니즘이 필요 없다. `base/effect-plan.md`의 그 메커니즘이 필요 없다(**[2026-08-28 10라운드 `H-150`]** 그 억제 주체는 "자기
내부 플래그"도 그 뒤의 사적 `Blocker`도 아니라 Effect 핸들의 `canExecute`다 —
생성자 안에선 아직 안 묶여 있어 설치 발화가 첫 가드에서 떨어진다,
`base/effect-plan.md` 생성자 의사코드). `base/effect-plan.md`의 그
항목에 달려 있던 "⚠️ `Gate` 설계에 딸려 있다"도 같이 해소됐다. 아래는 항목에 달려 있던 "⚠️ `Gate` 설계에 딸려 있다"도 같이 해소됐다. 아래는
원 서술: 원 서술:
2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을 2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을
@ -491,6 +500,25 @@ end
"게이팅 먼저"는 그대로 지켜진다 — 게이팅이 디스패치보다 먼저 지어진다. "게이팅 먼저"는 그대로 지켜진다 — 게이팅이 디스패치보다 먼저 지어진다.
`Blocker``GateNode`를 다시 만들지 말고 그 위의 정책으로 얹을 것. `Blocker``GateNode`를 다시 만들지 말고 그 위의 정책으로 얹을 것.
## 계약 — 게이트는 emit 경로만 미룬다 (2026-08-28 확정, 10라운드 `H-151`)
게이트가 하는 일은 **다운스트림 통지의 유보**뿐이고, 값은 안 가린다
(`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**:
- **`Effect`의 재바인드/재구독 캐치업** — 소진된 핸들이 다시 묶이면 초기 설치와
같은 뜻으로 `fn`이 돈다(`base/effect-plan.md` `_bindDestroying`). 유보 중이어도
돈다.
- **게이트 없는 형제 dep**`Effect(fn, gated, plain)`에서 `plain`이 깨우면 `fn`
안의 `gated:Get()`은 최신값이다.
- 유보됐던 emit이 나중에 풀려 들어오면 그냥 재실행 — 생성 직후 유보분이 들어오는
것과 같은 경로. `Effect``_epochs`는 emit을 받을 때만 갱신된다(Observer·중간
State와 같다).
**사용자 원문**: *"block 은 단지 유보만 해줄뿐이라서. - Effect 도 observer 랑
똑같게, 중간 state 랑 똑같게, emit 받을때에만 epoch 맵을 업데이트 하면 돼. 계약
추가로 끝나는 일로 보여"*. 막는 갈래(캐치업이 dep 노드의 `emitEpochMap`을 보게
하기 / value-hold 재개방)는 둘 다 기각 — 전자는 `EpochMap` 계약 변경, 후자는
`base/debounce-throttle-plan.md` 4절이 철회한 (B). 발견 원문은 `qa-request/pre-implementation-handtrace-round10.md` `H-151`.
## 관련 문서 ## 관련 문서
- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상). - `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상).

View file

@ -354,8 +354,8 @@ function bindLifetime(inst, value)
-- `base/effect-plan.md`의 "확정 구조" 절이 소스. -- `base/effect-plan.md`의 "확정 구조" 절이 소스.
if isEffect(value) then if isEffect(value) then
value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회** value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회**
-- (`local depsChanged = self._epochs:Refresh()` 먼저, -- (`if not self._installed then self:Rerun() end` —
-- `if not self._installed or depsChanged then self:Rerun() end`) -- [2026-08-28 `H-151`] 옛 `_epochs:Refresh()` 캐치업은 폐기)
-- 의사코드는 `base/effect-plan.md`가 소스. -- 의사코드는 `base/effect-plan.md`가 소스.
-- 그 안에서 주입 op `onDestroying(inst, fn)` -- 그 안에서 주입 op `onDestroying(inst, fn)`
-- 부른다(base는 Instance를 모른다). -- 부른다(base는 Instance를 모른다).
@ -446,14 +446,17 @@ end
진입점 전량이고 소스다(사용자 원문 *"구현이 한 벌"*). **[2026-08-27 9라운드 진입점 전량이고 소스다(사용자 원문 *"구현이 한 벌"*). **[2026-08-27 9라운드
`H-127`, 같은 날 (b)로 정정]** `EffectHandle`은 **같은 레지스트리 둘과 같은 `H-127`, 같은 날 (b)로 정정]** `EffectHandle`은 **같은 레지스트리 둘과 같은
`canBound` 게이트를 쓰되 네 진입점 본문은 자기 것**이다 — 한때 "같은 넷을 `canBound` 게이트를 쓰되 네 진입점 본문은 자기 것**이다 — 한때 "같은 넷을
그대로 재사용(함수 배정)"으로 적었는데, 아래 `Subscribe`/`Unsubscribe`가 그대로 재사용(함수 배정)"으로 적었는데, 당시 `Subscribe`/`Unsubscribe`가
`self:WeakSubscribe()`/`self:WeakUnsubscribe()`로 **콜론 위임**하므로 그 함수를 `self:WeakSubscribe()`/`self:WeakUnsubscribe()`로 **콜론 위임**하고 있어서 그 함수를
`EffectHandle`에 배정하면 위임이 `EffectHandle`의 오버라이드로 가서(재구독 꼬리 `EffectHandle`에 배정하면 위임이 `EffectHandle`의 오버라이드로 가서(재구독 꼬리
두 번, 첫 번째는 강한 킵 전) 깨진다(감사 4라운드, `luau` 재현). **사용자 두 번, 첫 번째는 강한 킵 전) 깨졌다(감사 4라운드, `luau` 재현; **[2026-08-28
`H-149`]** 그 위임 자체도 이제 없다 — 아래 코드는 인라인). **사용자
확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두 확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두
일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect` 일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect`
쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는 쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는
**[2026-08-27 `H-144`]** 등록 끝에 `_epochs:Refresh()` + 조건부 `Rerun`)은 **[2026-08-27 `H-144`]** 등록 끝에 `not _installed → Rerun`, 넷 다 첫 줄에
**[2026-08-28 `H-147`]** `_running`/`_cleanupRunning` 가드 — `fn`/cleanup은 자기 구독을
못 바꾼다)은
`base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스: `base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스:
```lua ```lua
@ -479,8 +482,8 @@ function Observer:WeakUnsubscribe()
-- `.Subscribed = false`**조용히 죽이면서** 강한 레지스트리엔 항목을 -- `.Subscribed = false`**조용히 죽이면서** 강한 레지스트리엔 항목을
-- 남겨 **영원히 GC 안 되는** 반쪽짜리 해제가 된다(바로 아래에서 금지하는 -- 남겨 **영원히 GC 안 되는** 반쪽짜리 해제가 된다(바로 아래에서 금지하는
-- 그것). 사용자 확정: fail-fast — `Subscribe()`로 건 건 `Unsubscribe()` -- 그것). 사용자 확정: fail-fast — `Subscribe()`로 건 건 `Unsubscribe()`
-- 푼다. 아래 `Unsubscribe`가 강한 킵을 **먼저** 지우고 위임하므로 자기 -- 푼다. 아래 `Unsubscribe`는 이 함수에 위임하지 않고 양쪽을 직접 지우므로
-- 가드에 걸리지 않는다(순서가 계약이다). -- (**[2026-08-28 `H-149`]**) 이 가드와는 무관하다.
if Subscribed[self] ~= nil then if Subscribed[self] ~= nil then
error("...: subscribed strongly; use :Unsubscribe()", 2) error("...: subscribed strongly; use :Unsubscribe()", 2)
end end
@ -494,7 +497,18 @@ end
-- ── 그 위의 "GC 안 되게 킵" 한 겹 ─────────────────────────── -- ── 그 위의 "GC 안 되게 킵" 한 겹 ───────────────────────────
function Observer:Subscribe() function Observer:Subscribe()
self:WeakSubscribe() -- 게이트·플래그·약한 등록을 전부 여기서 -- ⭐ [2026-08-28 확정, 10라운드 `H-149`] `self:WeakSubscribe()`에 **위임하지 않고
-- 펼쳐 쓴다.** 위임하면 (1) `error(…, 2)`가 사용자 호출부가 아니라 이 본문을
-- 가리키고(`H-104` level 계약 위반), (2) 콜론 위임은 서브 테이블의 오버라이드를
-- 탄다(`H-144` (b)의 교훈). 사용자: *"weak 나 아닌거나 줄 차이가 그리 안 커서,
-- 분리할 큰 이유가 없음."*
if not canBound(self) then
error(if self.Subscribed
then "이미 구독된 값"
else "이미 Instance에 바인딩된 값", 2)
end
self.Subscribed = true
WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵 하나만 더 Subscribed[self] = true -- 강한 킵 하나만 더
return self return self
end end
@ -508,16 +522,21 @@ function Observer:Unsubscribe()
if Subscribed[self] == nil then if Subscribed[self] == nil then
error("...: not subscribed strongly; use :WeakUnsubscribe()", 2) error("...: not subscribed strongly; use :WeakUnsubscribe()", 2)
end end
Subscribed[self] = nil -- 강한 킵을 먼저 놓고 Subscribed[self] = nil -- 강한 킵을 놓고
return self:WeakUnsubscribe() -- 나머지는 프리미티브에 위임(양쪽 테이블 대칭) WeakSubscribed[self] = nil -- 약한 쪽도 직접(양쪽 테이블 대칭) — [`H-149`] 위임 없음
self.Subscribed = false
return self
end end
``` ```
- **게이트는 한 번만 돈다**`Subscribe``WeakSubscribe`에 위임하므로 - **각 진입점이 자기 게이트를 정확히 한 번 돈다****[2026-08-28 `H-149`]**
`canBound` 검사가 중복되지 않는다. 한때 "`Subscribe`가 `WeakSubscribe`에 위임하므로 검사가 중복되지 않는다"였는데
위임을 풀었다(위 주석). 중복되는 세 줄은 같은 타입 안이라 dot 호출 로컬
헬퍼로 빼도 되지만 **`error(…, 2)` 줄만은 본문에 남길 것** — 헬퍼 안에서
던지면 level 2가 헬퍼의 호출 줄(quad 내부)을 가리킨다(`H-104`).
- **해제는 반드시 양쪽을 지운다.** `Unsubscribe``WeakSubscribed`를 안 - **해제는 반드시 양쪽을 지운다.** `Unsubscribe``WeakSubscribed`를 안
지우면 항목이 약한 테이블에 남아 반쪽짜리 해제가 된다 — 그래서 지우면 항목이 약한 테이블에 남아 반쪽짜리 해제가 된다 — 위임 대신 두 줄을
`WeakUnsubscribe`에 위임하는 모양이 정본이다. 직접 쓴다.
- **⭐ 해제는 *건 경로로* 푼다 — 양방향 대칭 가드**(사용자 확정 2026-08-26). - **⭐ 해제는 *건 경로로* 푼다 — 양방향 대칭 가드**(사용자 확정 2026-08-26).
강하게 구독된 값에 `WeakUnsubscribe`를 부르면 error, 약하게만 구독된 값에 강하게 구독된 값에 `WeakUnsubscribe`를 부르면 error, 약하게만 구독된 값에
`Unsubscribe`를 부르면 error. 후자가 없으면 **조용히 성공**해서 범용 정리 `Unsubscribe`를 부르면 error. 후자가 없으면 **조용히 성공**해서 범용 정리

View file

@ -241,8 +241,8 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
**⭐⭐ [2026-08-26 갱신, 8라운드 `H-107`/`H-108`] 아래 블록이 소스다.** **⭐⭐ [2026-08-26 갱신, 8라운드 `H-107`/`H-108`] 아래 블록이 소스다.**
원래 이 블록은 2026-08-24에 쓰였고 하루 뒤(2026-08-25) 확정된 두 가지 — 원래 이 블록은 2026-08-24에 쓰였고 하루 뒤(2026-08-25) 확정된 두 가지 —
`.Revision` 갱신과 약한 콜백 테이블 — 를 **소급으로 못 받았다.** `.Revision` 갱신과 약한 콜백 테이블 — 를 **소급으로 못 받았다.**
블록대로 짜면 `Effect`캐치업(`_epochs:Refresh()`)과 `Update(ref)` 블록대로 짜면 `Effect``Update(ref)` 판정이 전부 죽고(**[2026-08-28 `H-151`]**
판정이 전부 죽고, `Effect`가 건 `:WeakCallback`은 한 번도 발화하지 `_epochs:Refresh()` 캐치업은 폐기됐다), `Effect`가 건 `:WeakCallback`은 한 번도 발화하지
않는다. 확정된 순서는 **값 → 리비전 → 콜백**이다: 않는다. 확정된 순서는 **값 → 리비전 → 콜백**이다:
```lua ```lua
function Ref:Set(value) function Ref:Set(value)
@ -281,7 +281,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
(`function(inst) ... end`)은 두 번째 인자를 무시하면 그대로다. (`function(inst) ... end`)은 두 번째 인자를 무시하면 그대로다.
- **왜 리비전이 콜백보다 앞인가(`H-108`)** — 뒤면 콜백 안의 - **왜 리비전이 콜백보다 앞인가(`H-108`)** — 뒤면 콜백 안의
`Update(ref)`가 **옛 리비전**을 읽어 `false`를 돌려주고, 그 `Set` `Update(ref)`가 **옛 리비전**을 읽어 `false`를 돌려주고, 그 `Set`
`Rerun`이 접힌 채 다음 `Refresh()` 때에야 뒤늦게 돈다(간헐 지연). `Rerun`이 접힌 채 **다음 `Set`의 리비전 차이**로나 돈다(간헐 지연 —
**[2026-08-28 `H-151`]** 옛 표기 "다음 `Refresh()` 때"는 캐치업이 폐기돼
더 이상 없는 경로).
- **두 테이블을 다 훑는다**`.Callbacks`(강한 셋)와 - **두 테이블을 다 훑는다**`.Callbacks`(강한 셋)와
`.WeakCallbacks`(weak-키)를 **각각 스냅샷**해 한 배열로 잇되, `.WeakCallbacks`(weak-키)를 **각각 스냅샷**해 한 배열로 잇되,
**양쪽에 다 있는 키는 한 번만 싣는다**(*"중복 등록은 dedup이 계약"*이 **양쪽에 다 있는 키는 한 번만 싣는다**(*"중복 등록은 dedup이 계약"*이
@ -440,9 +442,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
`Source`/`Ref`는 `:Sync`, `State``:TrackFrom`, `base/state-epoch-plan.md` `Source`/`Ref`는 `:Sync`, `State``:TrackFrom`, `base/state-epoch-plan.md`
§4·§8. 균일해지는 건 **판정 쪽**이다). §4·§8. 균일해지는 건 **판정 쪽**이다).
그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이 그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이
**대칭**이 되고, 판정이 `local depsChanged = self._epochs:Refresh()` + **대칭**이 되고, 판정이 `fire`의 `_epochs:Update(from)` 하나로 균일해진다
`if not self._installed or depsChanged then self:Rerun() end` 두 줄이 된다 (**[2026-08-28 `H-151`]** 재바인드 캐치업의 `Refresh()`는 폐기 — `_epochs`
(`Refresh()`는 항상 먼저 — `base/effect-plan.md``_bindDestroying` 캐비엇). emit을 받을 때만 갱신, `base/effect-plan.md``_bindDestroying`).
- 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로 - 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로
**공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 **공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로
먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다. 먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다.

View file

@ -233,8 +233,9 @@ Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운
절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()` 절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`
별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 — 별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 —
실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 **Slot의 실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 **Slot의
마운트 경로 자체**(`attachSlot` 분해분 — 별도 `Mount` 함수가 아니다, **[2026-08-27 마운트 경로 자체**(`attachSlot` 분해분 — 별도 `Mount` 함수가 아니다; **[2026-08-28]**
`H-146`]** v2에 v1식 `Mount(parent, tree)` 표면은 없다)가 이 강제를 담당. 이미 있는 트리를 quad가 소유하는 `Claim``research/existing-mount-plan.md`에서
논의 중이고 그것도 이 단일 마운트 불변식을 그대로 진다)가 이 강제를 담당.
Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만 Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만
구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서 구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서
@ -2144,10 +2145,11 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이
마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워 마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워
넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라 넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라
조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-27 확정, 조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-28 10라운드
9라운드 `H-146`] 이 금지의 범위는 *quad가 관리하는 자식 자리*다 — quad 트리의 `H-148`]** 루트(`PlayerGui` 등 quad 밖 부모)는 사용자가 `.Parent =`로 붙이는 게
루트를 quad 밖 부모(`PlayerGui` 등)에 붙이는 `.Parent =`는 여기 해당하지 않고 아니라 **quad가 `Claim`으로 소유**하는 쪽으로 방향이 확정됐다
사용자가 밖에서 한다**(`base/bind-system-plan.md`의 `H-142` 항목). (`research/existing-mount-plan.md`, M5 이후) — 그래서 이 금지에 예외가 없어진다.
(2026-08-27에 하루 있었던 "루트는 밖에서" 예외는 폐기.)
## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar) ## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar)

View file

@ -1329,7 +1329,10 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다 평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다
(**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 (**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나
단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉
`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다. `Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이다(**[2026-08-28 `H-149`]**
"구현이 한 벌"은 이제 **의미**가 한 벌이라는 뜻이지 위임이 아니다 — `Subscribe`
`self:WeakSubscribe()`를 부르면 `error(…, 2)`가 quad 내부 줄을 가리키고 콜론
위임이 서브 테이블 오버라이드를 타므로 인라인했다, `lifecycle-pattern.md` (2)).
- **⭐⭐ [2026-08-26 확정, 8라운드 `H-111`] `WeakSubscribe``.Subscribed = true` - **⭐⭐ [2026-08-26 확정, 8라운드 `H-111`] `WeakSubscribe``.Subscribed = true`
세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고, 세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고,

View file

@ -97,7 +97,9 @@ store.hp:Compute(function(s) ... end)
실제로 지우는 것도 `Source<None> → None → nil`로 핸들러 계열을 타고 실제로 지우는 것도 `Source<None> → None → nil`로 핸들러 계열을 타고
말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다. 말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다.
- **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을 - **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을
만든다(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에 만든다(**[2026-08-28 확정, 10라운드 `H-153`]** 그림자 = **store 자신**`store.key`
평범한 레코드 필드라는 계약과 맞고, 메소드는 `__index`에 있어 `Names()`가 안
센다; 별도 테이블 + 프록시 안은 기각. `table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미 키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미
`Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자 `Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자
`Store<<{}>>()`도 유효한데 `table.clone(nil)` `Store<<{}>>()`도 유효한데 `table.clone(nil)`
@ -109,7 +111,11 @@ store.hp:Compute(function(s) ... end)
에러로 죽는다. `H-40``:List` 요소 검증을 블랙리스트에서 화이트리스트로 에러로 죽는다. `H-40``:List` 요소 검증을 블랙리스트에서 화이트리스트로
뒤집은 것과 같은 성격의 자리다 — **사용자 확정**으로 여기도 화이트리스트를 뒤집은 것과 같은 성격의 자리다 — **사용자 확정**으로 여기도 화이트리스트를
둔다. `defaults`를 한 번 순회하며 `isSource(v)`가 거짓이면 둔다. `defaults`를 한 번 순회하며 `isSource(v)`가 거짓이면
**`error(..., 2)`**(사용자 입력 검증이므로 `level 2`, 메시지는 영어 — **`error(..., 2)`**(**[2026-08-28 10라운드 `H-153`]** 같은 순회에서 **예약 이름**
(`RESERVED` 테이블 — 아래 `Of` 절의 리터럴이 런타임 단일 소스; `__reservedCheck`
팬텀이라 `__index`에서 도출할 수 없다)도 `error(..., 2)``Store({ Of = Source(1) })`가 통과하면
`s:Of(…)`가 *attempt to call a table value*로 엉뚱한 자리에서 죽는다, 실측
`t23`; 사용자 확정 *"나도 a 동의"*)(사용자 입력 검증이므로 `level 2`, 메시지는 영어 —
`base/architecture.md`의 error 계약). 생성 시 1회라 hot path가 아니다. `base/architecture.md`의 error 계약). 생성 시 1회라 hot path가 아니다.
`isModifier` 쪽 가드는 여기가 아니라 **`Source` 생성자**가 맡는다 `isModifier` 쪽 가드는 여기가 아니라 **`Source` 생성자**가 맡는다
(`base/modifier-plan.md` 7번). (`base/modifier-plan.md` 7번).
@ -192,11 +198,20 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
조회하면 `nil``Source<U>` 타입으로 돌려주고 호출부가 `:Get()`에서 조회하면 `nil``Source<U>` 타입으로 돌려주고 호출부가 `:Get()`에서
타입 에러 없이 nil 역참조한다. 타입 에러 없이 nil 역참조한다.
```lua ```lua
-- ⭐ [2026-08-28 `H-153`] 예약 이름의 **런타임 단일 소스**. 세 이름을 리터럴로 —
-- `__reservedCheck`는 팬텀이라 `__index`에서 도출할 수 없다(아래 주석).
-- 타입 함수 `CheckReservedKeys`의 목록은 이 테이블의 사본이라 이름을 바꿀 땐
-- 둘을 같이(코퍼스의 다른 산문 나열은 전부 이 테이블을 가리키기만 할 것).
local RESERVED = { Of = true, Names = true, __reservedCheck = true }
function Store:Of(name) -- 동적 키 전용 function Store:Of(name) -- 동적 키 전용
local src = shadow[name] if RESERVED[name] then -- 동적 키는 타입이 못 막는다
error("reserved store key: " .. name, 2)
end
local src = rawget(self, name) -- 그림자 = store 자신(`H-153`) — `__index`를 안 타게 raw
if src == nil then if src == nil then
src = Source() -- == Source(nil) src = Source() -- == Source(nil)
shadow[name] = src self[name] = src
end end
return src return src
end end
@ -280,9 +295,9 @@ type Store<T> = T & {
-- 감수하되, 두 가지를 문서화한다: -- 감수하되, 두 가지를 문서화한다:
-- (1) **읽지 말 것** — 사용자 표면이 아니다. -- (1) **읽지 말 것** — 사용자 표면이 아니다.
-- (2) `store:Names()`(그림자 테이블의 키)에는 **안 들어간다**. 그래서 -- (2) `store:Names()`(그림자 테이블의 키)에는 **안 들어간다**. 그래서
-- `store:Of("__reservedCheck")`런타임에선 그냥 통과해 충돌하는 -- `store:Of("__reservedCheck")`타입 쪽 `CheckReservedKeys`가 못 막는다
-- `Source`를 만든다 — 타입 쪽은 `CheckReservedKeys`가 막지만 -- (**동적 키는 이름이 타입에 안 실린다**) — 그래서 **[2026-08-28 `H-153`]**
-- **동적 키는 이름이 타입에 안 실리므로** 못 막는다. -- `Of(name)`과 생성자가 런타임 예약 이름 가드로 `error(…, 2)`한다(위).
``` ```
**⭐⭐ [2026-08-26 확정, 8라운드 `H-112`] 예약 키 진단 타입 함수는 `T`가 아니라 **⭐⭐ [2026-08-26 확정, 8라운드 `H-112`] 예약 키 진단 타입 함수는 `T`가 아니라
@ -327,8 +342,12 @@ end
같은 §0이 경고하는 내장 `index<>`/`keyof<>`도 **형제 필드의 `*error-type*` 같은 §0이 경고하는 내장 `index<>`/`keyof<>`도 **형제 필드의 `*error-type*`
오염되지 않는다**는 것이 별도 실측(`CheckedQuad`)으로 재확인됐다. 오염되지 않는다**는 것이 별도 실측(`CheckedQuad`)으로 재확인됐다.
**⚠️ [2026-08-26, `/code-review high`] 빈 Store(`Store<<{}>>()`)는 아직 실측 **✅ [2026-08-28 실측 완료, 10라운드 `H-157`] 빈 Store(`Store<<{}>>()`)는 클린이다** —
안 됐다.** `H-83`은 무인자 생성이 유효해야 한다고 확정했는데(`or {}` 방어의 최종형(`StateData<T>`/`State<T>` 쪼개기, `Source<T>` 필드, `CheckReservedKeys<keyof<T>>`)
`Store({} :: {})`를 넣으면 진단 0(`keyof<{}>`는 에러가 아니라 빈 유니온이라
검사가 그냥 통과), `Names()`/`Of("dyn")` 정상(`audit/handtrace-round10-reference-impl/`
`ty11`). 아래는 실측 전 서술: **[2026-08-26, `/code-review high`] 빈 Store는 아직
실측 안 됐다.** `H-83`은 무인자 생성이 유효해야 한다고 확정했는데(`or {}` 방어의
존재 이유), 위 실측은 **키가 있는 `T`로만** 돌았다. `keyof<{}>`가 Luau에서 존재 이유), 위 실측은 **키가 있는 `T`로만** 돌았다. `keyof<{}>`가 Luau에서
빈 유니온이 되는지 에러가 되는지에 따라 **무인자 Store 전체가 스퓨리어스 빈 유니온이 되는지 에러가 되는지에 따라 **무인자 Store 전체가 스퓨리어스
타입 에러를 뒤집어쓸 수 있다** — `H-112``CheckReserved<T>`에서 찾은 실패 타입 에러를 뒤집어쓸 수 있다** — `H-112``CheckReserved<T>`에서 찾은 실패

View file

@ -27,7 +27,7 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선
(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`), (소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`),
`question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋 `question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋
`9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/` `9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`
반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 그 반영분의 `/code-review`가 낸 셋(`H-147`~`H-149`)은 **[2026-08-28] 10라운드 문항지**(`-round10.md`, 광범위 탐사 결과와 함께 배치 회신 대기)로. 게이트 0. 저장소 루트에 반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 **[2026-08-28] 10라운드**(`-round10.md`, 광범위 탐사 `H-150`~`H-157` 포함)도 같은 날 전량 결정·반영(소스 `-round10-followup.md`) — 둘이 뒤집혔다(`fn`은 자기 구독을 못 바꿈 / 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절 둘(게이트 아님). 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md` 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -0,0 +1,257 @@
# 구현 전 손 트레이싱 **10라운드** — 결정과 반영 (2026-08-28)
> **이 파일이 무엇인가**: `-round10.md` §4 문항 7건(+ 갈래 없는 넷)에 대한
> 사용자 결정과 근거, 그리고 `base/` 반영 결과. **결정의 소스는 이 파일**이고
> 발견 원문은 `-round10.md`. 사용자가 *"하나하나 같이 보자"*라고 해 이번 라운드는
> 대화형으로 처리한다(배치 회신 대신) — 반영은 전부 정한 뒤 한 번에.
## 진행 표 (상태의 소스)
| 문항 | 무엇 | 상태 |
|---|---|---|
| `H-147` | 죽은 핸들에서 `Rerun()` | ✅ **확정 — 문항의 전제를 뒤집음**: `fn`/cleanup은 자기 생명주기를 못 바꾼다(A). `H-143`도 함께 소멸 |
| `H-148` | `Parent` 거부 문구 | ✅ **전제 정정** — 문구가 아니라 루트 마운트 표면의 부재. `research/existing-mount-plan.md` 신설(`Claim` + `D.Mapper`), `H-146` 루트 예외 폐기, 전용 문구 철회 |
| `H-149` | Observer `Subscribe` 위임과 `level 2` | ✅ **확정 (a)**`Subscribe`/`Unsubscribe`도 게이트·등록을 인라인, 위임 없음 |
| `H-150` | `Effect._blocker` 죽은 부품 | ✅ **확정 (a)** — 제거, 억제는 Effect 핸들의 `canExecute` |
| `H-151` | 게이트 우회 계약 | ✅ **확정 — (a) 문서화 + `Refresh` 캐치업 폐기**: Effect의 `_epochs`는 emit 수신 때만 갱신, 재바인드/재구독은 초기 설치와 같다 |
| `H-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ⏳ 권고: 폐기 → `state:Apply(blocker)` |
| `H-159`~`H-161` | 반영 뒤 `/code-review high`가 낸 새 메커니즘 셋 (`-round10.md` §4 하단) | ⏳ 판단 대기 — 바인드 전 emit 캐치업 / `Destroying` 경로 cleanup `Rerun` / M5 루트 부착·다중 스크립트 `Claim` |
| `H-153` | Store 예약 이름 런타임 가드 | ✅ **확정 (a)** — 생성자·`Of(name)`에 예약 이름 검사(level 2), 그림자 = store 자신 (I) |
| `H-154` | `InstanceChildHandler` dedup | ✅ **확정 (a)** — retractor 첫 줄 `if nextValue == v then return end` |
| `H-152`/`H-155`~`H-157` | 갈래 없음 | ✅ 반영(`gate-plan.md` 조립 첫 줄 `StateBrand:register` / `ROADMAP.md` M6×3·M11 / `debounce-throttle-plan.md` 7절 `H-32` 문단 / `store-plan.md` 빈 Store 실측 완료) |
## `H-147` — 전제 정정: `fn`/cleanup은 자기 구독을 바꿀 수 없다 (A) — `H-143` 소멸
문항은 "(a) UB / (b) `_everAlive` / (c) `wasAlive` 위치"였는데, 대화 중에 **문제의
뿌리가 `H-143`의 허용 자체**라는 것이 드러났다.
**경위**:
1. 사용자가 *"canExecute 자체가 유저함수인 cleanup 아래 있으면"*을 제안 → 그러면
생성자 최초 실행이 죽는 문제(감사 2라운드와 같은 함정)를 짚었고, `wasAlive`
cleanup 앞에서 잡는 절충을 냈다.
2. 사용자: *"처음부터 rerun 이 're'-run 인데도 초기 실행까지 담당하고 있잖아 …
rerun 자체에 인자로써 force: boolean? 처럼 주거나, rawRerun(force: boolean) 을
만들어 생성 시점과 실행 시점에서 이를 명시하는게 맞지 않아?"* → `wasAlive`
호출자가 아는 사실("초기 설치냐")을 상태로 추론하던 편법이었음을 인정,
`rawRerun(self, force)` 분리 + `not force and not canExecute` 게이트 제안.
3. 사용자가 그것도 기각: *"not force 가지고 확인하면 안 될 부분같음. 초기
설치에서도 본인을 직접 죽이거나 바운딩을 걸거나 할 수 있잖아. … 유저 함수가
본인을 죽이고 살린다는점 자체가 모순이였다는 문제가 나와. 처음 실행해 unsub
했는데, 아래에서 sub 해버릴 수도 있지. 이건 의도 동작일까? 게다가 unbind/bind
는 본인이 못 해. 같은 계층으로 sub/unsub 가 본인이 할 수 있어야할 이유 제공
자체가 큰 그림에서 무언가 잘못된거 아닐까?"*
4. 갈래 (A) `fn`/cleanup의 자기 구독 변경 금지(leaf와 대칭) / (B) 자기 해제만 허용
(비대칭 감수)을 올렸고 **사용자 확정 (A)**: *"그런것 같아. 나는 지원 안 할 이유가
안 보였었는데, 지금 보면 엄청난 모순이네. 나는 너의 권고처럼 A가 맞아보여."*
**확정된 것**:
- **Effect의 생애는 묶은 쪽이 소유한다** — leaf면 Instance, `:Subscribe()`면 그
호출자. `fn`은 dep을 읽고 부작용을 내고 cleanup을 돌려주는 것까지. leaf가
`fn` 안에서 unbind/bind를 못 하는 것과 **대칭**.
- **`Subscribe`/`Unsubscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 `self._running`
또는 `self._cleanupRunning`이면 error(level 2, "cannot change subscription from
inside fn or cleanup")**. **[반영 뒤 감사 2라운드 정정]** 처음엔 `_running`
하나로 적었는데 cleanup은 `rawRerun` 밖(`Unsubscribe()`·leaf `Destroying`)에서도
돌아 그 안의 `self:Subscribe()`가 가드를 지났다(재`Unsubscribe`만 레지스트리
가드에 걸렸다). 갈래 (a) `_consumeCleanup``_running`을 세움 / (b) UB 문서화 /
(c) 별도 플래그 → **사용자 확정 (c)** `_cleanupRunning`: *"_running 으로 묶어
보는건 여전히 별로 괜찮은 이유가 없음. _cleanupRunning 같은걸 넣지 말아야할
이유가 없는것"* — 한 플래그에 두 뜻을 얹지 않는다.
- **`fn` 안에서 자기 leaf `inst`를 파괴하는 것은 UB**(같은 감사 2라운드 미서술
항목): `SignalBehavior = Immediate``Destroying` 콜백이 `fn` 도중 동기 발화해
cleanup이 영구 미소진. 사용자 확정: *"bind/unbind 에 간접 영향을 주는건데, UB
인게 맞다는 생각."* — `effect-plan.md` `_bindDestroying` 아래 주석.
- **`H-143` 소멸** — `fn` 안 자기 해제 지원 철회. `Rerun` 본체는 원래 모양
(`_consumeCleanup → fn → 저장`), `wasAlive`도 사후 판정도 없다. 종료 신호는
다시 **둘**(강한 `Unsubscribe` / leaf 사망).
- **`rawRerun(self, force)` / 공개 `Rerun()` 분리** — "re"-run과 초기 설치는 다른
일. 공개 `Rerun`은 진입에서 `canExecute` 게이트(죽은 핸들·안 묶인 핸들은
**정의된 no-op**, `fire`와 같은 규칙), 생성자는 `rawRerun(self, true)`로 게이트만
건너뛴다. (A)에서는 `fn`이 전이를 못 일으키므로 `force`가 건너뛰는 것은 정확히
"아직 안 묶임" 하나. 사용자 확인: *"unsub 뒤에 오는 rerun 은 그냥 실행 안
되는게 원래 정상적 형태"* — 맞다.
- **원샷("딱 한 번 처리하고 끝")은 지원 목록에서 빠진다** — 소유자가 밖에서
`Unsubscribe`하거나, 나중에 `Once`류 슈가로 별도 결정. 코어에 넣지 않는다.
**반영 대상**: `base/effect-plan.md`(`Rerun` 의사코드 → `rawRerun`/`Rerun`, `H-143`
관련 서술 전부 — 꼬리 분기·"종료 신호 셋"·"`fn` 안 허용 호출" bullet·`self`를 주는
덕에 bullet, 네 진입점에 `_running` 가드, 실측 bullet), `base/lifecycle-pattern.md`
(포인터), `ROADMAP.md` M2 `Effect` 체크박스, `conventions.md`(원칙 사례로 추가 여부는
반영 시 판단), `-round9-followup.md` `H-143` 절에 소멸 배너, `-round9.md` 요약 표
`H-143` 행.
## `H-148` — 전제 정정: 문구 문제가 아니라 루트 마운트 표면의 부재 → `Claim` + `D.Mapper` (research 신설)
권고 (a)(전용 문구 철회)를 올렸더니 사용자가 더 큰 공백을 짚었다: *"slot 은
물리 장치에 mount 할 방법이 거의 존재하지 않음. … PlayerGui 가 상위에 있고
거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 될것 같은
느낌이 듦. 이건 Parent 이상의 문제인것 같아."* → 이미 있는 트리를 quad가
소유하는 `Claim(inst, D.Mapper.<Class> "Name" {…})` 제안(원문·확정·갈래 전량은
`research/existing-mount-plan.md`).
**확정된 것**: 방향 자체 / 디스크립터는 `D.Mapper`에(`D.Frame`에 직접 얹지 않음)
/ `Claim` DFS(내려가며 해석 → 자식부터 `drive`)는 derive 위의 한 겹 / 부기 대상
자식은 전부 매핑, 숏핸드(`UI*`)는 부기 밖 — quad가 직접 쓰거나 실제 객체로
매핑하거나 / 이름 중복·부재는 UB + debug 모드 `seen` 검사 / `nativeFindChild`
프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 이후.
**따름**: 전용 문구 **철회**(일반 매치 실패 그대로), `H-146` (a)의 "루트는 밖에서
`.Parent =`" **폐기**(루트가 quad 소유). `H-142` 키 금지는 그대로.
**미결 6개**는 그 research 문서 §5 — 다음 배치 문항.
## `H-149` — Observer `Subscribe`/`Unsubscribe`도 인라인 (a)
**사용자 확정**: *"a 로 가는게 맞는듯. weak 나 아닌거나 줄 차이가 그리 안 커서,
분리할 큰 이유가 없음."* — `Observer:Subscribe``self:WeakSubscribe()`
위임하지 않고 게이트(`canBound`, `error(…, 2)`)·플래그·약한 등록·강한 킵을 자기
안에 펼친다. `Unsubscribe`도 같은 이유로 `self:WeakUnsubscribe()` 위임을 풀어
양쪽 레지스트리 삭제를 직접 한다(콜론 위임 자체를 남기지 않는다 — `H-144` (b)의
교훈). `lifecycle-pattern.md`의 *"게이트는 한 번만 돈다 — `Subscribe`
`WeakSubscribe`에 위임하므로"* 문장은 "각 진입점이 자기 게이트를 한 번 돈다"로.
`EffectHandle`의 넷과 모양이 같아진다(거기에 `H-147` (A)의 `_running` 가드가
하나 더 붙는 것만 다름 — Observer엔 `_running`이 없다).
**반영 대상**: `base/lifecycle-pattern.md` Observer 네 진입점 의사코드 + "게이트는
한 번만 돈다" bullet, `base/effect-plan.md``EffectHandle` 블록 머리 주석(Observer
위임 서술 참조 부분).
## `H-150``Effect._blocker` 제거 (a)
사용자가 확인한 전제: *"canExecute 와 별개로 처음 observer 생성에는 callback 이
실행되어야하는게 맞잖아. … 그러니까, Effect 의 canExecute 를 보겠다는거지? 그럼
그건 맞는것 같아."* — 두 층이 다르다: 내부 Observer/`Ref` 콜백의 **설치 발화는
그대로 일어나고**(그 계약 불변), 그 콜백이 부르는 `fire`의 첫 줄
`canExecute(self)``self`가 **Effect 핸들**이라 생성자 안(아직 안 묶임)에선
거기서 흡수된다. `_blocker`는 같은 흡수를 한 번 더 하려던 장치라 어떤 경로에서도
판정에 닿지 않는다(실측 `t18`). `H-147` (A)로 `fn`이 생성자 안에서 자기를 묶을
수도 없어졌으니 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식.
**확정**: `_blocker` 필드·`On()`/`OffWithoutEmit()` 제거. 생성자 주석은 *"등록
즉시 1회는 Effect의 `canExecute`가 막는다"*로. 7라운드 `H-58`의 지시(*"모든
옵저버와 callback 등록에 있어서 이를 수행해야할 것임"*)는 그 전제("등록 즉시
1회가 `Rerun`에 닿는다")가 성립하지 않았던 것으로 정정 배너.
**반영 대상**: `base/effect-plan.md` 생성자 의사코드·"확정 구조" 절·`H-58` 문단·
필드 목록, `base/gate-plan.md` 7번(`_installing` 잔재), `ROADMAP.md` M2 `Effect`
체크박스의 "선행: `Blocker` 기본 메커니즘" 문구(Effect 자체는 이제 Blocker 불필요
`Blocker.luau` 선행 요구는 `GateNode`/Slot 쪽만).
## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기)
**사용자 확정**: *"해당 우회는 더 크게 보면, 처음부터 Effect 가 Rerun 되는
경로라서 그건 맞아. 그리고 Observer 에서 받은 emit 의 epoch|{epoch:boolean} 를
받는 시점은 emit 되어야하는 시점이 맞기도 하지. 우린 애초에 Refersh 를 할 필요가
없는거야. 재진입은 초기 설정해주는 요소이고, 그건 처음 생성할때랑 같은거야. 사실
처음 생성할 때에도 Block 되어있던게 나중에 다시 들어오는 경로가 있어. 그 경우도
그냥 재실행 해주지. 또 observer 도 마찬가지야. block 은 단지 유보만 해줄뿐이라서.
- Effect 도 observer 랑 똑같게, 중간 state 랑 똑같게, emit 받을때에만 epoch 맵을
업데이트 하면 돼. 계약 추가로 끝나는 일로 보여"*
**확정된 것**:
- **계약(문항의 (a))**: 게이트는 emit 경로만 미룬다. 재바인드/재구독 캐치업과
게이트 없는 형제 dep의 emit은 게이트를 거치지 않고, 그때 `:Get()`은 최신값.
유보됐던 emit이 나중에 풀려 들어오면 그냥 재실행 — 생성 직후 유보분이 들어오는
것과 같은 경로.
- **`_epochs``fire``Update(from)`에서만 갱신** — Observer·중간 State와
같다. `_bindDestroying`·`resubscribeTail`의 `_epochs:Refresh()``depsChanged`
**폐기**. 캐치업은 `if not self._installed then self:Rerun() end` 하나(초기 설치와
같은 뜻 — 소진돼 있으면 다시 설치). `H-144`의 "`Refresh` 먼저" 하위 결정과
그 단축평가 캐비엇(`ROADMAP.md`·`lifecycle-pattern.md`·`ref-plan.md`에 오늘 넣은
두 줄 형태)은 전부 이 결정으로 **소멸**.
- 따름: 죽어 있는 동안 떨어뜨린 emit은 다음 emit 때 리비전 차이로 잡힌다 —
Observer와 같은 정도의 "캐치업 없음"이고 계약으로 적는다. 재구독 뒤 게이트
flush가 같은 값으로 한 번 더 도는 것(`H-144` 재트레이싱의 케이스)은 **허용**
(유보가 풀리는 정상 재실행).
- `EpochMap:Refresh`는 State의 `rawInvalid == false` 경로(`state-epoch-plan.md`
§4)에만 남는다 — Effect 소비자 삭제.
**반영 대상**: `base/effect-plan.md`(`_bindDestroying`·`resubscribeTail`·`H-65`
캐치업 서술·`H-144` 블록 주석), `base/gate-plan.md`(계약 문장),
`base/debounce-throttle-plan.md` 11절, `base/lifecycle-pattern.md` 357행 주석,
`base/ref-plan.md` 244·284·443행(`Refresh` 전제 서술 — 284행의 "다음 `Refresh()`
때에야" 추론은 재검토), `ROADMAP.md` M2·M6 캐치업 문구, `-round9-followup.md`
`H-144` 절에 소멸 배너.
## `H-158``state:Block(blocker)` 슈가 (이 대화에서 나옴, 미결)
사용자: *"`:Block` 은 이제 없는거 아냐? Apply(Blocker) 이긴 할꺼야 (표면은
:Gate 만 남아 Blocker 는 Apply 슈거와 Policy 를 주는 프리미티브)"* — 확인 결과
`base/blocker-plan.md``state:Block(blocker)`가 `state:Gate(function(emit)
return b:Policy(emit) end)` 위의 슈가로 **아직 있다**(60·94·163·178·207행).
갈래: (a) `:Block` 폐기, Blocker가 `state:Apply(factory)` 프로토콜을 만족해
`state:Apply(blocker)`로 / (b) `:Block` 유지. **권고 (a)** — 동사 하나가 줄고
"Blocker = Policy를 주는 프리미티브 + Apply 슈가"로 뜻이 하나. 반영 시
`blocker-plan.md`·`gate-plan.md`·`debounce-throttle-plan.md`의 `:Block` 예시 전부.
## `H-153` — Store 예약 이름 런타임 가드 (a)
**사용자 확정**: *"나도 a 동의."* — 생성자의 `isSource` 순회에 `if RESERVED[k]
then error(…, 2) end`, `store:Of(name)`에 같은 검사. `H-122` 화이트리스트와 같은
자리·같은 논거(조용히 받고 엉뚱한 자리에서 죽는 것을 fail-fast로). 부수로
스케치의 "그림자 테이블"은 **store 자신**(I)으로 못박는다 — `store.key`가 평범한
레코드 필드라는 계약과 맞고, 메소드는 `__index`에 있어 `Names()`가 안 센다.
`RESERVED`는 메소드 이름 집합(`Of`/`Names`/… — 구현 시 `__index` 테이블의 키에서
자동 도출하면 두 곳에 안 적어도 된다, 권고).
**반영 대상**: `base/store-plan.md` 구현 스케치·`Of` 절·`__reservedCheck`
주석(동적 키는 런타임 가드가 맡는다고 명시), `ROADMAP.md` M2 Store 체크박스.
## `H-154``InstanceChildHandler` retractor에 같은 값 dedup (a)
**사용자 확정**: *"a 동의. … 간단한 dedup 이고 말단 핸들러가 v 를 정확히 알아서
retract 가 정확히 해소되는 부분이 맞네."* — retractor 첫 줄에 `if nextValue == v
then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil → inst`
`recompute` 2회가 사라진다. **반영 대상**: `base/dispatch-core-plan.md` `H-134`
문단, `ROADMAP.md` M5 `InstanceChild.luau` 체크박스.
## 반영 기록 (2026-08-28)
`base/`: `effect-plan.md`(`rawRerun`/`Rerun` 분리, `_blocker` 제거, `Refresh` 캐치업
폐기, 네 진입점 `_running` 가드, `H-143` 관련 서술 전부) / `lifecycle-pattern.md`
(Observer `Subscribe`/`Unsubscribe` 인라인, 캐치업 주석) / `gate-plan.md`(7번 정정,
조립 첫 줄 브랜드, "계약 — 게이트는 emit 경로만 미룬다" 절 신설) /
`debounce-throttle-plan.md`(7절 `H-32` 문단, 11절 `Effect`) / `ref-plan.md`(`Refresh`
전제 셋) / `store-plan.md`(그림자 = store 자신, 예약 이름 가드 둘, 빈 Store 실측) /
`dispatch-core-plan.md`(`InstanceChildHandler` dedup) / `bind-system-plan.md`(전용 문구
철회, 루트 예외 폐기 배너) / `slot-plan.md`(각주 둘). `archive/existing-instance-bind-rejected.md`
부활 배너. `ROADMAP.md` 배너·M2 `Effect`/`GateNode`/Store·M5·M6×3·M8·M11·백로그.
`-round9-followup.md`/`-round9.md`의 `H-143`/`H-144`/`H-146` 소멸·정정 배너.
**남은 미결**: `H-158`(`state:Block` 슈가 폐기 → `state:Apply(blocker)`, 권고만) —
`question.md`에; `research/existing-mount-plan.md` §5 갈래 6개 — 다음 배치.
## 감사 루프 (2026-08-28, 10라운드 반영분)
`quad-doc-auditor` 한 턴에 하나, diff 범위, 각도 교체. 새 발견 3→5→2→3→1→**0**
(6라운드에서 수렴). 라운드별: 1 문구 잔존(ROADMAP 필드 목록 `_blocker` / 콜론 위임
현재형 둘 / `rawRerun` 선언 순서) · 2 의미론(README `effect-plan.md` 행 /
`StateBrand:add`→`register` / 예약 이름 도출 권고와 팬텀 필드 모순 /
**`_running` 가드가 `Unsubscribe`·`Destroying`의 cleanup을 안 덮음 → 사용자 결정
`_cleanupRunning`** / `fn` 안 자기 leaf 파괴 미서술 → UB) · 3 수정분 재검토(`Store:Of`
스니펫의 `shadow` 업밸류 / UB 괄호) · 4 전체 diff(`gate-plan.md` 새 절의 "4절"
인용에 문서명 / 인용문 한 구절 / README archive 행 부활 포인터) · 5 수렴
확인(`CLAUDE.md` 볼드 짝) · 6 형식·라벨 정합 **0건**.
## `/code-review high` (2026-08-28, 10라운드 반영분 + 감사 6라운드 뒤)
10건. **일곱 반영**, **셋은 새 메커니즘·기존 결정 변경이라 문항**(`-round10.md`
`H-159`~`H-161`, `question.md`).
반영한 일곱:
1. `bindLifetime` 경로에 (A)의 강제가 없었다 — `fn``New "Frame" { self }`
자기를 leaf에 묶을 수 있었다. `_bindDestroying` 첫 줄에 `isRunning` 가드.
2. `guardNotRunning` 헬퍼 안의 `error(…, 2)`가 헬퍼 호출 줄(quad 내부)을 가리킴
(`H-104`, `H-149`와 같은 이유) — 술어 `isRunning`만 헬퍼로, `error`는 다섯 본문에
인라인(`level 3` 선례를 만들지 않음). `lifecycle-pattern.md`*헬퍼로 빼도 된다* 문장에
단서.
3. `RESERVED`가 어디에도 정의되지 않았고 예약 이름 셋이 네 곳에 리터럴 — `Of` 절에
`local RESERVED = {…}` 단일 소스, 나머지는 가리키기만.
4. `H-154` 서술 정확화 — dedup이 없애는 건 물리 detach/attach와 `recompute` **1회**
(2→1); process 쪽 skip은 옛 값을 몰라 안 둔다(`SlotHandler`의 `claimOwnerAt`
다른 점 명시).
5. 산문 ↔ 의사코드 모순 — *기존 플래그 재사용, 새 상태 없음* / *재`Unsubscribe`는
레지스트리 가드* / *`fn` 안 직접 호출은 게이트하지 않는다* / `lifecycle-pattern.md`
*`_running` 가드*만 — 전부 `_cleanupRunning`·진입 게이트 반영.
6. `-round9-followup.md` 진행 표 행·`H-146` 절에 2026-08-28 소멸 표시, `todos.md`
*`Refresh` 먼저*·*뒤집힌 것 둘*→셋, *갈래 6개* 다섯 곳 → 개수는 §5가 소스.
7. stale 문장 — `effect-plan.md`*게이트가 아니라 `Blocker`를 쓰는 이유* / 포탈
근거를 *`H-64` 캐치업*으로 적은 것(재마운트는 `_installed` 참이라 캐치업이 아예
없다) / `source-state-plan.md`*구현이 한 벌*(위임 아님으로 정정).

View file

@ -8,7 +8,7 @@
> 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의 > 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의
> "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것. > "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것.
> >
> 상태: **[2026-08-28 기준] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) / 회신 대기.** `H-147`~`H-149`는 > 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 미결로 남은 것은 거기서 새로 생긴 `H-158`(`:Block` 슈가)과 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는
> `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존 > `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존
> 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md` > 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`
> 마지막 code-review 절). > 마지막 code-review 절).
@ -347,10 +347,53 @@ spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18`
| **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 | | **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 |
| **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 | | **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 |
| **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 | | **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 |
| **`H-159`** | **[2026-08-28 `/code-review`, 반영 뒤]** `H-151`이 잃은 캐치업 — 바인드 **전**에 온 emit(특히 `Ref`)은 다시 안 온다 | (a) `_bindDestroying`/`resubscribeTail`에 **"묶이는 시점 1회 `Refresh`"**만 되살림(emit 경로의 `_epochs` 갱신은 `H-151`대로 `Update`만) / (b) `Ref` dep만 바인드 시 `.Revision` 대조 / (c) 계약으로 두고 사용자에게 "`Ref`를 dep으로 쓰는 Effect는 그 leaf 뒤에 두라" 문서화 | **(a)** — `H-151`의 근거("다음 emit이 잡는다")가 `Ref`엔 성립하지 않는다; (a)는 `H-151`을 되돌리는 게 아니라 "emit 경로만 미룬다"는 계약과 양립(바인드는 emit 경로가 아님) |
| **`H-160`** | leaf `Destroying` 콜백이 도는 cleanup 안의 `self:Rerun()`/`dep:Set()` — `canExecute`가 아직 참이라 죽는 inst에서 `fn`이 돌고 새 cleanup이 영구 고아 | (a) `rawRerun` 진입에서 `_cleanupRunning`이면 **버린다**(no-op) — "cleanup은 자기 생명주기를 못 바꾼다"의 `Rerun`판 / (b) `Destroying` 콜백이 `_consumeCleanup` **전에** `.Subscribed`류 표식으로 죽음을 먼저 세움(새 상태) / (c) UB 문서화 | **(a)** — 새 상태 없이 기존 플래그 하나로, `Unsubscribe` 경로와 같은 결과 |
| **`H-161`** | `H-148` 이후 **M5에 승인된 루트 부착 경로가 없다** + 여러 스크립트가 같은 `PlayerGui``Claim`하면 이중 claim error / 다중 quad UB라 `Claim`이 자기 동기 사례를 막는다 | (a) `Claim`**M5 스코프**로 당기고(프로바이더 마일스톤이라 자연스러움) `research/existing-mount-plan.md` §5-7·8 갈래를 같이 정한다 / (b) `Claim` 전까지 임시로 `H-146` 루트 예외(밖에서 `.Parent =`)를 M5 한정으로 되살림 / (c) 루트 컨테이너(부기 대상 아님)는 claim 없이 자식만 붙이는 얇은 표면 신설 | **(a)** — 임시 예외는 하루 만에 뒤집힌 것을 되살리는 것이고, (c)는 `Mount` 기각의 재개방. §5-7(다중 스크립트)은 `Claim`의 "전부 매핑" 계약이 **루트 컨테이너에는 안 맞는다**는 신호라 갈래를 그 문서에 적었다 |
갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), 갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷),
`H-156`(`H-32` 문단), `H-157`(실측 완료 표기). `H-156`(`H-32` 문단), `H-157`(실측 완료 표기).
**[2026-08-28 추가] `H-159`~`H-161`은 §4 문항 7건을 반영한 뒤 `/code-review high`
낸 10건 중 새 메커니즘·기존 결정 변경인 셋**(나머지 일곱은 반영 — `-round10-followup.md`
마지막 code-review 절). 상세는 아래.
### `H-159` 🟡 — `H-151`이 잃은 캐치업: 바인드 전에 온 emit은 다시 안 온다
`D.Frame { Effect(function(self) if ref.Value then … end end, ref), D.TextButton { ref } }`.
Lua는 배열 원소를 순서대로 평가한다 — `Effect(...)`가 먼저 생성돼 `fn`이 1회 돌고
(`ref.Value == nil`), 그다음 `D.TextButton { ref }``ref:Set(button)``fire`
`canExecute(E)` 거짓(아직 안 묶임) → **`_epochs`를 안 건드리고 버림**. 그 뒤 Frame의
`drive`가 E를 leaf에 묶음 → `_bindDestroying``_installed == true``Rerun` 없음.
`Ref``Set`될 때만 발화하므로 **`fn`은 영원히 버튼을 못 본다**(배열 순서를 바꾸면
된다 — 순서 의존 버그). 같은 구멍이 생성자 안에도 있다: `fn`이 자기 dep을 `Set`하면
그 emit은 `fire` 첫 가드에서 버려지고(`self:Rerun()`처럼 지연되지 않는다) `_installed`
참이 돼 바인드가 재실행하지 않는다. 옛 `_epochs:Refresh()`는 둘 다 잡았다. `H-151`
근거 *"죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로 잡힌다"*는 **다음
emit이 오는 dep**에만 성립한다. 갈래·권고는 §4 표.
### `H-160` 🟡 — leaf `Destroying` 경로의 cleanup은 `canExecute`가 아직 참인 채 돈다
`SignalBehavior = Immediate`: `inst:Destroy()``Destroying``_unbindDestroying()`(
`_destroyConn`만 해제, gcconn은 아직 `.Connected`) → `_consumeCleanup()` → cleanup이
`self:Rerun()`(또는 `dep:Set()``fire`) → `rawRerun(false)`: `_running` 거짓,
`canExecute` **참** → 죽는 inst에서 `fn`이 돌고 `_cleanup = c2`, `_installed = true`;
`_destroyConn`은 이미 nil이라 c2를 소진할 연결이 없고, 나중 포탈 재바인드는
`_installed` 참을 보고 재설치를 건너뛴다(조용히 죽은 Effect). `Unsubscribe()` 경로가
안전한 건 `.Subscribed = false`를 소진 **전에** 세우기 때문 — `Destroying` 경로엔
그 대응물이 없다. `rawRerun` 주석의 *"해제 뒤 cleanup의 재요청 … 정의된 no-op"*은 이
경로에서 거짓. 갈래·권고는 §4 표.
### `H-161` 🟡 — M5에 승인된 루트 부착 경로가 없다 / `Claim`이 자기 동기 사례를 막는다
`H-148``H-146`의 루트 예외를 폐기하고 `Claim`은 "M5 이후" 백로그라, M5(프로바이더·
`D`·`InstanceChildHandler`)가 끝나도 quad가 만든 트리를 `PlayerGui`에 붙이는 승인된
경로가 코퍼스에 없다. 그리고 `research/existing-mount-plan.md`의 *이중 claim은 error /
여러 quad가 한 트리를 claim은 UB / 부기 대상 자식은 전부 매핑* 계약을 그대로 두면
`Shop.client.luau``Inventory.client.luau`가 각각 `Claim(PlayerGui, …)`하는 **가장
흔한 사례가 error**다 — 그 문서 §5엔 이 문항이 없었다(추가: §5-7·§5-8). 갈래·권고는
§4 표.
## §5 이상 없다고 확인한 것 ## §5 이상 없다고 확인한 것
전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/ 전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/

View file

@ -31,7 +31,7 @@
| — | `H-139` `New`/`D` 파이프라인 | ✅ **의사코드 신설** — 쓰면서 `H-142` 발견 | | — | `H-139` `New`/`D` 파이프라인 | ✅ **의사코드 신설** — 쓰면서 `H-142` 발견 |
| — | `H-132`/`H-137`/`H-140` | ✅ Q1~Q3 처리 때 닫힘 | | — | `H-132`/`H-137`/`H-140` | ✅ Q1~Q3 처리 때 닫힘 |
| — | `H-142` 해시 파트 `Parent` 순서 | ✅ **확정·반영** — props에 `Parent` 금지(순서 문제 소멸) | | — | `H-142` 해시 파트 `Parent` 순서 | ✅ **확정·반영** — props에 `Parent` 금지(순서 문제 소멸) |
| — | `H-143`~`H-146` (`/code-review high` 발견 중 새 메커니즘 넷) | ✅ **확정·반영 (전부 권고 (a))**`Rerun` 꼬리 실행 중 사망이면 즉시 소진(`wasAlive`) / 재구독 꼬리(`Refresh` 먼저; 감사 4라운드로 진입점 소유권 (b) — `EffectHandle` 자기 것 — 추가 확정) / `indexOfElement` weak-key / 루트는 금지 범위 밖 + 전용 문구 | | — | `H-143`~`H-146` (`/code-review high` 발견 중 새 메커니즘 넷) | ✅ 2026-08-27 확정·반영 → **⚠️ [2026-08-28] 10라운드가 셋을 뒤집음**(`-round10-followup.md`가 소스): `H-143` 소멸(`fn` 안 자기 해제 지원 폐기, `H-147`) / `H-144` 꼬리는 유지하되 `Refresh` 먼저는 폐기(`H-151`), 진입점은 `EffectHandle` 자기 것 (b) / `H-145` weak-key **유지** / `H-146` 루트 예외·전용 문구 폐기(`H-148` → `Claim`) |
**[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과 **[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과
열어둔 확인은 아래 "반영 기록" 절. **같은 날 이어서 Q4~Q8·Q10·`H-138`·`H-139`를 열어둔 확인은 아래 "반영 기록" 절. **같은 날 이어서 Q4~Q8·Q10·`H-138`·`H-139`를
@ -664,7 +664,11 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음
아니거든) 해당 부분을 해결하기 위해서 다른 API 를 제공 할 이유가 없기도 해. 해당 아니거든) 해당 부분을 해결하기 위해서 다른 API 를 제공 할 이유가 없기도 해. 해당
부분은 각 엔진을 사용하는 최종 사용자의 몫."* 부분은 각 엔진을 사용하는 최종 사용자의 몫."*
### `H-143``Rerun` 꼬리에서 실행 중 사망(`wasAlive and not canExecute`)이면 반환 cleanup 즉시 소진 (a) ### `H-143` — ⛔ [2026-08-28 소멸, 10라운드 `H-147`] `fn` 안 자기 해제 지원 자체가 폐기됨 — 아래는 하루 살았던 결정
**`-round10-followup.md` `H-147`이 소스.** `fn`/cleanup은 자기 구독을 못 바꾼다(leaf와 대칭), `Rerun` 꼬리의 사망 판정도 없다.
#### (폐기) `Rerun` 꼬리에서 실행 중 사망(`wasAlive and not canExecute`)이면 반환 cleanup 즉시 소진 (a)
- 하위 결정: 판정은 **"이 실행 중에 죽었는가"(`wasAlive and not canExecute`)** - 하위 결정: 판정은 **"이 실행 중에 죽었는가"(`wasAlive and not canExecute`)**
`fn``WeakUnsubscribe`도 같은 경로로 소진된다. `fn``WeakUnsubscribe`도 같은 경로로 소진된다.
@ -683,7 +687,7 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음
- 반영: `base/effect-plan.md` `Rerun` 의사코드 + "`self`를 주는 덕에" bullet - 반영: `base/effect-plan.md` `Rerun` 의사코드 + "`self`를 주는 덕에" bullet
(허용 문장이 확정으로), `ROADMAP.md` M2 `Effect` 체크박스. (허용 문장이 확정으로), `ROADMAP.md` M2 `Effect` 체크박스.
### `H-144``Subscribe`/`WeakSubscribe` 등록 끝에 `Refresh()` 먼저, `not _installed or depsChanged → Rerun` (a) + 진입점은 `EffectHandle` 자기 것 (b, 감사 4라운드) ### `H-144``Subscribe`/`WeakSubscribe` 등록 끝에 재설치 꼬리 (a) + 진입점은 `EffectHandle` 자기 것 (b, 감사 4라운드) — **[2026-08-28 10라운드 `H-151`] `Refresh()` 먼저는 폐기**(`_epochs`는 emit 때만 갱신, 꼬리는 `not _installed → Rerun` 하나)
사용자가 요청한 **재트레이싱** 결과(회신의 두 질문): 사용자가 요청한 **재트레이싱** 결과(회신의 두 질문):
1. *재구독 뒤 "다음 emit"이 오면 꼬여도 괜찮은가* — 괜찮다. `fire`의 가드 1. *재구독 뒤 "다음 emit"이 오면 꼬여도 괜찮은가* — 괜찮다. `fire`의 가드
@ -738,7 +742,11 @@ epoch 를 전부 잘 설정해주는게 나을수도 있어. 왜냐하면 처음
5번째 인자 근거 정리(weak가 되며 "옛 키 잔존" 근거는 사라지고 "지속 클로저 5번째 인자 근거 정리(weak가 되며 "옛 키 잔존" 근거는 사라지고 "지속 클로저
없음"만 남음), `ROADMAP.md` M3 `getBookkeeping` 체크박스. 없음"만 남음), `ROADMAP.md` M3 `getBookkeeping` 체크박스.
### `H-146` — 루트는 금지 범위 밖, `Mount` 표면 없음, 거부는 전용 문구 (a) ### `H-146` — ⛔ [2026-08-28 폐기, 10라운드 `H-148`] 루트는 밖에서 `.Parent =`가 아니라 quad가 `Claim`으로 소유 — 아래는 하루 살았던 결정
**`research/existing-mount-plan.md`와 `-round10-followup.md` `H-148`이 소스.** 전용 문구도 철회.
#### (폐기) 루트는 금지 범위 밖, `Mount` 표면 없음, 거부는 전용 문구 (a)
- 인용문 *"외부에서 직접 Parent 설정해주지 말것"*의 범위를 **quad가 관리하는 - 인용문 *"외부에서 직접 Parent 설정해주지 말것"*의 범위를 **quad가 관리하는
자식 자리**로 명문화. 루트 부착은 엔진마다 다른 최종 사용자 코드. (b) 자식 자리**로 명문화. 루트 부착은 엔진마다 다른 최종 사용자 코드. (b)

View file

@ -57,10 +57,10 @@ high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견
| `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"*`SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — | | `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"*`SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — |
| `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — | | `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — |
| `H-142` | 🟡→닫힘 | **[2026-08-27 `H-139` 의사코드에서 발견]** `Dispatch.drive`의 해시 파트 순서가 계약이 아니라 `Frame { Parent = x, Size = … }``Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 — 사용자 확정은 순서가 아니라 **props에 `Parent` 금지** | `bind-system-plan.md` × `ROADMAP.md` M5 | 사냥 밖 — 의사코드 작성이 드러냄 | — | | `H-142` | 🟡→닫힘 | **[2026-08-27 `H-139` 의사코드에서 발견]** `Dispatch.drive`의 해시 파트 순서가 계약이 아니라 `Frame { Parent = x, Size = … }``Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 — 사용자 확정은 순서가 아니라 **props에 `Parent` 금지** | `bind-system-plan.md` × `ROADMAP.md` M5 | 사냥 밖 — 의사코드 작성이 드러냄 | — |
| `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun``fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a)** | — | | `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun``fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) → [2026-08-28] 소멸**(10라운드 `H-147`: `fn` 안 자기 해제 자체가 폐기) | — |
| `H-144` | 🟡 | **[2026-08-27 `/code-review high`]** `EffectHandle.Subscribe = Observer.Subscribe` 배정이라 `Unsubscribe`로 cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 **재설치(`Rerun`)가 없다** — leaf 재바인드 경로(`_bindDestroying`의 `not _installed → Rerun`, `H-65`)와 비대칭 | `effect-plan.md` | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a)`Refresh` 먼저, 두 구독 진입점 모두 꼬리; 진입점은 (b) `EffectHandle` 자기 것** | — | | `H-144` | 🟡 | **[2026-08-27 `/code-review high`]** `EffectHandle.Subscribe = Observer.Subscribe` 배정이라 `Unsubscribe`로 cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 **재설치(`Rerun`)가 없다** — leaf 재바인드 경로(`_bindDestroying`의 `not _installed → Rerun`, `H-65`)와 비대칭 | `effect-plan.md` | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) 두 구독 진입점 모두 꼬리; 진입점은 (b) `EffectHandle` 자기 것 — [2026-08-28] `Refresh` 먼저는 폐기(10라운드 `H-151`)** | — |
| `H-145` | 🟡 | **[2026-08-27 `/code-review high`]** 최상위 `SlotHandler` retractor가 `setLength(inst, k, 0)`으로 해제할 때 `bk(inst).indexOfElement[slot]`**안 지워진다**(해제 호출엔 요소가 없고 `setLength``element ~= nil`일 때만 쓴다) — 교체마다 옛 Slot이 부모 `bk`에 강참조로 쌓여 부모가 죽을 때까지 산다(`InstanceChildHandler` 쪽은 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 State라 요소가 필요하다) | `dispatch-core-plan.md` `setLength` × `slot-plan.md` 494 | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) weak-key** | — | | `H-145` | 🟡 | **[2026-08-27 `/code-review high`]** 최상위 `SlotHandler` retractor가 `setLength(inst, k, 0)`으로 해제할 때 `bk(inst).indexOfElement[slot]`**안 지워진다**(해제 호출엔 요소가 없고 `setLength``element ~= nil`일 때만 쓴다) — 교체마다 옛 Slot이 부모 `bk`에 강참조로 쌓여 부모가 죽을 때까지 산다(`InstanceChildHandler` 쪽은 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 State라 요소가 필요하다) | `dispatch-core-plan.md` `setLength` × `slot-plan.md` 494 | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) weak-key** | — |
| `H-146` | 🟡 | **[2026-08-27 `/code-review high`]** `H-142`(props에 `Parent` 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 **루트**(`ScreenGui` → `PlayerGui`)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 v1 `Mount(ScreenGui, …)`, `slot-plan.md` 233행의 *"v2는 mount 함수 자체가"*의 그 함수는 어느 표면에도 없다. 부수로 거부 배선의 에러가 일반 매치 실패 메시지(*"provider가 초기화됐는지 확인"*)라 오해를 부른다 | `bind-system-plan.md` `H-142` | 리뷰 발견, 처방은 새 표면. **[2026-08-27] 확정 (a) 루트 예외 + 전용 문구** | — | | `H-146` | 🟡 | **[2026-08-27 `/code-review high`]** `H-142`(props에 `Parent` 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 **루트**(`ScreenGui` → `PlayerGui`)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 v1 `Mount(ScreenGui, …)`, `slot-plan.md` 233행의 *"v2는 mount 함수 자체가"*의 그 함수는 어느 표면에도 없다. 부수로 거부 배선의 에러가 일반 매치 실패 메시지(*"provider가 초기화됐는지 확인"*)라 오해를 부른다 | `bind-system-plan.md` `H-142` | 리뷰 발견, 처방은 새 표면. **[2026-08-27] 확정 (a) 루트 예외 + 전용 문구 → [2026-08-28] 둘 다 폐기**(10라운드 `H-148`: 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`) | — |
--- ---

View file

@ -12,16 +12,23 @@
--- ---
## ⭐ 최우선 — 10라운드 문항지 (2026-08-28 갱신, 배치 회신 대기) ## ⭐ 최우선 — 10라운드 후속 둘 (2026-08-28 갱신)
**[2026-08-28] 10라운드 문항지가 `qa-request/pre-implementation-handtrace-round10.md` **[2026-08-28] 10라운드 §4 문항 7건은 사용자와 대화형으로 전량 결정·반영됐습니다**
있습니다** — §4 표를 위에서 아래로 읽고 갈래만 회신하시면 됩니다. 씨앗은 (소스는 `qa-request/pre-implementation-handtrace-round10-followup.md`). 그 대화에서
`H-143`~`H-146` 반영분에 `/code-review high`가 낸 판단 대기 셋(`H-147` 죽은 새로 생긴 것만 남아 있습니다 — **둘 다 M2 게이트 아님**:
핸들에서 `Rerun` / `H-148` `Parent` 거부 전용 문구는 새 메커니즘 / `H-149` - **`H-158`** `state:Block(blocker)` 슈가가 `blocker-plan.md`에 아직 있다 — 사용자
Observer `Subscribe` 위임과 `level 2`), 그 아래에 신선한 탐사자의 **광범위 언급(*"Apply(Blocker) 이긴 할꺼야 (표면은 :Gate 만 남아 …)"*)대로 **폐기하고
탐사**(M2~M8, 레인 A/C/B/D — 지시서 `-round10-brief.md`) 결과가 이어집니다. `state:Apply(blocker)`로** 갈지 확인(권고 (a) 폐기).
**M2 착수 게이트는 아닙니다**(셋 다 구현 중 정해도 되는 크기). 사용자 판단: - **`H-159`~`H-161`** (`-round10.md` §4 표 아래 셋, 10라운드 반영분에
*"인간을 기다리는거 엄청 비효율이라서 … batch 로 처리될 필요가 있는듯"*. `/code-review high`가 낸 것) — `H-151`이 잃은 "바인드 전 emit(특히 `Ref`)"
캐치업(권고 (a) 묶이는 시점 1회 `Refresh`만 복원) / leaf `Destroying` 경로의
cleanup 안 `Rerun`(권고 (a) `_cleanupRunning`이면 `rawRerun` no-op) / **M5
루트 부착 경로 부재 + 다중 스크립트 `Claim`**(권고 (a) `Claim`을 M5로, 다중
스크립트는 권고 없음).
- **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는
`Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트
사용·debug 검사 범위·표면 이름·마일스톤 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5 이후.
**[2026-08-27] 9라운드**(Q1~Q10·`H-138`·`H-139`·`H-142`·`H-143`~`H-146`)는 **[2026-08-27] 9라운드**(Q1~Q10·`H-138`·`H-139`·`H-142`·`H-143`~`H-146`)는
전량 처리·반영됐습니다 — 소스는 `-round9-followup.md`. 전량 처리·반영됐습니다 — 소스는 `-round9-followup.md`.

View file

@ -0,0 +1,159 @@
# 이미 있는 트리를 quad가 소유하기 — `Claim` + `D.Mapper` (가칭)
> **[2026-08-28 신설, 사용자 발의]** 10라운드 `H-148`(`Parent` 거부 문구)을
> 논의하다 **더 큰 표면의 공백**이 드러나 만든 문서. 상태: **설계 논의 중 —
> 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 이후**
> (프로바이더 op가 필요). 결정이 나면 `base/`로 승격한다.
>
> **`archive/existing-instance-bind-rejected.md`(2026-08-14 기각)와의 관계**:
> 그 기각은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*였고 사유는
> "quad가 만들지 않은 트리의 자식 구성을 바깥이 밀고 당기면 `setLength`/
> `setOffsetSource` 부기가 깨진다"였다. 이 문서는 그 반대 방향 — **한 번
> claim하면 quad가 소유하고 직계 자식은 사용자가 전부 매핑한다**는 계약이라
> claim 뒤엔 quad가 만든 트리와 같은 불변식이 성립한다. **재바인드는 여전히
> 미지원**(claim은 1회, 디스크립터는 `Processed`로 소진). 그 archive엔 "좁은
> 형태로 부활"이라는 배너를 달 것(반영 시).
## 1. 왜 필요한가 (사용자 원문)
`H-146`이 "루트는 사용자가 밖에서 `.Parent =`"로 닫혔는데, 사용자가 이어서
지적했다: *"slot 은 물리 장치에 mount 할 방법이 거의 존재하지 않음. Parent =
처럼 마운트 할 방법이 없는데? 그럼 PlayerGui 가 상위에 있고 거기에 GUI 를
여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 될것 같은 느낌이 듦. 이건
Parent 이상의 문제인것 같아."* — 즉 **루트가 Slot일 수 없다**(Slot은 quad가
부기를 가진 부모 `inst` 아래에만 산다). 그리고: *"생성할 요소들 자체가 너무
많은 경우 Clone 이 엇청 더 싸서, 그 Clone 된 것 아래 quad 를 바인딩 할 방법이
있으면 좋은것도 사실인듯. … web 에서도 템플릿에 의해 유효한 요소일꺼고,
roblox 에서도 보면서 만들어낸 GUI를 바인딩하는건 흔한 요구라서 이 역시 흔한
필요일꺼야."*
결론(사용자): *"이런 방식으로, 이미 있는 PlayerGui 아래 마운트를 거는거지.
… 이것도 똑같이 Quad 가 소유하게 될 요소가 되는거지. … 이렇게 끝내면 parent
를 설정할 문제 자체가 사라져"* — **`H-146`의 루트 예외와 `H-148`의 문구 문제가
같이 소멸**한다.
## 2. 모양 (사용자 스케치 + 확정된 것)
```lua
local M = D.Mapper -- 정의는 D 안에 산다. 유저가 필요하면 꺼낸다
local cloned = Claim(template:Clone(), M.Frame "root" { -- ← 루트 이름은 미결(§5-1)
M.TextLabel "Title" { Text = title },
M.Frame "List" {
Slot { … }, -- 기존 부모 아래 Slot — 이제 가능
},
BackgroundColor3 = color, -- props는 New와 같은 derive 테이블
}) -- -> cloned (claim한 루트 Instance)
```
- **`D.Mapper.<Class> "Name" { … }`는 Instance를 만들지 않고 디스크립터만
만든다**(브랜드 `Mapper`류) — derive 테이블 + 매칭 키. `D.Frame`에 직접 얹지
않는다(사용자: *"D.Frame 에 바로 바인딩은 위험한듯. 의미가 겹쳐버려"*).
- **`Claim(inst, descriptor) -> inst`가 최상위**. `inst`는 quad 밖에서 온 것
(PlayerGui, `Clone()` 결과, Studio에서 만든 GUI). 이 호출로 `inst`와 매핑된
하위 전부가 **quad 소유**가 된다 — `New`가 만든 것과 같은 gcconn/gchold·부기.
- **처리 순서는 `New`와 반대 방향에서 시작한다.** `New`는 안쪽 생성자가 먼저
평가돼 자연히 bottom-up이지만, 매퍼의 안쪽은 평가 시점에 자기 Instance를
모른다(부모가 아직 없다). 그래서 `Claim`이 **DFS로 내려가며 이름으로 해석 →
자식부터 `drive` → 올라오며 부모 `drive`**. 사용자: *"핸들러가 된다면 위험해.
일반 생성과 다르게, 상위 부터 처리하거든 … DFS 로써, 내려가는게 먼저고 그
뒤에서 derive 를 걸어야해. 이건 derive 에선 구현하지 않고, 그 위의 무언가로써
구현되어야할듯."* — `drive`/핸들러 층은 안 바뀌고 그 **위의 한 겹**이다.
- **매핑된 정적 자식은 `InstanceChildHandler`와 같은 부기(`setLength(inst,k,1)`)만
하고 `.Parent =`는 안 한다**(이미 거기 있다). Slot은 평소처럼 `native*`
기존 부모 아래 끼운다.
- **디스크립터는 1회용** — claim 뒤 `Processed`로 소진(`PreRef`와 같은 관용구).
재사용·이중 claim은 error.
- **매칭은 프로바이더 주입 op** `nativeFindChild(inst, key)`(가칭) — Roblox는
`Name`, web은 id/selector. **quad-base가 순회·부기 전반을 구현하고
프로바이더는 이 핸들만 낸다**(사용자: *"quad-base 에서 전반을 구현해주고
필요 핸들을 구현하라고 남기는건 괜찮은 생각"*).
- **여러 quad 인스턴스가 한 트리를 claim — UB**(사용자 확정).
- **`H-142`(props에 `Parent` 금지)는 그대로.** 루트가 quad 소유가 되므로
`H-146`의 "루트는 밖에서 `.Parent =`" 예외는 **폐기**.
## 3. 계약 — 자식은 전부 매핑한다
사용자: *"모든 개체를 유저가 직접 네임을 매핑해서 derive 테이블 안에서 내부
요소를 전부 매핑해준다를 계약으로 잡으면 문제가 없다고 생각해."*
- **부기 대상(그려지는 자식)은 전부 매핑해야 한다.** 안 된 자식이 남으면
`nativeInsert`의 삽입 위치(web은 곧 DOM 순서)와 Length/Offset이 어긋난다.
- **숏핸드(`UICorner` 등 `UI*`)는 부기 대상이 아니다** — 그려지지 않고 Roblox에만
있으며 단순 `Parent` 대입 요소. 사용자 확정: *"숏핸드를 quad 에서만 직접
쓰거나 … 아니면 실제 UI 객체를 바인딩해서 숏핸드를 안 쓰거나"* — 둘 중 하나:
(i) 템플릿엔 `UI*`가 없고 quad가 숏핸드 키로 만든다, (ii) 템플릿의 `UI*`
`M.UICorner "UICorner" {…}`처럼 **실제 객체로 매핑**하고 그 부모에 숏핸드
키는 안 쓴다. 섞으면(템플릿에 `UICorner`가 있는데 숏핸드 키도 씀) 둘이
생기는 것은 UB.
- **이름 중복·부재는 UB**(사용자 확정). **debug 모드**에선 `seen` 맵으로 중복을
잡아 error(사용자 제안) — 부재·클래스 불일치도 같은 자리에서 검사.
## 4. 이 문서가 여는 것
- **루트**: `Claim(PlayerGui, M.ScreenGui "…" { Slot {…} })` — PlayerGui가
quad 소유 부모가 되어 Slot이 그 아래 산다. `ScreenGui``New`로 만들어
붙이는 경우도 `Claim(PlayerGui, M.PlayerGui(...) { New "ScreenGui" {…} })`처럼
정적 자식으로 들어간다(→ §5-3 루트 이름 문제).
- **템플릿 대량 생성**: `template:Clone()``Claim` — 각 사본이 독립 소유.
Claim이 Instance를 돌려주므로 **Slot 요소로도 그대로 쓸 수 있다**(요소는
`inst`) — "요소가 너무 많은 경우"의 답.
- **비루트 사용**: `New "Frame" { Claim(clone, …) }` — 반환된 `inst`가 정적
자식으로 들어가면 `InstanceChildHandler``Parent =`와 부기를 한다. 평가
순서상 `Claim`이 먼저 끝나므로 bottom-up이 유지된다.
## 5. 미결 — 다음 배치 문항
1. **루트 디스크립터의 이름** — 루트는 `Claim``inst`를 직접 받으니 매칭 키가
필요 없다. 사용자: *"최상위는 이름을 뭐로 둬야할지 아직 모르겠음. 비워두는걸
D.Mapper.Frame{} 으로 제공하는건 더 나빠보이는데. 아니면 테이블로써
MapperRoot = {} Mapper.Frame (MapperRoot) {} 모양이 되어도 될것같음."*
갈래: (a) 센티널 `MapperRoot`(`M.Frame(MapperRoot) {…}`) / (b) 루트는
`Claim(inst, { … })`처럼 클래스 없는 맨 테이블(클래스는 `inst`가 이미 안다)
/ (c) 이름을 받되 무시. **권고 (b)** — 루트 클래스를 두 번 말하지 않고,
`M.<Class>`는 "찾아야 하는 자식"에만 쓰여 뜻이 하나가 된다. 단 타입(`D`
생성기가 만든 props 타입)을 잃으므로 `Claim<<"Frame">>(inst, {…})`처럼
타입 인자로 보완 — `New<<X>>`와 같은 관용구.
2. **물리 순서 계약** — 디스크립터 배열 순서와 기존 트리의 실제 순서가 다를 때.
Roblox는 물리 순서가 의미 없어 무관, web은 DOM 순서라 `nativeInsert` 위치가
어긋난다. 갈래: (a) 디스크립터 순서가 정본이고 일치는 사용자 책임(UB,
debug 검사) / (b) claim 시 quad가 `nativeMove`로 실제 순서를 디스크립터에
맞춘다. **권고 (a)** — "이미 있는 걸 그대로"의 취지, Roblox에선 비용 0.
3. **`New`로 만든 자식을 claim된 부모에 넣는 것** — 위 §4 첫 항목. 정적 자식이니
`InstanceChildHandler``Parent =`를 하면 되고 새 결정은 없어 보이나,
"매핑(이미 있음)"과 "생성(새로 붙임)"이 한 배열에 섞이는 것이 계약상
괜찮은지 확인 문항.
4. **debug 검사의 범위** — 이름 중복 / 부재 / 클래스 불일치 / 미매핑 부기
대상 자식 / 같은 quad의 이중 claim 중 어디까지. 권고: 전부(debug에선 싸다).
5. **표면 이름**`Claim`/`Mount`/`Adopt`, `D.Mapper`/`D.Existing`.
`Mount(root, parent)``H-146`에서 기각한 사유("부기 없는 대상에 quad 객체
주입")는 여기 반대로 적용된다 — claim은 부기를 *세우는* 행위. 권고 `Claim`.
6. **마일스톤** — M5 이후(`nativeFindChild`가 프로바이더 표면). `ROADMAP.md`
백로그에 포인터만. **[2026-08-28 `/code-review`, `H-161`]** 단 `H-146` 루트
예외를 폐기한 지금 **M5에 승인된 루트 부착 경로가 없다** — (a) `Claim`을 M5
스코프로 당김 / (b) `Claim` 전까지 M5 한정 임시 예외 / (c) 루트 컨테이너용
얇은 표면. 권고 (a).
7. **[2026-08-28 `/code-review`, `H-161`] 여러 스크립트/여러 quad가 같은 루트
컨테이너를 쓰는 경우** — 위 "이중 claim error / 다중 quad UB / 부기 대상 자식
전부 매핑"을 그대로 두면 `Shop.client.luau``Inventory.client.luau`가 각각
`Claim(PlayerGui, …)`하는 **가장 흔한 사례가 막힌다**. 이건 "전부 매핑" 계약이
**루트 컨테이너**(부기 대상이 아닌 `PlayerGui`·`CoreGui`류 — 자식 순서가
의미 없고 quad가 그 형제들을 관리하지 않는다)에는 안 맞는다는 신호다. 갈래:
(a) `Claim`은 **부기를 갖는 노드**에만, 루트 컨테이너엔 "quad가 만든 자식
하나를 붙이는" 별개 표면(부기 없음, 여러 스크립트 공존, 이름은 §5-5와 같이) /
(b) `Claim`에 "이 노드의 다른 자식은 관리하지 않는다"(부분 매핑) 모드 — 단
web처럼 물리 순서가 의미 있는 엔진에선 위험 / (c) 다중 claim을 허용하되 각
claim이 자기가 매핑한 자식만 소유(UB 대신 정의) — 부기 충돌 없음이 조건.
**권고 없음** — (a)는 `H-146`에서 기각한 `Mount(root, parent)`의 재개방과
경계가 얇고, (b)(c)는 계약을 약화시킨다. 사용자 판단.
8. **매핑된 정적 자식의 `Parent` 대입** — §2는 "부기만, `.Parent =`는 안 한다"인데
`InstanceChildHandler``v.Parent = inst`가 계약(`dispatch-core-plan.md`
`H-134`). 같은 핸들러를 쓰면 이미 거기 있는 자식에 같은 값을 재대입(엔진
no-op)하는 것뿐이라 별도 핸들러가 필요 없어 보인다 — 확인 문항(권고: 같은
핸들러, 재대입 감수).
## 6. 반영 시 고칠 자리 (승격 때 체크리스트)
`archive/existing-instance-bind-rejected.md` 배너 / `base/bind-system-plan.md`
`H-142` 항목의 `H-146` 루트 bullet(폐기 → 이 문서 포인터) / `base/slot-plan.md`
"동적 자식은 반드시" 절 각주 / `ROADMAP.md` M5 `Property.luau` 전용 문구 삭제 +
백로그 항목 / `research/documentation-content-map.md` / `question.md`.

View file

@ -1978,4 +1978,15 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`. 결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`.
**[2026-08-28]** 감사 8라운드 수렴 → `/code-review high` 10건(일곱 반영, 셋은 **[2026-08-28]** 감사 8라운드 수렴 → `/code-review high` 10건(일곱 반영, 셋은
판단 필요) → 사용자 판단으로 **10라운드 문항지**(`-round10.md`, `H-147`~`H-149` 판단 필요) → 사용자 판단으로 **10라운드 문항지**(`-round10.md`, `H-147`~`H-149`
씨앗 + 광범위 탐사)로 이관, 배치 회신 대기. 씨앗 + 광범위 탐사)로 이관.
- **`session/2026-08-28-01-handtrace-round10-resolution.md`** — 10라운드 7문항 대화형
결정·반영. **뒤집힌 것 둘**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147` (A) —
어제 `H-143`의 원샷 지원 소멸, `rawRerun(force)`/`Rerun` 분리, 네 진입점 `_running`
가드) / 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148` →
`research/existing-mount-plan.md`, 2026-08-14 기각과 다른 claim-once·own-all).
나머지: Observer 진입점 인라인(`H-149`) / `Effect._blocker` 제거(`H-150`) /
`_epochs`는 emit 때만 갱신, `Refresh` 캐치업 폐기 + "게이트는 emit 경로만 미룬다"
계약(`H-151`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 런타임
가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`) / ROADMAP·debounce·store
stale(`H-155`~`H-157`). 미결 `H-158`(`:Block` 슈가). 소스는
`qa-request/pre-implementation-handtrace-round10-followup.md`.

View file

@ -0,0 +1,36 @@
# 2026-08-28 — 10라운드 결정·반영 (대화형) + `Claim` 방향
**무엇을 했나**: 어제 밤 탐사자가 만든 10라운드 문항지(`-round10.md` §4, 7건)를
사용자가 *"하나하나 같이 보자"*라 해 대화형으로 처리하고 `base/`·`ROADMAP.md`에
반영했다. 결정의 소스는 `qa-request/pre-implementation-handtrace-round10-followup.md`
(사용자 발언 원문 전부 거기). 여기는 흐름과 문서에 안 들어간 것.
## 흐름
1. `H-147`부터. 사용자의 첫 제안("`canExecute`를 cleanup 아래에")에 생성자 함정을
짚었더니 *"rerun 이 're'-run 인데 초기 실행까지 담당"*이라는 더 정확한 지적 →
`rawRerun(force)` 분리. 그 다음 턴에 *"not force 로 확인하면 안 될 부분"*과
함께 **뿌리를 뒤집었다**: `fn`이 자기를 sub/unsub할 수 있다는 것 자체가 leaf
(unbind/bind 불가)와 비대칭이고, 어제 `H-143`부터 오늘까지의 결함 넷이 전부 그
허용의 파생물. (A) 금지 확정. 어제 사용자가 *"지원 안 할 이유가 딱히
없다"*고 한 것을 스스로 *"엄청난 모순이네"*로 뒤집은 자리.
2. `H-148`에서 사용자가 더 큰 공백을 짚음 — 루트가 Slot일 수 없다(`PlayerGui`
아래 `Slot { Shop{} }` 불가). 2026-08-14 기각(재바인드)과 다른 방향(claim-once·
own-all)임을 archive와 대조해 확인하고 `research/existing-mount-plan.md` 신설.
`H-146`의 "루트는 밖에서 `.Parent =`" 예외는 하루 만에 폐기.
3. `H-149`~`H-154`는 권고대로. `H-151`에서 사용자가 *"우린 애초에 Refresh 를 할
필요가 없는거야"* — 어제 `H-144`에서 세운 "`Refresh` 먼저" 하위 결정이 소멸.
`H-150`은 사용자가 "Observer 설치 발화는 일어나는 게 맞지 않나"를 확인한 뒤
(Effect 핸들의 `canExecute`라는 것을 갈라 답함) 확정.
4. 그 대화에서 `:Block` 슈가 잔존(`H-158`)이 드러남 — 미결로 남김.
## 시행착오 / 다음 세션이 알아야 할 것
- **어제 결정 셋이 하루 만에 뒤집혔다**(`H-143` 지원, `H-144` `Refresh` 먼저,
`H-146` 루트 예외). 셋 다 "권고 (a)를 사용자가 승인"한 것이었고, 문제는 갈래
자체가 **더 위의 질문**(소유권 / 캐치업이 필요한가 / 루트를 누가 소유하나)을
안 묻고 증상 층위에서 만들어졌다는 것. 다음 라운드 문항지는 "이 갈래들이
공유하는 전제가 뭔가"를 한 줄 적는 습관이 필요하다.
- 사용자가 결정 직전에 전제를 묻는 패턴(*"그게 진짜 날 수 있어?"*, *"canExecute 가
막는다가 말이 맞아?"*)이 두 번 다 유효한 정정으로 이어졌다 — 그때 "맞다"로
넘기지 말고 층을 갈라 답할 것.

View file

@ -48,12 +48,17 @@
Q9는 문항 전제가 틀린 것(Tween 절 스케치 한 줄 복사 오류)으로 닫힘. Q9는 문항 전제가 틀린 것(Tween 절 스케치 한 줄 복사 오류)으로 닫힘.
**[2026-08-27 기준] 감사 8라운드·`/code-review high`까지 돌렸다 — 리뷰 10건 중 **[2026-08-27 기준] 감사 8라운드·`/code-review high`까지 돌렸다 — 리뷰 10건 중
여섯은 반영, 넷(`H-143`~`H-146`, 새 메커니즘)은 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 사용자 결정으로 여섯은 반영, 넷(`H-143`~`H-146`, 새 메커니즘)은 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 사용자 결정으로
전부 권고 (a) 확정·반영(`Rerun` 꼬리 즉시 소진 / 재구독 꼬리 `Refresh` 먼저(진입점은 `EffectHandle` 자기 것) / 전부 권고 (a) 확정·반영(`Rerun` 꼬리 즉시 소진 / 재구독 꼬리(진입점은 `EffectHandle` 자기 것) /
`bk.indexOfElement` weak-key / 루트 부착은 금지 범위 밖). **[2026-08-28]** 그 `bk.indexOfElement` weak-key / 루트 부착은 금지 범위 밖). **[2026-08-28]** 그
반영분에 `/code-review high`가 또 셋(`H-147`~`H-149`)을 냈고, 사용자 판단으로 반영분에 `/code-review high`가 또 셋(`H-147`~`H-149`)을 냈고, 사용자 판단으로
**10라운드 문항지**(`qa-request/pre-implementation-handtrace-round10.md`)로 **10라운드 문항지**(`qa-request/pre-implementation-handtrace-round10.md`)로
올려 신선한 탐사자의 광범위 탐사(지시서 `-round10-brief.md`)와 함께 **배치 올려 신선한 탐사자의 광범위 탐사(지시서 `-round10-brief.md`, 발견 `H-150`~`H-157`)
회신 대기**. 남은 액션: 그 회신 처리 → M2 착수.** 아래는 돌리기 전(2026-08-26) 서술: 와 함께 **같은 날 대화형으로 전량 결정·반영**(소스 `-round10-followup.md`).
**어제 결정 중 뒤집힌 것 셋**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸) /
재구독·재바인드의 `Refresh` 캐치업 폐기(`H-151`) / 루트는 밖에서 `.Parent =`
아니라 quad가 `Claim`으로 소유(`H-148`, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절의 둘
(`H-158` `:Block` 슈가, `Claim` 갈래 — 개수는 `research/existing-mount-plan.md` §5가 소스) — 게이트 아님. 남은 액션: M2 착수.**
아래는 돌리기 전(2026-08-26) 서술:
지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는 지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는
**커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤 **커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤
`/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도 `/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도

View file

@ -14,9 +14,11 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
`.claude/qa-request/pre-implementation-handtrace-round8-followup.md` `.claude/qa-request/pre-implementation-handtrace-round8-followup.md`
(7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은 (7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은
`-round9-followup.md` — Q1~Q10·`H-138`·`H-139`·`H-142`, 그리고 `/code-review` `-round9-followup.md` — Q1~Q10·`H-138`·`H-139`·`H-142`, 그리고 `/code-review`
`H-143`~`H-146`까지 전량 반영 완료**)이고, **[2026-08-28] `.claude/question.md` `H-143`~`H-146`까지 전량 반영 완료**; **[2026-08-28] 10라운드 몫은
최우선 절엔 10라운드 문항지(`-round10.md` §4 — `H-147`~`H-149` + 광범위 탐사 `-round10-followup.md`** — 광범위 탐사 `H-150`~`H-157`까지 전량 결정·반영, 그중
결과)가 배치 회신 대기**로 올라 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md` `H-143``H-146` 루트 예외는 하루 만에 뒤집힘)이고, `.claude/question.md` 최우선
절엔 그 대화에서 새로 생긴 둘(`H-158` `:Block` 슈가 / `Claim` 갈래 —
`research/existing-mount-plan.md` §5)만 남아 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`. 항상 루트 `ROADMAP.md`.

View file

@ -28,9 +28,14 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> 부기 / `reconcile` 배치 Blocker / `New`·`drive` 파이프라인 / props `Parent` 금지), > 부기 / `reconcile` 배치 Blocker / `New`·`drive` 파이프라인 / props `Parent` 금지),
> 그 반영분에 `/code-review`가 낸 `H-143`~`H-146`(`Rerun` 꼬리 실행 중 사망이면 즉시 > 그 반영분에 `/code-review`가 낸 `H-143`~`H-146`(`Rerun` 꼬리 실행 중 사망이면 즉시
> 소진 / 재구독 꼬리 + 진입점은 `EffectHandle` 자기 것 / `bk.indexOfElement` > 소진 / 재구독 꼬리 + 진입점은 `EffectHandle` 자기 것 / `bk.indexOfElement`
> weak-key / 루트 부착은 사용자 몫)도 같은 날 확정·반영. **[2026-08-28]** 그 반영분의 > weak-key / 루트 부착은 사용자 몫)도 같은 날 확정·반영. **[2026-08-28] 10라운드**
> `/code-review`가 낸 셋(`H-147`~`H-149`)은 **10라운드 문항지**(`-round10.md`)에서 > (`-round10-followup.md`가 소스) — 그중 둘은 하루 만에 다시 뒤집혔다: `fn`/cleanup은
> 광범위 탐사 결과와 함께 배치 회신 대기(게이트 아님). > 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸 · `rawRerun(force)`/`Rerun` 분리) /
> 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148`,
> `research/existing-mount-plan.md`, M5 이후). 그 밖에 `_epochs`는 emit 때만 갱신
> (`Refresh` 캐치업 폐기, `H-151`) / `Effect._blocker` 제거(`H-150`) / Observer
> 진입점 인라인(`H-149`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름
> 런타임 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`).
> **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에 > **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에
> 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 > 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐
> 있습니다). M1까지의 산출물은 > 있습니다). M1까지의 산출물은
@ -374,6 +379,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`). (`luau-test/STATUS.md`).
- [ ] `Source.luau`/`State.luau`/`Store.luau` - [ ] `Source.luau`/`State.luau`/`Store.luau`
- [ ] **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)`
**예약 이름 런타임 가드**(`error(…, 2)`) — 동적 키는 타입이 못 막는다;
그림자 = store 자신(`base/store-plan.md`).
- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>` - [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>`
런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열
커링은 기각). **콜론 메소드로 확정**했고, 예약 키 커링은 기각). **콜론 메소드로 확정**했고, 예약 키
@ -479,13 +487,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] `Effect(fn, ...deps)`**⚠️ 선행: `Blocker`의 기본 메커니즘** - [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
(`On`/`Off`/`IsOn`/`OffWithoutEmit`). 생성자가 등록 구간 억제에 사적 (**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적
`Blocker` 하나를 쓴다(`base/effect-plan.md`). 아래 `Blocker.luau` `Blocker``fire` 첫 줄의 `canExecute`가 이미 같은 억제를 해서 한 번도
체크박스가 이 항목보다 뒤에 있지만 **그 기본 넷은 `GateNode`/`:Policy`와 판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/
무관하게 독립 완결**이라(`base/blocker-plan.md`의 "메커니즘" 절) 그 Slot 쪽 요구뿐.) (`base/effect-plan.md`, **[2026-08-21 5라운드
부분만 먼저 만들면 된다 — `:Policy`/`state:Block` 배선은 `GateNode`
뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
(State/Source는 `Observer`, `Ref``:WeakCallback`**[2026-08-27 (State/Source는 `Observer`, `Ref``:WeakCallback`**[2026-08-27
@ -498,16 +504,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
**[2026-08-27 9라운드 `H-143`/`H-144`]** `Rerun` 꼬리는 `fn` 실행 중에 **[2026-08-28 10라운드 `H-147`]** `rawRerun(self, force)` 본체 + 공개
핸들이 죽었으면(`wasAlive and not canExecute`) 반환 cleanup을 **즉시 `Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들은 정의된 no-op),
소진**하고 `_pending`을 버린다(`fn` 안 `self:Unsubscribe()` 지원 — 생성자는 `rawRerun(self, true)`. **`fn`/cleanup은 자기 구독을 못 바꾼다** —
`not canExecute` 하나로 판정하면 생성자 최초 설치가 죽는다), 네 진입점은 네 진입점 첫 줄에 `_running` 가드(2026-08-27의 "`fn` 안 `Unsubscribe` 지원"
**`EffectHandle` 자기 것**(Observer 함수 본문을 배정하지 않는다 — 공유는 `H-143``wasAlive` 꼬리는 소멸). 네 진입점은 **`EffectHandle` 자기 것**
`Observer.luau`의 레지스트리 둘과 `canBound`뿐; 콜론 위임이 오버라이드를 (`H-144` (b) — 공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐),
타 꼬리가 두 번 도는 걸 감사가 잡아 사용자가 (b)로 확정), `Subscribe`/ `Subscribe`/`WeakSubscribe`는 등록 끝에 `not _installed → Rerun`(재구독
`WeakSubscribe`는 등록 끝에 `_epochs:Refresh()` + `not _installed or 재설치; **[`H-151`]** `_epochs:Refresh()` 캐치업은 폐기 — `_epochs`
depsChanged → Rerun` 꼬리(재구독 재설치, leaf `_bindDestroying`과 동형) — `fire``Update`에서만 갱신) — 의사코드는 `base/effect-plan.md`.
의사코드는 `base/effect-plan.md`.
**동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md` **동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md`
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션) "동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
@ -525,8 +530,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
있었으나 근거 없는 서술이라 삭제됐다(사용자 확정: 두 콜백은 이질적이고, 있었으나 근거 없는 서술이라 삭제됐다(사용자 확정: 두 콜백은 이질적이고,
Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은 Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은
클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 · 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 ·
**`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing` ~~**`handle._blocker`**~~(**[2026-08-28 `H-150`]** 제거 — 등록 구간의
플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) · 즉시-1회 호출은 별도 필드 없이 `fire` 첫 줄의 `canExecute`가 억제한다; 옛
`_installing` 플래그도 생성자 구간만 덮어 폐기됐었다) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가 **`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup 같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup
반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) ·
@ -543,9 +549,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/ **⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/
`:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 `:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다
(`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다 (`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다
(`H-7`). 캐치업은 바인드 직후 **조건부 최대 1회** (`H-7`). 캐치업은 바인드 직후 **재설치 1회뿐**(`if not self._installed then
(`local depsChanged = self._epochs:Refresh()` **먼저**, 그다음 `if not self._installed or depsChanged then self:Rerun() end``or` 한 줄로 단축평가에 걸면 재설치 경로에서 `Refresh()`가 건너뛰어져 다음 emit이 헛돈다, `base/effect-plan.md` 캐비엇 self:Rerun() end` — **[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()`는 폐기,
`H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 `_epochs`는 emit 받을 때만 갱신`H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 - [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
@ -575,6 +581,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) — - [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) —
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드 emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦). (`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
**[2026-08-28 10라운드 `H-152`] 조립 첫 줄은 `StateBrand:register(node)`** —
빠지면 `_emitDown``isState`가 거짓이라 통지만 조용히 죽는다(`base/gate-plan.md`
조립 절). **[`H-151`]** 게이트는 emit 경로만 미룬다는 계약(같은 문서).
유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을 유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을
통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet` 통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet`
스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를 스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를
@ -950,7 +959,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`QuadRoblox(Quad): QuadRoblox``QuadTypes.CheckedQuad<T, Pattern>`으로 `QuadRoblox(Quad): QuadRoblox``QuadTypes.CheckedQuad<T, Pattern>`으로
주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고) 주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고)
- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **[2026-08-27 9라운드 `H-142`] 생성되는 props 타입에서 `Parent`를 제외할 것** — props에 `Parent`는 올 수 없다(부모가 하는 일, 같은 문서의 파이프라인 절). `New` ①~④ 순서는 그 절의 의사코드가 소스 - [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **[2026-08-27 9라운드 `H-142`] 생성되는 props 타입에서 `Parent`를 제외할 것** — props에 `Parent`는 올 수 없다(부모가 하는 일, 같은 문서의 파이프라인 절). `New` ①~④ 순서는 그 절의 의사코드가 소스
- [ ] `Handlers/Property.luau`(**[2026-08-27 9라운드 `H-142`]** `isHandlable``"Parent"` 키를 **거부**한다 — 매치 핸들러가 없어지면 `Dispatch.process`의 "매치 핸들러 없음 → 즉시 error"에 걸리는 것으로 런타임 가드가 공짜로 생긴다, 새 메커니즘 없음. **사용자 확정은 "props에 `Parent` 금지"라는 규칙이고, 이 거부 배선은 에이전트 선택**`base/bind-system-plan.md``H-142` 항목이 그렇게 갈라 적음; **[2026-08-27 `H-146`]** 그 거부는 **전용 에러 문구** — "`Parent` is not a prop" 취지 — 를 내고, 루트를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`로 한다(`Mount` 표면 없음, 같은 항목)), `Handlers/InstanceChild.luau` - [ ] `Handlers/Property.luau`(**[2026-08-27 9라운드 `H-142`]** `isHandlable``"Parent"` 키를 **거부**한다 — 매치 핸들러가 없어지면 `Dispatch.process`의 "매치 핸들러 없음 → 즉시 error"에 걸리는 것으로 런타임 가드가 공짜로 생긴다, 새 메커니즘 없음. **사용자 확정은 "props에 `Parent` 금지"라는 규칙이고, 이 거부 배선은 에이전트 선택**`base/bind-system-plan.md``H-142` 항목이 그렇게 갈라 적음. **[2026-08-28 10라운드 `H-148`]** 전용 문구는 **철회**(일반 매치 실패 그대로) — 루트는 밖에서 `.Parent =`가 아니라 quad가 `Claim`으로 소유하는 쪽으로(`research/existing-mount-plan.md`, 아래 백로그)), `Handlers/InstanceChild.luau`(**[2026-08-28 `H-154`]** retractor 첫 줄 `if nextValue == v then return end` — 같은 값 재발행 dedup, `SlotHandler` 동형)
**⭐ [2026-08-27 9라운드 `H-134`] `InstanceChildHandler`도 말단이라 **⭐ [2026-08-27 9라운드 `H-134`] `InstanceChildHandler`도 말단이라
부기를 등록한다**: `process`에서 `setOffsetSource(inst, k, None)` 부기를 등록한다**: `process`에서 `setOffsetSource(inst, k, None)`
`v.Parent = inst``setLength(inst, k, 1, inst)`(정적 단일 자식은 상수 `v.Parent = inst``setLength(inst, k, 1, inst)`(정적 단일 자식은 상수
@ -961,6 +970,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
문단. 빠뜨리면 문단. 빠뜨리면
`Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 — `Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 —
`base/dispatch-core-plan.md``H-39` 블록(그 다섯째 항목)이 소스. `base/dispatch-core-plan.md``H-39` 블록(그 다섯째 항목)이 소스.
- [ ] **[2026-08-28 백로그, M5 이후]** `Claim(inst, D.Mapper.<Class> "Name" {…})` — 이미 있는 트리(PlayerGui·`Clone()` 사본)를 quad가 소유. 프로바이더 op `nativeFindChild` 필요. 갈래 미결 — `research/existing-mount-plan.md` §5가 소스(개수도), 다음 배치 문항.
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션
확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/
lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에
@ -1190,8 +1200,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- **해제 시 owner 등록 되돌리는 순서 고정** - **해제 시 owner 등록 되돌리는 순서 고정**
`setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**. `setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**.
반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset 반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset
`Source`에 헛된 `:Set()`을 날림. `recompute``sourceList[i]``nil` `Source`에 헛된 `:Set()`을 날림. `recompute``sourceList[i]``nil`이면
이어도 `None`처럼 skip(방어). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]** **즉시 `error`**(부기가 깨졌다는 신호 — 4라운드 `C-6`; **[2026-08-28
`H-155`]** 여기 한때 "`None`처럼 skip(방어)"라 적혀 있었다, `base/slot-plan.md`가 소스). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]**
여기 한때 *"해제 시 `slot.Offset = nil`"*이 붙어 있었는데 그건 4라운드 여기 한때 *"해제 시 `slot.Offset = nil`"*이 붙어 있었는데 그건 4라운드
`SL-75`/`D-60`이 **폐기**한 문장이다 — `nil`로 갈아치우면 그 Source를 `SL-75`/`D-60`이 **폐기**한 문장이다 — `nil`로 갈아치우면 그 Source를
구독 중인 다운스트림이 끊겨 포탈이 깨진다. `slot.Offset`은 생성자에서 구독 중인 다운스트림이 끊겨 포탈이 깨진다. `slot.Offset`은 생성자에서
@ -1202,8 +1213,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
spurious 재발행만 `false`, `Frame{slot,slot}`은 error). spurious 재발행만 `false`, `Frame{slot,slot}`은 error).
`releaseOwner`는 불일치 시 즉시 error. `releaseOwner`는 불일치 시 즉시 error.
- **`rawRemove``releaseOwner`를 부를 것**(옛 의사코드에서 누락돼 있었음), - **`rawRemove``releaseOwner`를 부를 것**(옛 의사코드에서 누락돼 있었음),
**`destroySlotTree`가 자식 소유권 반납 + `_mounted`/`_mountedInst` 복원** ~~**`destroySlotTree`가 자식 소유권 반납 + `_mounted`/`_mountedInst` 복원**~~
(GC에 맡기면 재사용이 GC 타이밍 의존으로 비결정적 실패). (**[2026-08-28 `H-155`]** 이 수정은 5라운드 `C-4`**되돌려졌다**
`base/slot-plan.md``State<Slot>` 재설정 표 정정 문단이 소스).
- **`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함** - **`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함**
— no-op을 반환하면 다음 진짜 교체 때 정리 주체가 사라짐(`retractFrom`은 — no-op을 반환하면 다음 진짜 교체 때 정리 주체가 사라짐(`retractFrom`은
클로저가 early-return해도 체인에서 항상 소비하므로). 클로저가 early-return해도 체인에서 항상 소비하므로).
@ -1300,7 +1312,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**`data:Observer(fn)` 구독은 `:List()` 호출 시점이 아니라 Slot **`data:Observer(fn)` 구독은 `:List()` 호출 시점이 아니라 Slot
마운트 시점까지 lazy — `Dispatch.setLength`와 같은 패턴으로 마운트 시점까지 lazy — `Dispatch.setLength`와 같은 패턴으로
`bindLifetime(inst,observer)`(마운트 이후 `:List()`가 불리면 `bindLifetime(inst,observer)`(마운트 이후 `:List()`가 불리면
`self._mounted` 확인 후 즉시 활성화)** (2026-08-09 일곱 번째 세션, `self._physicalTarget` 확인 후 즉시 활성화 — **[2026-08-28 `H-155`]** 옛
`_mounted` 기준은 6라운드 `H-2`로 바뀌었다)** (2026-08-09 일곱 번째 세션,
`base/slot-plan.md` "`Slot:List(data, updateFn, keyFn?)`"의 "구독 시점" 절) `base/slot-plan.md` "`Slot:List(data, updateFn, keyFn?)`"의 "구독 시점" 절)
**`Slot.Offset: Source<number>``Slot.Length`처럼 공개 필드로 **`Slot.Offset: Source<number>``Slot.Length`처럼 공개 필드로
노출 — `Length`와 같은 자리, 즉 생성자에서 `Source(0)`으로 만들고 마운트 노출 — `Length`와 같은 자리, 즉 생성자에서 `Source(0)`으로 만들고 마운트
@ -1501,9 +1514,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고 않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고
`_bindDestroying`**`Ref` dep 콜백을 (재)등록하지 않는다** — `_bindDestroying`**`Ref` dep 콜백을 (재)등록하지 않는다** —
dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과 dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과
**조건부 캐치업 두 줄**(`local depsChanged = self._epochs:Refresh()` 뒤 **재설치 캐치업 한 줄**(`if not self._installed then self:Rerun() end` —
`if not self._installed or depsChanged then self:Rerun() end``Refresh()` **[2026-08-28 `H-151`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
`or` 뒤에 두면 단축평가로 건너뛰어진다)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다
(그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md` (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`
`base/effect-plan.md`가 소스. **[2026-08-14 `base/effect-plan.md`가 소스. **[2026-08-14
@ -1675,8 +1687,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`TweenBrand`, `Value: T` plain만 받고 State 재귀 없음) `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
- [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존 - [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
(`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 (**[2026-08-28 `H-155`]** `base/tween-plan.md`의 "3-상태 저장" 절이 소스 —
없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 활성 트윈은 엔진 객체가 아니라 `{Tween, Value}` **테이블**, `Tween.Finish`
목표값을 알아야 해서; 옛 표기 `RobloxTween | true | nil`) + 첫 세팅은 무조건 애니메이션 없이
스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만
새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
- [ ] `quad-roblox/Animate.luau`**시그니처도 이미 확정 완료**(2026-08-12 - [ ] `quad-roblox/Animate.luau`**시그니처도 이미 확정 완료**(2026-08-12