diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md
index 54a83cc..3732703 100644
--- a/.claude/base/blocker-plan.md
+++ b/.claude/base/blocker-plan.md
@@ -167,7 +167,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을
-재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
+재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의
+`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다)
+**그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md
index 57a6747..e30bed3 100644
--- a/.claude/base/dispatch-core-plan.md
+++ b/.claude/base/dispatch-core-plan.md
@@ -1381,7 +1381,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
동일하게 적용"* — 즉:
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`를
등록할 때마다 `bk.N`이 `i`로 늘어난다(`Dispatch.drive`의 배열 파트
- 순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
+ 순회, `materializeSlotTree`의 등록 배치, Slot의 런타임 단건 `rawAdd` 전부 이
하나의 규칙) — **`Dispatch.setOffsetSource`는 `bk.N`을 건드리지
않는다**, 호출 순서가 항상 `setOffsetSource(i)` → `setLength(i)`라서
(아래 "`setLength` 구현" 절) `bk.N`을 `setLength`에서만 올려야
@@ -1396,7 +1396,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
-등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
+등록 중(`Dispatch.drive`/`materializeSlotTree`의 등록 루프)엔 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
@@ -1645,7 +1645,13 @@ position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
**적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로
-별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
+별도 Blocker**
+(**[2026-08-21] `attachSlot` 쪽은 이제 정확히는 그 안의
+`materializeSlotTree`다** — `attachSlot`이 "부기만 만드는 재귀"와 "물리만
+붙이는 재귀" 둘로 분해되면서 Blocker가 **등록 쪽 하나만** 감싸게 됐고,
+그래서 "배치 *등록* 게이팅"이라는 이 절의 정의와 실제 범위가 정확히
+일치하게 됐다. 옛 코드는 물리 마운트까지 같이 감싸고 있었음.
+`base/slot-plan.md`의 "재귀 메커니즘" 절이 소스): 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md
index 35640c0..5bf1a96 100644
--- a/.claude/base/slot-plan.md
+++ b/.claude/base/slot-plan.md
@@ -471,8 +471,11 @@ releaseOwner(element, slot)
클레임 미접촉) 무조건 error가 맞음 —
top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함.
-**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawRemove`/
-`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다. `elementOwner`는
+**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/
+`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화,
+2026-08-21]** 여기 `rawRemove`를 같이 적었었는데 그건 파괴 경로다 —
+`rawRemove`의 `releaseOwner`는 아래 재정정 기준으로 보면 불필요하지만 요소가
+어차피 죽으므로 무해해서 그대로 둔다). `elementOwner`는
값도 `SetWeak`이라 "아무에게도 참조되지 않게 되면 소유권 기록도 저절로
사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC 타이밍에 달려 있어서**
그 전에 같은 element를 다른 곳에 넣으려 하면 "이미 마운트돼 있음" error가
@@ -1069,7 +1072,21 @@ lifecycle-pattern.md` "quad는 자신이 만든 Instance의 라이프사이클")
Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로
자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`한
`Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건
-UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
+UB.**
+
+**⚠️ [예시 추가, 2026-08-21] quad가 만든 Instance도 "GC만으로 정리되는 값"이
+아니다.** 지금까지 이 절은 `:Subscribe()`한 Observer만 예로 들어서 **Instance는
+안전해 보였는데, 정반대다** — quad는 자기가 만든 Instance마다 gcconn을 걸고 그
+클로저가 `inst`를 캡처하므로("참조를 놓는 것만으로는 회수되지 않고 반드시
+`Destroy`로 회수된다", `base/lifecycle-pattern.md`의 "(0)" 절) **`ud`에 담아둔
+Instance는 아무도 안 들고 있어도 영원히 남는다.**
+
+- 요소를 잠깐 떼어뒀다 되쓰고 싶으면 **`ud`에 직접 담지 말고 `Detach`를
+ 반환**할 것 — 그러면 `slot._detached`가 관리하고 owner가 죽을 때 같이
+ 정리된다(위 "Detach된 요소는 `slot._detached`가 보유한다" 절). `Detach`가
+ 생긴 뒤로는 `ud`에 Instance를 담을 이유 자체가 없다.
+- 그래도 담겠다면 **정리 책임은 전적으로 사용자**다 — `:List`는 `ud` 안을
+ 들여다보지 않는다. `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역
GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로
적용한 것뿐 — 새 원칙 아님.
@@ -1104,6 +1121,40 @@ function activateList(self, inst)
local offset = self.Offset
local mounted, userdata, keyIndex = {}, {}, {}
+ -- [2026-08-21] 한 키의 처분을 실제로 수행하는 공통 로직 — 정상 사이클과
+ -- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남).
+ local function settle(key, result, detach, pos)
+ local wasMounted = mounted[key]
+ local wasDetached = self._detached[key] -- Slot 필드(아래 "Detach된 요소는 `slot._detached`가 보유한다" 절)
+ local prev = wasMounted or wasDetached
+
+ if detach then
+ -- "이 자리를 비우되 죽이지 마라". 이미 detach 상태면 **nop**.
+ if wasMounted ~= nil then
+ rawDetach(self, wasMounted) -- 언마운트하되 **소유권은 유지**
+ self._detached[key], mounted[key] = wasMounted, nil
+ end
+ elseif result == nil then
+ -- "지워라". Owned=false면 파괴 대신 언마운트만(위 "Owned" 절).
+ if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
+ mounted[key], self._detached[key] = nil, nil
+ elseif result == prev then
+ if wasDetached ~= nil then
+ self._detached[key] = nil -- **재마운트** — detach된 걸 되살림
+ rawAdd(self, result, pos)
+ mounted[key] = result
+ elseif keyIndex[key] ~= pos then
+ rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
+ end
+ else
+ -- 교체 — 밀려난 prev 처분은 Owned가 정한다
+ if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
+ self._detached[key] = nil
+ rawAdd(self, result, pos)
+ mounted[key] = result
+ end
+ end
+
local function reconcile(items)
local newKeyIndex, seen = {}, {}
local pos = 0 -- 압축된(실제 마운트된) 위치 카운터, raw 루프 인덱스 i와 다름
@@ -1115,7 +1166,9 @@ function activateList(self, inst)
end
seen[key] = true
- local prev = mounted[key]
+ -- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든.
+ -- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고).
+ local prev = mounted[key] or self._detached[key]
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
@@ -1132,35 +1185,30 @@ function activateList(self, inst)
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
end
- if result ~= prev then
- -- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래
- -- "`nil` 리턴은 파괴가 기본" 절이 소스:
- -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만**
- -- — state 교체와 동형, 지우라고 한 적이 없음.
- -- (b) Detach: **언마운트만**, 재사용은 ud가 홀드.
- -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시.
- if prev ~= nil then
- if result ~= nil or detach then rawUnmount(self, prev)
- else rawRemove(self, prev) end
- end
- if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
- mounted[key] = result
- elseif prev ~= nil and keyIndex[key] ~= pos then
- rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
- end
+ settle(key, result, detach, pos)
userdata[key] = ud -- result와 무관, 그대로 기록
- -- (Detach 재사용은 여기 담긴 { old = ... }가 담당)
newKeyIndex[key] = pos
end
+
+ -- [재설계, 2026-08-21] 소멸 루프 — 조용히 파괴하지 않고 **처분을 묻는다**.
+ -- 아래 "`KeyGone`" 절이 소스.
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
if not seen[key] then
- local prev = mounted[key]
- if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴
- mounted[key], userdata[key] = nil, nil
+ local prev = mounted[key] or self._detached[key]
+ local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key])
+ if result == None then result = nil end
+ local detach = (result == Detach)
+ if detach then result = nil end
+ if result ~= nil and result == prev then
+ error("Slot:List — KeyGone에 prev를 그대로 반환할 수 없음(키가 없는데 마운트 유지는 모순). "
+ .. "계속 들고 있으려면 Detach를 반환할 것")
+ end
+ settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함)
+ userdata[key] = ud -- 유저가 nil을 반환해야 지워짐
end
end
- keyIndex = newKeyIndex
+ keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다**
end
local data = self._listData
@@ -1229,8 +1277,10 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
같이 GC됨(아래 "구독 시점" 절).
-- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/
- `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
+- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawMove`와, 처분 헬퍼
+ `releaseElement`(→ `rawRemove` 또는 `rawUnmount`)/`rawDetach`**
+ (**[2026-08-21]** `Detach` 확정으로 `rawDetach`가, `Owned` 확정으로
+ `releaseElement`가 추가됨 — 처분 분기가 한 군데(`settle`)로 모였다) (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을
**부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고,
값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이
@@ -1262,40 +1312,114 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 |
|---|---|---|
-| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 |
-| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
-| `Detach` | **언마운트만** + 재사용 대기 | 아래 |
-| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) |
+| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(위 "`Owned` 옵션" 절) |
+| `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
+| `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** |
+| `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 |
+| 키가 데이터에서 사라짐 | **`updateFn`에게 `KeyGone`으로 처분을 묻는다** | **[재설계, 2026-08-21]** 아래 "`KeyGone`" 절 |
**`Detach` — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.**
`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번
-파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`와 함께 userdata를
-반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
+파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`를 반환**하면 그
+자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
```lua
-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다
-return Detach, { old = prev, source = ... }
+return Detach
```
-- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐
- `userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의
- `old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`이
- 같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내
- 그대로 반환하면 **재마운트**된다(`rawAdd` 경로).
-- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기
- 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다.
-- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서
- 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를
- **둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미
- `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는
- **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이
- 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고
- 못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다.
- **세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번):
- (a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴,
- (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침),
- (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다.
- **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지.
+**[정정, 2026-08-21] 여기에 userdata를 딸려 보낼 필요는 없다.** 옛 서술은
+`return Detach, { old = prev }`처럼 사용자가 직접 붙잡는 모양이었으나, 보존
+주체가 `slot._detached`로 바뀌면서 다음 사이클에 그 요소가 **`prev`로 그대로
+전달**된다 — 그냥 `return prev`하면 재마운트된다. 바로 아래 절.
+
+#### ⭐ Detach된 요소는 `slot._detached`가 보유한다 — `userdata`가 아님 (2026-08-21 확정)
+
+**[전면 정정]** 옛 서술은 *"보존 주체는 `userdata`"* 였다 — `updateFn`이
+`return Detach, { old = prev }`처럼 자기 `ud`에 담아 붙잡고, 다음 사이클에
+그걸 꺼내 반환하면 재마운트된다는 모양. **이건 최종 처분이 불가능해서
+폐기**한다(사용자 확정: *"Detach 요소는 slot 안에 보관하는게 내 생각이였어서
+… ud 에 넣는거로는 최종 처분이 불가하다에 동의함"*).
+
+**왜 `userdata`로는 안 되나 — 세 가지**:
+
+1. **`:List`가 안을 못 들여다본다.** `userdata`는 계약상 완전히 opaque다
+ (아래 "`userdata`의 생명주기 제약" 절) — 파괴할 시점이 와도 **뭘 죽여야
+ 하는지 알 방법이 없다.**
+2. **소유권이 붕 뜬다.** detach된 요소는 여전히 이 Slot이 관리 중이어야
+ 남이 못 가져간다(`elementOwner`). `ud`에만 있으면 Slot 쪽엔 아무 기록이
+ 없다.
+3. **파괴 walk가 안 닿는다.** `destroySlotTree`는 `_elements`를 훑는데
+ detach된 요소는 거기서 이미 빠졌다 — `ud` 안은 walk 대상이 아니다.
+
+**그리고 이건 "GC가 언젠가 치우겠지"로 넘어갈 수 없다.** quad는 자기가 만든
+Instance마다 gcconn을 걸고 **그 클로저가 `inst`를 캡처**하므로("참조를 놓는
+것만으로는 회수되지 않고 반드시 `Destroy`로 회수된다",
+`base/lifecycle-pattern.md`의 "(0)" 절), `Parent = nil`인 detach 노드는
+**아무도 안 들고 있어도 자기 시그널 커넥션이 자기를 살려 영원히 남는다.**
+GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다.
+
+**확정된 형태**:
+
+- **`slot._detached: {[key]: T}` — Slot 필드.** 클로저 업밸류가 아니라
+ 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. (`mounted`/`userdata`/
+ `keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 이유 —
+ **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.)
+- **소유권은 유지된다** — `Detach`는 `rawUnmount`(소유권 반납)가 아니라
+ `rawDetach`(**소유권 유지**)를 쓴다. 아래 "raw 3형제" 참고.
+- **`prev`로 그대로 돌려준다** — reconcile이 `mounted[key] or self._detached[key]`를
+ `prev`로 넘기므로, **`updateFn`이 `ud`로 붙잡을 필요가 없다.** 그대로
+ 반환하면 재마운트된다.
+- **이미 detach인데 또 `Detach`를 반환하면 nop**(사용자 확정) — 상태가
+ 그대로 유지될 뿐 아무 일도 안 일어난다. 매 사이클 filter에 걸리는 흔한
+ 경로라 여기서 뭔가를 반복하면 안 된다.
+- **`ud`에 담는 것 자체는 여전히 자유** — 다만 **보존을 위해 담을 필요가
+ 없어졌고**, 담더라도 그건 사용자 몫이라 `:List`가 정리해주지 않는다
+ (아래 "`userdata`의 생명주기 제약" 절).
+
+```lua
+-- filter에서 걸러진 아이템 — 그냥 Detach만 반환하면 된다
+if not shouldShow(item) then
+ return Detach, ud -- slot._detached가 붙잡아둠, ud로 홀드할 필요 없음
+end
+-- 다시 보여야 하면 prev를 그대로 반환 → 재마운트
+if prev then return prev, ud end
+```
+
+#### `KeyGone` — 키가 데이터에서 사라질 때 처분을 묻는다 (2026-08-21 신설)
+
+**옛 갭**: 소멸 루프가 `mounted[key]`/`userdata[key]`를 조용히 지웠는데,
+`Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 `nil`이라 파괴 대상이
+아니었다 — **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겼다**
+(그리고 위 gcconn 때문에 GC도 안 됐다). 사용자 판정으로 **`updateFn`에게
+한 번 더 물어보는 것**으로 해소:
+
+```lua
+updateFn(item: T | KeyGone, index, offset, prev, ud)
+```
+
+- **`KeyGone`은 `Detach`/`None`과 같은 급의 sentinel**이고, 공개 표면 위치도
+ 같다(패키지 최상위 export, 정의는 Slot 관련 파일 옆 — 아래 `Detach`의
+ "공개 표면 위치 확정" 항목과 동일한 근거).
+- **`updateFn`은 `if item == KeyGone then ... end`로 분기**해 처분을 고른다 —
+ 반환값 의미는 정상 사이클과 **완전히 같다**(`nil`=파괴, `Detach`=계속 홀드,
+ 새 값=교체). 그래서 reconcile의 처분 로직(`settle`)이 한 벌로 공유된다.
+- **`prev`를 그대로 반환하는 것만 `error`** — 키가 없는데 마운트를 유지하라는
+ 건 모순이다. 계속 들고 있으려면 `Detach`를 반환해야 한다. (다른 CRUD 에러
+ 조건들과 같은 fail-fast 톤.)
+- **`index`는 `0`** — 사라진 키는 자리를 안 차지한다. `offset`/`sum`이 이미
+ 0-based 개수라 "아무 자리도 없음"이 `0`으로 자연스럽고, `index: number`라는
+ 시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을
+ 그대로 넘긴다(항상 유효).
+- **한 번만 묻는다 — 새 규칙이 필요 없다.** 소멸 루프는 **직전 사이클의
+ `keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번
+ 사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된
+ 것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시
+ 나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다.
+- **`userdata`도 유저가 정한다** — `updateFn`이 `nil`을 반환해야 지워진다.
+ 키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면
+ 전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의
+ 소유주가 죽으면 같이 죽는다. ud 도 모두 정리된다"*).
- **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고
사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더
생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가
@@ -1364,12 +1488,11 @@ Slot:Single(state, updateFn?, opts?)
- **⚠️ 이름은 `Owned`로 잠정** — `elementOwner`/`claimOwner`/`releaseOwner`와
같은 뿌리라 골랐다. 다른 가칭들과 함께 용어 정리 대기열(`question.md` 1번).
-**⚠️ 아직 안 닫힌 짝 항목** — `Detach`로 홀드된 요소를 **어디에 보관하고
-언제 파괴하는지**(`slot._detached` 필드 + owner 죽을 때 `Effect`로 정리)와
-`KeyGone` 센티널은 별개로 확인 대기 중이다.
-`qa-request/pre-implementation-qa-round4-followup.md`의 `F-3` 절이 소스 —
-**그게 닫히기 전엔 이 절의 "파괴" 칸을 구현하지 말 것**(무엇을 파괴 대상으로
-훑을지가 거기서 정해짐).
+**[2026-08-21 짝 항목도 닫힘]** `Detach`로 홀드된 요소의 보관 위치
+(`slot._detached` 필드)와 최종 처분(owner 죽을 때 `Effect`), `KeyGone`
+센티널까지 같은 라운드에 확정됐다 — 아래 "Detach 요소는 `slot._detached`가
+보유한다"/"`KeyGone`" 절이 소스. `destroySlotTree`가 `_owned == false`면
+파괴 대신 언마운트로 빠지는 것도 그 확정의 일부.
- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가
이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가
@@ -1620,97 +1743,118 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
`qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절.
```lua
--- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
-local function attachSlot(slot, physicalTarget, ownerKey, position)
- -- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용
- -- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제
- -- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적
- -- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract
- -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
- -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
- -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
+-- quad-base, Slot.luau
+-- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot`을
+-- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/
+-- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다.
+-- 근거와 대안 비교는 `research/slot-attach-decomposition.md`.
+-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
+local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
+ -- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1)
local offsetSource = Source(0)
- Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
+ Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
- -- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 —
- -- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야
- -- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속
- -- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트
- -- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리
- -- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`를
- -- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다
- -- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가
- -- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직
- -- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅
- -- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에
- -- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를
- -- 다시 순회하며 `attachSlot`을 **또** 불러 이중 실행됨(`RC-4`).
- -- `_mounted`를 `activateList` 뒤로 미루면 이 함수 안에서 실제
- -- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 —
- -- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은
- -- `qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절.
- if slot._listed then
- activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고)
- end
+ -- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전"
+ -- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이
+ -- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음.
+ if slot._listed then activateList(slot, physicalTarget) end
- slot._mounted = true
- slot._mountedInst = physicalTarget
-
- -- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직
- -- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`가
- -- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함:
- -- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면
- -- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의
- -- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음"
- -- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가
- -- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고
- -- 이제는 안 맞아 정정.
- Dispatch.setLength(ownerKey, position, slot.Length)
-
- -- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`된
- -- 것) **및** 방금 `activateList`가 `_elements`에만 채워둔 `:List`
- -- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한
- -- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채
- -- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시
- -- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로
- -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
- -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
+ -- 자식 부기만, 재귀로 길이를 bottom-up 확정.
+ -- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는
+ -- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
for i, element in ipairs(slot._elements) do
if isSlot(element) then
- attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신
+ -- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴
+ materializeSlotTree(element, physicalTarget, slot, i)
else
- -- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
- -- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
+ -- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None,
+ -- length는 상수 1. 순서는 늘 offsetSource → setLength(C4).
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1)
- element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행
end
end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
- if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨
+ if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정
+
+ -- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
+ -- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에
+ -- 만족시킬 수 없었던 지점.
+ Dispatch.setLength(ownerKey, position, slot.Length)
+end
+
+-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요.
+local function mountSlotTree(slot, physicalTarget)
+ slot._mounted = true
+ slot._mountedInst = physicalTarget
+ -- [2026-08-21] Detach 홀드분의 최종 처분 경로 — physicalTarget이 죽을 때
+ -- `slot._detached`를 비운다(아래 "Detach 요소는 slot._detached가 보유한다" 절).
+ -- Effect가 유일한 도구인 이유: bindLifetime은 "실행해도 되는가"만 게이팅할 뿐
+ -- 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
+ slot._detachCleanup = Effect(function()
+ return function()
+ for key, element in pairs(slot._detached) do
+ if slot._owned ~= false then
+ if isSlot(element) then destroySlotTree(element) else element:Destroy() end
+ end
+ slot._detached[key] = nil
+ end
+ end
+ end)
+ bindLifetime(physicalTarget, slot._detachCleanup)
+
+ for i, element in ipairs(slot._elements) do
+ if isSlot(element) then mountSlotTree(element, physicalTarget)
+ else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행
+ end
+end
+
+-- (3) 공개 진입점 — 이름/시그니처/호출부 전부 옛것 그대로. 몸통만 두 줄.
+local function attachSlot(slot, physicalTarget, ownerKey, position)
+ materializeSlotTree(slot, physicalTarget, ownerKey, position)
+ mountSlotTree(slot, physicalTarget)
end
```
-**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서
-이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은
-`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey,
-position, slot.Length)`(위 코드)는 `slot.Length`가 `State`라 등록 즉시
-1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`의
-배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어
-안전하게 스킵되지만, **배치 밖**(예: `state` 값이 steady state에서
-반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의
-`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한
-번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면
-정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고
-최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이
-자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은
-`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함
-없음" 절.
+**왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은
+`research/slot-attach-decomposition.md`):
+
+1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가
+ 하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를
+ 포기해야 했다 — C7을 지키고 `Length = 0`으로 등록한 뒤 자기 교정하는
+ 쪽이었다. 분해하면 둘 다 성립하고, **배치 밖 재마운트의 부모 `recompute`가
+ 2회 → 1회**로 준다(아래 "해소" 참고).
+2. **순서 제약이 줄 순서가 아니라 함수 경계로 강제된다.** `RC-1`/`RC-3`/`RC-4`가
+ 전부 "한 함수 안에서 줄 순서를 잘못 잡아" 난 버그였는데, `_mounted`를 켜는
+ 코드가 `materializeSlotTree`엔 아예 없으므로 그 실수 클래스가 구조적으로
+ 사라진다. **사용자 확정 근거**: *"이게 하나의 큰 복잡한 복합 함수라 여러
+ session 간의 실수가 발생하던 부분이고 … 지금 의사코드를 건들이는 비용이,
+ 추후 실수가 누적되는 비용보다 싸다고 생각함."*
+3. **`_mounted`의 의미가 정직해진다** — 옛 코드에선 "`activateList`는 지났고
+ flush는 아직"이라는 중간 시점이었는데, 이제 문자 그대로 "mount 단계를
+ 지났는가"다.
+4. **Blocker의 범위가 정의와 일치하고, 백엔드에 seam이 생긴다** —
+ `mountSlotTree`가 부기를 안 건드리는 순수 walk라, 일괄 삽입이 유리한
+ 백엔드(웹 `DocumentFragment` 등)가 **이 함수 하나만** 갈아끼울 수 있다.
+
+**⚠️ 바뀌는 관측 가능한 동작 하나 — 물리 마운트가 "부기 완료 후 일괄"이 된다.**
+`Parent` 대입 **순서 자체는 동일**(둘 다 깊이 우선 같은 순서)하지만, 옛
+코드는 부기와 물리가 인터리브됐고 지금은 부기가 전부 끝난 뒤 물리가 몰린다.
+`Parent` 대입은 `ChildAdded`/`DescendantAdded`를 **동기 발화**시키므로 사용자
+핸들러가 이 차이를 관측할 수 있는데, **분해 쪽이 더 정확하다** — 첫
+`ChildAdded`가 뜰 때 서브트리 전체의 `Length`/`Offset`이 이미 최종값이다(옛
+코드는 `inner.Length == 0`인 미완성 스냅샷을 보여줬다). `slot.Length`
+구독자도 `0` → 최종 두 번이 아니라 최종값으로 한 번 발화한다.
+
+**✅ [해소, 2026-08-21] 옛 "좁은 엣지 케이스"는 이 분해로 사라졌다.**
+여기 있던 ⚠️ 항목(배치 밖 단독 재마운트 시 부모 `recompute`가 아직 안 굳은
+`slot.Length`로 한 번 헛도는 것)은 `setLength`가 `materializeSlotTree` 끝으로
+가면서 **처음부터 최종값**이 되어 발생 경로가 없어졌다. 트레이싱 원문은
+`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 없음" 절.
**최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:**
```lua
@@ -1726,7 +1870,7 @@ if isSlot(element) and self._mounted then
attachSlot(element, self._mountedInst, self, index)
end
-- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에
--- attachSlot될 때 위 flush 루프가 처리
+-- attachSlot될 때 materializeSlotTree/mountSlotTree의 루프가 처리
```
**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인,
@@ -1772,6 +1916,16 @@ local function unmountSlotTree(slot)
unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제
end
end
+ -- [2026-08-21] Detach 정리용 Effect도 같이 푼다 — 안 풀면 **옛
+ -- physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 Slot의
+ -- `_detached`를 파괴**한다(포탈 경로에서 실제로 터짐). 재마운트 때
+ -- mountSlotTree가 새 target에 다시 건다.
+ if slot._detachCleanup then
+ unbindLifetime(slot._detachCleanup)
+ slot._detachCleanup = nil
+ end
+ -- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고,
+ -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
slot._mounted, slot._mountedInst = false, nil
-- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면
-- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐).
@@ -1781,6 +1935,12 @@ local function unmountSlotTree(slot)
end
local function destroySlotTree(slot)
+ -- [2026-08-21] Owned=false면 이 Slot은 요소를 만든 적이 없다 — 파괴 대신
+ -- 언마운트만(위 "`Owned` 옵션" 절). `Slot:Add(state)` sugar가 그 경우.
+ if slot._owned == false then
+ unmountSlotTree(slot)
+ return
+ end
for i, element in ipairs(slot._elements) do
-- [재정정, 2026-08-20 구현 전 QA 4라운드 `C-4`] 여기서 releaseOwner를
-- 명시적으로 부르지 **않는다** — 2026-08-13 감사가 넣었던 것을 되돌림.
@@ -1791,6 +1951,17 @@ local function destroySlotTree(slot)
element:Destroy()
end
end
+ -- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에
+ -- 없으므로(rawDetach가 뺐음) 위 루프가 못 닿는다. **`dispose`가 재귀적으로
+ -- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
+ for key, element in pairs(slot._detached) do
+ if isSlot(element) then destroySlotTree(element) else element:Destroy() end
+ slot._detached[key] = nil
+ end
+ if slot._detachCleanup then
+ unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음
+ slot._detachCleanup = nil
+ end
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
if bk then
for i, observer in pairs(bk.observers) do
@@ -1830,6 +2001,41 @@ function rawUnmount(self, index)
recompute(self, bk)
end
+-- [신설, 2026-08-21] rawUnmount의 "소유권 유지" 짝 — `Detach`가 쓴다.
+-- rawUnmount와 **딱 하나만 다름: releaseOwner를 안 부른다.**
+-- detach된 요소는 여전히 이 Slot이 들고 있으므로(slot._detached) 소유권을
+-- 놓으면 남이 가져갈 수 있게 되어 "다중 마운트 금지" 불변식이 깨진다.
+function rawDetach(self, index)
+ local element = self._elements[index]
+ local bk = getBookkeeping(self)
+ if bk.observers[index] then
+ unbindLifetime(bk.observers[index])
+ end
+ -- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
+ if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
+
+ spliceArraysDown(self, index)
+ recompute(self, bk)
+end
+
+-- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다
+-- (위 "`Owned` 옵션" 절). detach 중이던 요소는 이미 `_elements`에 없으므로
+-- spliceArraysDown/recompute가 필요 없어 경로가 갈린다.
+function releaseElement(self, element, wasDetached)
+ if wasDetached then
+ if self._owned ~= false then
+ if isSlot(element) then destroySlotTree(element) else element:Destroy() end
+ end
+ return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
+ end
+ if self._owned ~= false then rawRemove(self, element) -- 파괴
+ else rawUnmount(self, element) end -- 언마운트만(사용자 것)
+end
+
+-- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**:
+-- rawRemove : 소유권 반납 + **파괴**
+-- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로
+-- rawDetach : **소유권 유지** + 파괴 안 함 ← 내가 계속 들고 있는 경로
function rawRemove(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
@@ -2131,6 +2337,13 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로
요구되고 있으면 파괴를 거부하고 즉시 `error`.** 떼어내주지 않음 —
떼어내는 건 `Set`(언마운트)의 몫이고, `dispose`는 그 뒤에 부르는 것.
아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀).
+ **[확인 완료, 2026-08-21] Slot-in-Slot에서도 재귀가 성립한다** — 파괴 walk는
+ `destroySlotTree`이고 그게 `_elements`를 훑다 `isSlot(element)`면 재귀하며,
+ **`_detached`(Detach로 홀드 중인 것)까지 같은 함수가 훑는다**(위 코드).
+ `_owned == false`면 파괴 대신 언마운트로 빠지는 것도 그 안에 있다. 즉
+ `dispose(slot)` 한 번으로 서브트리 전체 + 홀드분 전체가 정리된다 —
+ **남는 예외는 `userdata` 안에 사용자가 직접 넣어둔 것뿐**이고, 그건
+ 위 "`userdata`의 생명주기 제약" 절대로 사용자 책임이다.
- **왜 거부가 맞는가(사용자)**: "실제로 클리어 하거나 Destroy 해도
로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요."
엔진은 조용히 넘어가지만 quad의 `_elements`/`lengthList`/`sourceList`/
diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md
index 7ff5ec3..2f51f7a 100644
--- a/.claude/qa-request/pre-implementation-qa-round4-followup.md
+++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md
@@ -1,8 +1,13 @@
# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문
-**상태**: **[2026-08-21] 3차 처리 완료 — 아래 G절이 최신.**
+**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.**
+`F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다.
+**이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지
+않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록.
+
+**[2026-08-21] 3차 처리 — 아래 G절.**
`F-4`는 전부 닫혔고(`F-4-3`은 `research/slot-attach-decomposition.md`로 넘어감),
-**남은 건 `F-3`의 (1)~(3)과 "+"뿐**(요약은 `G-3`).
+그 시점에 남았던 건 `F-3`의 (1)~(3)과 "+"였다(요약은 `G-3`, H절에서 닫힘).
**[2026-08-21] 2차 처리 — 아래 F절.**
B절은 전부 확인됐고 C절 결정도 대부분 반영됐다. **지금 열려 있는 건 F절의
@@ -1069,3 +1074,104 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
→ **여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/"+"가 한 번에 닫히고, 그때
`base/` 반영과 `attachSlot` 분해 논의를 이어서 하면 된다.**
+
+---
+
+# H절 — 4차 처리 (2026-08-21): `F-3` 전량 확인 + 함수 분해 확정, `base/` 반영 완료
+
+**입력**: 사용자 회신 — "Detach 요소는 slot 안에 보관하는게 내 생각이였어서
+(2) 제안에 동의. ud 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach
+인데 또 detach 를 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를
+사용하게 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘.
+그런데 함수 분해는 확정해도 좋을것 같음. 이게 하나의 큰 복잡한 복합 함수라
+여러 session 간의 실수가 발생하던 부분이고, 지금 적절한 방향으로 이동하지
+않으면 계속 실수에 의한 시간/기술비용이 축적될것 같음. 지금 의사코드를
+건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함."
+
+이걸로 **`G-3`의 `F-3` (1)~(5)와 `G-2`의 분해 결정이 한 번에 닫혔다.**
+아래는 실제로 `base/`에 들어간 것.
+
+## H-1. `Detach` 보존 주체 — `userdata` → `slot._detached` (확정·반영)
+
+- `base/slot-plan.md`에 **"Detach된 요소는 `slot._detached`가 보유한다"** 절
+ 신설. 옛 서술(보존은 반환한 `userdata`가 담당)은 그 자리에서 정정.
+- **raw 3형제로 분화** — `rawRemove`(소유권 해제 + 파괴) /
+ `rawUnmount`(소유권 해제 + 파괴 안 함, 요소가 떠남) /
+ **`rawDetach`(소유권 **유지** + 파괴 안 함, 내가 계속 들고 있음)**.
+ `Detach` 경로는 `rawDetach`를 쓴다 — 소유권을 놓으면 `destroySlotTree`가
+ 못 줍는다.
+- **재-`Detach`는 nop**(이미 `_detached`에 있으면 아무것도 안 함),
+ **`prev`를 그대로 반환하면 재마운트**(`_detached`에서 빼고 `rawAdd`).
+ 둘 다 `settle()` 안에서 `wasDetached` 분기로 처리 — 정상 사이클과 소멸
+ 루프가 같은 함수를 공유하므로 두 경로가 갈라질 수 없다.
+- `releaseElement(self, element, wasDetached)` — detached 상태에서 처분될
+ 땐 이미 소유권을 들고 있으므로 `rawRemove`가 아니라 바로 파괴
+ (`_owned ~= false`일 때).
+
+## H-2. `KeyGone` 센티널 (확정·반영)
+
+- 키가 데이터에서 사라진 자리는 조용히 처분하지 않고
+ **`updateFn(KeyGone, 0, offset, prev, userdata[key])`로 한 번 더 묻는다.**
+ 반환은 정상 사이클과 같은 `settle()`로 흘린다.
+- **`index`는 `0`**(자리가 없어졌으므로 유효한 인덱스가 없음),
+ **`KeyGone`에 `prev`를 그대로 반환하면 `error`**(자리 없는 요소를 유지할
+ 방법이 없음) — `G-3`의 추천 그대로 확정.
+- **다시 안 묻기는 자동 성립** — 소멸 루프가 이전 `keyIndex`만 돌기 때문.
+- owner가 죽을 때의 최종 처분은 `mountSlotTree`가 거는 `Effect`의 cleanup이
+ `slot._detached`를 전부 비우는 것으로 담당(`_owned == false`면 파괴 안 함).
+
+## H-3. `Owned` 옵션 (확정·반영)
+
+`Detach`(사이클 단위, "내 건데 잠깐 빼둠")와 `Owned`(설치 단위, "애초에 내
+게 아님")는 **직교하는 축**이라는 정리가 여기서 확정됐다. `:List`/`:Single`의
+설치 시점 플래그(기본 `true`)이고, `false`면 어떤 경로로도 파괴하지 않고
+언마운트만 한다 — `state`처럼 사용자가 만들어 넘긴 요소용.
+`C-2`의 "`:List`의 값 교체 파괴가 `state` 의미론과 충돌"이 이걸로
+닫힌다(값 교체는 `Owned = true`면 파괴가 맞다 — `updateFn`이 만든 걸 자기
+손으로 못 지우면 새는 쪽이 된다).
+
+## H-4. `attachSlot` 분해 (확정·반영)
+
+`research/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를
+`base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**:
+
+- **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만.
+ `Offset` 설치 → `activateList`(`_mounted`는 아직 `false`) → Blocker `On`
+ → 자식 재귀/`setLength` → `OffWithoutEmit` → `recompute` →
+ **마지막에 `setLength(ownerKey, position, slot.Length)`**.
+- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과
+ `_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요.
+- **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부
+ 전부 그대로라 다른 문서의 참조가 안 깨진다.
+
+이걸로 **C6("부모에게 미는 길이는 최종값이어야 한다")와 C7("부기가 물리보다
+먼저 끝난다")가 처음으로 동시에 만족된다** — 한 함수 안에서는
+`setLength` 슬롯이 하나뿐이라 원리적으로 불가능했던 조합이다. 부수로 배치
+밖 재마운트의 부모 `recompute`가 2회 → 1회.
+
+**사용자가 우려한 관측 가능한 차이**("일자 진행 vs 관측 이후 일괄 등록")는
+**`Parent` 대입 순서 자체는 안 바뀌고**, `ChildAdded` 핸들러가 볼 때 서브트리
+부기가 이미 최종값이라는 점만 바뀐다 — 옛 코드는 미완성 스냅샷을 보여줬으므로
+**엄밀히 더 정확해지는 방향**이다. 분석 원문은 그 문서의 7절.
+
+## H-5. `SL-38` userdata 제약 보강 (반영)
+
+`userdata`에 "GC만으로 정리되는 값만" 담으라는 제약의 예시에 **quad-제작
+Instance**를 추가했다 — gcconn 트릭 때문에 참조를 놓아도 회수되지 않아
+`Destroy` 없이는 영구 누수다. 기존 예시가 `:Subscribe()` Observer뿐이라
+Instance는 안전해 보였다.
+
+## H-6. `ROADMAP.md` 정합 (반영)
+
+M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠️ 미결)를
+그대로 서술하고 있어 정정했고, Slot-in-Slot 항목에 분해 결과를 반영했다.
+"값 교체와 `Detach` 경로만 파괴 안 함"도 `Owned` 기준으로 재정정.
+
+## H-7. 남은 것
+
+- **`question.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다.
+- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는
+ 것으로 끝낼 수 있어보임"(D절 회신).
+- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
+ `table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
+ `luau-test/STATUS.md`.
diff --git a/.claude/question.md b/.claude/question.md
index 8164ad1..bd21959 100644
--- a/.claude/question.md
+++ b/.claude/question.md
@@ -164,18 +164,18 @@
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
-- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던
- 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
- `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
- `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고
- 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
- `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의
- `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
- 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
- 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
- 필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
- 기본" 절.
-- **[신설, 2026-08-21 구현 전 QA 4라운드] `attachSlot` 책임 분해** — 한
+- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** —
+ 선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을
+ 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
+ **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
+ 닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이
+ 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
+ **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의
+ "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.
+- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**,
+ `base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은
+ `attachSlot`). 근거 기록은 `research/slot-attach-decomposition.md`.
+ 아래는 그 열려 있던 시점의 서술: 한
함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치
게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의
최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에
diff --git a/.claude/research/slot-attach-decomposition.md b/.claude/research/slot-attach-decomposition.md
index a751e4e..5c1af7c 100644
--- a/.claude/research/slot-attach-decomposition.md
+++ b/.claude/research/slot-attach-decomposition.md
@@ -1,7 +1,18 @@
# `attachSlot` 책임 분해 — 확장 논의 준비 자료
-**상태**: research — **논의 전 준비 자료.** 아무것도 확정하지 않았고, 다음
-논의가 바로 시작될 수 있게 **지금 무엇이 얽혀 있는지**를 한 곳에 모은 것.
+**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`에
+반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
+설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` /
+`mountSlotTree` / 얇은 `attachSlot`)이 소스다.
+
+**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게
+하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금
+적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것
+같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고
+생각함."*
+
+아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전
+준비 자료"였다.
**왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를
flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가
@@ -9,8 +20,8 @@ flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자
attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게
문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."*
-정본은 여전히 `base/slot-plan.md`의 "재귀 메커니즘" 절 —
-**이 문서가 그 확정을 대체하지 않는다.**
+정본은 `base/slot-plan.md`의 "재귀 메커니즘" 절 — **이 문서가 그 확정을
+대체하지 않는다.**
---
diff --git a/.claude/session-summary.md b/.claude/session-summary.md
index 19b7b05..073d540 100644
--- a/.claude/session-summary.md
+++ b/.claude/session-summary.md
@@ -1609,3 +1609,47 @@ Luau 함정을 발견해 `typing-limits.md` §6으로 승격.
(`type function`은 outer local 참조 불가, cross-package엔 `export type
function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로
구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋.
+
+## 2026-08-19 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
+
+원문: `session/2026-08-19-04-qa-round4-questionnaire.md`
+
+사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 …
+서브에이전트는 쓰지 말아줘")으로 `base/` 확정 주장 전체를 한 맥락에서 읽으며
+"예가 나와야 정상인 문항"으로 전수 문항화. **설계 결정도 정정도 하나도 안
+내리고** 문항지(`qa-request/pre-implementation-qa-round4.md`)만 남긴 채 회신
+대기로 끝난 세션.
+
+## 2026-08-19 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소
+
+원문: `session/2026-08-19-09-handover-prep-roadmap-status-sync.md`
+
+`quad-roblox-types` 언급 확인 요청에서 시작했으나 훨씬 큰 공백 둘을 발견 —
+이 색인에 당일 세션 5개(04~08)가 통째로 빠져 있었고,
+`CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 시작 전"이라는
+낡은 전제를 깔고 있었다(실제로는 M0/M1이 이미 완료·커밋됨). 둘 다 즉시
+반영하고 감사 라운드로 재검증.
+
+## 2026-08-20 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
+
+원문: `session/2026-08-20-01-qa-round4-response-processing.md`
+
+업스트림 12커밋(pesde 전환, mise/selene, RunInit 재설계, quad-types/
+type-version-check 신설, M0 스파이크)을 먼저 rebase로 병합한 뒤, 사용자
+회신을 (a) 바로 반영 / (b) 설명 보강 후 재질문 / (c) 사용자 판단 필요 /
+(d) 조사해서 답이 나옴으로 갈라 (a)만 `base/`에 반영. 나머지는
+`pre-implementation-qa-round4-followup.md`로 정리해 남김.
+
+## 2026-08-21 — `Detach` 보존 주체/`KeyGone`/`Owned` 확정, `attachSlot` 분해
+
+원문: `session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md`
+
+QA 4라운드의 마지막 열린 항목이 닫힌 세션. gcconn 트릭 때문에 detach된
+quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체를
+`userdata` → **`slot._detached` 필드**로 뒤집고, 키 소멸 처분을 **`KeyGone`
+센티널**로 확정(재-`Detach`는 nop, `prev` 반환은 재마운트). `Detach`(사이클
+단위)와 **`Owned`**(설치 단위)를 직교 축으로 분리해 `state` 의미론
+충돌도 해소. 같이 **`attachSlot`을 `materializeSlotTree`+`mountSlotTree`로
+분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수
+안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를
+건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정.
diff --git a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md
new file mode 100644
index 0000000..3f1b778
--- /dev/null
+++ b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md
@@ -0,0 +1,139 @@
+# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해
+
+**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로
+전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` +
+`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가
+확정돼 `base/slot-plan.md`에 반영됐다.
+
+**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
+처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`의
+H절, 분해 근거는 `research/slot-attach-decomposition.md`. 이 파일은 그
+결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다.
+
+---
+
+## 1. 발단 — `F-3`을 제안하게 된 관측
+
+3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이
+남아 있었다.
+
+1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`이
+ `Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가
+ 담당"한다고 확정해뒀다.
+2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번,
+ `ROADMAP.md` M6에 ⚠️로 박혀 있었음).
+
+2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 —
+`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는
+`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과
+`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.** 즉
+detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면
+**GC 폴백이 아예 없이 영구 누수**다. `userdata`는 `:List`에게 opaque라
+"이 안에 뭐가 들었으니 죽여라"를 알 수 없다.
+
+그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가
+죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번
+더 묻는다.
+
+## 2. 사용자 회신 — 두 가지를 더 얹었다
+
+> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud
+> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를
+> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게
+> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."
+
+여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 →
+재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가
+`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는
+따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로
+돌려주니까 사용자 코드가 `old`를 `ud`에 담아 다니지 않아도 된다.
+
+## 3. 반영 — `settle()`이 두 경로를 강제로 합친다
+
+구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를
+공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가
+`wasMounted`/`wasDetached`를 함께 보고 분기한다:
+
+- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동.
+ 이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop.
+- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트.
+- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`.
+
+두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로
+났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"**
+계열이었다.
+
+`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) /
+**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 —
+"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`는
+정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이
+안 됐다.
+
+## 4. `Owned` — `Detach`와 직교하는 두 번째 축
+
+`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state` 의미론과 충돌)를
+닫은 것이 이 축이다. 정리하면:
+
+- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠."
+- **`Owned`** = 설치 단위. "애초에 내 게 아님."
+
+이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는
+`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`가
+안 지우면 아무도 못 지운다). 사용자가 `state`에 담아 넘긴 요소는
+`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다.
+
+## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정
+
+3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게
+재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."
+
+핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다:
+
+- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는
+ flush가 끝나야 정해지는 **최종값**이어야 한다.
+- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.
+
+한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회
+헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다.
+
+사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:
+
+> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가
+> 많아질 위험이 보임."
+
+> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던
+> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한
+> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후
+> 실수가 누적되는 비용보다 싸다고 생각함."
+
+이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지
+방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다** —
+이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그
+클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다.
+분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다.
+
+### 사용자가 스스로 답한 우려
+
+같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:
+
+> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고...
+> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건
+> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이
+> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한,
+> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운
+> 함수를 만드는건 옳지 않아보임."
+
+확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건
+`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
+점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
+동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
+`research/slot-attach-decomposition.md` 7절.
+
+## 6. 이 세션이 안 한 것
+
+- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로
+ 끝낼 수 있어보임"에 따름.
+- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`,
+ `table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
+- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
+ **일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
diff --git a/.claude/todos.md b/.claude/todos.md
index 7ce1437..cfbe457 100644
--- a/.claude/todos.md
+++ b/.claude/todos.md
@@ -5,21 +5,19 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등).
-00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영
- 완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.**
+00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~4라운드 전부 `base/`
+ 반영 완료로 종결. 열린 항목 없음.**
- **4라운드 — [2026-08-21] 회신 3차 처리 완료, `F-3` 일부만 열려 있음.** 문항지는
+ **4라운드 — [2026-08-21] 종결.** 문항지는
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
- `-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`가
- 소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서
- `base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고,
- C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다:
- `F-3`의 **(1)~(3)과 "+"**(detached 요소를 `slot._detached` 필드가 들고
- owner 죽을 때 `Effect`로 정리 / `KeyGone` 세부 / `dispose` 재귀) — 요약은
- followup `G-3`. `F-3`의 `Owned` 설치 플래그와 `F-4` 셋은 전부 닫혔고,
- `F-4-3`(`setLength` 위치)은 **`attachSlot` 책임 분해**라는 더 큰 논의로
- 넘어가 `research/slot-attach-decomposition.md`에 준비 자료를 만들어뒀다
- (M6 착수 전 필요). 아래는 그 회신 전 서술:
+ `-response.md`, 처리 결과 전량은 **`-followup.md`가 소스**(여기서 목록을
+ 세지 않음 — 마지막 H절이 최신). 4차 처리로 `F-3`이 전량 확인되며
+ **`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**,
+ **`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
+ (`materializeSlotTree` + `mountSlotTree`, 근거는
+ `research/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
+ **5라운드 문항지는 만들지 않는다** — 사용자 지시("이후 stale 만 잡는것으로
+ 끝낼 수 있어보임"). 아래는 그 회신 전 서술:
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
@@ -86,12 +84,10 @@
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
위치도 `None`과 같은 최상위 export로 같이 확정). 원문은
`archive/question-resolved.md`.
- - **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분**
- (`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고
- 참조만 끊김. **[정정, 2026-08-18 `/code-review high` — `ROADMAP.md`의
- M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수
- 전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥
- 지나칠 위험이 있었음.
+ - **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 —
+ **`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는
+ `slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리.
+ `base/slot-plan.md`의 "`KeyGone`" 절.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
diff --git a/ROADMAP.md b/ROADMAP.md
index 58d0344..8d6aae2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -429,7 +429,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
- `:List`의 `reconcile` 중 **값 교체와 `Detach` 경로만**.
+ `:List`의 `reconcile` 중 **`Owned = false` 설치와 `Detach` 경로**
+ (**[재정정, 2026-08-21]** 값 교체는 `Owned = true`면 파괴가 맞다 —
+ `updateFn`이 만든 걸 자기 손으로 못 지우기 때문. `state`
+ 의미론은 `Owned = false`가 담당).
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
@@ -513,16 +516,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
저비용 재사용 경로.
- **[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** —
- `updateFn`이 `Detach, { old = ..., source = ... }`를 반환하면 그
- 자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고,
- 보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내
- 반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용
- 경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/
- slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절).
- **⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를
- 어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론
- 필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
+ **[2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정]
+ `Detach` 반환 경로** — `updateFn`이 `Detach`를 반환하면 그 자리는
+ **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠진다.
+ **보존 주체는 `userdata`가 아니라 `slot._detached` 필드**(Slot 필드여야
+ `destroySlotTree` walk가 닿고 소유권도 유지됨 — `ud`로는 최종 처분이
+ 불가능). reconcile이 `prev`로 그대로 돌려주므로 `ud`에 담을 필요 없이
+ **그대로 반환하면 재마운트**되고, 이미 detach 상태에서 또 `Detach`면
+ **nop**. 언마운트는 소유권을 유지하는 `rawDetach`를 씀(`rawUnmount`
+ 아님). `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. 공개
+ 표면은 `None`과 같이 패키지 최상위 export(`base/slot-plan.md`의
+ "Detach된 요소는 `slot._detached`가 보유한다" 절).
+ **[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
+ `KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로
+ 한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건
+ `Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
+ **`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
+ `true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
+ `state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저,
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가
`prev`보다 앞이었음).
**`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열
@@ -588,8 +599,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
- 해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
- Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
+ 해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
+ **[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
+ `materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
+ `mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
+ 순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
+ 전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
+ 먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
+ `recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
+ 경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
+ 나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
+ `research/slot-attach-decomposition.md`.
+ **관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
+ "부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
+ `Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상