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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

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

View file

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

View file

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