qa: 반영 후 감사 6라운드 — 실제 크래시 3건 포함 18건 수정

커밋 9b7f847(Detach/KeyGone/Owned/attachSlot 분해) 반영 후 각도를 바꿔가며
quad-doc-auditor를 6라운드 돌린 결과. 라운드별 확실 발견 4/6/2/3/3/0으로
6라운드에서 새 발견 0건 — 수렴 확인 후 종료. 경위 전량은
qa-request/pre-implementation-qa-round4-followup.md의 I절이 소스.

트레이싱 라운드(4~5)가 잡은 실제 크래시 — 셋 다 Detach가 신설한 경로가
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것:

- I-1 (치명): rawDetach가 소유권을 유지하는데 재마운트는 rawAdd →
  claimOwner를 거치고, claimOwner는 같은 owner의 재클레임도 무조건 error다
  (2026-08-13 감사가 Slot{a,a}를 막으려고 넣은 것). 문서가 권장하는
  "prev를 그대로 반환하면 재마운트" 패턴이 그대로 죽었음. fromDetached
  플래그로 그 경로만 좁게 예외 처리.
- I-2: 재마운트된 자식 Slot이 activateList를 두 번 실행해 구독이 이중으로
  생기고 mounted/keyIndex 클로저 상태가 통째로 새로 만들어짐 → 멱등 가드.
  가드만으로는 :List 구독이 옛 physicalTarget에 앵커된 채 남아 포탈
  재마운트 후 조용히 멈추므로, _listObserver 핸들 보관 + 재앵커까지 처리.
- I-3: _detachCleanup이 releaseOwner를 안 불러 Owned=false 요소가 죽은
  Slot을 owner로 달고 남음 → 두 분기 공통으로 호출.
- I-6: 위 수정의 회귀 트레이싱 — destroySlotTree에 _listObserver 해제 누락,
  claimOwner의 옛 논증 두 문단이 fromDetached와 정면 모순, 소유권 예시
  코드가 C-4와 모순.
- I-7: 사용자가 별도 상의해 가져온 두 건 — _detachCleanup 설치를
  mountSlotTree → activateList로 이관(:List 없는 Slot마다 no-op Effect를
  트리 크기만큼 심고 있었음), activateList의 inst → physicalTarget 리네이밍.
  이관 근거가 멱등 가드 이전 동작을 전제하고 있어 가드 분기의 재앵커까지
  같이 반영. 이로써 _listObserver/_detachCleanup이 같은 범주로 통일됨.

I-4(materializeSlotTree 중 예외 시 Blocker 잔류)는 사용자 판단으로 pcall
없이 문서화만 — 아직 밟은 적 없는 경로이고 옛 단일 attachSlot에도 있었을
구조적 갭.

문서 정합성 라운드(1~3)에서 나온 것: slot-plan의 "값 교체는 비파괴" 잔존,
분해 완료 후에도 남아 있던 "논의 대기 중" 배너, attachSlot의 flush 루프를
가리키던 문장 5곳, README 색인 행이 2026-08-19에서 멈춰 있던 것,
qa-round4 문항지/followup의 "회신 대기" 상태줄, todos의 용어 목록 이중 소스,
dispatch-core의 raw* 일반 계약에 rawDetach 누락.

luau-test: 스파이크 01이 "재작성 필요" 마커를 단 채 done/에 남아 있어
STATUS.md 자신의 "폴더가 곧 상태" 규칙을 어기고 있었음 → rewrite-required/로
이동하고 개수 정정. "만들어야 할 스파이크" 절 신설(아직 파일조차 없는 실측
항목이 어느 폴더로도 표현되지 않아 구조적으로 잊히던 자리).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-21 13:20:42 +09:00
parent 9b7f847014
commit 4622fbeec8
Signed by: qwreey
GPG key ID: D28DB79297A214BD
12 changed files with 508 additions and 89 deletions

File diff suppressed because one or more lines are too long

View file

@ -1592,8 +1592,8 @@ end
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 **해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: 자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기 1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가
신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그 기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작 owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
@ -1611,7 +1611,7 @@ end
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
배치 중이라도 항상 최신값을 보게 됨. 배치 중이라도 항상 최신값을 보게 됨.
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()` `materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, 호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위 `ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
@ -1700,7 +1700,10 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
- **각 연산에 적용하면**: - **각 연산에 적용하면**:
- `rawAdd``Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) → - `rawAdd``Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) →
`element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단). `element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단).
- `rawRemove`/`rawUnmount` — 파괴/언마운트 → `spliceArraysDown` - `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
`spliceArraysDown`
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과 `recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직 어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는 트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는

View file

@ -297,11 +297,23 @@ local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak
-- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고) -- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고) local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고)
-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error -- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error.
local function claimOwner(element, ownerKey) -- [2026-08-21 감사] 예외 하나: **detach 재마운트**. `rawDetach`가 소유권을
if elementOwner:GetWeak(element, OWNER) ~= nil then -- 일부러 유지하므로(`slot._detached`가 계속 들고 있음), 다음 사이클에
-- `prev`를 그대로 돌려주는 문서 권장 패턴이 여기서 무조건 죽고 있었다.
-- top-level `claimOwnerAt`이 이미 "같은 (inst,k) 자리의 재발행은 통과"라는
-- 같은 모양의 예외를 갖고 있어 대칭이 맞는다.
local function claimOwner(element, ownerKey, fromDetached)
local cur = elementOwner:GetWeak(element, OWNER)
if cur ~= nil then
-- 내가 계속 들고 있던 요소를 내가 다시 넣는 경우만 통과.
-- `fromDetached` 없이 같은 owner라는 것만으로 통과시키면
-- `Slot{a, a}`가 다시 조용히 새어나간다(2026-08-13 감사가 막은 것).
if not (fromDetached and cur == ownerKey) then
error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지") error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지")
end end
return -- 이미 내 것이므로 SetWeak도 불필요
end
elementOwner:SetWeak(element, OWNER, ownerKey) elementOwner:SetWeak(element, OWNER, ownerKey)
end end
@ -411,7 +423,20 @@ end
이 소유권 논증 자체는 그대로 성립), 이 소유권 논증 자체는 그대로 성립),
`rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가 `rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가
갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건 갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건
error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로 error가 맞음**.
**⚠️ [정정, 2026-08-21] 이제 그 경로가 정확히 하나 생겼다 — `Detach`
재마운트.** `rawDetach`는 일부러 `releaseOwner`를 안 부르므로(소유권 유지가
`Detach`의 핵심), `settle`의 재마운트 분기는 `rawUnmount`를 거치지 않고
곧바로 `rawAdd`를 부른다 — 위 문단이 "없다"고 단정한 바로 그 모양이다.
그래서 `claimOwner`**`fromDetached` 플래그가 참일 때만** 통과하는 좁은
예외를 뒀다(위 그 함수의 정의). **플래그 없이 "같은 owner면 통과"로
완화하면 안 된다** — 이 절이 애초에 막으려던 `Slot { a, a }`가 다시
새어나간다. 나머지 논증(top-level은 `claimOwnerAt`으로 구분)은 그대로
유효하다. 경위는 `qa-request/pre-implementation-qa-round4-followup.md`
`I-1`.
반대로 top-level은 store 재발행마다 같은 Slot으로
`process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고, `process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고,
그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함. 그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함.
@ -451,14 +476,17 @@ Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
봄: 봄:
```lua ```lua
-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리 -- rawAdd(self, element, index, fromDetached?) 안, "이미 마운트" 에러 체크 자리
claimOwner(element, self) -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error claimOwner(element, self, fromDetached) -- self = 담는 Slot. 이미 누가(같은 self
-- 포함) 소유 중이면 error — 단 detach
-- 재마운트만 예외(위 그 함수)
-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리 -- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
releaseOwner(element, self) releaseOwner(element, self)
-- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(아래 "파괴" 절) -- [삭제됨, 2026-08-20 `C-4`] destroySlotTree에는 명시적 releaseOwner가 없다 —
releaseOwner(element, slot) -- 2026-08-13 감사가 넣었던 걸 되돌렸다(자식이 어차피 죽으므로 불필요).
-- 아래 "소유권 반납은 GC에 맡기면 안 됨" 절의 재정정이 소스.
``` ```
**[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.** **[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.**
@ -470,6 +498,9 @@ releaseOwner(element, slot)
바뀌었으나 둘 다 `releaseOwner`를 부르므로 논증 동일, `rawMove`/`rawSwap`은 바뀌었으나 둘 다 `releaseOwner`를 부르므로 논증 동일, `rawMove`/`rawSwap`은
클레임 미접촉) 무조건 error가 맞음 — 클레임 미접촉) 무조건 error가 맞음 —
top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함.
(**[정정, 2026-08-21]** "재클레임이 정당한 경우가 하나도 없다"는 전제는
`Detach` 재마운트 하나가 예외로 생겼다 — 바로 위 ⚠️ 문단이 소스.
`claimOwner`가 반환값 없이 error만 낸다는 이 항목의 결론 자체는 그대로다.)
**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/ **[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/
`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화, `rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화,
@ -1116,7 +1147,30 @@ end
-- Dispatch/Slot.luau의 process(inst,k,self,index)가 마운트 시점에 1회 호출 -- Dispatch/Slot.luau의 process(inst,k,self,index)가 마운트 시점에 1회 호출
-- (self._mounted=true/self._mountedInst=inst, self.Offset 세팅과 같은 자리) -- (self._mounted=true/self._mountedInst=inst, self.Offset 세팅과 같은 자리)
function activateList(self, inst) -- [리네이밍, 2026-08-21] 2번째 인자는 `inst`였으나 `physicalTarget`으로 통일 —
-- 옆 함수들(materializeSlotTree/mountSlotTree/attachSlot)이 쓰는 이름과 같은
-- 개념이고, 실제로 항상 물리 Instance다(bindLifetime의 첫 인자로 들어감).
-- Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다.
function activateList(self, physicalTarget)
-- [2026-08-21 감사] **멱등 가드 — 두 번째 호출은 즉시 반환.**
-- `Detach`로 뗐다 돌아온 자식 Slot이 `:List`를 갖고 있으면
-- `materializeSlotTree``slot._listed`를 보고 여기를 다시 부른다.
-- 가드가 없으면 `data:Observer(fn)` 구독이 하나 더 생기고
-- `mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져**
-- 옛 상태를 잃은 채 reconcile이 두 벌 돈다(기존 요소를 전부 새 것으로
-- 오인해 다시 그림). `_crudUsed`/`_listed`와 같은 결의 플래그.
if self._listActivated then
-- 재마운트(포탈 포함) — 클로저 상태(mounted/userdata/keyIndex)는
-- 그대로 두고 **GC 앵커만 새 target으로 옮긴다.** 이걸 안 하면
-- 자원이 옛 physicalTarget에 매달린 채로 남아, 그게 죽는 순간
-- 살아있는 이 Slot의 `:List`가 조용히 반응을 멈추고(`_listObserver`)
-- 살아있는 detached 요소가 파괴된다(`_detachCleanup`).
bindLifetime(physicalTarget, self._listObserver)
bindLifetime(physicalTarget, self._detachCleanup)
return
end
self._listActivated = true
local keyFn, updateFn = self._keyFn, self._updateFn local keyFn, updateFn = self._keyFn, self._updateFn
local offset = self.Offset local offset = self.Offset
local mounted, userdata, keyIndex = {}, {}, {} local mounted, userdata, keyIndex = {}, {}, {}
@ -1135,13 +1189,15 @@ function activateList(self, inst)
self._detached[key], mounted[key] = wasMounted, nil self._detached[key], mounted[key] = wasMounted, nil
end end
elseif result == nil then elseif result == nil then
-- "지워라". Owned=false면 파괴 대신 언마운트만( "Owned" 절). -- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절).
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
mounted[key], self._detached[key] = nil, nil mounted[key], self._detached[key] = nil, nil
elseif result == prev then elseif result == prev then
if wasDetached ~= nil then if wasDetached ~= nil then
self._detached[key] = nil -- **재마운트** — detach된 걸 되살림 self._detached[key] = nil -- **재마운트** — detach된 걸 되살림
rawAdd(self, result, pos) -- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로
-- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석).
rawAdd(self, result, pos, true)
mounted[key] = result mounted[key] = result
elseif keyIndex[key] ~= pos then elseif keyIndex[key] ~= pos then
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동 rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
@ -1211,13 +1267,43 @@ function activateList(self, inst)
keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다** keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다**
end end
-- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`
-- 모든 Slot마다 설치했으나 **여기로 옮겼다**(사용자 판단). `_detached`
-- 채우는 유일한 지점이 아래 `settle``rawDetach`이므로, `:List`가 없는
-- Slot의 `_detached`는 정의상 영원히 빈 테이블이다 — 중첩 트리 크기만큼
-- no-op Effect와 `bindLifetime` 엔트리를 심고 있었다. 이제
-- `_listObserver`와 **완전히 같은 취급**을 받는다(생성은 여기 1회,
-- 언마운트 시 앵커만 해제하고 핸들 보존, 재마운트 시 위 가드가 재앵커,
-- 파괴 시 해제+`nil`) — "`activateList`가 소유하고 물리 target에
-- 앵커되는 자원"이라는 한 범주.
-- Effect가 유일한 도구인 이유: `bindLifetime`은 "실행해도 되는가"만
-- 게이팅할 뿐 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
self._detachCleanup = Effect(function()
return function()
for key, element in pairs(self._detached) do
-- releaseOwner를 **먼저, 두 분기 공통으로**. 빠뜨리면
-- `_owned == false` 요소가 죽은 Slot을 owner로 달고 남아,
-- 사용자가 그 값을 다른 Slot에 넣을 때 GC 타이밍에 따라
-- "이미 마운트돼 있음" error가 난다 — 위 "소유권 반납은
-- GC에 맡기면 안 됨" 절이 경계하는 실패 모드.
releaseOwner(element, self)
if self._owned ~= false then
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
end
self._detached[key] = nil
end
end
end)
bindLifetime(physicalTarget, self._detachCleanup)
local data = self._listData local data = self._listData
if isState(data) then if isState(data) then
local observer = data:Observer(function() reconcile(data:Get()) end) local observer = data:Observer(function() reconcile(data:Get()) end)
self._listObserver = observer -- [2026-08-21] 재마운트 시 앵커를 옮기려면 보관 필요
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute -- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute
-- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) — -- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
-- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속 -- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
bindLifetime(inst, observer) bindLifetime(physicalTarget, observer)
else else
reconcile(data) reconcile(data)
end end
@ -1312,7 +1398,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 | | updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 |
|---|---|---| |---|---|---|
| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state<Frame>` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당( "`Owned` 옵션" 절) | | 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state<Frame>` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(아래 "`Owned` 옵션" 절) |
| `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | | `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
| `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** | | `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** |
| `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 | | `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 |
@ -1415,7 +1501,10 @@ updateFn(item: T | KeyGone, index, offset, prev, ud)
`keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번 `keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번
사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된 사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된
것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시 것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시
나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다. 나타나면 `prev`로 부활하고, (b) owner가 죽으면 `activateList`가 설치한
`_detachCleanup` Effect가 정리한다(**[이관, 2026-08-21]** 원래
`mountSlotTree`에 있었으나, `_detached`를 채우는 건 `:List`뿐이라
`:List` 없는 Slot마다 no-op Effect를 심고 있었다).
- **`userdata`도 유저가 정한다** — `updateFn``nil`을 반환해야 지워진다. - **`userdata`도 유저가 정한다** — `updateFn``nil`을 반환해야 지워진다.
키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면 키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면
전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의 전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의
@ -1690,22 +1779,25 @@ nil/None 금지)는 그대로.
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
> **🔭 [2026-08-21] `attachSlot`의 책임 분해가 확장 논의 대기 중** — 아래 > **✅ [해소, 2026-08-21] `attachSlot`의 책임 분해 — 분해로 확정, 아래
> 의사코드가 지금 정본이고 그대로 유효하지만, 이 함수 하나가 지고 있는 책임이 > 반영 완료.** 이 함수 하나가 책임을 일곱 개(부모 등록 offset/length,
> 일곱 개(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 > `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀) 지고
> 게이팅, 자식 배치, 재귀)라는 사용자 지적이 있었다 — *"attachSlot 의 기능이 > 있다는 사용자 지적에서 시작했고 — *"attachSlot 의 기능이 너무 다양해진게
> 너무 다양해진게 문제같음"*. 특히 **"부모에게 알리는 길이의 최종값은 flush가 > 문제같음"* — 진단은 **"부모에게 알리는 길이의 최종값은 flush가 끝나야
> 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 > 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 만족되지
> 만족되지 않는다.** 책임 목록·순서 제약의 출처·분해 후보는 > 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능).
> `research/slot-attach-decomposition.md`에 정리해뒀다. **M2/M3를 막지는 > **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고
> 않지만 M6(`:List`) 구현 전엔 결론이 나 있어야 한다.** > `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/
> 호출부 전부 불변. 근거 기록은 `research/slot-attach-decomposition.md`.
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 **✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 `Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md` 수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그
해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래 해법이 **`materializeSlotTree`의 등록 루프**에 어떻게 적용되는지만
코드의 `blocker` 관련 줄). 다룬다(아래 코드의 `blocker` 관련 줄) — **[2026-08-21]** 분해 전엔 이 게이팅이
`attachSlot` 본체에 있었고, 물리 마운트 쪽(`mountSlotTree`)은 Blocker가
필요 없다.
`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
@ -1716,8 +1808,8 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
`setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.** `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.**
`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출
순서는 `setOffsetSource``setLength`"** 일반 규칙(해제 시점 계약에서 순서는 `setOffsetSource``setLength`"** 일반 규칙(해제 시점 계약에서
나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속 나왔지만 등록 시점에도 그대로 적용)과 이 의사코드(**[2026-08-21]** 분해
어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게 후엔 `materializeSlotTree`)가 계속 어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각 되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각
요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset 요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset
전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length``activateList` 전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length``activateList`
@ -1777,6 +1869,16 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
Dispatch.setLength(slot, i, 1) Dispatch.setLength(slot, i, 1)
end end
end end
-- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해
-- Blocker가 켜진 채 남는다** — 그 Slot의 `recompute`가 이후 영원히
-- 게이팅돼 `Length`가 영구 stale해진다. `pcall`로 감싸지 않는 것이
-- **사용자 판단(2026-08-21)**: 마운트 도중 예외는 quad가 복구를 보장하지
-- 않는 상태이고(에러 경계는 `base/fallback-plan.md``Fallback`/
-- `Traceback`이 담당), 아직 실제로 밟은 적 없는 경로다 —
-- `conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운
-- 원칙 그대로. 실제로 물리면 그때 넣는다.
-- 참고: 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도
-- 있었을 구조적 갭이다(옛 코드도 같은 구간에 정리 코드가 없었음).
blocker:OffWithoutEmit() blocker:OffWithoutEmit()
local bk = getBookkeeping(slot) local bk = getBookkeeping(slot)
if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정 if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정
@ -1791,22 +1893,10 @@ end
local function mountSlotTree(slot, physicalTarget) local function mountSlotTree(slot, physicalTarget)
slot._mounted = true slot._mounted = true
slot._mountedInst = physicalTarget slot._mountedInst = physicalTarget
-- [2026-08-21] Detach 홀드분의 최종 처분 경로 — physicalTarget이 죽을 때 -- [이관, 2026-08-21] `_detachCleanup` Effect 설치가 여기 있었으나
-- `slot._detached`를 비운다(아래 "Detach 요소는 slot._detached가 보유한다" 절). -- `activateList`로 옮겼다 — `_detached`를 채우는 건 `:List``settle`뿐이라
-- Effect가 유일한 도구인 이유: bindLifetime은 "실행해도 되는가"만 게이팅할 뿐 -- List 없는 Slot마다 no-op Effect를 심고 있었다(위 그 함수의 주석이 소스).
-- 죽는 순간의 콜백을 안 준다(`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 for i, element in ipairs(slot._elements) do
if isSlot(element) then mountSlotTree(element, physicalTarget) if isSlot(element) then mountSlotTree(element, physicalTarget)
else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행 else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행
@ -1916,14 +2006,16 @@ local function unmountSlotTree(slot)
unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제 unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제
end end
end end
-- [2026-08-21] Detach 정리용 Effect도 같이 푼다 — 안 풀면 **옛 -- [2026-08-21] `activateList`가 소유하는 두 자원의 **앵커만** 푼다.
-- physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 Slot의 -- 안 풀면 옛 physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이
-- `_detached`를 파괴**한다(포탈 경로에서 실제로 터짐). 재마운트 때 -- Slot의 `:List`가 조용히 멈추고(`_listObserver`) `_detached`
-- mountSlotTree가 새 target에 다시 건다. -- 파괴된다(`_detachCleanup`) — 포탈 경로에서 실제로 터진다.
if slot._detachCleanup then -- **핸들과 `_listActivated`는 보존한다**(언마운트는 파괴가 아니다) —
unbindLifetime(slot._detachCleanup) -- 구독과 그 클로저 상태(`mounted`/`userdata`/`keyIndex`)는 재마운트
slot._detachCleanup = nil -- 후에도 이어져야 하고, 새 target에 다시 걸어주는 건 `activateList`
end -- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다.
if slot._listObserver then unbindLifetime(slot._listObserver) end
if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end
-- `slot._detached`**안 건드린다** — 언마운트는 파괴가 아니고, -- `slot._detached`**안 건드린다** — 언마운트는 파괴가 아니고,
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유). -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
slot._mounted, slot._mountedInst = false, nil slot._mounted, slot._mountedInst = false, nil
@ -1962,6 +2054,19 @@ local function destroySlotTree(slot)
unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음 unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음
slot._detachCleanup = nil slot._detachCleanup = nil
end end
-- [2026-08-21 감사] `:List` 구독도 푼다 — **파괴이므로 `unmountSlotTree`
-- 달리 핸들과 `_listActivated`까지 지운다.** 안 풀면 `gchold[physicalTarget]`
-- observer를(그리고 그 클로저가 붙잡은 `mounted`/`userdata`/`keyIndex`와
-- 파괴된 slot 자신을) 계속 강하게 붙잡는다 — 중첩 Slot은 아무리 깊어도
-- `physicalTarget`이 트리 최상위 inst 하나라(`materializeSlotTree`가 같은
-- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한
-- 경우**다. 그러면 `data`가 emit될 때마다 이미 죽은 자식들에 대해
-- reconcile이 계속 돈다.
if slot._listObserver then
unbindLifetime(slot._listObserver)
slot._listObserver, slot._listActivated = nil, nil
end
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들 local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
if bk then if bk then
for i, observer in pairs(bk.observers) do for i, observer in pairs(bk.observers) do
@ -2075,8 +2180,8 @@ end
동일하게 적용"*). 즉 `bk.N``Dispatch.setLength`가 이전에 본 적 동일하게 적용"*). 즉 `bk.N``Dispatch.setLength`가 이전에 본 적
없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는 없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는
건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔 건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔
`lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush `lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`materializeSlotTree`의
배치도, Slot의 런타임 단건 배치 등록도, Slot의 런타임 단건
`rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를 `rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를
물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive` 물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`
`inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은 `inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은
@ -2084,7 +2189,7 @@ end
등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은
규칙의 특수한 안정 상태다. 규칙의 특수한 안정 상태다.
- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/ - **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/
`attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker `materializeSlotTree`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker
게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) — 게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) —
그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도 그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에
@ -2458,20 +2563,25 @@ state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는
그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의 그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의
코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` +
`unbindLifetime` + `releaseOwner`를 부름. `unbindLifetime` + `releaseOwner`를 부름.
2. **`:List``reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 2. **`:List``reconcile`** — **[재정정, 2026-08-18 구현 전 QA;
비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn``nil`/`None`을 2026-08-21 `Owned` 도입으로 다시 정정]** 이 항목은 원래 "비파괴가 되는
반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 **값 교체와 `Detach`뿐**"이라고 적혀 있었는데, **값 교체 쪽은
판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. 틀렸다**. 지금 확정은 **`Detach`만 비파괴**이고, 값 교체·`nil`/`None`·
키 소멸은 전부 **`Owned` 플래그가 결정**한다(기본 `true` = 파괴,
`false` = 언마운트만). 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절의
표가 소스 — 그 표와 이 항목이 어긋나면 표가 맞다.
2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은
`:List`에는 안 맞는 일반화였음. `:List`에는 안 맞는 일반화였음.
**여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()` **여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()`
(CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의 (CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의
`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 `:List` 경로 전부(`Owned = true`일 때 — 값 교체 포함). 즉 일반 규칙은
지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 **"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되,
"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" **`:List`가 소유하는 요소는 그 규칙의 예외로 파괴가 기본이다** —
철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를 `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지우기 때문(reconcile
명시한다. 중엔 `dispose`가 거부됨). `updateFn`이 지우지 않길 원하면 `Detach`로 그
의도를 명시하고, **애초에 `:List`의 것이 아닌 요소**(`state<Frame>` 등)는
설치 시점에 `Owned = false`로 선언한다.
`unmountSlotTree``destroySlotTree`가 하는 일 중 **실제 파괴와 자식 `unmountSlotTree``destroySlotTree`가 하는 일 중 **실제 파괴와 자식
소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`, 소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`,

View file

@ -1,6 +1,11 @@
# 스파이크 상태판 — **폴더가 곧 상태** # 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>` > 마지막 갱신: **2026-08-21** — QA 4라운드 `F-4-1``Dispatch.drive`
> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
> `01`이 낡아 `done/``rewrite-required/` 이동(검증 대상인 순서 계약
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을
> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 → > 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성. > `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 > 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
@ -62,7 +67,7 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨). 아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건) ## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
@ -77,8 +82,13 @@
`10`**Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성 `10`**Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성
후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다. 후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다.
**[2026-08-21] `01`이 합류** — 같은 "설계가 바뀐" 유형이다. 통과 상태로
`done/`에 두면 이제는 구현이 안 하는 두 루프 순회를 "검증됨"으로 오독하게
된다.
| 파일 | 상태 | 무엇을 고쳐야 하나 | | 파일 | 상태 | 무엇을 고쳐야 하나 |
|---|---|---| |---|---|---|
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
@ -93,16 +103,16 @@
|---|---| |---|---|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (19건) ## ✅ `done/` — 통과 or 판정 끝 (18건)
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는 **런타임 13개 전원 통과**(crash 0 / FAIL 0 — **[2026-08-21]** `01`
`rewrite-required/`로 나가며 14 → 13) — **[열네 번째 세션] `04`/`19`는
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19] 검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13` `05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
런타임 절반, PostRef까지 확장)가 합류**: 런타임 절반, PostRef까지 확장)가 합류**:
| 파일 | 확인된 것 | | 파일 | 확인된 것 |
|---|---| |---|---|
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive`의 순서 계약과 `PreRef` 호이스팅의 전제. **⚠️ [2026-08-21 재작성 필요]** 이 스파이크는 숫자 `for` + 일반화 `for` **두 루프** 버전인데, 구현이 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — 검증 대상(순서 계약)은 그대로이고 스파이크 코드만 그 형태로 다시 쓸 것 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 | | `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 |
@ -157,3 +167,18 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
``` ```
추측이 아니라 **실제로 GC가 안 됨**`Slot`의 두-`Relate` 수정이 필수 추측이 아니라 **실제로 GC가 안 됨**`Slot`의 두-`Relate` 수정이 필수
조치였음이 입증. 조치였음이 입증.
## 🔵 만들어야 할 스파이크 — 아직 파일이 없음 (2026-08-21 신설)
폴더가 곧 상태인 이 문서에서 **"아직 파일조차 없는 실측 항목"**은 어느
폴더로도 표현되지 않아 그냥 잊혔다. 실제로 QA 4라운드 followup(H-7)이
"실측으로 남은 것"의 소스로 이 문서를 지목했는데 여기 항목이 없었다.
앞으로 이 절이 그 소스다 — 파일을 만들면 `not-run/` 또는 실행 결과에 따라
해당 폴더로 옮기고 여기서 지운다.
| 검증할 것 | 왜 | 출처 |
|---|---|---|
| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` |
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 |
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |

View file

@ -1,6 +1,8 @@
# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 # 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문
**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.** **상태**: **[2026-08-21] 4차 처리로 종결 + 반영 후 감사까지 완료 — 아래
I절이 최신.** 감사 4라운드(의사코드 트레이싱)가 **실제 크래시 3건**을 잡아
같은 날 전부 닫았다(`I-1`~`I-3`). H절이 반영 내용, I절이 그 감사 결과다.
`F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다. `F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다.
**이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지 **이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지
않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록. 않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록.
@ -373,6 +375,9 @@ observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것
### C-1. ⭐ `SL-40`/`SL-43`/`SL-45` — `KeyGone` 센티널 신설 ### C-1. ⭐ `SL-40`/`SL-43`/`SL-45` — `KeyGone` 센티널 신설
> **✅ [해소, 2026-08-21] 확정·반영 완료 — 아래 H-2가 결론이다.**
> 이 절은 그 제안의 원문이다.
**사용자 제안**: 키가 데이터에서 사라지면 `updateFn``KeyGone`으로 한 번 더 **사용자 제안**: 키가 데이터에서 사라지면 `updateFn``KeyGone`으로 한 번 더
불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임. 불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임.
이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다. 이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다.
@ -408,6 +413,9 @@ export**가 일관적이다(`None`/`Detach` 선례). 이름은 `KeyGone`이 의
### C-2. ⭐⭐ `SL-43` vs `SL-51` — "밀려난 `prev`는 dispose"와 `state<Frame>` 의미론이 충돌한다 ### C-2. ⭐⭐ `SL-43` vs `SL-51` — "밀려난 `prev`는 dispose"와 `state<Frame>` 의미론이 충돌한다
> **✅ [해소, 2026-08-21] `Owned` 설치 플래그로 확정 — 아래 H-3이 결론이다.**
> 이 절은 그 충돌을 처음 짚은 원문이다.
**이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을 **이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을
가리킨다: 가리킨다:
@ -1140,7 +1148,9 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
→ 자식 재귀/`setLength` → `OffWithoutEmit``recompute` → 자식 재귀/`setLength` → `OffWithoutEmit``recompute`
**마지막에 `setLength(ownerKey, position, slot.Length)`**. **마지막에 `setLength(ownerKey, position, slot.Length)`**.
- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과 - **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과
`_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요. `_mounted = true`만. Blocker 불필요. (**[정정, 2026-08-21 `I-7`]** 여기
`_detachCleanup` Effect 설치도 있었으나 `activateList`로 이관됐다 —
이제 이 함수는 정말로 물리 대입만 한다.)
- **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부 - **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부
전부 그대로라 다른 문서의 참조가 안 깨진다. 전부 그대로라 다른 문서의 참조가 안 깨진다.
@ -1175,3 +1185,163 @@ M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠
- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`), - 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
`table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는 `table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
`luau-test/STATUS.md`. `luau-test/STATUS.md`.
---
# I절 — 반영 후 감사 (2026-08-21): 트레이싱이 실제 크래시 3건을 잡음
H절의 반영을 커밋한 뒤 `quad-doc-auditor`를 각도를 바꿔 4라운드 돌렸다.
1~3라운드(문서 대조)에서 나온 것은 전부 "고친 결정이 다른 자리에 안
옮겨졌다" 유형이었고, **4라운드(의사코드 시나리오 손 트레이싱)에서 실제로
실행이 깨지는 결함 3건**이 나왔다. 셋 다 **`Detach`가 신설한 새 경로가
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것**이다.
## I-1. detach 재마운트가 `claimOwner`에서 무조건 크래시 (⭐⭐ 치명)
`rawDetach`는 일부러 `releaseOwner`를 안 부른다(소유권 유지가 설계의
핵심). 그런데 재마운트는 `rawAdd`를 거치고, `rawAdd`가 부르는
`claimOwner`는 **같은 owner의 재클레임도 무조건 error**다 — 2026-08-13
감사가 `Slot{a, a}`를 막으려고 일부러 엄격하게 만든 것이다. 결과:
**문서가 권장하는 "다음 사이클에 `prev`를 그대로 돌려주면 재마운트" 패턴이
그대로 `error("이 요소는 이미 마운트돼 있음")`로 죽는다.**
**확정(사용자 판단)**: `claimOwner`에 **detach 재마운트 전용 예외**를 넣는다
`claimOwner(element, ownerKey, fromDetached)`에서 `fromDetached and
cur == ownerKey`일 때만 통과. top-level `claimOwnerAt`이 이미 "같은
`(inst, k)` 자리의 재발행은 통과"라는 같은 모양의 예외를 갖고 있어 대칭이
맞는다. **`fromDetached` 없이 "같은 owner면 통과"로 완화하면 안 된다** —
`Slot{a, a}`가 다시 새어나간다.
## I-2. 재마운트된 자식 Slot이 `activateList`를 두 번 실행 (⭐⭐)
`I-1`을 고치면 바로 드러나는 다음 문제. `rawAdd`는 Slot 요소를 이미
마운트된 부모에 넣을 때 `attachSlot`을 부르고, `materializeSlotTree`
`slot._listed`**무조건** `activateList`를 다시 실행한다. `:List`를 가진
자식 Slot이 detach에서 돌아오면 `data:Observer` 구독이 하나 더 생기고
`mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져**
기존 요소를 전부 새 것으로 오인해 다시 그린다.
**확정(사용자 판단)**: `activateList`**멱등 가드**(`slot._listActivated`).
`_crudUsed`/`_listed`와 같은 결의 플래그다.
**가드만으로는 반쪽이라 하나 더 닫았다** — `:List``data:Observer`
`bindLifetime(inst, observer)`로 **물리 target에 앵커**돼 있는데,
`unmountSlotTree`가 푸는 건 `bk.observers`뿐이라 이 구독은 **옛
physicalTarget에 매달린 채** 남는다. 포탈로 다른 target에 재마운트하면
옛 target이 죽는 순간 살아있는 Slot의 `:List`가 조용히 반응을 멈춘다.
그래서 `slot._listObserver`로 핸들을 보관하고, `unmountSlotTree`
`unbindLifetime`만 하고(핸들과 `_listActivated`는 보존), 멱등 가드가
`bindLifetime(inst, self._listObserver)`로 앵커를 새 target에 다시 건다.
`_detachCleanup`이 이미 받고 있던 처리와 같은 모양이다.
## I-3. `_detachCleanup``releaseOwner`를 안 부름 (⭐)
owner가 죽을 때 `_detached`를 비우는 `Effect``_owned == false` 분기에서
요소를 파괴하지 않는 건 맞는데, **소유권 기록도 안 푼다.** 그러면 그
`state<Frame>`**죽은 Slot을 owner로 달고** 남아, 사용자가 같은 값을
다른 Slot에 넣을 때 그 죽은 Slot이 GC되기 전까지 비결정적으로 "이미
마운트돼 있음" error가 난다. 이 문서 스스로 "소유권 반납은 GC에 맡기면
안 됨" 절에서 경계했던 실패 모드다.
**반영**: 두 분기 공통으로 `releaseOwner(element, slot)`를 먼저 부른다
(`rawRemove`도 파괴 전에 부르므로 대칭).
## I-4. `materializeSlotTree` 중 예외 시 Blocker가 켜진 채 남음 — 문서화만
`blocker:On()``OffWithoutEmit()` 사이에서 자식 재귀가 예외를 던지면
Blocker가 영구히 켜져 그 Slot의 `Length`가 영원히 stale해진다.
**확정(사용자 판단)**: `pcall`로 감싸지 않고 **문서화만 한다.** 마운트
도중 예외는 quad가 복구를 보장하지 않는 상태이고(에러 경계는
`base/fallback-plan.md`), 아직 실제로 밟은 적 없는 경로다 —
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운 원칙
그대로. 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도
있었을 구조적 갭이다.
## I-5. 문서 대조 라운드(1~3)에서 나온 것
전부 "고친 결정이 다른 자리에 안 옮겨졌다" 유형이라 여기 나열하지 않는다
— 커밋 diff가 소스. 대표적인 것만: `slot-plan.md`가 한쪽에선 `Owned`
기준 표를, 다른 쪽에선 옛 "값 교체는 비파괴"를 동시에 서술하고 있었고,
`attachSlot`의 flush 루프를 가리키던 문장 5곳이 `materializeSlotTree`
안 옮겨져 한 문서 안에 신/구 표현이 공존했으며, `luau-test/STATUS.md`
자기 규칙("폴더가 곧 상태")을 어기고 재작성 대상 스파이크를 `done/`
두고 있었다.
## I-6. 5라운드(수정분 회귀 트레이싱) — 3건 더, 전부 반영
`I-1`~`I-3`의 수정 자체를 다시 트레이싱한 라운드. 새 결함은 안 나왔지만
**그 수정이 닿았어야 할 자리 3곳**이 나왔다.
1. **`destroySlotTree``_listObserver`를 안 푼다.** `unmountSlotTree`
넣었는데 파괴 경로엔 빠졌다. `physicalTarget`은 중첩 깊이와 무관하게
**트리 최상위 inst 하나**라(`materializeSlotTree`가 같은 값을 재귀에
그대로 넘김), 자식 Slot만 죽고 그 inst는 살아있는 게 흔한 경우 —
그러면 `gchold[inst]`가 observer와 그 클로저 상태(그리고 죽은 slot
자신)를 계속 붙잡고, `data`가 emit될 때마다 이미 죽은 자식들에 대해
reconcile이 계속 돈다. **반영**: 파괴이므로 `unmountSlotTree`와 달리
핸들과 `_listActivated`까지 `nil`로 지운다.
2. **`claimOwner`의 옛 논증 두 문단이 `fromDetached`와 정면 모순.**
2026-08-13 세션의 *"nested엔 재클레임이라는 개념이 애초에 없다 …
무조건 error가 맞음"*이 그대로 남아 있었다 — 그 논증의 근거(reconcile은
항상 release → claim 순서)가 `Detach`로 깨졌는데 갱신이 안 따라갔다.
함수 정의 옆 주석만 정정돼 있었다. **반영**: 두 문단에 ⚠️ 정정을 달고,
"플래그 없이 같은 owner면 통과로 완화하면 `Slot { a, a }`가 다시
새어나간다"는 경계도 같이 명시.
3. **소유권 예시 코드가 `C-4`와 모순.** `-- destroySlotTree(slot) 안,
자식들을 파괴하기 직전` + `releaseOwner(element, slot)` 예시가
2026-08-12 서술로 남아 있었는데, 2026-08-20 `C-4`가 그 호출을 도로
뺐고 실제 의사코드도 "부르지 **않는다**"라고 명시한다. 이번 감사
각도(`releaseOwner` 이중 호출 추적)에서 걸렸다. **반영**: 예시를
삭제 표시로 교체.
**회귀 없음으로 확인된 것**: `claimOwner`의 early return은 `OWNER_POS`
stale하게 남기지 않고(그 필드는 top-level `claimOwnerAt` 전용),
`fromDetached`를 안 넘기는 기존 호출부는 동작이 이전과 완전히 동일하며,
`_listActivated` 가드는 정상 마운트/`Slot:List()` 경로를 막지 않고,
언마운트~재마운트 구간에도 `_listObserver`는 Slot 필드 강참조라 GC되지
않으며, `releaseOwner` 이중 호출 경로는 없다(`destroySlotTree`가 `C-4`
이후 자식에 대해 안 부르므로).
## I-7. `_detachCleanup``activateList`로 이관 + `activateList``inst` 리네이밍
사용자가 별도 에이전트와 상의한 내용을 가져와 확정한 두 건. 둘 다 **의미
변화 없는 배치/이름 정리**지만, 첫 건은 실제 낭비를 없앤다.
1. **`_detachCleanup` Effect 설치가 `mountSlotTree``activateList`로 이관.**
`_detached`를 채우는 유일한 지점이 `settle``rawDetach`이고 그건
`activateList` 클로저 안에만 있으므로, **`:List`가 없는 Slot의
`_detached`는 정의상 영원히 빈 테이블**이다. 그런데 `mountSlotTree`
재귀 전체(중첩 포함)에 Effect + `bindLifetime`을 심고 있었다 — 트리
크기만큼의 no-op. 확인: `rawDetach` 호출부는 `settle` 한 곳뿐이고
`_detached`에 쓰는 코드도 전부 그 클로저 안이다.
**이관하면서 `_listObserver`와 완전히 같은 취급으로 통일했다**
생성은 `activateList` 최초 1회, `unmountSlotTree`는 **앵커만 해제하고
핸들 보존**, 재마운트는 멱등 가드가 재앵커, `destroySlotTree`만 해제 +
`nil`. 이전엔 이 둘이 서로 다른 함수에서 만들어지고 언마운트 처분도
갈렸다(하나는 보존, 하나는 `nil`). 이제 **"`activateList`가 소유하고
물리 target에 앵커되는 자원"**이라는 한 범주가 되어, 이런 자원이 또
생겨도 훑을 자리가 하나다.
**⚠️ 이 이관에는 `I-2`의 멱등 가드와의 상호작용이 걸려 있다.** 원
제안의 근거는 *"`materializeSlotTree`가 `_listed`면 재마운트 때도
`activateList`를 다시 부르니 lifecycle이 보존된다"*였는데, 그건
**가드를 넣기 전 동작**이다(그리고 그게 `I-2`의 버그였다). 가드가 있는
지금은 두 번째 호출이 early return하므로, **가드 분기가
`_detachCleanup`도 같이 재앵커하지 않으면** 포탈 재마운트 후 그 Slot의
detached는 owner 사망 시 아무도 치우지 않는다. 가드 분기에서 두 자원을
같이 `bindLifetime`하도록 반영했다.
`_detached` **테이블 자체는 Slot 필드로 그대로 둔다**
`destroySlotTree``activateList` 클로저 **밖**에서 walk해야 하는 게
애초에 `ud`를 버리고 필드로 간 이유다(`I-1`의 (2)번 근거).
2. **`activateList(self, inst)``activateList(self, physicalTarget)`.**
그 인자는 `bindLifetime`의 첫 인자로 들어가니 타입상 물리 Instance일
수밖에 없고, 호출부 둘(`materializeSlotTree`의 `physicalTarget`,
`Slot:List()``self._mountedInst`)이 넘기는 값도 같은 개념이다.
Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다. 옆 함수들
(`materializeSlotTree`/`mountSlotTree`/`attachSlot`)과 이름을 맞춘
**순수 리네이밍**.

View file

@ -1,6 +1,16 @@
# 구현 전 QA **4라운드** — 확정 전수 재심사 문항지 # 구현 전 QA **4라운드** — 확정 전수 재심사 문항지
**상태**: **작성 중 / 사용자 회신 대기**(2026-08-19 세션에서 생성). **상태**: **[2026-08-21] 완료·종결.** 2026-08-19 세션에서 생성 → 사용자가
`-response.md`로 회신 → 4차에 걸친 처리로 전량 반영 완료. **처리 결과의
소스는 `pre-implementation-qa-round4-followup.md`**(마지막 H절이 최신) —
이 파일은 문항지 원본 그대로 남긴다.
**[2026-08-21 계획 변경] 아래 "회신 방법" 문단이 예고한 "아니오가 나온
항목만 남긴 근거 기록으로 재편"은 하지 않기로 했다** — 1라운드는 회신을
문항지 안에 받아서 재편이 곧 정리였지만, 이 라운드는 회신을 별도 파일로
받았고 처리 결과가 전부 `-followup.md`에 쌓였다. 여기서 또 재편하면 같은
정보가 두 곳으로 갈라져 이 코퍼스에서 반복적으로 터진 "개수·목록 이중 소스"
패턴을 새로 만든다.
**왜 이 라운드가 있는가**: 사용자 요청 — *"요즘 변경이 엄청 많고, 틀려서 **왜 이 라운드가 있는가**: 사용자 요청 — *"요즘 변경이 엄청 많고, 틀려서
정정한게 엄청 많아서, 모든 부분에 있어서 내 심사를 좀 받아야할듯. 예 가 정정한게 엄청 많아서, 모든 부분에 있어서 내 심사를 좀 받아야할듯. 예 가

View file

@ -61,6 +61,13 @@
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
헷갈릴 수 있음. 헷갈릴 수 있음.
- **`Owned`(3순위, 2026-08-21 신설)**: `:List`/`:Single`의 설치 시점
플래그(기본 `true`, `false`면 어떤 경로로도 파괴 안 함).
`elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐지만
**잠정 이름**이다 — 형용사라 옵션 테이블 키로는 자연스러운데, 실제로
묻는 건 "이 Slot이 요소의 수명을 책임지는가"라서 `OwnsElements`처럼
주어를 드러내는 쪽이 나을 수도 있음. `base/slot-plan.md`
"소유권은 설치 시점에 정해진다" 절.
- **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데 - **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데
이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이
있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열 있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열
@ -168,7 +175,8 @@
선택지 (c)로 확정: `updateFn`**`KeyGone`으로 한 번 더 불러 처분을 선택지 (c)로 확정: `updateFn`**`KeyGone`으로 한 번 더 불러 처분을
묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
**`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가 **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이 닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`
정리한다. 원래 갭이
치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
**GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md` **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`
"Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절. "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.

View file

@ -1,4 +1,4 @@
# `attachSlot` 책임 분해 — 확장 논의 준비 자료 # `attachSlot` 책임 분해 — 결정 근거 기록 (2026-08-21 확정)
**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md` **상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한 반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한

View file

@ -137,3 +137,96 @@ detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`. `table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는 - 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다. **일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
---
## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다
H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`**6라운드** 돌렸다
(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:
| 라운드 | 각도 | 확실 발견 |
|---|---|---|
| 1 | `base/slot-plan.md` 내부 문장 정합성 | 4 |
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS) | 6 |
| 3 | `archive/` + 바깥 `base/` + `reference/` | 2 |
| 4 | **의사코드 시나리오 손 트레이싱** | **3 (실제 크래시)** |
| 5 | 그 수정분의 회귀 트레이싱 | 3 |
| 6 | 수렴 확인 | **0** |
**이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다.** 1~3라운드는 전부
"고친 결정이 다른 자리에 안 옮겨졌다"는 **문서 정합성** 유형이었고, 각도를
트레이싱으로 바꾼 4라운드에서야 **실행이 깨지는 결함**이 나왔다. 문서 대조를
몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고,
**서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는** 종류였기
때문이다.
### 4라운드가 잡은 것의 공통 구조
셋 다 **`Detach`가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안
고쳐진 것**이다. 특히 `I-1`이 뼈아프다:
- `rawDetach`**일부러** `releaseOwner`를 안 부른다 — 소유권 유지가 이번
세션 설계의 핵심이었다.
- `claimOwner`**일부러** 같은 owner의 재클레임도 error다 — 2026-08-13
감사가 `Slot { a, a }`를 막으려고 엄격하게 만든 것이다.
- 둘 다 각자 맞는데, **"`prev`를 그대로 돌려주면 재마운트"라는 이 세션이
확정한 권장 패턴이 정확히 그 교차점을 지난다.** 그대로 구현했으면
문서가 권장하는 사용법이 첫 실행에서 죽었다.
`claimOwner`의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시
클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고
단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그
문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.
### 내가 감사 결론을 한 번 넘어선 곳
`I-2`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다.
`:List``data:Observer``bindLifetime(inst, observer)`로 **물리 target에
앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만
넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`
조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로
마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라
새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**(
`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나
추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.
### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)
1. **재마운트 경로**`claimOwner``fromDetached` 예외(대안: 전용
`rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경).
2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고
`pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래
요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는
`base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라
옛 단일 `attachSlot`에도 있었을 구조적 갭이다.
### 부수 정리
2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧
상태"라면서 재작성 대상 스파이크 `01``done/`에 둔 채 ⚠️ 마커만 달아둔
것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크`
절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼
구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를
지목했는데 정작 항목이 없었다.
### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건
`_detachCleanup` Effect 설치를 `mountSlotTree``activateList`로 이관,
그리고 `activateList(self, inst)`의 2번째 인자를 `physicalTarget`으로
리네이밍. 상세는 followup `I-7`.
**여기서 짚어둘 것은 상호작용 하나다.** 이관 제안의 근거가
*"`materializeSlotTree`가 `_listed`면 재마운트 때도 `activateList`를 다시
부르니 lifecycle이 보존된다"*였는데, **그건 오늘 오후에 내가 멱등 가드를
넣기 전의 동작**이고 사실 그게 `I-2`의 버그였다. 가드가 있는 지금은 두 번째
호출이 early return하므로, 가드 분기가 `_detachCleanup`도 같이 재앵커하지
않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만
근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.
부수적으로 `_listObserver``_detachCleanup`이 **같은 범주로 통일**됐다 —
"`activateList`가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는
앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 `nil`).
오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이
훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.

View file

@ -86,7 +86,8 @@
`archive/question-resolved.md`. `archive/question-resolved.md`.
- **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 — - **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 —
**`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는 **`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는
`slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리. `slot._detached` 필드가 보유하고 owner 사망 시 `activateList`가 건
`Effect`가 정리.
`base/slot-plan.md`의 "`KeyGone`" 절. `base/slot-plan.md`의 "`KeyGone`" 절.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
@ -167,12 +168,11 @@
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State` stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 **[2026-08-21] 여기 있던 이름 나열은 지웠다.** 바로 위 문장이 이미
완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영 "`question.md` 1번이 최신 소스"라고 선언해놓고 다음 줄에서 목록을 다시
완료로 목록에서 빠짐**): `Slot`(2순위), 나열하고 있었고, 예고대로 실제로 갈라졌다(2026-08-21에 추가된 `Owned`
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 그 전부터 있던 `hintValue`가 둘 다 빠져 있었음 — 감사가 발견).
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ **열린 항목이 뭔지는 `question.md` 1번을 열어볼 것.**
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스 3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스
재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md` 재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`
이전됨 — 더 이상 상의할 스코프 항목이 아님. 이전됨 — 더 이상 상의할 스코프 항목이 아님.

View file

@ -529,7 +529,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"Detach된 요소는 `slot._detached`가 보유한다" 절). "Detach된 요소는 `slot._detached`가 보유한다" 절).
**[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은 **[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)` `KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`
한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건 한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건
`Effect``_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절). `Effect``_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본 **`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가 `true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가