qa: 9라운드 손 트레이싱 실행 + Q1~Q3 확정·반영 — 체크포인트 (Q4~Q10 다음 세션)

8라운드가 써둔 지시서(-round9-brief.md)대로 커밋 9dd8213의 델타를 재트레이싱해
발견 18건(H-124~H-141)을 냈고(qa-request/pre-implementation-handtrace-round9.md),
§4 문항 중 Q1~Q3를 사용자와 확정해 base/·ROADMAP에 반영했다. 결정의 소스는
-round9-followup.md(진행 표가 상태의 소스). 🔴 둘 다 luau 실측 재현.

Q1 H-124 — recompute가 lengthList[i]를 되감기 판정보다 먼저 읽어, offset:Set 안의
  사용자 코드가 커서 뒤 자리 수를 줄이면 sum += nil로 죽고 recomputeBlocker가
  영구 On. 되감기 판정을 앞으로(continue), 읽기·누적은 되감지 않을 때만.
Q2 H-125 — 재마운트 시 setOffsetSource가 slot.Offset을 바꾸는 순간 _baseObserver가
  unbind 상태라 두 필드가 0으로 안 내려가 옛 베이스의 offsetCache[1]을 씀.
  사용자 확정: Offset·_baseObserver를 Slot 생성자로(첫 마운트/재마운트 분기
  소멸), materializeSlotTree는 blocker:On → bindLifetime → setOffsetSource,
  파괴는 _destroyed 플래그 하나(핸들은 unbind만, mutate CRUD·:List·마운트 진입
  error, Owned=false는 안 섬, 이중 dispose no-op).
Q3 H-126/H-137/H-141 — element→index 맵이 Slot 층(slot._elemIndex)과 Dispatch
  층(bk.tokens/indexOfToken)에 두 벌 있었고, 후자의 token은 사용자가 정한 적
  없는 것(2026-08-25 /code-review가 발명해 사용자 인용문 옆에 앉아 있던 것).
  bk.indexOfElement 하나로 통일, setLength 5번째 인자 element, splice가 비운
  자리는 세 배열 전부 처리. H-137 소멸.

부수: H-140(ROADMAP의 폐기된 "해제 시 slot.Offset = nil" 잔존) 정정,
H-125 피해 범위를 "중첩 Slot의 Offset"으로 축소(유저 체인은 요소 인스턴스에
바인드돼 전파됨 — 실측), G각도로 "for d in seen do는 유효한 Luau가 아니다"가
거짓임을 확인, keyof<{}> 빈 Store 실측 클린.

conventions.md 신설: "/code-review(그리고 메인 세션)가 내놓는 새 필드·인자·
이름·메커니즘은 발견이지 결정이 아니다" — 이 세션에서 메인 세션도 같은 실수를
세 번 했다(subject 인자 / Observer 위치 필드 = 기각된 Effect userdata 재개방 /
조회 클로저).

검증: quad-doc-auditor 6라운드(확실 1→1→3→1→1→0, base/ 본문 결함은 1라운드
이후 0건 — 나머지는 인덱스 레이어·인용처), doc-check ERROR 0. /code-review high는
Q4~Q10 반영 뒤 한 번에(같은 파일을 또 건드려 diff가 섞이므로 지금 체크포인트).

Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn
Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-27 01:30:48 +09:00
parent 9dd82136bd
commit 031495cc0b
Signed by: qwreey
GPG key ID: D28DB79297A214BD
16 changed files with 1923 additions and 161 deletions

File diff suppressed because one or more lines are too long

View file

@ -38,10 +38,18 @@
| `core.luau`/`dispatch.luau`의 부기가 **단일 `bk.invalidAfter`** | **두 필드로 갈라졌다**`bk.offsetCacheValidUpTo`(캐시)와 `bk.offsetSetUpTo`(`:Set` 완료). 옛 단일 필드가 두 뜻을 겸한 게 되감기 신호가 지워지는 원인이었다 | `/code-review high` 4차 |
| `d7_splice_fix.luau:14`의 splice 무효화가 `math.min(bk.invalidAfter, index)` | splice는 **`index - 1`**(커서 위치 splice가 "변경 없음"과 구분이 안 된다). `dispatch.luau:84``setLength``math.min(…, i)`**정정 대상이 아니다** — 그쪽 공식은 원래 `i`다 | `H-113` |
| `Ref`를 쓰는 자리의 콜백 호출 | `fn(value, ref)` — 두 번째 인자가 `Ref` 자신(= `Epoch`). `:Set` 순서는 **값 → `Revision` → 콜백**, 순회는 `.Callbacks` + `.WeakCallbacks` | `H-107`/`H-108` |
| **[2026-08-27 추가, 9라운드]** `dispatch.luau``recompute``lengthList[i]`를 읽고 누적한 **뒤** 되감기를 판정 | 되감기 판정이 **먼저**(`continue`), 읽기·누적은 되감지 않을 때만 — 옛 순서는 커서 뒤 자리 수가 줄면 `sum += nil` | `H-124`/Q1 |
| **[2026-08-27 추가]** `dispatch.luau``setLength``box.pos`(가변 박스)로 위치를 캡처, 8라운드 전사물엔 `bk.tokens`/`indexOfToken` | **토큰 폐기** — 5번째 인자 `element`를 캡처해 `bk.indexOfElement[element]` 조회. `slot._elemIndex`도 이 맵으로 통합 | `H-141`/Q3 |
| **[2026-08-27 추가]** `newSlot``Offset`을 만들고 `materializeSlotTree` 상당이 `setOffsetSource` 뒤에 관측자를 붙임 | `Offset`·`_baseObserver`는 **생성자**에서, 순서는 `blocker:On()``bindLifetime``setOffsetSource` | `H-125`/Q2 |
**`d7_splice_fix.luau`의 결론(`H-102`가 토큰 역참조로 닫힌다)은 영향받지
**`d7_splice_fix.luau`의 결론(`H-102`가 역참조 *조회*로 닫힌다)은 영향받지
않는다** — 그 테스트는 recompute가 끝난 뒤 splice하는 시나리오라 `H-113`
문제 삼은 "루프 커서 위치에서의 splice" 경계를 애초에 안 밟는다. 공식만 낡았다.
(**[2026-08-27 정정]** 여기 한때 *"토큰 역참조로 닫힌다"*였는데, 토큰은 9라운드
`H-141`/Q3로 폐기됐다 — 지금은 **요소 캡처 + `bk.indexOfElement` 조회**다. 결론
("캡처 말고 조회")은 그대로다. 9라운드 전사물(요소 키)은 세션 스크래치패드
`ref9/`에 있고 근거는 `qa-request/pre-implementation-handtrace-round9.md`
인라인 전사돼 있다.)
---

View file

@ -272,7 +272,7 @@ quad/
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, ...deps)` — deps 없으면 설치1회+leaf사망시 정리, 있으면 dep마다 약하게 등록(State/Source면 `:WeakSubscribe`, `Ref``:WeakCallback`)해 재실행. **[2026-08-26 `H-107`]** 등록 클로저는 dep 종류별로 **둘**(`onRefFire`/`onStateFire` — 두 콜백 계약의 자리 수가 다르다), 강한 주인은 항상 `_deps`(`base/effect-plan.md`)
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자(**[2026-08-27 Q2]** `Length`·`Offset`·`_baseObserver`를 여기서 만든다, 파괴는 `_destroyed`), 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
│ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨**`InitDebug(module)`, `module.debug = false`(`base/project-setup-plan.md`)
│ ├── Dispatch/
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)

View file

@ -1344,7 +1344,7 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
```lua
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?)
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?, element?) -- [2026-08-27 9라운드 Q3] 5번째 = 그 자리의 inst|slot
Dispatch.setOffsetSource(ownerKey, i, offset: Source<number> | None)
Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset
```
@ -1493,6 +1493,9 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
**저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에
귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장)
+ **`indexOfElement`**(**[2026-08-27, 9라운드 Q3]** 그 자리에 등록된 요소
`inst|slot` → position 인덱스. 옛 `slot._elemIndex`가 여기로 왔고, 옛
`tokens`/`indexOfToken`은 폐기 — 아래 `setLength` 절)
`Relate(parentInst)`에 lazy 생성.
**⭐ [2026-08-24 보강, 6라운드 손 트레이싱 `H-4`] 접두합 캐시 두 필드도 여기
@ -1510,7 +1513,8 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
같은 파일 안에서 어떤 자리는 가드하고 어떤 자리는 안 하는 불일치를 만든다.
**가드를 두지 말 것.**
- **`getBookkeeping``bk`를 만들 때 `offsetCache = {}`, `offsetCacheValidUpTo = 0`,
`offsetSetUpTo = 0`, `recomputeBlocker = Blocker()`으로 초기화한다.**
`offsetSetUpTo = 0`, `recomputeBlocker = Blocker()`, **`indexOfElement = {}`**
(**[2026-08-27 Q3]**)으로 초기화한다.**
(**[2026-08-26]** `offsetCacheValidUpTo`은 같은 날 `offsetSetUpTo`에서 갈라져 나온
필드다 — 아래 "두 필드" 절.) (`bk.N`만 `nil` 시작을
유지한다 — 그쪽은 `or 0` 방어가 이미 자리를 잡았고 "아직 아무 자리도 등록
@ -1518,9 +1522,12 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
**⭐ [2026-08-26 추가, `/code-review high`] `recomputeBlocker`가 이 열거에
빠져 있었다** — `H-101`이 나중에 도입했는데 생성 규칙이 어디에도 없었고,
`H-119``base/slot-plan.md``bk.recomputeBlocker:IsOn()` 역참조를 네 개
더 늘렸다. `_baseObserver`의 등록 즉시 1회 발화는 `getBlocker(slot):IsOn()`
참이라 `or` 단락으로 **우연히** 살아나지만, 그 우연에 기대고 있었다 —
이 절이 `offsetSetUpTo`에 대해 잡아낸 것과 정확히 같은 종류의 nil 역참조다.
더 늘렸다. (**[2026-08-27 갱신, 9라운드 Q2]** 한때 여기 *"`_baseObserver`의
등록 즉시 1회 발화는 `getBlocker(slot):IsOn()`이 참이라 `or` 단락으로 우연히
살아난다"*고 적혀 있었는데, 그 Observer는 이제 **Slot 생성자**에서 나므로 그
1회는 콜백 머리의 `_physicalTarget == nil` 가드가 삼킨다 — `bk`에 닿지도
않는다. `base/slot-plan.md``materializeSlotTree`가 소스.) 이 절이
`offsetSetUpTo`에 대해 잡아낸 것과 정확히 같은 종류의 nil 역참조다.
**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner
타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔
@ -1883,9 +1890,17 @@ local function recompute(ownerKey, bk)
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
-- ⭐⭐ [2026-08-27 재배치, 9라운드 `H-124`] **되감기 판정이 `lengthList[i]`
-- 읽기보다 먼저다.** 옛 순서(읽기·누적 → 판정)에선 `offset:Set(abs)` 안의
-- 사용자 코드가 요소를 제거해 `i > bk.N`이 되면(커서가 마지막 자리일 때
-- 아무 자리나 제거, 또는 `rawSplice`/`rawClear`의 다중 제거)
-- `lengthList[i]`가 이미 `nil`이라 `sum += nil`로 죽고, 그 error가
-- `recomputeBlocker:On()``OffWithoutEmit()` 사이라 **차단기가 영원히
-- 켜진 채 남는다**(그 owner의 레이아웃 영구 동결, `H-87` 부류). 되감으면
-- `sum`은 어차피 `prefix[i]`로 덮이므로 읽기·누적은 되감지 않을 때만
-- 한다(사용자: *"되감는다면 sum 이 이전걸로 구해져서 새로 계산한 sum
-- 자체를 안 씀"*). 제거가 커서보다 **뒤**(`p > i`)면 `offsetSetUpTo`
-- `p-1 ≥ i`로만 내려가 판정이 거짓이고 그땐 `bk.N ≥ i`라 안전하다.
if bk.offsetSetUpTo < i then -- 누군가 낮췄다 되감기
-- ⭐ [2026-08-25] `+1`이 아니라 **그 자리부터** 다시 돈다.
-- `prefix[j+1]`**옛** `lengthList[j]`로 누적된 값이라, 길이가
@ -1903,9 +1918,13 @@ local function recompute(ownerKey, bk)
-- 의미), `prefix[1] = 0`이라 1로 되감으면 정확하다.
i = math.max(bk.offsetSetUpTo, 1)
sum = prefix[i]
else
i += 1
continue
end
-- 되감지 않을 때만 — 읽기는 여전히 `Set` **뒤**라 `H-113`의 *"`sum`은
-- 안 낡는다"* 논증이 그대로 성립한다(재방문 때도 마찬가지).
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
i += 1
end
-- ⭐ [2026-08-25] 커서 마감과 블로커 해제를 **`Length:Set` 앞에** 둔다.
-- `Length:Set`은 상위 owner의 사용자 코드를 돌릴 수 있는데, 그 도중
@ -1989,11 +2008,20 @@ end
`offsetSetUpTo` 둘이다 — 위 "두 필드" 절.
- **`H-102`(splice가 observer를 옮겨도 클로저에 박힌 인덱스는 안 고쳐진다)가
이걸로 같이 닫힌다** — 아래 `setLength``gatedRecompute`가 **인덱스를
캡처하지 않고 조회**한다. `slot._elemIndex`(물리 요소 → 인덱스 역방향
맵, 6라운드 `H-39`)와 같은 개념을 **Dispatch 층위로 격상**해 `bk`
소유하고, splice가 배열을 당길 때 같이 갱신한다 — 그래서
`base/slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다.
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*.)
캡처하지 않고 조회**한다. 옛 `slot._elemIndex`(물리 요소 → 인덱스 역방향
맵, 6라운드 `H-1`)를 **Dispatch 층위로 격상**해 `bk.indexOfElement``bk`
소유한다 — owner가 Slot이든 물리 `inst`든 **규칙 하나**다
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*).
**⚠️ [2026-08-27 정정, 9라운드 `H-141`/Q3] 여기 한때 *"그래서 `slot-plan.md`
splice 요구 목록에 항목이 늘지 않는다"*고 이어졌고, 실제 구현은 그 말과 달리
`bk.tokens`/`bk.indexOfToken`이라는 **사용자가 정한 적 없는 신원(`token = {}`)**을
만들어 요구 목록에 항목을 둘 늘렸다**(2026-08-25 `/code-review`가 "`len`은
자리마다 유일하지 않다"를 잡으며 원래 키(요소)로 되돌아가는 대신 발명한 것).
둘 다 **폐기**됐다 — 사용자: *"난 층위 상 어떠한 값이든, 마운트된 부기객체 ->
index(기여량이 아님) 를 얻고자 했음"*. 클로저는 **요소를 캡처**하고
`bk.indexOfElement[element]`를 조회한다 — 요소의 신원은 안 변하므로 splice가
클로저 쪽에서 갱신할 것이 없고, 맵은 Slot 층의 `reindexFrom`이 자기 이유로
이미 유지한다(`base/slot-plan.md`). 이제 요구 목록에 항목이 **실제로** 안 는다.
두 필드를 `0`으로 뭉개는 안은 기각 — *"0 으로 두면, 모든 부분에
있어 캐시가 무관해져요"*.
@ -2034,7 +2062,11 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지
-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라
-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리
-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다).
function Dispatch.setLength(ownerKey, i, len, anchor)
-- ⭐⭐ [2026-08-27, 9라운드 Q3] 5번째 인자 `element` — **그 자리에 등록되는 요소
-- (`inst|slot`)**. `gatedRecompute`가 인덱스 대신 이걸 캡처하고 `bk.indexOfElement`
-- 조회한다(아래). 길이가 상수인 자리(`NilHandler`/`NoneHandler`의 `0`)는 지속
-- 클로저가 안 생기므로 생략해도 된다 — 그땐 캡처한 `i`가 그대로 유효하다.
function Dispatch.setLength(ownerKey, i, len, anchor, element)
anchor = anchor or ownerKey
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
@ -2047,13 +2079,10 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
-- ⭐ [2026-08-25] 이 자리의 유일한 토큰 — 아래 클로저가 인덱스 대신 이걸 캡처한다.
local token = bk.tokens[i]
if token == nil then
token = {}
bk.tokens[i] = token
end
bk.indexOfToken[token] = i
-- ⭐ [2026-08-27, 9라운드 Q3] 요소 → 인덱스 **등록**. 자리가 밀리면(splice/move)
-- Slot 층의 `reindexFrom`이 이 맵을 다시 쓴다 — `lengthList`가 여기서 등록되고
-- `spliceArrays*`가 옮기는 것과 같은 분담. `None`/`nil` 자리는 안 적는다.
if element ~= nil then bk.indexOfElement[element] = i end
-- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은
-- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표).
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i) -- 무효화는 둘 다
@ -2061,20 +2090,20 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
local function gatedRecompute()
-- ⭐ [2026-08-25, 7라운드 `H-102`] `i`**캡처하지 않는다** — splice가
-- 자리를 당기면 박힌 인덱스가 낡는다. `bk`가 소유한 역방향 맵에서
-- 현재 인덱스를 조회한다(`slot._elemIndex`와 같은 개념을 Dispatch로
-- 격상한 것 — 위 `recompute` 절).
-- 자리를 당기면 박힌 인덱스가 낡는다. **요소를 캡처**하고 `bk`가 소유한
-- 역방향 맵에서 현재 인덱스를 조회한다(위 `recompute` 절의 `H-102` 항목).
--
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 이
-- 자리의 토큰**이다. `len`은 자리마다 유일하지 않다 — 같은
-- `Source`/`State`가 두 자리의 길이를 몰면 역방향 맵에서 **두
-- 자리가 한 항목으로 접혀** 엉뚱한 인덱스를 무효화하고, 앞선
-- 자리는 영영 다시 offset을 못 받는다. `token`은 등록 시점에
-- 만드는 유일한 테이블로 `bk.tokens[i]`에 같이 저장되고 splice가
-- 다른 배열과 **함께** 당긴다. (`slot._elemIndex`가 물리 요소를
-- 키로 쓰는 건 요소가 자리마다 유일해서 성립하는 것 — 그 성질을
-- Dispatch에선 토큰이 맡는다.)
local cur = bk.indexOfToken[token]
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 요소**다.
-- `len`은 자리마다 유일하지 않다 — 같은 `Source`/`State`가 두 자리의
-- 길이를 몰면 역방향 맵에서 **두 자리가 한 항목으로 접혀** 엉뚱한
-- 인덱스를 무효화하고, 앞선 자리는 영영 다시 offset을 못 받는다.
-- 요소는 유일하다(Slot 안에선 `claimOwner`가 이중 배치를 막고 `None`
-- `_elements`에 안 들어간다; `inst`의 배열 자리는 애초에 안 밀린다).
-- **⛔ [2026-08-27, 9라운드 Q3/`H-141`]** 여기 한때 그 키가 `token`
-- (등록 시점에 만드는 빈 테이블 + `bk.tokens`/`bk.indexOfToken`)이었다 —
-- 사용자가 정한 적 없는 신원이라 폐기됐다. 요소를 캡처하면 신원이 안
-- 변하므로 **클로저 쪽에서 갱신할 것이 없다**.
local cur = if element ~= nil then bk.indexOfElement[element] else i
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur) -- 나중 emit도
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur) -- 같은 무효화가 필요
if blocker:IsOn() then return end -- 배치 등록 중
@ -2142,6 +2171,12 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧
물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인
자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다.
- **[2026-08-27, 9라운드 Q3] 5번째 `element``anchor`와 축이 다르다** —
owner 종류가 아니라 **그 자리에 지속 등록(길이 State → Observer)이 생기는가**로
갈린다. 중첩 Slot의 `.Length`를 넘기는 자리(`materializeSlotTree` 꼬리,
최상위 `SlotHandler` 경로 포함)는 그 Slot 자신을 넘기고, plain 요소(`rawAdd`/
`rawReplace`)는 그 요소를 넘긴다. `NilHandler`/`NoneHandler`처럼 상수 `0`
자리는 생략.
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
@ -2369,9 +2404,15 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source<number>`.
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
세션 명시화)** — **⭐ [2026-08-27 정정, 9라운드 `H-125`/Q2] `Length`와 같은
자리, 즉 `Slot` 생성자에서 `Source(0)`으로 만든다**(여기 한때 *"Slot이
마운트되는 시점에 `setOffsetSource`를 등록하는 바로 그 자리에서 같은 Source
객체를 `self.Offset`으로도 저장"*이라고 적혀 있었다 — 그러면 첫 마운트와
재마운트가 갈려 재마운트 캐시가 낡는다, `base/slot-plan.md`
`materializeSlotTree` 절). 마운트 시점엔 **이미 있는** `slot.Offset`
`setOffsetSource(ownerKey, position, slot.Offset)`로 등록만 한다. 아래 `D-60`/
`SL-75`가 말한 "마운트 전엔 `0`"이 이걸로 코드에서도 성립한다.
**[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** —
사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이
나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상

View file

@ -504,6 +504,33 @@ end
다른 곳에 다시 넣으면 `attachSlot`이 새 물리 부모로 다시 flush함. 아무도
안 들고 있으면 그냥 GC. 지금 확실히 죽이려면 `dispose`(아래 절).
**⭐ [2026-08-27 신설, 9라운드 Q2] 파괴된 Slot은 재사용 불가 — `slot._destroyed`.**
지금까지 파괴 쪽 계약이 어디에도 없었다. `destroySlotTree`**`_elements`
안 비운다**(요소만 `nativeDispose`) — 파괴된 Slot은 죽은 Instance를 든 좀비이고,
`_mounted``false`로 되돌려놓으므로 *"마운트된 Slot의 재마운트는 즉시 throw"*
가드에도 안 걸려 재마운트하면 죽은 Instance를 다시 `Parent` 대입한다.
- `destroySlotTree` 꼬리에서 `slot._destroyed = true`. **"파괴됨"은 이 플래그
하나만 말한다** — 핸들(`_baseObserver`/`_listObserver`/`_listActivated`)을
`nil`로 지워 그 뜻을 겸하게 하지 않는다(사용자: *"두 일을 겸하는걸 만들다가
사고가 난 적 많아"* — `invalidAfter`가 그랬다). 핸들은 `unbindLifetime`만.
- **`attachSlot`/`materializeSlotTree` 진입과 공개 CRUD(`:Add` 등) 진입에서
`if self._destroyed then error(..., 2) end`** — 사용자 입력 검증이므로
`level 2`, 메시지는 영어(`base/architecture.md`의 error 계약). CRUD까지 막는
이유: `_elements`가 안 비워지므로 죽은 Slot에 `Add`하면 **조용히** 좀비 배열이
자란다.
- **`Owned = false`인 Slot은 `_destroyed`가 서지 않는다** — `destroySlotTree`가 그
분기에서 `unmountSlotTree`로 빠져 꼬리(플래그 세팅)에 안 닿는다. 그 Slot은
요소를 만든 적이 없으니 좀비가 없고 재사용도 그대로 가능하다 — **사용자
확정**(*"owned = false 은 state<Frame> -> slot(single) 형태가 구현되는 것이라
맞아"*). "파괴 대신 언마운트만"이라는 그 분기의 뜻 그대로.
- **이중 `dispose`는 얼리리턴 no-op** — teardown 경로가 겹치는 건 실재하고
GC-native 기조와 맞는다. 플래그 세팅도 얼리리턴도 `destroySlotTree` 쪽이라
다형 진입점 `dispose(value)`는 공짜로 물려받는다. 이름은 `_disposed`
아니다 — 사용자: *"`dispose` 는 형질이 다른 엔진 요소를 포함할 수 있는 것에
대한 공동 소멸자인 네이밍. 자신이 삭제되고 그 여부는 `destroy` 가 맞아보이고,
`dispose` 는 슈거로써 `destroy` 와 별도의 맥락에서 해석해야해."*
**[전면 정정, 2026-08-13 감사] `claimOwner`를 nested/top-level 두 함수로
쪼개고, top-level은 `(inst, k)`까지 본다.** 원래는 하나의 `claimOwner`
"같은 ownerKey면 `false` 반환(no-op)"을 양쪽에 공유했는데, 그 분기가
@ -705,6 +732,12 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
**인덱스 기준**으로 재확정 — 레퍼런스만 갖고 있으면 `IndexOf`로 먼저
인덱스를 구하면 됨(아래):
**⭐ [2026-08-27, 9라운드 Q2] 아래 표의 mutate 연산 전부**(`Add`~`Swap` — 조회
`Get`/`IndexOf` 제외)**가 진입 첫 줄에 `if self._destroyed then error(..., 2) end`
둔다.** 의사코드가 있는 건 `:Add`/`:List`뿐이라 거기만 적혀 있지만, 규칙은 표
전체다 — `_elements`가 파괴 뒤에도 안 비워지므로 어느 하나라도 빠지면 죽은
Slot의 좀비 배열이 조용히 자란다(아래 "파괴된 Slot은 재사용 불가" 절).
| 연산 | 시그니처 | 복잡도 | 의미 |
|---|---|---|---|
| `Add` | `Slot:Add(element, index?): number` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 — **실제로 삽입된 인덱스를 반환** |
@ -869,9 +902,25 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
`Slot():Add(a):Add(b):Add(c)`와 같은 일을 하는 표기일 뿐이라서:
```lua
function Slot(initial)
-- [2026-08-24 `H-1`] `_elements`와 짝인 역방향 맵 `_elemIndex`도 여기서
-- 같이 만든다(둘은 항상 함께 산다 — 위 raw* 인자 규약).
local self = setmetatable({ _elements = {}, _elemIndex = {}, ... }, Slot_mt)
-- ⭐⭐ [2026-08-27, 9라운드 `H-125`/Q2] **`Offset``_baseObserver`도 여기서
-- 난다 — `Length`와 같은 자리.** 마운트 시점에 만들면 첫 마운트(생성 →
-- "등록 즉시 1회"가 우연히 캐시를 0으로)와 재마운트(`bindLifetime`만 →
-- 바인드는 발화가 아니라 안 함)가 갈려 재마운트 캐시가 낡았다. 이제 그
-- 분기가 없다. 둘 다 **bind/unbind로만 관리하고 제거/생성하지 않는다**
-- (사용자: *"단순히 bind/unbind 로 관리해야지 그것 자체를 제거/생성
-- 하는건 안 맞아보임"*). `SL-75`/`D-60`의 "마운트 전엔 `0`"이 이걸로
-- 코드에서도 성립한다. 콜백 본문은 아래 `materializeSlotTree` 절의
-- `makeBaseObserver`가 소스(머리에 미실체화 가드 — 이 "등록 즉시 1회"가
-- `bk`를 eager 생성하지 않도록).
-- **[2026-08-27, Q3] 옛 `_elemIndex`는 여기 없다** — 그 맵은
-- `bk.indexOfElement`로 Dispatch 부기에 산다(`indexOfRaw`가 조회).
local self = setmetatable({
_elements = {},
Length = Source(0),
Offset = Source(0),
...
}, Slot_mt)
self._baseObserver = makeBaseObserver(self) -- 등록 즉시 1회는 가드가 삼킨다
if initial ~= nil then
self._crudUsed = true -- 빈 테이블이어도 즉시 잠금(아래 참고)
for _, v in ipairs(initial) do -- ipairs가 첫 nil에서 멈춤
@ -1294,6 +1343,7 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에
```lua
function Slot:List(data, updateFn, keyFn, opts)
if self._destroyed then error("Slot: destroyed Slot cannot be reused", 2) end -- [2026-08-27 Q2]
assert(not self._listed, "Slot already has :List installed")
-- [2026-08-24 6라운드 손 트레이싱 `H-37`] **역방향 가드** — 산문(위 "CRUD API
-- 확정" 절)이 확정해둔 대칭 가드가 의사코드에 안 옮겨져 있었다. 이게 없으면
@ -1373,7 +1423,8 @@ function activateList(self, physicalTarget)
-- 옛 설계는 `keyIndex[key]``_elements` 인덱스로 쓰고 `raw*`에 그대로
-- 넘겼는데, 그 값은 **사이클 도중엔 stale**이다(`rawAdd`/`rawRemove`가
-- 배열을 시프트하는데 교체는 사이클 끝에 한 번뿐). 이제 인덱스는
-- `indexOfRaw(self, element)`(= `slot._elemIndex` 조회)로 그때그때 구하고,
-- `indexOfRaw(self, element)`(= `bk.indexOfElement` 조회 — **[2026-08-27 Q3]**
-- 옛 `slot._elemIndex`는 Dispatch 부기로 갔다)로 그때그때 구하고,
-- 여기 남는 건 **"직전 사이클에 존재했던 키"**라는 집합 용도뿐이다.
local mounted, userdata, prevKeys = {}, {}, {}
@ -2203,21 +2254,68 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
-- 근거와 대안 비교는 `reference/slot-attach-decomposition.md`.
-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
-- ⭐⭐ [2026-08-27, 9라운드 `H-125`/Q2] `_baseObserver`의 콜백 — **Slot 생성자가
-- 만든다**(위 `Slot(initial)`). 이 함수는 생성자가 부르는 팩토리일 뿐이고, 여기
-- 두는 이유는 본문이 `bk`·`recompute`를 알아야 해서다.
local function makeBaseObserver(slot)
return slot.Offset:Observer(function()
-- ⭐ 미실체화면 할 일이 없다 — 생성자의 "등록 즉시 1회"를 여기서 삼킨다.
-- 이 가드가 없으면 그 1회가 **모든 Slot**에 대해 `getBookkeeping`
-- 불러 `bk` + `recomputeBlocker`를 eager 생성한다(한 번도 마운트 안 되는
-- Slot까지). 의미도 맞다 — 미실체화 Slot의 베이스 변경엔 할 일이 없다.
if slot._physicalTarget == nil then return end
-- ⭐ [2026-08-26, 8라운드 `H-119`/`H-3`] 두 줄이 빠져 있었다:
-- (1) 배치 Blocker만 보고 `recomputeBlocker`를 안 봐서 재진입 차단이
-- 우회됐고, (2) `H-3`의 3번("베이스가 바뀐 경우라
-- 두 필드 `0`")이 의사코드에 없었다.
local bk = getBookkeeping(slot)
-- [2026-08-26] 무효화는 **두 필드를 다** 내린다(캐시도 낡고 Set도
-- 다시 해야 한다) — `dispatch-core-plan.md`의 "두 필드" 절.
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 -- 베이스가 바뀜 → 1번부터 전부
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
recompute(slot, bk)
end)
end
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- [2026-08-27, 9라운드 Q2] 파괴된 Slot은 마운트 불가(위 "파괴된 Slot은 재사용
-- 불가" 절). `attachSlot`이 이 함수를 거치므로 여기 한 번이면 된다.
if slot._destroyed then error("Slot: destroyed Slot cannot be mounted", 2) end
-- [2026-08-24 6라운드 `H-2`] **앵커를 먼저 저장한다.** `setLength`의 4번째
-- 인자(그리고 length가 State일 때 `bk.observers`의 앵커)가 실체화 시점부터
-- 필요한데 `_mountedInst``mountSlotTree`에서야 채워진다. `raw*`가 이
-- 필드를 본다(위 "`isMounted` 이중 추적 분리" 절의 3상태).
slot._physicalTarget = physicalTarget
-- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1)
-- [2026-08-21 5라운드 G절 검토 중 발견] **기존 Source가 있으면 재사용한다.**
-- 매번 `Source(0)`을 새로 만들면, 언마운트가 `slot.Offset`을 일부러 보존해둔
-- ⭐⭐ [2026-08-27 순서 재배치, 9라운드 `H-125`/Q2] **게이트와 관측자 바인드가
-- `setOffsetSource`(= 베이스가 바뀌는 emit) *위*로 왔다.** 옛 순서는
-- `setOffsetSource``blocker:On()` → (`_baseObserver`가 있으면 bind /
-- 없으면 생성)이었는데, 재마운트에선 그 관측자가 `unmountSlotTree`
-- unbind해둔 상태라 emit이 `canExecute`에 걸러져 **두 필드가 0으로 안
-- 내려가고**, 꼬리 `recompute`가 옛 베이스의 `offsetCache[1]`을 그대로 썼다
-- (첫 마운트는 "등록 즉시 1회"가 *우연히* 0으로 만들어 안 보였다 — 그
-- 비대칭이 버그의 집). 이제 `Offset`/`_baseObserver`는 생성자에서 나므로
-- 여기엔 생성 분기가 없고, 바인드는 **발화가 아니므로** 순서가 곧 계약이다.
-- Blocker가 감싸는 게 **등록뿐**이라 "배치 등록 게이팅"이라는 정의와 범위가
-- 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
-- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가
-- 밀리면 내 자식들의 offset도 다시 계산돼야 한다. `_listObserver`/
-- `_detachCleanup`**같은 취급**: 재마운트 땐 앵커만 새 target으로(앵커가
-- 물리 target인 근거는 위 `C-4`). **바인드가 `blocker:On()` *뒤*인 게
-- 중요하다** — 아래 emit이 깨운 콜백이 게이트 없이 `recompute(slot)`
-- 완주하면, 그 시점 `bk`는 언마운트 전 옛 부기(`Relate(slot)` 위에
-- 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다. 이 Blocker는 이 Slot의 것이고
-- `setOffsetSource`**부모 owner의** blocker를 보므로 간섭이 없다.
bindLifetime(physicalTarget, slot._baseObserver)
-- offset — activateList가 updateFn에 이 값을 넘겨야 하므로(C1). 여기서
-- `slot.Offset`이 새 베이스로 `Set`되고, 위 관측자가 두 필드를 0으로 내린 뒤
-- 게이트에 걸려 돌아온다. **이미 있는 Source를 등록만 한다** — 매번
-- `Source(0)`을 새로 만들면 언마운트가 `slot.Offset`을 일부러 보존해둔
-- 이유(이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**,
-- `SL-75`/`DC-6`)가 재마운트에서 그대로 무너진다 — 그 구독자들은 옛 객체를
-- 계속 보고 있어 새 위치가 영원히 반영 안 됨. identity 유지가 포탈의 전제.
local offsetSource = slot.Offset or Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
-- `SL-75`/`DC-6`)가 무너진다. identity 유지가 포탈의 전제.
Dispatch.setOffsetSource(ownerKey, position, slot.Offset)
-- [2026-08-21 5라운드 G절] `recompute(slot, ...)``0`이 아니라 **이
-- `slot.Offset`**에서 시작한다 — 그래야 자식 offset이 절대값이 된다(안 그러면
-- depth ≥ 2에서 부모 베이스만큼 어긋남). 베이스를 부기에 따로 복사해두지
@ -2231,44 +2329,12 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- `Dispatch.getOffsetAt`이 성립하고, `updateFn`에 넘기는 `index`를 거기서
-- 구할 수 있다(아래 "`:List`의 `index`도 nested-Slot 결과의" 절).
-- 자식 부기만, 재귀로 길이를 bottom-up 확정.
-- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는
-- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
-- **⭐ [2026-08-24 순서 변경, `H-2`] `activateList`가 이제 Blocker *안*에서
-- 돈다.** 5라운드 `AS-5`가 이걸 밖에 둬도 된다고 한 근거는 *"그 안에선
-- 게이팅할 recompute 자체가 안 일어난다"*였는데, 위 정정으로 일어나게
-- 됐다 — 밖에 두면 population 중 아이템마다 `recompute`가 돌아 O(n²)다.
-- (`getOffsetAt`은 `lengthList`를 직접 읽으므로 게이트 안에서도 정확하다 —
-- Blocker가 막는 건 `recompute`지 부기 등록이 아니다.)
-- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가
-- 밀리면 내 자식들의 offset도 다시 계산돼야 한다.
-- `_listObserver`/`_detachCleanup`과 **같은 취급**: 생성 1회, 재마운트 땐
-- 앵커만 새 target으로(앵커가 물리 target인 근거는 위 `C-4`).
-- **생성이 `blocker:On()` *뒤*인 게 중요하다** — Observer의 "등록 즉시 1회
-- 실행"이 여기서 곧바로 `recompute`를 태우면 아직 자식 등록이 하나도 안 된
-- 상태를 훑게 된다. 게이트 안에서 만들면 그 1회가 그냥 삼켜지고, 아래
-- 마지막 `recompute`가 어차피 정확한 값을 계산한다.
if slot._baseObserver then
bindLifetime(physicalTarget, slot._baseObserver)
else
slot._baseObserver = offsetSource:Observer(function()
-- ⭐ [2026-08-26, 8라운드 `H-119`/`H-3`] 두 줄이 빠져 있었다:
-- (1) 배치 Blocker만 보고 `recomputeBlocker`를 안 봐서 재진입 차단이
-- 우회됐고, (2) `H-3`의 3번("베이스가 바뀐 경우라
-- 두 필드 `0`")이 의사코드에 없었다.
local bk = getBookkeeping(slot)
-- [2026-08-26] 무효화는 **두 필드를 다** 내린다(캐시도 낡고 Set도
-- 다시 해야 한다) — `dispatch-core-plan.md`의 "두 필드" 절.
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 -- 베이스가 바뀜 → 1번부터 전부
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
recompute(slot, bk)
end)
bindLifetime(physicalTarget, slot._baseObserver)
end
-- **⭐ [2026-08-24 분기, `H-2`. 같은 날 `/code-review high` 지적으로 조건 정정]**
-- `activateList`**최초 population을 실제로 수행할 때만** 이 루프를
-- 건너뛴다 — 그때는 그 안의 `rawAdd`가 이미 자리마다 등록을 마쳤으므로
@ -2293,7 +2359,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None,
-- length는 상수 1. 순서는 늘 offsetSource → setLength(C4).
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`)
Dispatch.setLength(slot, i, 1, physicalTarget, element) -- 4번째 = 생명주기 앵커(5라운드 `C-4`), 5번째 = 요소(9라운드 Q3)
end
end
end
@ -2318,8 +2384,9 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
-- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에
-- 만족시킬 수 없었던 지점.
Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget)
-- 만족시킬 수 없었던 지점. 5번째 = 이 Slot 자신(9라운드 Q3 — 길이가 State라
-- 지속 등록이 생기는 자리, 클로저가 `bk.indexOfElement[slot]`을 조회한다).
Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget, slot)
end
-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요.
@ -2483,7 +2550,7 @@ local function unmountSlotTree(slot)
-- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다.
if slot._listObserver then unbindLifetime(slot._listObserver) end
if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end
if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- [2026-08-21 G절]
unbindLifetime(slot._baseObserver) -- [2026-08-21 G절; 2026-08-27 Q2] 생성자에서 나므로 항상 있다 — 가드 없음
-- `slot._detached`**안 건드린다** — 언마운트는 파괴가 아니고,
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
slot._mounted, slot._mountedInst = false, nil
@ -2497,6 +2564,9 @@ local function unmountSlotTree(slot)
end
local function destroySlotTree(slot)
-- [2026-08-27, 9라운드 Q2] 이중 파괴는 no-op — teardown 경로가 겹치는 건
-- 실재한다(`dispose`가 이 함수를 부르므로 이중 `dispose`도 여기서 흡수).
if slot._destroyed then return end
-- [2026-08-21] Owned=false면 이 Slot은 요소를 만든 적이 없다 — 파괴 대신
-- 언마운트만(위 "`Owned` 옵션" 절). `Slot:Add(state)` sugar가 그 경우.
if slot._owned == false then
@ -2535,14 +2605,18 @@ local function destroySlotTree(slot)
-- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한
-- 경우**다. 그러면 `data`가 emit될 때마다 이미 죽은 자식들에 대해
-- reconcile이 계속 돈다.
if slot._listObserver then
unbindLifetime(slot._listObserver)
slot._listObserver, slot._listActivated = nil, nil
end
if slot._baseObserver then -- [2026-08-21 G절] 파괴는 핸들까지
unbindLifetime(slot._baseObserver)
slot._baseObserver = nil
end
-- ⭐⭐ [2026-08-27 정정, 9라운드 Q2] **핸들은 unbind만 하고 지우지 않는다.**
-- 여기 한때 `slot._listObserver, slot._listActivated = nil, nil`
-- `slot._baseObserver = nil`이 있었다 — 근거(*"안 풀면 `gchold[physicalTarget]`
-- observer를 계속 강하게 붙잡는다"*)는 실은 `unbindLifetime`이 하는 일이고,
-- `nil` 대입은 slot → observer 참조 하나를 놓는 것뿐인데 slot 자신이
-- 쓰레기라 공짜다. 대신 그 `nil`**"파괴됨"이라는 두 번째 뜻**을 세 필드에
-- 겸하게 했고(`_listObserver == nil`은 원래 "`data`가 plain table",
-- `_listActivated == nil`은 "최초 population 전"이라는 자기 뜻이 있다) —
-- `invalidAfter`가 두 뜻을 겸하다 사고 난 것과 같은 모양이라 그만둔다.
-- 파괴됨은 아래 `_destroyed` 하나만 말한다.
if slot._listObserver then unbindLifetime(slot._listObserver) end
unbindLifetime(slot._baseObserver) -- 생성자에서 났으므로 항상 있다(Q2)
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
for i, observer in pairs(bk.observers) do -- [2026-08-26] 가드 제거(위와 같은 이유)
unbindLifetime(observer)
@ -2552,6 +2626,10 @@ local function destroySlotTree(slot)
-- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음.
slot._mounted, slot._mountedInst = false, nil
slot._physicalTarget = nil -- [2026-08-24 `H-2`] 같은 이유로 앵커도
-- ⭐ [2026-08-27, 9라운드 Q2] 파괴됨은 이 플래그 하나가 말한다 — 마운트·공개
-- CRUD·`:List` 진입이 이걸 보고 error한다(위 "파괴된 Slot은 재사용 불가" 절).
-- `_mounted = false`만으로는 재마운트 throw 가드에 안 걸리는 게 문제였다.
slot._destroyed = true
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
-- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
@ -2576,7 +2654,15 @@ end
-- `keyIndex` 값은 전부 어긋나 있다(`[a,b]`→`[x,a,b]`가 조용히 `a,b,x`
-- 되고, 전체 삭제는 해시 순회 순서에 따라 `nil` 인덱싱까지 간다).
-- * **확정된 해법 — 역방향 인덱스 맵을 raw 층이 직접 든다.**
-- **`slot._elemIndex`**(`물리 요소 → _elements 인덱스`)를 `_elements`
-- **⭐ [2026-08-27 이동, 9라운드 Q3] 그 맵은 이제 `bk.indexOfElement`
-- (Dispatch 부기)다** — 여기 한때 `slot._elemIndex`였는데, 7라운드 `H-102`
-- 같은 뜻의 맵을 Dispatch 층에 따로 만들어(그것도 사용자가 정한 적 없는
-- `token`으로) **같은 맵이 두 층에** 살게 됐고, 그게 사고의 원인이었다
-- (사용자: *"elem->index 를 누가 관리하느냐가 어디서 관리하느냐가 명확하지
-- 않아서 자꾸 사고가 나는듯"*). 소유는 `bk` 하나, owner가 Slot이든 `inst`
-- 규칙 하나. raw 층은 `getBookkeeping(self).indexOfElement`를 **읽고
-- 갱신**한다 — 아래 항목들의 "맵"은 전부 이것.
-- (`물리 요소 → _elements 인덱스`)를 `_elements`
-- 같은 수명으로 두고, **자리를 밀거나 당기는 모든 연산**
-- (`spliceArraysUp`/`spliceArraysDown`/`rawMove`/`rawSwap`/`rawReplace`/
-- `rawAdd`)이 같이 갱신한다. detach된 요소는 `_elements` 밖이므로
@ -2586,9 +2672,10 @@ end
-- - **`indexOfRaw(self, element)`가 이 맵 조회가 되고, 폴백이 아니라
-- 기본 경로로 승격된다.** `settle``keyIndex[key]` 대신
-- `indexOfRaw(self, prev)`를 쓴다.
-- - **층 분리는 오히려 더 깨끗해진다** — 맵이 `_elements`와 같은 층에
-- 살아서 `raw*``:List`의 클로저 상태(`mounted`/`prevKeys`)를 볼
-- 필요가 없다. **사용자 판단**(2026-08-24): *"raw* 가 층위를 알아야할
-- - **층 분리는 오히려 더 깨끗해진다** — 맵이 `raw*`가 이미 만지는 부기
-- (`bk`)에 살아서 `raw*``:List`의 클로저 상태(`mounted`/`prevKeys`)를 볼
-- 필요가 없다(**[2026-08-27]** 원문은 *"`_elements`와 같은 층에"*였다 —
-- 맵이 `bk`로 가도 이 논거는 그대로다). **사용자 판단**(2026-08-24): *"raw* 가 층위를 알아야할
-- 이유를 모르겠는 상태. 그냥 k->realElem 을 list 에선 저장하고 …
-- realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸
-- 같이 업데이트해주는 편이 나아보이기도. quad-base 의 현 모양에서 더
@ -2622,7 +2709,7 @@ function rawUnmount(self, index)
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
end
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.indexOfElement/bk.N — 아래 참고
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
@ -2647,8 +2734,8 @@ function rawDetach(self, index)
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
end
spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도
-- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
spliceArraysDown(self, index) -- `bk.indexOfElement`에서도 이 요소·자리가
-- 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
@ -2694,10 +2781,13 @@ end
-- `rawMove`/`rawSwap`/`rawExtract`/`rawSplice`/`rawClear`는 이름만 있었다
-- (`rawMove`는 `reconcile`**직접 부르는** 함수인데도). 새 결정이 필요한 건
-- 위 `collectLeaves` 하나였고, 나머지는 아래 규약대로 쓰면 된다:
-- 1. **함께 치환되는 것**`_elements`, `slot._elemIndex`(`H-1`),
-- 1. **함께 치환되는 것**`_elements`, `bk.indexOfElement`(`H-1`,
-- **[2026-08-27 Q3]** 옛 `slot._elemIndex``reindexFrom`이 갱신),
-- `bk.lengthList`, `bk.sourceList`, `bk.observers`. 넷 다 **position
-- 인덱스**이고 `sourceList[i]`는 그 중첩 Slot 자신의 `slot.Offset`이라
-- position이 아니라 **요소에 귀속**된다 → 전부 같은 순열로 움직인다.
-- (**[2026-08-27]** 옛 `bk.tokens`/`bk.indexOfToken`은 폐기 — 이동 구간을
-- 규약 4가 `setLength`로 다시 태우므로 요소 키 맵은 거기서 다시 쓰인다.)
-- 2. **`bk.N`은 안 변한다** — 자리 수가 그대로이므로(`spliceArraysUp`/`Down`과
-- 갈리는 지점).
-- ⚠️ [2026-08-26 범위 정정, 5·6차 `/code-review high`] **`rawSplice`·
@ -2752,16 +2842,19 @@ end
-- 통째로 지웠었다.** 상태는 **셋**인데(미실체화/실체화/마운트) `_mounted` 경계만
-- 코드에 남기고 **첫 경계를 안 뒀다** — 그러면 `Slot { frameA }` 생성자가
-- (그 자체가 `:Add`를 부른다, 위 그 절) `materializeSlotTree`보다 훨씬 먼저
-- `Dispatch.setLength(self, 1, 1, nil)``gatedRecompute``getOffsetAt`
-- `ownerKey.Offset:Get()`에서 **`slot.Offset`이 아직 nil이라 즉시 죽는다.**
-- 중첩이면 더 나쁘다: `attachSlot(element, nil, …)``bindLifetime(nil, …)`
-- 이 문서가 스스로 치명적이라 적어둔 그것이다.
-- 부기를 시도한다. (**[2026-08-27 근거 정정, 9라운드 Q2]** 여기 한때 *"`getOffsetAt`
-- → `ownerKey.Offset:Get()`에서 `slot.Offset`이 아직 nil이라 즉시 죽는다"*가
-- 근거였는데, `Offset`이 생성자에서 나면서 **그 근거는 거짓**이 됐다 — 남는
-- 근거는 아래 하나다.) 중첩이면 치명적이다: `attachSlot(element, nil, …)`
-- `bindLifetime(nil, …)`은 이 문서가 스스로 치명적이라 적어둔 그것이다.
-- **`_physicalTarget`이 곧 "실체화됐는가"의 판정이다.**
function rawAdd(self, element, index, fromDetached)
claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외)
index = index or (#self._elements + 1)
table.insert(self._elements, index, element)
reindexFrom(self, index) -- `_elemIndex` 갱신 — **상태와 무관하게 항상**
reindexFrom(self, index) -- `bk.indexOfElement` 갱신 — **상태와 무관하게 항상**
-- ([2026-08-27 Q3] 그래서 미실체화 Slot도 `:Add`만 하면
-- `bk`가 생긴다 — `getBookkeeping`은 lazy라 안전)
if self._physicalTarget == nil then
-- **아직 실체화 전: 부기의 앵커도 베이스 offset도 없다.** `_elements`
@ -2788,7 +2881,7 @@ function rawAdd(self, element, index, fromDetached)
if self._mounted then -- [`H-12`] 물리 op만 가른다
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
end
Dispatch.setLength(self, index, 1, self._physicalTarget) -- **뒤를 미는 것**은 그 다음
Dispatch.setLength(self, index, 1, self._physicalTarget, element) -- **뒤를 미는 것**은 그 다음 (5번째 = 요소, 9라운드 Q3)
-- [`H-19`] 명시 `recompute(self, bk)`**삭제했다**`setLength`가 상수
-- 길이에도 마지막에 `gatedRecompute()`를 부르므로 두 번 돌고 있었고,
-- "recompute를 누가 부르는가"의 소스가 두 곳이면 `H-3`의 캐시 무효화를
@ -2800,7 +2893,8 @@ end
-- [신설, 2026-08-21 5라운드 `B-5`] rawReplace — 자리를 유지한 채 내용만 교체.
-- `destroyOld`는 호출부가 정한다(공개 `Replace`는 항상 true, `:List``_owned`).
-- [2026-08-21, **정정 2026-08-24 `H-1`**] `indexOfRaw(self, element)`
-- **`self._elemIndex[element]` 한 번 조회**. 공개 `Slot:IndexOf`와 다른 점은
-- **`getBookkeeping(self).indexOfElement[element]` 한 번 조회**(**[2026-08-27 Q3]**
-- 옛 `self._elemIndex`). 공개 `Slot:IndexOf`와 다른 점은
-- 그대로다: 그쪽은 사용자가 넘긴 **언래핑된 값**으로 찾아주는 API고(위
-- "래핑/언래핑" 절), 이쪽은 물리 요소를 그대로 찾는다.
-- **옛 서술("O(n) 선형 탐색하는 예외 경로용 폴백, reconcile은 `keyIndex`
@ -2813,8 +2907,11 @@ function rawReplace(self, index, newElement, destroyOld)
releaseOwner(oldElement, self)
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
-- [2026-08-24 `H-1`] 역방향 맵도 같이 — 자리 수는 안 변해도 **주인이 바뀐다**
self._elemIndex[oldElement] = nil
self._elemIndex[newElement] = index
-- ([2026-08-27 Q3] 맵은 `bk.indexOfElement`. 아래 `setLength(…, newElement)`
-- 새 주인을 등록하지만, 미실체화 분기는 `setLength`를 안 부르므로 여기서 직접)
local bk0 = getBookkeeping(self)
bk0.indexOfElement[oldElement] = nil
bk0.indexOfElement[newElement] = index
if not self._mounted then
if destroyOld then -- 물리 트리 밖이라 native* 교체가 필요 없다
@ -2827,7 +2924,7 @@ function rawReplace(self, index, newElement, destroyOld)
if isSlot(newElement) then attachSlot(newElement, self._physicalTarget, self, index, false)
else
Dispatch.setOffsetSource(self, index, None)
Dispatch.setLength(self, index, 1, self._physicalTarget)
Dispatch.setLength(self, index, 1, self._physicalTarget, newElement) -- [Q3] 5번째 = 요소
end
end
return
@ -2856,7 +2953,7 @@ function rawReplace(self, index, newElement, destroyOld)
local op = if destroyOld then nativeRemove else nativeExtract
op(self._mountedInst, offset, { oldElement }, { newElement }) -- 한 번에
end
Dispatch.setLength(self, index, 1, self._physicalTarget)
Dispatch.setLength(self, index, 1, self._physicalTarget, newElement) -- [Q3] 5번째 = 요소
-- [`H-19`] 명시 `recompute(self, bk)` 삭제 — `setLength`에 일임(위 `rawAdd` 참고)
end
@ -2887,7 +2984,7 @@ function rawRemove(self, index)
nativeDispose(element) -- 트리 밖이라 offset이 필요 없다
end
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.indexOfElement/bk.N — 아래 참고
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
@ -2898,17 +2995,22 @@ end
```
**⭐ [2026-08-24 6라운드 손 트레이싱 `H-1`/`H-5`/`H-3`] `spliceArraysUp`/
`spliceArraysDown`이 해야 하는 일이 셋 늘었다.**
`spliceArraysDown`이 해야 하는 일 — 아래 목록이 소스다**(**[2026-08-27, 9라운드
`H-132`]** 한때 "셋 늘었다"고 세어뒀는데 그 뒤 항목이 늘고 줄어 개수가 어긋났다 —
세지 않는다).
- **`slot._elemIndex`는 별도 헬퍼 `reindexFrom(self, from)`이 맡는다**(`H-1`,
**[2026-08-24 분리]**). 이 맵은 `_elements`의 역방향이라 **실체화 여부와
무관하게 항상** 정확해야 하는데, `spliceArrays*`는 부기(`bk`)를 만지므로
실체화된 뒤에만 부를 수 있다 — 그래서 둘을 갈랐다. `reindexFrom`
`from`부터 끝까지 `_elemIndex[self._elements[i]] = i`를 다시 쓰고,
제거 경로에선 빠지는 요소를 맵에서 **뺀 뒤** 부른다.
`_elements`를 시프트하는 자리는 **전부** 이걸 부른다(`rawAdd`의 미실체화
얼리리턴 포함). `spliceArraysUp`/`Down`은 자기 몫으로 이걸 같이 부르되,
하는 일은 아래 부기 항목들이다.
- **`bk.indexOfElement`는 별도 헬퍼 `reindexFrom(self, from)`이 맡는다**(`H-1`,
**[2026-08-24 분리, 2026-08-27 맵 이동]**). 이 맵은 `_elements`의 역방향이라
**실체화 여부와 무관하게 항상** 정확해야 한다. `reindexFrom``from`부터
끝까지 `bk.indexOfElement[self._elements[i]] = i`를 다시 쓰고, 제거 경로에선
빠지는 요소를 맵에서 **뺀 뒤** 부른다. `_elements`를 시프트하는 자리는
**전부** 이걸 부른다(`rawAdd`의 미실체화 얼리리턴 포함).
`spliceArraysUp`/`Down`은 자기 몫으로 이걸 같이 부르되, 하는 일은 아래 부기
항목들이다. (**[2026-08-27 Q3]** 분리의 옛 근거 *"`spliceArrays*`는 `bk`를
만지므로 실체화된 뒤에만 부를 수 있다"*는 맵이 `bk`로 가며 **소멸**했다 —
`getBookkeeping`은 lazy라 언제 불러도 된다. 분리는 "맵은 시프트마다, 부기
배열은 실체화 뒤"라는 **호출 시점 차이** 때문에 그대로 둔다. 따름정리:
미실체화 Slot도 `:Add`만 하면 `bk`가 생긴다.)
- **`spliceArraysUp``lengthList[index]`에 자리표시자(`0`)를 채운다**(`H-5`).
옛 순서는 `spliceArraysUp``bk.N`을 먼저 올리고 `lengthList[index]`
`setLength`가 마지막에 채워서, **그 사이에 `nativeInsert`가 끼어 있었다.**
@ -2918,6 +3020,16 @@ end
그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지"
불변식으로는 안 덮인다.** `sourceList``None`으로 채워지는 것과 대칭을
맞춰 창 자체를 없앤다.
- **⭐⭐ [2026-08-27 신설, 9라운드 `H-126`/Q3] 비워지는 자리는 세 배열 전부
처리한다.** `spliceArraysUp``index` 자리에 `lengthList = 0` / `sourceList =
None` / **`observers = nil`**, `spliceArraysDown`은 당긴 뒤 꼬리(`N`) 세 자리를
`nil`로. 자리표시자 두 줄만 적혀 있고 `observers`는 말이 없어서, `t[i+1] =
t[i]` 복사 루프로 짜면 `observers[index]`에 옛 값이 남아 `index``index+1`
**같은 Observer**를 가리켰다 — 이어지는 `setLength(self, index, …)`
`oldObserver`로 그걸 `unbindLifetime`해 **밀려난 요소의 길이 관측자를
죽인다**(그 요소가 커져도 뒤 형제가 영영 안 밀린다 — 9라운드 실측
`vacate=false`). `Down`의 꼬리 잔여는 `bk.N` 밖이라 당장은 안 보이지만 다음
`Up`이 그 자리를 범위 안으로 밀어 올리면 자리표시자 대신 유령 값이 들어온다.
- **캐시를 앞으로 당긴다**(`H-3`) —
**두 필드 다** `math.min(…, index - 1)`:
`bk.offsetCacheValidUpTo`(캐시 유효 상한)와 `bk.offsetSetUpTo`(offset
@ -2947,23 +3059,17 @@ end
되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서
`{a,a,a, b,b, c,c}`에서 앞의 `a,a`가 사라졌는데 커서가 이미 `c`
있어도 복구된다.
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-102`] `bk.tokens``bk.indexOfToken`
같이 밀어야 한다.** `setLength`가 만드는 `gatedRecompute` 클로저는 인덱스를
캡처하지 않고 **자리별 토큰**으로 `bk.indexOfToken[token]`을 조회한다
(`slot._elemIndex`와 같은 개념을 Dispatch 층위로 격상 —
`base/dispatch-core-plan.md`). 그래서 splice가 다음 둘을 반드시 해야 한다:
1. **`bk.tokens`를 다른 배열과 같이 당긴다**(`lengthList`/`sourceList`/
`observers`와 완전히 같은 처리).
2. **밀린 자리 전부에 대해 `bk.indexOfToken[token]`을 새 인덱스로
갱신하고, 사라진 자리의 토큰 항목은 지운다.** 강한 키 테이블이라
안 지우면 **누수**다.
**안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는
인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로**
`bk.offsetCacheValidUpTo`/`bk.offsetSetUpTo`를 당긴다(앞선 자리가 영영 다시
offset을 못 받는다).
이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래
결함이었다.
- **⛔ [2026-08-27 폐기, 9라운드 Q3/`H-141`] 여기 한때 *"`bk.tokens`와
`bk.indexOfToken`도 같이 밀어야 한다"*(7라운드 `H-102`, 2026-08-25)는 항목이
있었다.** `setLength``gatedRecompute`가 인덱스 대신 캡처할 신원으로
`token = {}`을 두고, splice가 (1) `bk.tokens`를 같이 당기고 (2) 밀린 자리
전부의 `bk.indexOfToken[token]`을 갱신·사라진 항목을 지우라는 요구였다.
**토큰은 사용자가 정한 적 없는 신원**이고(`/code-review`가 "`len`은 자리마다
유일하지 않다"를 잡으며 요소 키로 되돌아가는 대신 발명), 같은 뜻의 맵을 두
층에 두는 원인이었다. 이제 클로저는 **요소를 캡처**하고 `bk.indexOfElement`
조회하므로(위 첫 항목) 이 요구는 통째로 사라진다 — `H-102`의 결론(*"클로저에
박힌 인덱스가 낡는다 → 캡처 말고 조회"*)은 그대로이고, 조회 대상만 원래
사용자 지시(요소 → 인덱스)로 돌아왔다.
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
@ -3091,6 +3197,9 @@ Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-S
-- 한 줄이 전부였고, **같은 문서가 확정해둔 가드를 하나도 하지 않았다.**
-- 산문이 이미 정확한 알고리즘을 서술해뒀으므로 옮기기만 한 것이다.
function Slot:Add(element, index)
-- (0) [2026-08-27, 9라운드 Q2] 파괴된 Slot — 모든 공개 CRUD·`:List`·마운트
-- 진입점이 같은 가드를 둔다(위 "파괴된 Slot은 재사용 불가" 절).
if self._destroyed then error("Slot: destroyed Slot cannot be reused", 2) end
-- (1) `:List`가 설치돼 있으면 수동 CRUD 금지 (위 "CRUD API 확정" 절)
assert(not self._listed, "Slot: :List가 설치된 Slot엔 수동 CRUD를 쓸 수 없음")
-- (2) index 범위 검증 — **clamp 안 함**, 범위 밖이면 error

View file

@ -283,6 +283,27 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
바꾸고 그 이름이 서술하던 동작은 그대로, 새 규칙의 *근거*를 잘못 적어 그
근거가 다른 불변식을 함의하게 만듦). **수정분 자체가 새 결함을 만든다는
게 반복 관측됐으니, 반영이 끝난 뒤 한 번 더 돌리는 걸 기본으로 할 것.**
- **⭐⭐ [2026-08-27 신설] `/code-review`(그리고 메인 세션 자신)가 내놓는
*새 필드·인자·이름·메커니즘*은 발견이지 결정이 아니다 — 사용자 결정 없이
`base/`에 넣지 말 것.** code-review는 완전한 외부자라 이 코퍼스의 결정
이력(누가 그 책임을 갖는지, 어떤 모양이 이미 기각됐는지)을 모르고, 증상을
닫는 가장 가까운 코드를 제안한다. 실제 사고: 2026-08-25 `/code-review`
*"`len`은 자리마다 유일하지 않다"*를 잡고 원래 키(요소)로 되돌아가는 대신
`token = {}`이라는 신원을 발명해 `bk.tokens`/`indexOfToken` 두 구조를
넣었고, 그게 사용자 인용문(*"dispatch 로 격상"*) 옆에 앉아 **승인된
메커니즘처럼** 하루를 살았다(9라운드 `H-141`, 사용자: *"내가 등장시킨 적
없는 token 이 나와서 당황스러움"*). 같은 날 메인 세션도 같은 실수를 세 번
했다(`subject` 인자 / Observer에 위치 필드 — 이미 기각된 `Effect` userdata의
재개방 / 조회 클로저). 규칙:
1. **리뷰 발견을 반영하기 전에 (a) 그 책임의 현재 소유자와 (b) 그 모양이
과거에 기각된 적 있는지를 먼저 grep한다** — `base/`의 "검토 후 안
만들기로 한 것"류 목록과 `archive/`가 그 자리다.
2. **발견이 새 필드·인자·이름·메커니즘을 요구하면 그건 사용자 문항이다**
`question.md`나 그 라운드의 followup에 갈래로 올리고 결정을 받는다. 증상만
확실하고 처방이 새 개념이면 "증상 확정, 처방 미정"으로 남긴다.
3. **기존 사용자 인용문 옆에 새 메커니즘을 붙이지 않는다** — 인용문이 승인한
것과 다른 것을 그 옆에 적으면 다음 세션이 승인된 것으로 읽는다. 붙일
땐 *"이 인용은 X를 승인한 것이고 Y는 리뷰 제안"*이라고 갈라 적을 것.
- **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할
것.** 다만 위 체크리스트+`doc-check.py`+`quad-doc-auditor`가 자리잡으면
이 감사는 "기계도 서브에이전트도 못 보는 것"(설계 자체의 자기모순, 의사코드

View file

@ -25,7 +25,9 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선
[2026-08-26] 8라운드 손 트레이싱까지 처리 완료** — 7라운드 반영분을 겹쳐
재트레이싱한 발견 17건(`H-107`~`H-123`)의 결정을 전부 `base/`에 반영했고
(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`),
`question.md` 최우선 절은 다시 비어 있다. 저장소 루트에
`question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋
`9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`
반영됐고 Q4~Q10 대기** — 소스는 `-round9-followup.md`. 게이트는 여전히 0. 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -0,0 +1,235 @@
# 구현 전 손 트레이싱 **9라운드** 감사 지시서
> **이 파일이 무엇인가**: 9라운드 감사자에게 그대로 주는 지시서다. 발견
> 보고가 아니라 그 **앞단**이고, 산출물은 별도 파일이다(§6).
> **[2026-08-26] 지시서를 저장소에 남기는 건 이번이 처음이다** — 7·8라운드
> 지시서는 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는
> "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 앞으로 라운드마다
> `-roundN-brief.md`를 같이 남긴다.
당신은 Roblox 엔진용 DOMless UI 렌더러 **quad**의 설계 코퍼스를 감사한다.
저장소 루트는 이 세션의 작업 디렉토리다. **구현(M2 = 반응형 코어) 착수
직전**이고, 여기서 놓친 설계 결함은 구현 한참 뒤에 터져 M2/M3를 다시 짜는
비용이 된다.
---
## §0 먼저 읽을 것 (이 순서로)
1. `CLAUDE.md``.claude/conventions.md` / `.claude/project-context.md` /
`.claude/todos.md` — 프로젝트 관례와 현재 상태.
2. `.claude/qa-request/pre-implementation-handtrace-round8.md` — 8라운드
발견 원문(`H-107`~`H-123`). 특히 **§5(이상 없다고 확인한 것)** 와
**§6(남은 의심 / 못 본 것)**.
3. `.claude/qa-request/pre-implementation-handtrace-round8-followup.md`
**이 라운드의 출발점.** 8라운드 결정 Q1~Q10과, 그 반영 뒤 돌린
`quad-doc-auditor` 11라운드 + `/code-review high` **7패스**의 기록.
`/code-review high`의 차수별 절(1차부터 7차까지)을 반드시 정독할 것 —
거기 적힌 **반복 실패 모드**가 당신의 사냥 목록이다(§3).
4. `ROADMAP.md`(M2/M3 체크리스트) / `.claude/question.md` /
`.claude/luau-test/STATUS.md`.
5. `base/`는 레인별 필요 범위만(§2). 전량 완독은 요구하지 않는다 — 8라운드
3차 패스가 이미 전량 완독했고 🔴가 0건이었다.
**이전 라운드**(1~7)는 필요할 때만 인용 자리를 부분 확인하라. 재심사
대상이 아니다.
---
## §1 이 라운드의 전제
8라운드 결정 반영과 그 뒤의 code-review 7패스 수정이 **전부 단일 커밋
`9dd8213`에 들어 있고, 그 결과물을 아무도 처음부터 트레이싱한 적이 없다.**
```
git show 9dd8213 --stat
git show 9dd8213 -- .claude/base/ ROADMAP.md
```
이게 이 라운드가 볼 델타의 **완전한 정의**다. `base/` 주요 증분:
`dispatch-core-plan +250` / `source-state-plan +160` / `slot-plan +139` /
`lifecycle-pattern +124` / `store-plan +117` / `effect-plan +103`.
**왜 이 델타가 특히 위험한가** — 같은 프로젝트가 낸 실측이 셋 있다.
- 8라운드의 🔴 다섯(`H-107`/`108`/`109`/`112`/`119`)은 **전부** "직전
라운드(7라운드)의 반영분이 서로 겹치는 자리"에서 나왔다. 개별 함수의
버그가 아니라, 하루 차로 확정된 결정들이 서로를 못 본 자리다.
- code-review 7패스의 HIGH 추이는 `1 → 1 → 0 → 1(설계 역전) → 0 → 3 → 0`
이다. **5차가 HIGH 0인데 6차에서 HIGH 3이 나왔고 전부 5차 수정이 만든
것**이었다. "직전 패스가 조용했다"는 수렴의 증거가 아니다. 7차 수정분은
**아무도 안 봤다.**
- 4차에서 설계 역전이 있었다 — `H-101`의 "새 필드를 안 만든다"가 뒤집혀
`bk.invalidAfter``bk.offsetCacheValidUpTo` / `bk.offsetSetUpTo` **둘로
분리**됐고 40곳 넘게 치환됐다. 감사 사이클 **끝머리**에 들어온 구조
변경이라 가장 덜 검증됐다.
---
## §2 레인 — 우선순위 A > C > B
리포트를 레인별로 독립적으로 쓸 것. 예산이 모자라 B를 못 해도 A/C 결과가
그대로 쓸 수 있어야 한다.
### 레인 A (최우선) — `9dd8213` 델타의 상호 간섭
이번에 바뀐 계약들을 **서로 겹쳐서** 읽는다. 개별 문서가 자기 안에서
일관된지가 아니라, **두 결정이 만나는 자리에서 성립하는지**를 본다.
이번 라운드가 바꾼 계약(전량은 followup이 소스, 여기 나열은 진입점):
- `Ref` 콜백이 **`fn(value, ref)`** — 2번째 인자가 곧 출처 `Epoch`.
훅 슈가의 `guard(fn)`도 2-인자가 됐다.
- Observer `fn`**세 자리** `fn(targetState, self, emitFrom)` +
`observer._state` **강참조**.
- `WeakSubscribe``.Subscribed = true`를 세운다.
- `Subscribe`/`WeakUnsubscribe`/`Unsubscribe`가 **전부 fail-fast**
("해제는 건 경로로 푼다"). *idempotent* 서술은 세 번에 걸쳐 지워졌다 —
**네 번째 사본이 남아 있는지 전수하라.**
- 예약 키 진단 타입 함수가 `CheckReservedKeys<keyof<T>>`.
- **부기 필드 2분할**`offsetCacheValidUpTo`(올리는 쪽: `getOffsetAt`,
어디서 불리든) / `offsetSetUpTo`(올리는 쪽: `recompute`만). 무효화는 둘 다
내린다. 옛 이름 `invalidAfter`는 **의도적으로 전멸**시켰다(단, **인용문·
절 제목·정정 배너 안에서는 옛 이름이 정본**이다).
- 무효화 인덱스가 **세 가지 → 네 가지**(splice 계열은 `j - 1`,
`rawMove`/`rawSwap` 계열 행 신설, `rawExtract`**조건부**).
- `recompute` 재진입 게이트가 **명시 호출부 전부**로 확대(`raw*` 3형제,
`_baseObserver`, `:List` 활성화 꼬리, `mountSlotTree` 꼬리, `Dispatch.drive`
꼬리, 일반 배치 계약).
- Store 생성자의 `defaults` `isSource` 화이트리스트 검증(`error` level 2),
`isModifier` 가드가 `Source` 생성자로 이동.
**특히 겹쳐 볼 것**(과거에 실제로 여기서 🔴가 나왔다):
- `H-113`(splice `-1`) × `H-119`(`_baseObserver`가 마커를 0으로) —
이 둘이 겹쳐 `math.max(…, 1)` 클램프가 필요해졌다. **같은 종류의
0-하한/끝-초과 경로가 다른 조합에 또 있는가.**
- 부기 필드 분할 × 무효화 4규칙 × `bk.N` 예외(`rawSplice`/`rawClear`/
조건부 `rawExtract`) — 세 개를 동시에 만족하는 CRUD 시퀀스를 **실제 값으로**
돌려라. 특히 **한 콜백에서 CRUD를 두 번**(예: `Remove``Add`) 하는
2-연산 경로. 4차 HIGH가 정확히 그 형태였다.
- `Ref` 2-인자 × 훅 슈가 × `Effect`의 dep 종류별 클로저 둘.
- fail-fast 3종 × `Effect` cleanup × leaf 사망 경로.
### 레인 C — 참조 구현을 갱신해 **실제로 돌리기**
`.claude/audit/handtrace-round7-reference-impl/`에 7라운드가 만든 M2 코어 +
M2→M3 경계 참조 구현과 스파이크(`spikes/`)가 있다. **그 계약은 8라운드
이후로 낡았다.**
1. 그 README의 원문 대조표를 따라 **지금의 `base/` 서술로 갱신**하라
(계약 4변경 + 부기 필드 분할 + 무효화 4규칙 + 재진입 게이트 확대).
2. `luau` / `luau-analyze`로 돌려라.
3. **⚠️ 참조 구현은 확정 의사코드의 *전사물*이라 그 자체가 틀렸을 수
있다.** 재실행은 검증이 아니다 — 발견을 판정할 땐 `base/` 원문과 줄
단위로 대조하라. 반대로 **참조 구현이 안 돌아가면 그건 거의 항상 문서의
결함**이다(7차 code-review가 `for d in seen do`가 유효한 Luau가 아니라는
걸 이렇게 잡았다).
실행 환경:
- 저장소 테스트는 **반드시 `./scripts/test.sh`**. `luau` CLI가 심볼릭 링크를
못 타는데 pesde 워크스페이스 링크가 전부 심볼릭이라, 그냥 `luau`로 돌리면
스모크가 죽고 `luau-analyze`는 **조용히 통과**한다(거짓 클린).
- 독립 스파이크는 스크래치패드에 만들어 `luau <파일>` /
`luau-analyze <파일>`로 돌려라.
### 레인 B — M5+ 값 단위 트레이싱 (첫 시도)
8라운드 §6이 남긴 유일한 실질 공백이다. **문서 정독은 됐지만 의사코드를
실제 값으로 돌려본 적이 한 번도 없다**:
- 그룹 `Attribute` 위임 체인(`attribute-plan.md`)
- `D` 생성자 (`ui-shorthand-plan.md`)
- 숏핸드 → `PropertyHandler` 위임 (`ui-shorthand-plan.md` ×
`dispatch-core-plan.md`)
- `:List` reconcile의 **실제 값 대입** (`slot-plan.md`)
구체적인 인스턴스 트리와 구체적인 값으로 한 사이클을 손으로 돌려라
("계약이 이어진다"는 수준이 아니라 변수마다 값을 적으면서).
---
## §3 사냥 목록 — 이 코퍼스에서 **반복 관측된** 실패 모드
followup의 code-review 절이 명시적으로 남긴 것들이다. 델타를 읽을 때 이
패턴을 **능동적으로** 찾아라.
1. **⭐ 토큰만 바꾸고 그 토큰이 든 문장을 안 읽음** — 이 세션에서만 **네 번**
반복됐다(`_observers`→`_deps`, `CheckReserved`→`CheckReservedKeys`,
`invalidAfter`→`offsetSetUpTo`, 그리고 역사 인용문 오염). 이름이 바뀌었는데
**그 이름이 서술하던 동작·근거·불변식이 옛것 그대로**인 자리를 찾아라.
2. **전역 치환이 인용문·절 제목·정정 배너를 오염** — 그 셋은 *과거에 무엇이라
적혀 있었는가*를 보존하는 게 목적이다. `H-101` 인용문이 새 필드 이름으로
바뀌어 "새 필드를 안 만든다"와 자기모순이 된 사례가 실제로 있었다.
**반대 방향도 보라** — 되돌리다 살아 있는 서술까지 옛 이름으로 돌린 자리.
3. **표에 행만 넣고 헤딩/산문/개수는 그대로** — "세 가지"라고 쓰인 채 표엔
네 행. 두 번 반복됐고(2차/3차), `ROADMAP` 체크리스트에도 번졌다.
4. **폐기 블록을 만지면 오히려 해로움** — 죽은 문단에 이름만 고치거나 날짜
마커를 찍으면 **갓 정비된 것처럼** 보여 구현자가 더 믿는다. 폐기 배너
아래 본문이 최근에 수정된 흔적이 있는지 보라.
5. **한 곳에서 고친 거짓 전제가 새로 쓰는 글에서 되살아남**
*"Store는 `Source`를 만들지 않는다"*가 3차에 세 곳에서 고쳐졌는데 5차에
새 블록에 다시 들어갔다. **이미 거짓으로 판정된 문장의 새 사본**을 전수하라.
6. **같은 계약의 N번째 사본***idempotent* 서술이 1차·6차·7차에 걸쳐
세 번 지워졌다. 하나를 고쳤으면 `grep`으로 전수하라.
7. **`ROADMAP.md` 체크박스가 `base/`보다 낡음** — 7차의 2번이 그랬다.
**구현자가 실제로 보는 자리**라 심각도가 높다.
---
## §4 각도 (8라운드와 같은 문자를 유지 — 상호참조용)
- **A. 반영분 재트레이싱** (최우선, 레인 A)
- **B. 엔드투엔드 체인** — Store→State→Observer/Effect→Gate/Blocker→
`setLength`/`recompute`, 그리고 **M5+**(레인 B)
- **C. 확정 의사코드/타입 실측** (레인 C)
- **D. 구현 순서 시뮬레이션**`ROADMAP.md` M2 체크박스를 위에서부터
실제로 짜는 시뮬레이션. **체크박스만 보고 짰을 때 성립하는가**(7차 2번이
여기서 나왔다)
- **E. 공개 API 오용 진단** — 사용자가 틀리게 썼을 때 조용히 통과하는가
- **F. 코퍼스 미다룸 영역**
- **G. Luau 사실 주장 재확인** — 문서가 "확인했다"고 적은 언어 동작을 실제로
걸어보기
- **H. 비용 서술 점검**
---
## §5 규칙 (엄수)
1. **저장소를 수정하지 마라.** 산출물은 리포트 파일 **하나**뿐이다.
스파이크·참조 구현 갱신본은 스크래치패드에 만들고, 발견의 근거는 리포트
항목 안에 **인라인으로 전사**하라(파일이 유실돼도 재현 가능하게).
2. **`git stash`를 절대 쓰지 마라.** 과거 실동에서 메인 세션의 스테이지가
반복적으로 풀렸다. 과거 상태가 필요하면 `git show <ref>:<경로>` /
`git diff <ref> -- <경로>`.
3. **아무것도 반영하지 마라.** 판정은 사용자가 이 리포트를 보고 한다.
4. **8라운드까지의 결정은 뒤집지 않는 것을 기본으로 하라.** 뒤집어야 한다고
보면, **그 결정의 어느 추론이 틀렸는지**를 항목 안에서 지목하라
(근거 없이 "다시 생각해보니"는 안 된다).
5. **발견 번호는 `H-124`부터** 이어서 매긴다(라운드를 가로질러 연속).
심각도는 🔴(M2 착수 전 결정 필요) / 🟡(계약 결정) / 🟢(문서 정합·문서화).
6. `.claude/audit/`의 참조 구현은 **참고용**이다 — 재실행이 곧 검증이 아니다
(§2 레인 C의 ⚠️).
7. **확신이 안 서면 발견으로 올리지 말고 §6 "남은 의심"에 적어라.**
반대로, 확인해서 **이상 없었던 것**은 §5에 적어라 — 다음 라운드가 같은
자리를 다시 파지 않도록.
8. **못 본 범위를 §6에 정직하게 선언하라.** 리포트가 "감사 통과"로
읽히면 안 된다.
---
## §6 산출물
`.claude/qa-request/pre-implementation-handtrace-round9.md` 하나. 구성:
- **머리말** — 무엇인가 / 쓴 각도 / 실제로 본 범위 / 읽는 순서
- **요약 표**`| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |`
- **상세** — 항목마다: 무엇이 문제인가 / **어떻게 재현·트레이싱했는가**
(값 단위로) / 어느 문서 어느 줄이 근거인가 / 그대로 구현하면 무슨 일이
일어나는가 / (있으면) 실측 출력 전사
- **§4 사용자 결정이 필요한 것** — 배치 회신용. 문항마다 선택지와
**당신의 권고**를 달아라. 사용자는 이걸 위에서부터 답한다
- **§5 이상 없다고 확인한 것**
- **§6 남은 의심 / 못 본 것**
문서는 **한국어**로 쓴다(사용자가 읽는 문서). 코드·식별자는 원문 그대로.

View file

@ -0,0 +1,398 @@
# 9라운드 손 트레이싱 발견 — **사용자 결정과 반영 결과**
**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round9.md`의 발견
(`H-124`~)을 사용자와 대화형으로 처리한 결과. **결정의 소스는 이 문서**이고,
발견 원문·값 트레이스·실측 전사·"이상 없다고 확인한 것" 목록은 그 파일이
소스다(여기서 다시 서술하지 않음).
**진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** 순서를
따른다. 8라운드와 같다.
**⚠️ [2026-08-27 기준] 진행 중이다.** 아래 표가 어디까지 왔는지의 소스다.
처음엔 "문항을 다 처리한 뒤 일괄 반영"으로 잡았으나, Q1~Q3가 같은 `slot-plan.md`
구간에 몰려 있고 서로 얽혀(Q2의 생성자 이동이 Q3의 `_elemIndex` 삭제와 같은
줄) 사용자 지시로 **Q3까지 먼저 반영**했다. Q4 이후는 결정 뒤 반영한다.
| 문항 | 발견 | 상태 |
|---|---|---|
| Q1 | `H-124` `recompute` 루프 순서 | ✅ **확정** — (a), `continue` 형태 |
| Q2 | `H-125` 재마운트 캐시 | ✅ **확정** — 생성자 이동 + (c) 순서 + `_destroyed` |
| Q3 | `H-126` splice 빈자리 (+ `H-137` 소멸, `H-141` 신설) | ✅ **확정**`element → index``bk`가 소유, 토큰 폐기 |
| Q4~Q10 | `H-127`~`H-133` | ⏳ 대기 |
| — | `H-134`~`H-140` | ⏳ 대기(레인 B·부수 발견) |
**[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과
열어둔 확인은 아래 "반영 기록" 절.
---
## Q1 — `recompute`의 되감기 판정을 `lengthList[i]` 읽기 **앞**으로 (`H-124`, 확정)
**결정: (a).** 되감기 판정이 먼저 오고, `local v = bk.lengthList[i]``sum`
누적은 되감기가 **없을 때만** 돈다. 형태는 `continue`.
**사용자 논거** — 갈래 (a)를 고르면서 코드 모양까지 정정했다:
> *"단순히 sum 을 안 만들어도 되는게, 되감는다면 sum 이 이전걸로 구해져서 새로
> 계산한 sum 자체를 안 씀. v 얻고 sum 계산하는걸 else 아래 두는것도
> 괜찮아보이는듯. 컨티뉴나 else 아래나 둘 다 괜찮은데, 맥락 상 컨티뉴를 두고
> 아래 두는게 좋아보임."*
확정된 모양:
```lua
local abs = Dispatch.getOffsetAt(ownerKey, i)
bk.offsetSetUpTo = i
if offset ~= None and offset:Get() ~= abs then
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
end
if bk.offsetSetUpTo < i then -- 되감기 판정이 **먼저**
i = math.max(bk.offsetSetUpTo, 1)
sum = prefix[i]
continue
end
local v = bk.lengthList[i] -- 되감지 않을 때만 읽는다
sum += (if isState(v) then v:Get() else v)
i += 1
```
- **무엇이 깨져 있었나**: 옛 순서는 `lengthList[i]` 읽기·누적이 되감기 판정보다
**한 줄 앞**이었다. `offset:Set(abs)` 안의 사용자 코드가 요소를 제거해
`i > bk.N`이 되면(커서가 마지막 자리일 때 아무 자리나 제거, 또는 `rawSplice`/
`rawClear`의 다중 제거) `sum += nil`로 죽고, 그 `error`
`recomputeBlocker:On()``OffWithoutEmit()` 사이에서 나므로 **그 owner의
차단기가 영원히 켜진 채 남는다**(그 Slot의 레이아웃이 영구 동결 — `H-87`
"독 든 Blocker"와 같은 부류).
- **`H-113`*"`sum`은 안 낡는다"* 논증은 유지된다** — 그 논증의 근거는
"`lengthList[i]` 읽기가 `Set` 뒤"인데, 되감는 경우엔 그 자리를 **재방문**하며
읽으므로 여전히 `Set` 뒤다. 오히려 옛 요소의 길이를 한 번 더 더했다가 버리는
낭비가 사라진다.
- **제거가 커서보다 뒤(`p > i`)면 되감기 자체가 안 걸린다**
`math.min(offsetSetUpTo, p-1)`에서 `p-1 ≥ i`이므로 판정이 거짓이고,
그때는 `bk.N ≥ p-1 ≥ i``lengthList[i]`가 살아 있다. 즉 이 재배치가 정상
경로를 안 바꾼다.
- **실측**(9라운드 참조 구현 `ref9/`): 이 처방 전엔 `d10_remove_at_tail.luau`
`attempt to perform arithmetic (add) on number and nil`로 죽었고, 처방 뒤
**정상 종료 + 최종값 정확**(`s2.Offset 0 / s3.Offset 1 / O.Length 2`).
`d11_two_ops`(2-연산 경로) · `d13_splice_at_cursor`(커서 자리 splice) ·
`d14_baseline`(형제 offset 전파) 전부 회귀 없음.
**반영 대상**: `base/dispatch-core-plan.md``recompute` 의사코드(+ 되감기 절의
근거 문장 — "1로 클램프한다" 주석은 그대로 유효하다).
---
## Q2 — 재마운트: **`Offset`/`_baseObserver`를 생성자로 올리고**, 순서는 (c), 파괴는 `_destroyed` (`H-125`, 확정)
**결정: 문항의 (a)/(b)/(c) 중 (c)를 고르되, 사용자가 그보다 나은 형태를
제시해 그쪽으로 확정했다** — `slot.Offset``slot._baseObserver`를 **Slot
생성자에서** 만든다. 그러면 (c)가 고치려던 순서 문제의 **분기 자체**가 없어진다.
### 왜 (a)가 아닌가 — 그리고 문항의 근거 하나는 틀렸다
- 사용자가 (a)를 반대한 근거는 *"`:List`인데 어디선가 이미 그려진 적 있다면 …
offset 설정 결과가 전파될 방법이 없음. layoutorder 등을 설정하는 유저 함수
부분에 문제가 있을것"*이었는데, **그 부분은 실측에서 성립하지 않았다.**
`setOffsetSource``S.Offset:Set(newBase)`는 전파 루프를 정상적으로 돌고,
유저 체인의 말단 Observer는 **요소 자신의 인스턴스**에 `bindLifetime`돼 있어
(언마운트는 요소를 파괴하지 않고 `unbindLifetime`도 안 한다) `canExecute`
참이다. 건너뛰어지는 건 `_baseObserver` 하나뿐이다.
→ **그래서 `H-125`의 피해 범위는 "자식 서브트리 전체"가 아니라 *부기를
경유하는 중첩 Slot의 `Offset`*으로 좁혀진다**(발견 문서에 정정 반영).
- **(a)를 기각한 실제 이유는 소스 이원화**다. *"베이스가 바뀌면 1번부터
무효화"*(`H-3`의 3번)의 코드 경로는 `_baseObserver` 콜백 하나인데, (a)는 같은
규칙을 `materializeSlotTree` 진입부에 한 벌 더 둔다 — 그 절의 존재 이유가
정확히 *"표는 산문으로만 있었고 실제 코드 경로가 하나도 없었다"*를 닫는
것이라, 소스를 둘로 늘리는 건 후퇴다.
### 확정된 것 (넷)
**1. `Slot` 생성자가 `Offset``_baseObserver`를 만든다** — `Length`와 같은 자리.
> **사용자**: *"slot 자체를 생성할 때 offset/observer 이 같이 생성되지 말아야할
> 이유가 있음? 우린 stale 한 offset 을 허락하고 기본 생성에 0 이기 때문에, 초기
> 생성에 넣는거로 해줄 순 없는거야?"* / *"baseObserver 자체도, offset 에서
> 나므로, 초기에 생성하니, 같이 생성하면 돼. **단순히 bind/unbind 로 관리해야지
> 그것 자체를 제거/생성 하는건 안 맞아보임.**"*
- **새 결정이 아니라 이미 확정된 것의 반영이다**`SL-75`/`D-60`이
*"마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다"*로
확정했는데 의사코드만 lazy(`slot.Offset or Source(0)`)로 남아 산문과 어긋나
있었다. `Length`와의 대칭도 맞는다(둘 다 공개 필드인데 하나만 생성자에서 나던
비대칭).
- **`H-125`가 살던 분기가 사라진다**: 옛 `materializeSlotTree`는 첫 마운트(생성
→ "등록 즉시 1회"가 **우연히** 두 필드를 0으로)와 재마운트(`bindLifetime`만 →
**바인드는 발화가 아니라서** 안 만듦)로 갈렸고, 그 비대칭이 버그의 집이었다.
이제 갈래가 없다.
**2. `materializeSlotTree`의 순서** — 게이트와 바인드가 emit **위로**:
```lua
slot._physicalTarget = physicalTarget
local blocker = getBlocker(slot)
blocker:On() -- ← emit 위로
bindLifetime(physicalTarget, slot._baseObserver) -- ← emit 위로(생성 분기 없음)
Dispatch.setOffsetSource(ownerKey, position, slot.Offset) -- 여기서 베이스가 바뀐다
```
- `blocker:On()`도 같이 올려야 한다 — 안 그러면 emit이 깨운 콜백이 게이트 없이
`recompute(slot)`를 완주하는데, 그 시점 `bk(slot)`은 **언마운트 전 옛
부기**(`Relate(slot)` 위에 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다.
- **간섭 없음**: 이 Blocker는 그 Slot 자신의 것이고 `setOffsetSource`는 **부모
owner의** blocker를 본다. `getOffsetAt``lengthList`를 직접 읽으므로 게이트와
무관하다(*"Blocker가 막는 건 `recompute`지 부기 등록이 아니다"*).
- **기존 근거 유지**: *"`_baseObserver` 생성이 `blocker:On()` 뒤인 게
중요하다"*(등록 즉시 1회가 자식 등록 전에 `recompute`를 태움)는 그대로다 —
생성이 생성자로 갔고 **바인드도 `blocker:On()` 뒤**다.
- `local offsetSource = slot.Offset or Source(0)``slot.Offset = offsetSource`
두 줄, 그리고 `if slot._baseObserver then … else … end` 분기가 **전부 삭제**된다.
**3. `_baseObserver` 콜백 머리에 미실체화 가드**:
```lua
if slot._physicalTarget == nil then return end
```
- 없으면 생성자의 "등록 즉시 1회"가 **모든 Slot**에 대해 `getBookkeeping`
강제 호출해 `bk` + `recomputeBlocker`(Blocker 객체)를 **eager 생성**한다 — 한
번도 마운트 안 되는 Slot까지. 가드를 넣으면 실측상 `books`에 항목이 안 생긴다.
- 의미도 맞다 — 미실체화 Slot의 베이스 변경엔 할 일이 없다.
**4. 파괴는 `slot._destroyed` 플래그가 말한다 — 핸들은 안 지운다.**
> **사용자**: *"`slot._baseObserver` 가 두 일을 하는건 위험함. 이전에도
> `invalidAfter` 처럼, 두 일을 겸하는걸 만들다가 사고가 난 적 많아."* /
> (이름) *"`dispose` 는 형질이 다른 엔진 요소를 포함할 수 있는 것에 대한 공동
> 소멸자인 네이밍. 자신이 삭제되고 그 여부는 `destroy` 가 맞아보이고, `dispose`
> 는 슈거로써 `destroy` 와 별도의 맥락에서 해석해야해."*
- `destroySlotTree``_baseObserver`/`_listObserver`/`_listActivated`를 `nil`
지우던 것을 **전부 그만둔다 — `unbindLifetime`만 한다.** 그 nil이 들던 근거
(*"안 풀면 `gchold[physicalTarget]`이 observer를 계속 강하게 붙잡는다"*)는
실은 **`unbindLifetime`이 하는 일**이고, nil 대입은 slot → observer 참조
하나를 놓는 것뿐인데 slot 자신이 쓰레기라 그건 공짜다. 사용자가 좀비 slot을
계속 들고 있어도 observer는 unbind 상태라 `canExecute`가 거짓이라 발화하지
않는다.
- **세 필드가 겸하던 뜻을 플래그가 가져간다.** `_listObserver == nil`은 원래
*"`data`가 reactive가 아니다(plain table)"*라는 자기 의미가 있고(그걸 놓쳐서
`activateList` 재마운트 분기에 `bindLifetime(nil)` 버그가 났었다),
`_listActivated == nil`은 *"아직 최초 population을 안 했다"*였다. 거기에
"파괴됨"을 겹쳐 싣던 게 `invalidAfter`와 같은 모양이었다.
- **`_baseObserver`의 불변식이 한 문장이 된다**: 생성자에서 나서 Slot과 함께
죽는다, **절대 `nil`이 아니다.** `Length`/`Offset`과 같은 층위.
- **파괴된 Slot의 재사용은 error**(`level 2`, 메시지는 영어 —
`base/architecture.md`의 error 계약). 지금까지 **어디에도 안 적혀 있던
자리**다 — `slot-plan.md`는 *"언마운트된 Slot은 그대로 재사용 가능"*이라고만
하고 파괴 쪽은 침묵했으며, `destroySlotTree``_mounted``false`
되돌려놔서 *"마운트된 Slot의 재마운트는 즉시 throw"* 가드에도 안 걸렸다.
실제로 `destroySlotTree`**`_elements`를 안 비운다**(요소만 `nativeDispose`)
— 파괴된 Slot은 파괴된 Instance를 든 좀비이고, 재마운트하면 죽은 Instance를
다시 `Parent` 대입한다.
- **`attachSlot`/`materializeSlotTree` 진입**에 가드 — 필수.
- **공개 CRUD 진입**(`:Add`/`:Remove`/…)에도 가드 — `_elements`가 안
비워지므로 죽은 Slot에 `Add`하면 **조용히** 좀비 배열이 자란다.
`_crudUsed`/`_listed` assert 옆에 한 줄.
- **이중 `dispose`는 얼리리턴 no-op** — teardown 경로가 겹치는 건 실재하고
GC-native 기조와 맞는다. 막는 것은 **마운트/CRUD**뿐이다.
플래그를 세우는 것도 얼리리턴도 **`destroySlotTree` 쪽**에 두므로 다형
진입점 `dispose(value)`는 그걸 공짜로 물려받고 별도 가드가 필요 없다.
### 실측
9라운드 참조 구현(`ref9/`)에 `none`/`(a)`/`(c)`/`ctor` 네 변형을 넣고 같은
매트릭스를 돌렸다 — `O = { a, b, S }`, `S = { C(중첩 Slot), plain }`(베이스 2)를
마운트 → 언마운트 → 베이스 0인 다른 owner에 재마운트:
```
[none] 재마운트: S.Offset=0 C.Offset=2 (기대 0) userLO=1 (기대 1) ❌
[a] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
[c] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
[ctor] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
(넷 다) 앞에 z 삽입 후: S.Offset=1 C.Offset=1 userLO=2 ✅
```
`ctor` 변형으로 `d14`/`d10`/`d11`/`d13` 회귀 없음. 미실체화 Slot 생성 직후
`books`에 항목이 안 생기는 것(3번 가드), 미실체화 `rawAdd`가 그대로 얼리리턴하는
것도 같이 확인.
### 반영 대상
- `base/slot-plan.md``Slot(initial)` 생성자(필드 목록에 `Offset`/`_baseObserver`
추가) · `materializeSlotTree`(순서 + 분기 삭제 + 콜백 가드) ·
`unmountSlotTree`/`destroySlotTree`(핸들 보존, `_destroyed` 세팅, 이중 호출
얼리리턴) · 공개 CRUD 가드 · *"언마운트된 Slot은 그대로 재사용 가능"* 절에
파괴 쪽 계약 신설 · **`rawAdd``_physicalTarget == nil` 얼리리턴은 유지하되
근거 문장 정정**(*"`slot.Offset`이 아직 nil이라 `getOffsetAt`에서 즉시
죽는다"*는 이제 거짓 — 남는 근거는 중첩 Slot의
`attachSlot(element, nil, …)``bindLifetime(nil, …)` 하나다).
- `base/dispatch-core-plan.md``Slot.Offset` 서술(*"마운트 시점에
`setOffsetSource`가 등록하는 그 Source를 `self.Offset`으로도 저장"*)을 생성자
기준으로.
- `ROADMAP.md` — M6의 `Slot.Offset` 체크박스, 그리고 아래 `H-140`.
### 부수 발견 — `H-140`(🟢, 이 대화에서 나옴)
**`ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"***. `SL-75`/`D-60`이
전면 정정한 문장인데(`slot-plan.md:3593`이 *"옛 서술은 … 그러면 포탈이
무너진다"*로 폐기) **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면
포탈 구독자가 영구히 끊기고 이번 생성자 불변식도 같이 무너진다 — 9라운드
사냥 목록 #7(*"`ROADMAP.md` 체크박스가 `base/`보다 낡음"*) 그대로다. 발견
문서에 `H-140`으로 등록했고 처분은 정정 하나다(판단 불필요).
---
## Q3 — `element → index``bk`가 소유한다; 토큰 폐기 (`H-126` + `H-137` + `H-141`, 확정)
**결정: 문항의 (a)/(b)를 넘어 원인을 닫았다.** Q3의 표면 증상(`spliceArraysUp`이
비운 자리의 `observers`/`tokens`)을 파다가, **같은 뜻의 맵이 두 층에 있고 그
중 하나(`bk.tokens`/`bk.indexOfToken`)는 사용자가 정한 적 없는 것**임이 드러났다.
### 무엇이 있었나 — `token`의 출처
- 7라운드 `H-102`의 사용자 지시는 *"이미 `slot._elemIndex`: realElem → index 를
관리중"* / *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"* — **요소 →
인덱스 맵을 Dispatch로 올리라**는 것이었다.
- `base/`에 내려앉을 때 키가 `len`(그 자리의 길이 State)으로 구현됐고, 8라운드
직전 `/code-review high`가 *"`len`은 자리마다 유일하지 않다"*를 잡으면서 **그
자리에서 `token = {}`이 발명됐다**(2026-08-25). 원래 키(요소)로 되돌아가는
대신 새 신원을 만든 것이고, 사용자 인용문은 그 옆에 그대로 남아 승인된
메커니즘처럼 읽혔다.
- **사용자**: *"내가 등장시킨 적 없는 token 이 나와서 당황스러움"* / *"난 층위 상
어떠한 값이든, 마운트된 부기객체 -> index(기여량이 아님) 를 얻고자 했음"*.
"기여량이 아님"이 정확히 어긋난 지점이다 — 구현은 기여량(`len`)을 키로 잡았다.
### 확정된 것
**1. `element → index` 맵은 `bk`가 소유한다 — `bk.indexOfElement`. owner가 Slot이든
`inst`든 규칙 하나.**
> **사용자**: *"slot 이든 inst 든 elem -> index 는 Dispatch bk 에 있어. 클로저가
> 필요한 지점으로 안 보여. 문제의 elem->index 를 누가 관리하느냐가 어디서
> 관리하느냐가 명확하지 않아서 자꾸 사고가 나는듯 한데."*
- **`slot._elemIndex`는 삭제**한다 — 같은 뜻의 맵을 두 층에 두던 것이 사고의
원인. `indexOfRaw(self, element)``getBookkeeping(self).indexOfElement[element]`
조회가 되고, `reindexFrom(self, from)`은 **그 맵을 갱신하는 헬퍼**가 된다
(시프트하는 자리 전부가 부르는 것은 `H-1` 그대로).
- **`bk.tokens`/`bk.indexOfToken`은 삭제** — `token` 개념 소멸.
- **`None`/진짜 `nil` 자리는 기록하지 않는다** — 길이가 상수 `0`이라 지속
등록(Observer)이 없고, `nil`은 애초에 키가 못 된다. Slot 안에서 요소는
유일하다(`_elements`엔 `None`이 안 들어가고 `claimOwner`가 이중 배치를 막는다).
**2. `Dispatch.setLength(ownerKey, i, len, anchor, element)` — 5번째 인자는 그
자리의 `inst|slot`.** `gatedRecompute`는 **요소를 캡처**해 `bk.indexOfElement[element]`
조회한다.
> **사용자**: *"Dispatch.setLength 가 이제 받아야할 것은 inst|slot 이야. 그거 이외
> 클로저로 저걸 해줄 이유가 없어보이는데. 게다가 슬롯 아니면 nil 이 나오는것도
> 이상해."*
- 요소를 캡처하면 **갱신할 것이 없다** — 요소의 신원은 안 변하고, `요소 → index`
`reindexFrom`이 자기 이유로 이미 정확하게 유지한다. 위치를 캡처하던 옛
모양(splice마다 갱신)과 토큰(배열 + 역방향 맵 둘 다 갱신)이 관리하던 것이
전부 사라진다.
- `element`를 생략한 호출(상수 길이 자리 — plain 요소 없이 `Nil`/`None` 핸들러)은
지속 클로저가 안 생기므로 캡처한 `i`가 그대로 유효하다. 실제로 `len`
State인 호출은 **중첩 Slot의 `.Length`뿐**이고 그때 `element`는 그 Slot
자신이다.
**3. Q3 본문 — `spliceArraysUp`이 비운 자리는 세 배열 전부 처리한다.**
`lengthList[index] = 0`(`H-5`) · `sourceList[index] = None` · **`observers[index] = nil`**.
`spliceArraysDown`은 당긴 뒤 꼬리(`N`)의 세 자리를 `nil`로. 근거는 `H-126`
실측 — 복사 루프로 짜 `observers[index]`에 옛 값이 남으면 이어지는
`setLength(self, index, …)``oldObserver` 언바인드가 **밀려난 요소의 관측자를
죽인다**(`vacate=false`에서 `B.Offset`이 2에 멈춤).
### 기각된 것 (전부 이 세션에서 제가 냈다가 철회한 안)
- **`observer.pos` / `observer.inst`** — 프리미티브 인스턴스에 임의 페이로드를
얹는 것으로, **검토 후 안 만들기로 한 `Effect<UD>:Userdata()`/`SetUserdata`를
다시 여는 문**(사용자: *"그건 닫은 Effect 의 userdata 허용을 거의 여는
셈이야"*).
- **`setLength`에 조회 클로저 / `subject` 인자** — 소유 층위를 정하는 대신 우회하는
새 개념. 사용자가 정한 적 없는 이름.
- **토큰 유지(이름만 변경)** — 맵이 두 층에 남는 원인을 그대로 둔다.
### 소멸·신설
- **`H-137` 소멸** — `rawMove`/`rawSwap` 규약의 토큰 누락은 토큰이 없어지며
발견 자체가 사라진다(규약 4가 이동 구간 전부에 `setLength`를 다시 태워
`indexOfElement`를 다시 쓴다).
- **`H-141` 🟡 신설** — *확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을
승인한 것*(토큰 옆의 격상 인용, 그리고 *"splice 요구 목록에 항목이 늘지
않는다"*는 명분을 구현이 스스로 깬 것). 9라운드 사냥 목록 #1의 인용문 판.
### 반영에서 열어둔 확인 (판단 요청)
- **`H-1``reindexFrom``spliceArrays*`에서 분리한 근거가 소멸한다** —
*"`spliceArrays*`는 `bk`를 만지므로 실체화된 뒤에만 부를 수 있다"*였는데,
`getBookkeeping`은 lazy라 절대 `nil`이 아니다. 대신 **미실체화 Slot에 `:Add`
해도 `bk`(+ `recomputeBlocker`)가 생긴다**. Q2의 `_baseObserver` 가드가 막은
것(*모든* Slot의 eager `bk`)과 범위가 다르다(*요소를 넣은* Slot만). 이 정도는
괜찮다고 보고 반영했다 — 아니면 알려줄 것.
- **쓰기 지점이 둘이다** — 등록은 `setLength`(`bk.indexOfElement[element] = i`),
이동은 `reindexFrom`. `lengthList``setLength`(등록)/`spliceArrays*`(이동)로
갈리는 것과 같은 모양이라 그대로 뒀다.
---
## 반영 기록 — Q1~Q3 (2026-08-27)
**바뀐 파일**: `base/dispatch-core-plan.md`(`recompute` 루프 재배치 / `setLength`
5번째 인자 + `bk.indexOfElement` / `H-102` 문단 정정 + `H-141` 배너 / 저장 위치·
초기화 열거 / `_baseObserver` 즉시 발화 주석 / `Slot.Offset` 생성 자리 / `anchor`
절에 `element` 축 추가) · `base/slot-plan.md`(`Slot(initial)` 생성자 /
"파괴된 Slot은 재사용 불가" 절 신설 / `Slot:Add`·`Slot:List` 가드 /
`makeBaseObserver` + `materializeSlotTree` 순서 재배치 / `unmountSlotTree` 흔적
가드 제거 / `destroySlotTree` 핸들 보존·`_destroyed`·이중 호출 no-op / `H-1` 블록·
`H-29` 규약 1·`raw*` 주석·`rawAdd` 배너 근거·`rawReplace`·`indexOfRaw` 정의 /
splice 요구 목록 재작성 + `H-102` 항목 폐기 배너 + `H-132` 개수 제거) ·
`ROADMAP.md`(M3 `setLength` 시그니처·`getBookkeeping` 초기화 / M6 필드 목록·
`H-140`·`_elemIndex` 언급·`Slot.Offset` 체크박스) · 발견 문서(`H-141` 등록,
`H-137` 소멸 표기, `H-125` 범위 정정).
**반영 중 드러난 것 — 사용자가 바꾼 적 없는데 있던 것** (판단 요청 포함):
1. **`token` 자체**(`H-141`) — 위 Q3 절.
2. **`unmountSlotTree``if slot._baseObserver then` 가드** — 옛 lazy 생성의
흔적. 생성자 불변식("항상 있다")에 맞춰 무조건 호출로. 동작 차이 없음.
3. **`rawReplace`가 맵을 직접 쓴다** — 옛 `self._elemIndex[old] = nil; [new] = index`
두 줄이 `bk.indexOfElement`로 그대로 옮겨졌다. `setLength(…, newElement)`
등록하지만 **미실체화 분기는 `setLength`를 안 부르므로** 직접 쓰기가 남는다.
쓰기 지점이 `setLength`/`reindexFrom`/`rawReplace` 셋이 됐다 — 규칙("등록은
`setLength`, 이동은 `reindexFrom`")에서 `rawReplace`만 예외. 괜찮은지 확인
요청.
4. **`Owned = false`인 Slot은 `_destroyed`가 안 선다** — `destroySlotTree`
그 분기에서 `unmountSlotTree`로 빠져 꼬리(플래그 세팅)에 안 닿는다. "파괴
대신 언마운트만"이라는 그 분기의 뜻과 정합하고(요소를 만든 적 없으니 좀비도
없다) 재사용도 그대로 가능하다 — 의도대로라고 보고 그대로 뒀다. 확인 요청.
5. **미실체화 `:Add`가 `bk`를 만든다**(Q3 절의 열어둔 확인) — 그대로 반영.
6. `todos.md`의 6라운드 서술(*"`slot._elemIndex` 신설"*)은 역사 기록이라
이동 표기만 덧붙였다.
**[2026-08-27 사용자 확인] 위 1~5 전부 의도대로.** 원문: (1) *"length,
elem->index 를 위치를 재설정 해준다면 괜찮아"* — `rawReplace`의 직접 쓰기는
"자리는 그대로, 주인만 교체"라 `lengthList`·`indexOfElement` 둘 다 그 자리를
다시 쓰는 것으로 성립. (2) *"owned = false 은 state<Frame> -> slot(single) 형태가
구현되는 것이라 맞아"* — 그 Slot은 요소를 만든 적이 없으니 파괴가 아니라
언마운트이고 `_destroyed`가 안 서는 게 맞다. (3) *"미실체화 add 가 bk 만드는것도
맞아. 그래야 인덱싱 매핑을 만드니까"*. (4) *"slot._baseObserver 는 unbind 만 했고,
nil 로 지우는것만 안 한다면 맞아"*.
**아직 안 한 것**: `/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README
색인과 `todos.md` 00번 갱신은 감사 1라운드 지적으로 그 자리에서 했다. 감사
루프는 Q1~Q3 반영분에 대해 돌았고 6라운드에서 수렴했다(아래 "감사 루프" 절).
## 감사 루프 (2026-08-27, Q1~Q3 반영분)
관례대로 `quad-doc-auditor` 한 턴에 하나, diff 범위, 라운드마다 각도 변경.
| 라운드 | 각도 | 새 발견 | 처분 |
|---|---|---|---|
| 1 | `base/` 정합성 | 확실 1 · 판단 1 | `todos.md` 00번이 *"아직 안 돌렸다"*로 남아 있던 것 갱신 / README 색인(미뤄둔 것) 그 자리에서 추가 |
| 2 | 인덱스 레이어 + 새 문단 자기모순 | 확실 1 · 의심 1 | `dispatch-core-plan.md`의 Length/Offset 두 API 요약 선언(1347행)이 4-인자로 남은 것 갱신 / `_destroyed` 가드가 `:Add`·`:List` 의사코드에만 있어 CRUD 표 머리에 "mutate 연산 전부" 일반 규칙 한 줄 신설 |
| 3 | `archive/`·`reference/`·`luau-test/`·`audit/` 인용처 + 인용문 역방향 + 옛 발견 번호 재인용 | 확실 3 | `audit/handtrace-round7-reference-impl/README.md` 대조표에 9라운드 행 셋 + *"토큰 역참조"* 각주 / `ROADMAP.md` 머리 배너에 9라운드 소스 한 줄 / `README.md` `slot-plan` 색인 행의 `_elemIndex`에 통합 표기 |
| 4 | 앞 라운드 수정분 자체 + followup/발견 문서 내부 정합 | 확실 1 | `CLAUDE.md`·`project-context.md`(둘 다 `@import`)가 결정 소스로 8라운드까지만 나열 — 9라운드 한 줄 추가. 그 외 앞 수정분·두 문서 내부 정합은 줄 단위 대조로 이상 없음 |
| 5 | 4라운드 수정분 + 델타 밖 `base/` 문서 | 확실 1 · 판단 1 | `README.md``dispatch-core`/`slot-plan` 색인 행에 Q1/Q2 요약 추가 / `architecture.md``Slot.luau` 한 줄에 생성자 필드·`_destroyed` 표기(판단 항목 — 8라운드 행들이 같은 밀도라 넣음) |
| 6 | 5라운드 수정분 + 신설 규칙 문단 | **확실 0** · 의심 1 | *"`Owned=false`는 `_destroyed`가 안 선다"*가 README 요약과 followup에만 있고 `slot-plan.md` 산문엔 없던 것 — 사용자 확정 인용과 함께 명문화. **수렴**(확실 0) |

View file

@ -0,0 +1,794 @@
# 구현 전 손 트레이싱 **9라운드** — 발견 보고
**무엇인가**: `qa-request/pre-implementation-handtrace-round9-brief.md`(지시서)대로
**커밋 `9dd8213` 하나의 델타**(8라운드 결정 Q1~Q10 반영 + 그 뒤 `/code-review
high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견 번호는 `H-124`부터.
**저장소는 이 파일 말고 아무것도 수정하지 않았다**(스파이크·참조 구현 갱신본은
세션 스크래치패드 `ref9/`에 있고, 근거는 전부 아래 항목에 인라인 전사돼 있다).
**쓴 각도**(지시서 §4의 문자 유지): **A**(반영분 상호 간섭 재트레이싱, 최우선) ·
**C**(참조 구현을 현재 계약으로 다시 전사해 `luau`로 실행) · **G**(문서가
"확인했다"고 적은 Luau 사실 재확인) · **D**(`ROADMAP.md` M2 체크박스로 실제
짜는 시뮬레이션) · **B**(M5+ 값 단위 트레이싱 — 이 라운드의 첫 시도, 별도 절).
**H**(비용)와 **E**(공개 API 오용)/**F**(미다룸 영역)는 이번 델타에 걸리는
자리만 봤다(§6).
**실제로 본 범위**: `git show 9dd8213 -- .claude/base/ ROADMAP.md` 전량(2180줄)
+ 그 델타가 사는 원문 절 전문 — `dispatch-core-plan.md`(`getBookkeeping`
열거 / `getOffsetAt` / "두 필드" / 무효화 표·배치 목록 / `H-119` 절 /
`recompute` / `setLength` / `setOffsetSource` / 배치 게이팅 / `drive` 꼬리),
`slot-plan.md`(`Slot:List` 꼬리 / `activateList` 머리 / `materializeSlotTree` ·
`mountSlotTree` · `attachSlot` / `unmountSlotTree` · `destroySlotTree` /
`rawUnmount` · `rawDetach` · `rawAdd` · `rawReplace` · `rawRemove` / `H-29` 규약
/ `spliceArrays*` 요구 목록), `effect-plan.md`(생성자·훅·`Rerun`·
`Subscribe`/`Unsubscribe` 절·폐기 블록), `lifecycle-pattern.md`((1)·(2) 절),
`ref-plan.md`(콜백 계약·`WeakCallback`·`:Set` 순서·`Revision`),
`source-state-plan.md`(전파 루프·Observer 절·`WeakSubscribe` 절·이중 바인딩
절), `state-epoch-plan.md` §4, `store-plan.md`(`H-112`/`H-115`/`H-117` 블록),
`typing-limits.md` §0, `lifecycle-hooks-plan.md`(`guard`), `ROADMAP.md` M2
전량 + M8 머리. 8라운드 §5가 "이상 없다"고 닫은 자리는 다시 파지 않았다.
**읽는 순서**: 요약 표 → 🔴 둘(`H-124`/`H-125`, 둘 다 실측 재현) → §4 결정 문항
→ 나머지 상세 → §5(이상 없음 — 특히 **8라운드가 남긴 캐비엇 하나가 실측으로
닫혔다**) → §6.
---
## 요약 표
| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |
|---|---|---|---|---|---|
| `H-124` | 🔴 | `recompute``bk.lengthList[i]`**되감기 판정보다 먼저** 읽는다 — 커서 자리 이후로 자리 수가 줄면 `sum += nil` 크래시, 그 뒤 `recomputeBlocker`가 영구 On | `dispatch-core-plan.md` `recompute` | `H-113`×`H-119` 겹침 (A) | ✅ 재현 |
| `H-125` | 🔴 | 재마운트(포탈/Detach 복귀)에서 `setOffsetSource``slot.Offset`을 바꾸는 순간 `_baseObserver`가 **unbind 상태**라 두 필드가 0으로 안 내려가고, 꼬리 `recompute`가 **옛 베이스의 `offsetCache[1]`**을 그대로 쓴다 | `slot-plan.md` `materializeSlotTree`×`unmountSlotTree` | `H-3`×`H-119`×`C-4` 겹침 (A) | ✅ 재현 |
| `H-126` | 🟡 | `spliceArraysUp`이 비운 자리 `index``observers`/`tokens`를 비우라는 요구가 없다 — 복사 루프로 짜면 **같은 Observer·토큰이 두 자리에** 살아 `setLength`가 옆 자리 Observer를 unbind한다 | `slot-plan.md` splice 요구 목록 | `H-5`×`H-102` 겹침 (A) | ✅ 재현(구현 갈래 대조) |
| `H-127` | 🟡 | `EffectHandle:Unsubscribe()`의 서술 순서가 "플래그 → cleanup → fail-fast"라 leaf 바인딩된 Effect에 부르면 **cleanup을 먼저 소진한 뒤 error**한다(`E-11`이 막으려던 그것). Effect 쪽 `Subscribe`/`Unsubscribe` 의사코드가 없고 "네 진입점 전량"이 Observer만 가리킨다 | `effect-plan.md`×`lifecycle-pattern.md` | fail-fast 3종×`Effect` cleanup (A) | — |
| `H-128` | 🟡 | M2 체크리스트대로 `Effect`를 짜면 `isRef`/`Ref:WeakCallback`/`.Revision`이 필요한데 **`Ref`는 M8**이고 M2엔 그 표면의 체크박스가 없다(M8은 "M2가 전제한다"고 인정만) | `ROADMAP.md` M2×M8 | 구현 순서 (D) | — |
| `H-129` | 🟢 | `ROADMAP.md` M2 `Effect` 체크박스가 *"`Ref`는 `:Callback`"*`H-58` 이후 `:WeakCallback`이 정본(같은 체크박스 몇 줄 아래는 맞게 적혀 있다) | `ROADMAP.md` | 옛 문장 잔존 (사냥 #5) | — |
| `H-130` | 🟢 | `effect-plan.md`의 ⛔ 폐기 블록(`_observers` cascade) **안**을 이 커밋이 편집했다(`.Subscribed`는 구독 경로 전용) — 죽은 문단이 갓 정비된 것처럼 보이고, `H-114`의 "`_observers` 잔재 세 곳 지웠다"와도 어긋난다 | `effect-plan.md` 490~514 | 사냥 #4 | — |
| `H-131` | 🟢 | *"`for d in seen do`는 유효한 Luau가 아니다 — 테이블을 호출하려 든다"*(7차 #3, `effect-plan.md` 주석)는 **거짓** — 일반화 반복은 Luau가 지원하고 `--!strict`도 통과한다. `pairs`로 바꾼 것 자체는 무해, 근거만 틀림 | `effect-plan.md`×followup 7차 | Luau 사실 (G) | ✅ 실측 |
| `H-132` | 🟢 | `slot-plan.md` 2900행의 *"…해야 하는 일이 **셋** 늘었다"* 헤딩 아래 bullet이 넷(`H-102`가 넷째를 더함) | `slot-plan.md` 2900 | 사냥 #3 | — |
| `H-133` | 🟢 | `WeakUnsubscribe`는 **구독한 적 없는 값**에 조용히 통과한다(가드가 "강한 킵 있음"만 본다) — *"해제는 건 경로로 푼다 … 양방향 fail-fast"* 산문과 비대칭. 의도라면 명문화 | `lifecycle-pattern.md` (2) | 계약 정합 (E) | ✅ 실측 |
| `H-134` | 🟡 | `InstanceChildHandler`(`Frame { Frame {} }`의 정적 자식)의 배열 자리 부기(`setOffsetSource`/`setLength(…, 1)`)가 **어디에도 명세돼 있지 않다** — 말단 표에 행이 없고 의사코드도 없어 `H-39`의 "전수"가 못 잡았다. `Frame { Frame{}, Slot() }`이 첫 마운트에 `H-106` 가드로 즉사 | `dispatch-core-plan.md` 말단 표 × `architecture.md` × `ROADMAP.md` M5 | 레인 B (M5) | 값 트레이스 |
| `H-135` | 🟡 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘***"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 |
| `H-136` | 🟡 | `:List` **재실행** reconcile은 배치 Blocker 없이 돌아 raw op마다 `recompute` + `Length:Set`이 난다 — *"한 사이클 전체가 끝난 뒤 한 번만"*(`dispatch-core-plan.md` 2344) 서술과 모순, 최초 population만 감싼 O(n²) 논거가 재실행엔 빠져 있다 | `slot-plan.md` `activateList`/`reconcile` × `dispatch-core-plan.md` | 레인 B (M6) | 값 트레이스 |
| `H-137` | 🟢 | `rawMove`/`rawSwap` `H-29` 규약 1의 치환 목록에 `bk.tokens`/`bk.indexOfToken`이 없다(`H-102`가 splice에만 요구) — 규약 4(이동 구간 **전부** `setLength` 재등록)에 암묵 의존, 토큰은 자리 귀속이라 치환하면 안 된다는 것도 미명시 | `slot-plan.md` `H-29` 규약 | 레인 B | — |
| `H-138` | 🟢 | `UICorner`류 숏핸드 핸들러와 `PropertyHandler`의 매치 구분(리플렉션 거부? 우선순위?)이 명세에 없다 — 이름이 우연히 프로퍼티와 겹치면 조용히 `PropertyHandler`가 먹는다 | `ui-shorthand-plan.md` × `bind-system-plan.md` | 레인 B | — |
| `H-141` | 🟡 | **확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을 승인한 것**`H-102`*"dispatch 로 격상"* 인용 옆에 사용자가 정한 적 없는 `bk.tokens`/`indexOfToken`(`token = {}`, 2026-08-25 `/code-review`가 발명)이 확정 메커니즘처럼 앉아 있었고, 같은 문단의 *"splice 요구 목록에 항목이 늘지 않는다"*는 구현이 스스로 깼다(둘 늘렸다). 같은 뜻의 맵(`slot._elemIndex`)이 Slot 층에 따로 살아 두 층 이원화 | `dispatch-core-plan.md` `H-102` 문단 × `slot-plan.md` | 사냥 #1(인용문 판) — Q3 처리 중 발견 | — |
| `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"*`SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — |
| `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — |
---
## 상세
### `H-124` 🔴 — `recompute``lengthList[i]` 읽기가 되감기 판정보다 앞이다 (A×C)
**무엇이 문제인가.** 확정 의사코드(`dispatch-core-plan.md` `recompute`)의 루프
본문 순서는 이렇다:
```lua
local abs = Dispatch.getOffsetAt(ownerKey, i)
bk.offsetSetUpTo = i
if offset ~= None and offset:Get() ~= abs then
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
end
local v = bk.lengthList[i] -- ← (1) 여기서 읽고
sum += (if isState(v) then v:Get() else v) -- ← (2) 더한 다음에
if bk.offsetSetUpTo < i then -- (3) 되감기를 본다
```
`offset:Set(abs)` 안의 사용자 코드가 **같은 owner에서 요소를 제거**하면
(`H-119`가 "실재하는 재진입 경로"로 확정하고 게이트를 세운 바로 그 상황),
`rawRemove``spliceArraysDown``bk.N`을 줄이고 두 필드를 `index - 1`
내린 뒤 **재진입 게이트에 걸려 `recompute`를 건너뛴다**(`H-119` 설계대로).
제어가 (1)로 돌아왔을 때 **`i > bk.N`이면 `lengthList[i]`는 이미 `nil`**이다 —
(2)에서 `attempt to perform arithmetic (add) on number and nil`. (3)의 되감기는
한 줄 늦다.
`i > bk.N`이 되는 조건은 "커서 `i`가 마지막 자리(`i == N`)일 때 어느 자리든
하나 제거" 또는 "`i`보다 앞·같은 자리에서 여러 개 제거(`rawSplice`/`rawClear`)".
`H-113`(커서 자리 splice)은 `j == i < N`만 봤고, `H-119`는 "건너뛴 몫은 되감기가
복구한다"고 했는데 **되감기에 도달하기 전에 죽는다**.
**어떻게 재현했나** — 참조 구현을 현재 계약(두 필드·`-1`·`H-119` 게이트·
클램프)으로 다시 전사한 `dispatch9.luau` 위에서(`recompute`는 위 순서 그대로):
```lua
-- d10_remove_at_tail.luau (요지)
local s1, s2, s3 = L("s1"), L("s2"), L("s3") -- 각각 평범한 자식 하나(길이 1)
local O = D.newSlot("O", { s1, s2, s3 }); D.mountTop(inst, O) -- offsets 0,1,2 / O.Length 3
local w = s3.Offset:Observer(function(_, _, from) -- 마지막 자리(i = N = 3)의 offset을 관측
if from == nil or fired then return end; fired = true
D.rawRemove(O, 1) -- 그 통지 도중 O:Remove(1)
end); q.bindLifetime(inst, w)
D.rawAdd(s1, {name="s1_y"}) -- s1 길이 1→2 → O의 setLength Observer → recompute(O) → i=3에서 s3.Offset:Set(3) → watcher
```
출력(전사):
```
초기: s3.Offset 2 O.Length 3
[watcher] s3.Offset -> 3 → O:Remove(1) 호출
[watcher] rawRemove 반환. N = 2 offsetSetUpTo = 0
rawAdd(s1, child) → ERROR: ./dispatch9.luau:77: attempt to perform arithmetic (add) on number and nil
O.Length 3 | N 2 | s2.Offset 2 (기대 0) | s3.Offset 3 (기대 1)
```
`rawRemove`는 게이트(`bk.recomputeBlocker:IsOn()` = true)에서 정확히 건너뛰었고
(`spliceArraysDown`이 `offsetSetUpTo``0`으로 당겨둔 것까지 설계대로), 바깥
루프가 `lengthList[3]`(이제 `nil`)을 읽다 죽었다.
**그대로 구현하면.** (a) 사용자가 `slot.Offset`을 관측하는 콜백에서 형제를 지우면
— 코퍼스가 정상 경로로 확정한 재진입 — 익명 산술 에러로 죽고, (b) `error`
`bk.recomputeBlocker:On()``OffWithoutEmit()` 사이에서 나므로 **그 owner의
`recomputeBlocker`가 영원히 켜진 채 남아** 이후 모든 `gatedRecompute`/명시 호출이
조용히 건너뛰어진다(`H-87`의 "독 든 Blocker"와 같은 부류 — 그 Slot의 레이아웃이
영구 동결). `Length`도 낡은 채(위 출력의 `O.Length 3`, 실제 2).
**처방 갈래**(§4 Q1): (a) 되감기 판정을 `lengthList[i]` 읽기 **앞**으로 —
`if bk.offsetSetUpTo < i then i = math.max(…, 1); sum = prefix[i]; continue end`
뒤에 읽기·누적·`i += 1`. 되감기가 없는 경우엔 지금과 동일하게 "Set 뒤에 읽는다"가
유지되므로 `H-113`*"`sum`은 안 낡는다"* 논증도 그대로다. 제거가 `i`보다 **뒤**
자리면 `N ≥ i``nil`이 안 나온다(`p > i` 제거는 `offsetSetUpTo``p-1 ≥ i`로만
내려 되감기 없음, 그러나 `N ≥ p-1 ≥ i`). (b) 읽기 앞에 `if i > (bk.N or 0) then
break end`만 두는 안 — 크래시는 막지만 그 자리부터 되감기 없이 끝나므로 (a)가
필요 없어지지 않는다. **권고 (a).**
### `H-125` 🔴 — 재마운트에서 `_baseObserver`가 잠들어 있는 창 (A×C)
**무엇이 문제인가.** `H-3`이 세운 "베이스가 바뀌면 두 필드를 `0`으로"의 코드
경로는 **`slot._baseObserver` 콜백 하나**다(`H-119`가 그 콜백에 `bk.offsetCacheValidUpTo,
bk.offsetSetUpTo = 0, 0`을 실제로 넣었다). 그런데 재마운트 순서가:
```lua
-- unmountSlotTree(slot) (slot-plan.md)
if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- ← 관측자가 잠든다
-- … 나중에 attachSlot → materializeSlotTree(slot, newTarget, ownerKey, position)
local offsetSource = slot.Offset or Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- ← 여기서 slot.Offset:Set(새 베이스)
slot.Offset = offsetSource
local blocker = getBlocker(slot); blocker:On()
if slot._baseObserver then
bindLifetime(physicalTarget, slot._baseObserver) -- ← 그 뒤에야 깨어난다(바인드는 발화가 아니다)
```
`setOffsetSource``source:Set(offset)`이 전파 루프를 돌 때 `_baseObserver`
`canExecute` 거짓(gcconn 없음, `.Subscribed` 없음)이라 **조용히 건너뛰어진다**
(그게 `canExecute` 게이트의 정상 동작이다). 두 필드는 옛 값(`N_old`)에 그대로,
`offsetCache[1]`은 옛 베이스. 이어서 꼬리의 `recompute(slot, bk)``i = 1`에서
`getOffsetAt(slot, 1)``1 <= offsetCacheValidUpTo`**`offsetCache[1]`(옛
베이스) 반환** → 자식 offset이 옛 베이스 + 접두합으로 "이미 맞다"고 판정돼
`Set`이 안 난다. 그 다음 부모의 `setLength(ownerKey, position, slot.Length)`
부모 `recompute`를 돌려도 `slot.Offset`은 이미 새 값이라 `~=` 가드에 걸려
`_baseObserver`**끝내 안 불린다**.
`H-3` 자신이 *"재마운트는 더 나쁘다: `bk``Relate(slot)` 위에 있어 언마운트를
넘어 살아남으므로 `offsetCache[1]`에 옛 베이스가 남는다"*라고 정확히 경고해뒀는데,
그걸 닫는 유일한 경로가 그 순간 잠들어 있다. **파괴(`destroySlotTree`) 뒤
재사용은 무사하다** — 거기선 `_baseObserver = nil`이라 재마운트가 새 Observer를
만들고, 그 "등록 즉시 1회 실행"이 두 필드를 `0`으로 내린다(가드에 걸려
`recompute`는 안 하지만 `0`은 남는다). **깨지는 건 언마운트 보존 경로**
(`rawUnmount`/`rawDetach` → `Detach` 복귀·`State<Slot>` 교체·포탈)뿐이다.
**어떻게 재현했나** (`d12_remount_stale.luau`, 같은 전사물):
```lua
local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}})
local S = D.newSlot("S", { t1, t2 })
local O = D.newSlot("O", { {name="a"}, {name="b"}, S }) -- S의 베이스 2
D.mountTop(inst, O) -- t1.Offset 2, t2.Offset 3
D.rawRemove(O, 3) -- (전사물에선 unmountSlotTree로 흉내 = rawUnmount 경로)
local O2 = D.newSlot("O2", {}); D.mountTop(inst2, O2)
D.rawAdd(O2, S) -- → materializeSlotTree(S, inst2, O2, 1): 베이스 0
```
출력(전사):
```
1차 마운트: S.Offset 2 (기대 2) t1 2 (2) t2 3 (3)
S.bk: cacheValid 2 setUpTo 2 cache[1] 2
언마운트 후: canExecute(S._baseObserver) = false | S.Offset(보존) 2
재마운트: S.Offset 0 (기대 0) | t1.Offset 2 (기대 0) | t2.Offset 3 (기대 1)
S.bk: cacheValid 2 setUpTo 2 cache[1] 2 (베이스 0이어야)
❌ 자식 offset이 옛 베이스 위에 남았다
```
**도달 경로는 넷** — 포탈(언마운트→재마운트), `Detach` 복귀(레인 B가 `:List`
`settle``rawAdd(…, true)`로 실제로 밟는 것을 확인 — `H-139` 꼬리의 교차 항목),
`State<Slot>` 교체(= 언마운트, 같은 `unmountSlotTree`), `rawUnmount`로 꺼낸 Slot의
재배치.
**그대로 구현하면.** 포탈/Detach 복귀/`State<Slot>` 교체로 옮겨진 Slot의
**중첩 Slot 자식**이 옛 베이스 위의 `Offset`을 유지한다 — `H-3`이 경고한
*"위로는 맞고 옆으로만 틀린다"*의 재마운트 판. 그 아래로 재귀하므로 중첩
서브트리가 통째로 옛 좌표계에 남고, 다음 계기(그 자리 앞 형제의 길이 변경)까지
아무도 고치지 않는다.
> **⚠️ [2026-08-26 범위 정정 — Q2 처리 중 실측] 피해 범위는 "자식 전부"가
> 아니라 *부기를 경유하는 중첩 Slot의 `Offset`*이다.** 처음엔 유저 체인
> (`updateFn`이 받은 `offset`으로 `LayoutOrder`를 정하는 것)까지 안 고쳐진다고
> 적었는데 **틀렸다**`setOffsetSource``Set`은 전파 루프를 정상적으로 돌고,
> 유저 체인의 말단 Observer는 **요소 자신의 인스턴스**에 `bindLifetime`돼 있어
> (언마운트는 요소를 파괴하지도 `unbindLifetime`하지도 않는다) `canExecute`
> 참이다. 건너뛰어지는 건 `_baseObserver` 하나뿐이다. 실측은
> `-round9-followup.md`의 Q2 절(`d16_remount_variants.luau`, `[none]` 행에서
> `userLO`는 정확하고 `C.Offset`만 어긋난다).
**처방 갈래**(§4 Q2): (a) **`materializeSlotTree` 진입부**(`setOffsetSource`
앞)에서 `bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0` — "실체화는 항상
1번부터"라는 한 줄 불변식이 되고 첫 마운트엔 무해(이미 0). (b) `unmountSlotTree`
꼬리에서 같은 초기화 — 잠재우는 쪽이 치우는 대칭. (c) `_baseObserver` 바인드와
`blocker:On()``setOffsetSource` **앞**으로 옮겨 그 콜백이 깨어 있게 하는 안 —
콜백이 가드에 걸려 `0`만 남기고 돌아오므로 동작은 되지만, "관측자가 깨어 있어야
초기화된다"는 우연에 계속 기댄다. **권고 (a)**`_baseObserver``0`
런타임 베이스 변경용으로 그대로 두고, 실체화 경계엔 명시 초기화를 둔다.
### `H-126` 🟡 — `spliceArraysUp`이 비운 자리의 `observers`/`tokens` (A×C)
**무엇이 문제인가.** `slot-plan.md``spliceArrays*` 요구 목록은 비워지는
자리에 대해 `lengthList[index] = 0`(`H-5`)과 `sourceList[index] = None`
(*"`sourceList`가 `None`으로 채워지는 것과 대칭"*)만 명시한다. `bk.observers`·
`bk.tokens`는 *"다른 배열과 같이 당긴다 / 완전히 같은 처리"*라고만 돼 있다.
`table.insert(t, index, x)` 의미론이면 자리가 자연히 비지만, **7라운드 전사물
`d7_splice_fix.luau`처럼 `for i … do t[i] = t[i+1]` 복사 루프로 짜면**(그 전사물의
Down이 그렇고, Up은 아무도 안 썼다) `observers[index]``tokens[index]`에 옛
값이 **남는다** — 곧 `index``index+1`이 같은 Observer·같은 토큰을 가리킨다.
그 뒤 `setLength(self, index, …)``oldObserver = bk.observers[index]`를 보고
**`unbindLifetime`한다** — 그게 이제 `index+1`(밀려난 요소)의 Observer다. 그리고
`indexOfToken[token]``index`로 덮여 `index+1``gatedRecompute`가 엉뚱한
자리를 무효화한다(`H-102` 그 자체).
**어떻게 재현했나** (`d15_spliceup_vacate.luau` — 두 구현 갈래를 같은 전사물
위에서 대조; `vacate`가 비운 자리를 `nil`로 두느냐):
```
[vacate=true] A.Length=3 후 B.Offset = 4 (기대 4) | canExecute(observers[2]) = true | indexOfToken[tokens[2]] = 2
[vacate=false] A.Length=3 후 B.Offset = 2 (기대 4) | canExecute(observers[2]) = false | indexOfToken[tokens[2]] = 1
```
`vacate=false`에선 밀려난 `A`(2번 자리)의 길이 Observer가 unbind돼 **A가
커져도 B가 영영 안 밀린다.**
**그대로 구현하면.** 구현자가 어느 쪽으로 짜든 "문서대로"라고 말할 수 있는
자리에서, 한쪽은 `:Add(x, index)`(중간 삽입) 뒤 그 자리에 있던 요소의 길이
변경이 조용히 무시된다.
**처방**(§4 Q3): 요구 목록에 한 줄 — *"`spliceArraysUp`은 `observers[index]`·
`tokens[index]``nil`로 비운다(`lengthList`/`sourceList` 자리표시자와 같은
자리)"* 또는 *"네 배열 전부 `table.insert`/`table.remove` 의미론"*. 새 결정이
아니라 누락 명시.
### `H-127` 🟡 — `EffectHandle:Unsubscribe()`의 순서와 의사코드 부재 (A)
**무엇이 문제인가.** 이 커밋이 `Subscribe`/`Unsubscribe`/`WeakUnsubscribe`를
전부 fail-fast로 확정하며 `lifecycle-pattern.md` (2)에 **`Observer:` 네 진입점**
의사코드를 새로 썼고 *"아래가 네 진입점 전량이고 소스다"*라고 적었다. 그런데
`Effect`도 같은 레지스트리·같은 `.Subscribed`를 쓰고(`isBoundAlive`가
`isObserver(value) or isEffect(value)`), `effect-plan.md``EffectHandle`
`:Subscribe()`/`:Unsubscribe()`를 **산문으로만** 규정한다. 그 산문의 순서가:
1. `handle.Subscribed = false` (향후 재실행 끊김)
2. 직전 cleanup을 정확히 1회 호출
3. **[2026-08-26 정정]** fail-fast — 약하게만 구독된/구독 안 한 값이면 error
`E-11`은 *"leaf 바인딩된 핸들에는 `:Unsubscribe()`가 아예 안 먹는다"*를
확정했다(cleanup을 앞당기면 dedup 재디스패치에서 Effect가 조용히 죽는다는
근거). 위 순서대로 짜면 leaf 바인딩된 Effect에 `:Unsubscribe()`를 부를 때
**2번이 cleanup을 소진한 뒤 3번이 error** — 에러는 나지만 `E-11`이 막으려던
피해(cleanup 앞당김, `_installed = false`)는 이미 일어난 뒤다. Observer 쪽
의사코드는 가드가 첫 줄이라 이 문제가 없다.
부수로: `EffectHandle``WeakSubscribe`/`WeakUnsubscribe`가 있는지, `Subscribe`
Observer의 것을 그대로 쓰는지(`canBound` 게이트 포함) 어디에도 없다 — 전사물은
`Observer.Subscribe` 등을 그대로 물려받는 쪽으로 옮겼고(`core9.luau`), 그렇게
하면 `t12` 매트릭스가 전부 기대대로 돈다(§5).
**처방**(§4 Q4): `effect-plan.md`에 의사코드 한 블록 — `Unsubscribe`
**Observer의 게이트를 먼저**(`Observer.Unsubscribe(self)` 위임 → 통과해야만)
`_consumeCleanup()`; `Subscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 Observer
것을 그대로 재사용한다고 명시(핸들 자신만, 내부 Observer 무관 — `H-59`).
`lifecycle-pattern.md`*"아래가 네 진입점 전량이고 소스다"* 문장은 "Observer의
네 진입점, Effect는 같은 것을 재사용"으로.
### `H-128` 🟡 — M2의 `Effect`가 M8의 `Ref` 표면을 전제한다 (D)
**무엇이 문제인가.** `ROADMAP.md` M2를 위에서부터 짜는 시뮬레이션: `Effect(fn,
...deps)` 체크박스와 그 아래 "구현 시 같이 만들 것"이 `isRef(d)` 분기 →
`d:WeakCallback(onRefFire)``self._epochs:Sync(d)`(`d.Revision` 읽기)를
요구한다. 셋 다 **`Ref.luau`의 표면**이고 `Ref.luau`는 **M8**이다. M2 "공통
기반"엔 `Brand`/`Relate`/`LifetimeHandle`만 있고 `Ref`의 최소형 체크박스가 없다.
M8 머리는 *"M2가 이미 이 표면을 전제한다"*라고 **인정만** 하고, M2는 이 앞으로
참조를 어디서도 말하지 않는다. 8라운드 §5 D각도의 *"앞으로 참조 없음"*은 이
자리를 못 봤다(그때 `Effect``Ref` 분기가 `H-107`로 막 다시 쓰이던 중이었다).
**그대로 구현하면.** M2 구현자가 `Effect``Ref` 분기를 (a) `isRef` 스텁으로
비워두거나 (b) M8을 앞당겨 절반쯤 짜게 된다 — 어느 쪽인지 체크박스가 정하지
않으므로 M2의 "mock 대상 테스트"가 `Ref` dep 경로를 안 돌린 채 M2가 끝난다.
**처방 갈래**(§4 Q5): (a) M2 공통 기반에 **`Ref` 최소형 체크박스** 신설 —
`.Value`/`.Revision`/`:Set`/`:WeakCallback`/`:Callback`/`:Uncallback`/`isRef`
(`Epoch`를 만족하는 데 필요한 것만; `PreRef`/`PostRef`/`:Wait`/디스패치 핸들러는
M8 그대로). `Ref``Epoch`인 것 자체가 M2의 결정(`H-58`/`H-64`/`H-70`)이라
자연스럽다. (b) M2에선 `Ref` dep을 **명시적으로 미룬다**고 적고 M8에 "`Effect`의
`Ref` 분기 + 그 테스트"를 체크박스로. **권고 (a)**`Effect``_epochs`
배선이 `isEpoch` 분기로 갈리는 걸 M2 안에서 한 번은 실제로 돌려봐야 한다.
### `H-129` 🟢 — ROADMAP `Effect` 체크박스의 *"`Ref`는 `:Callback`"*
`ROADMAP.md` M2 `Effect(fn, ...deps)` 체크박스: *"각각에 맞는 구독(State/Source는
`Observer`, `Ref``:Callback`)을 걸어"*. `H-58` 이후 정본은 `:WeakCallback`
(강한 셋에 걸면 `Ref``Effect`를 영원히 붙든다 — 7차 #4가 다이어그램에서 잡은
바로 그것). 같은 체크박스 몇 줄 아래 "구현 시 같이 만들 것"은 `:WeakCallback()`
으로 맞게 적혀 있다. 사냥 #5(고친 사실이 옆 문장에서 되살아남)의 사례.
### `H-130` 🟢 — 폐기 블록 안을 이 커밋이 편집했다
`effect-plan.md` 468~514: ⛔⛔ 배너(*"이 문단 전체는 옛 모델이다 … `_observers`
배열/cascade/`Subscribe` 순회는 전부 `_deps` 하나와 `_blocker`로 대체됐다"*)
아래 죽은 문단인데, 이 커밋의 diff가 그 안 한 줄(`.Subscribed`는 전역 →
**구독 경로** 전용)을 고쳤다. 지시서 §3-4가 경고한 정확히 그 모양이다 — 날짜
마커는 없지만 살아 있는 문장과 같은 표기로 갱신돼 있어 "관리되는 문단"으로 읽힌다.
같은 블록에 `handle._observers[i] = observer` 등 옛 필드가 그대로 살아 있어
followup의 *"같은 파일의 `_observers` 잔재 표기 세 곳도 같이 지웠다"*(`H-114`)와도
안 맞는다(배너 아래라 오독은 안 되지만 "세 곳"이 전수가 아니었다). 처분: 그
한 줄을 되돌리거나(배너만 있고 본문은 안 만진다는 새 규칙), 블록을 `archive/`로.
### `H-131` 🟢 — *"`for d in seen do`는 유효한 Luau가 아니다"*는 거짓 (G)
followup 7차 #3과 `effect-plan.md` 생성자 주석(*"`for d in seen`은 테이블을
호출하려 들어 죽는다"*). 실측:
```lua
--!nocheck (g3_for_in_table.luau)
local seen = { a = true }
local ok, err = pcall(function() for d in seen do print(d) end end)
print("for d in seen do (일반화 반복):", ok, err) --> a / true nil
```
```lua
--!strict (g3b_for_in_strict.luau → luau-analyze exit 0, 진단 0건)
local seen: { [string]: boolean } = { a = true }
for d in seen do print(d) end
```
Luau의 일반화 반복(`__iter`/테이블 직접 순회)은 런타임·`--!strict` 둘 다
통과한다 — 7라운드 전사물 `core.luau` 자신이 `for sub in node.subs do`로 돌고
있었다. `pairs(seen)`로 바꾼 코드는 무해하지만 **근거 문장이 틀렸고**, 그
문장이 `base/`에 Luau 사실로 남아 있으면 다음 구현자가 일반화 반복을 피해
돌아간다. 주석의 근거를 지우거나 "스타일 통일"로 바꿀 것.
### `H-132` 🟢 — "셋 늘었다" 아래 bullet 넷
`slot-plan.md` 2900 *"`spliceArraysUp`/`spliceArraysDown`이 해야 하는 일이 **셋**
늘었다"* — 아래 bullet은 `_elemIndex`(2903) / 자리표시자(2912) / 캐시 당김(2921)
/ **`bk.tokens`·`indexOfToken`(2950, 7라운드 `H-102` 신설)** 넷. 이 커밋이 그
절의 캐시 bullet을 크게 고치면서 헤딩 개수를 안 봤다(사냥 #3, 8라운드 2차/3차와
같은 모양). `H-126`을 반영하면 다섯이 되므로 개수 대신 "아래 목록이 소스"로.
### `H-133` 🟢 — `WeakUnsubscribe`의 비대칭 (E)
`lifecycle-pattern.md` (2) 의사코드의 `WeakUnsubscribe``Subscribed[self] ~= nil`
(강한 킵)만 막는다. 구독한 적 없는 값·이미 `WeakUnsubscribe`된 값에 부르면
**조용히 통과**(`WeakSubscribed[self] = nil; .Subscribed = false`). 실측
(`t12_subscribe_failfast.luau`):
```
구독 안 한 값에 Unsubscribe → error: not subscribed strongly; use :WeakUnsubscribe()
구독 안 한 값에 WeakUnsubscribe → 통과(no error)
```
반면 산문은 *"양방향 대칭 가드 … 구독한 적 없는 값에 부르는 것도 error다"*
(`Unsubscribe`에 대해서만 말하지만, "해제는 건 경로로 푼다 **하나**"라는 문장은
약한 쪽도 포함해 읽힌다). leaf 바인딩된 Observer에 `WeakUnsubscribe`를 부르면
`.Subscribed = false` 대입만 하고 지나간다(gcconn 경로는 안 건드려 무해). 의도된
비대칭이면(약한 해제는 GC 대체 수단이라 관대해도 된다) 그렇게 명문화, 아니면
`WeakSubscribed[self] == nil`도 error.
---
## 레인 B — M5+ 값 단위 트레이싱 (첫 시도)
*(별도 패스로 수행 — 지시서 §2 레인 B 네 항목을 실제 값으로 한 사이클씩
돌렸다: 그룹 `Attribute` 위임 체인, `D` 생성자, 숏핸드 → `PropertyHandler`,
`:List` reconcile `{a,b}``{b,a,c}``{b}` + 중첩 Slot 요소 + `Detach`. 발견은
메인 패스가 원문과 대조해 확인한 뒤 `H-134`부터 번호를 붙였다.)*
### `H-134` 🟡 — `InstanceChildHandler`에 배열 자리 부기 명세가 없다
**무엇이 문제인가.** 말단 핸들러는 *"예외 없이 자기 배열 위치의
`setOffsetSource`/`setLength`를 등록한다"*(`dispatch-core-plan.md`의 `H-39`
블록)인데, `Frame { Frame {} }`의 자식 Instance를 받는 핸들러
(`quad-roblox/src/Handlers/InstanceChild.luau`, `architecture.md` 306행
*"k:number, v:Instance"*)는 (a) `dispatch-core-plan.md`의 말단 핸들러 표에
**행이 없고**, (b) 의사코드가 코퍼스 어디에도 없어 `H-39`의 *"전수 grep"*이 잡을
수 없었으며, (c) `ROADMAP.md` M5 체크박스(903행)는 파일 이름만 적혀 있다.
Length/Offset 절 머리(1365행)가 *"정적 단일 자식은 상수 `1`"*이라고 하지만
**누가** 등록하는지는 안 적혀 있다.
**값 단위 트레이스** — `inst = Frame { Frame{} (k=1), Slot() (k=2) }`,
`Dispatch.drive(inst, {[1]=child, [2]=S})`:
1. `getBlocker(inst):On()`. `k=1`: `getHandler(inst,1,child)`
`InstanceChildHandler``process`: `child.Parent = inst`(물리) — 부기 호출이
명세에 없으므로 **없다고 가정**. `bk(inst)` = `{lengthList={}, sourceList={},
N=nil, offsetCacheValidUpTo=0, offsetSetUpTo=0}` 그대로.
2. `k=2`: `SlotHandler.process``attachSlot(S, inst, inst, 2)`
`materializeSlotTree(S, inst, inst, 2)` → `Dispatch.setOffsetSource(inst, 2,
S.Offset)` → `Dispatch.getOffsetAt(inst, 2)`:
- `offsetCacheValidUpTo == 0``offsetCache[1] = 0`, `offsetCacheValidUpTo = 1`
- `at(2) > 1``cur = offsetCache[1] = 0`; `for i = 1, 1`:
**`bk.lengthList[1] == nil`** → `H-106` 가드가 `error(...)` — 첫 마운트에서 즉사.
3. `Slot()``k=1`이고 `Frame{}``k=2`면 반대로 **안 터진다**(`bk.N`이 1에서
멈춤) — `H-39`가 서술한 *"가끔 되고 가끔 터지는"* 모양 그대로다.
**그대로 구현하면** — 정적 자식 뒤에 Slot이 오는 가장 흔한 레이아웃이 첫
마운트에서 죽는다. `H-39`가 넷을 고치면서 이 핸들러만 놓친 것은 의사코드가
아예 없어서다.
**처방**(§4 Q8) — 말단 표에 `InstanceChildHandler | 말단 | Parent 대입 (+ 부기 —
`setOffsetSource(inst, k, None)``setLength(inst, k, 1, inst)`)` 행을 넣고,
`ROADMAP.md` M5 체크박스에 같은 두 줄을 명시. 반환 클로저는 (B)/단순 철거 시
`setOffsetSource(None)``setLength(0)` 순서로 해제(`SlotHandler`의 retractor와
같은 모양). 대안(부기를 `Dispatch.drive``type(k)=="number"` 분기에서 일괄
등록)은 이미 기각된 안(*"모든 핸들러가 `k=number`일 때 처리하도록 두는"*,
1387행)이라 비권고. 갈래는 사실상 없다 — 확인만.
### `H-135` 🟡 — 숏핸드 retractor의 두 모양
**무엇이 문제인가.** `ui-shorthand-plan.md`*"`v`가 `nil`인 경우"* 절(136행)은
*"반환 클로저가 할 일이 없어 `function() end`이면 충분"*이라 확정하는데, 같은
문서의 *"Tween 지원"* 절 스케치(169행)는
`return function(hint) if hint == nil then destroyManagedChild(inst, k) end end`다.
하강 diff 계약상 `hint == nil`**단순 철거**(`retractFrom`) 또는 **(A) 분기에서
새 값이 `nil`**일 때다.
**값 단위 트레이스** — `Frame { UICorner = cornerState }`, `cornerState:
State<number?>`:
1. `drive`: `(inst,"UICorner")` index 1 = `StoreBind`(관측자 등록) → 즉시 1회 →
`Dispatch.process(inst,"UICorner",8,2)` → index 2 = `UICornerHandler`
`ensureManagedChild``child`(`Relate[(inst,"UICorner")] = child`) →
`Dispatch.process(child,"CornerRadius",UDim.new(0,8),1)``PropertyHandler`
슬롯 `nil` → 즉시 세팅, 슬롯 `true`. 반환 R(hint 버전).
2. `cornerState:Set(Tween{Value=12,Time=0.3})` → (A): `R(tween)``hint ~= nil`
→ 아무것도 안 함 ✓ → `process` → 같은 `child` 재사용 →
`Dispatch.process(child,"CornerRadius",Tween{Value=UDim(0,12)},1)` → PH 슬롯
`true` → 트윈 시작 ✓.
3. `cornerState:Set(nil)``getHandler(inst,"UICorner",nil)` = `UICornerHandler`
(키 매치) → (A): **`R(nil)``destroyManagedChild`** → 이어서
`process(inst,"UICorner",nil,2)``v == nil`**`destroyManagedChild` 한 번
더** — 이중 파괴(`Relate` 항목이 이미 `nil`이면 무해하지만 계약상 두 자리가
같은 일을 한다).
4. `State<State<number>>`에서 안쪽이 `8`(값) ↔ `innerState`로 바뀌면 index 2의
핸들러가 `UICornerHandler``StoreBind`로 바뀌어 **(B) 분기** →
`retractFrom(inst,"UICorner",2)``R(nil)` → 자식 파괴 → 새 핸들러 설치 →
결국 다시 `UICornerHandler``ensureManagedChild`**새 자식 생성**
`(child',"CornerRadius")` 슬롯이 `nil`이라 *"첫 세팅은 스냅"* 규칙에 걸려
진행 중이던 트윈 문맥이 사라진다. `function() end`였다면 자식이 `Relate`
남아 재사용되고 슬롯도 이어진다.
**그대로 구현하면** — 어느 스케치를 옮기느냐에 따라 자식 생존 정책이 갈린다.
두 서술이 같은 문서에서 각각 "확정"이다.
**처방 갈래**(§4 Q9) — (a) `function() end`로 통일(자식 제거는 `process(nil)`
한 경로, `Relate``inst` weak라 부모가 죽으면 같이 사라짐 — `UI-11`의 논리와
결이 같다). Tween 절의 스케치를 이에 맞춰 고친다. (b) `hint == nil` 파괴를
유지하고 (A)-`nil` 이중 파괴와 (B) 깜빡임을 문서화. **권고 (a).**
### `H-136` 🟡 — `:List` 재실행 reconcile에 배치 Blocker가 없다
**무엇이 문제인가.** `dispatch-core-plan.md` 2344행: *"`:List` reconcile에서
`Length` 갱신 시점: 한 사이클(여러 항목이 한꺼번에 추가/제거되는 경우 포함)
전체가 끝난 뒤 **한 번만**"*. 그런데 `activateList`
`data:Observer(function() reconcile(data:Get()) end)`가 **재실행**될 때는 아무도
`getBlocker(self):On()`을 하지 않는다 — Blocker는 최초 population
(`materializeSlotTree` / `Slot:List``_physicalTarget` 분기)만 감싼다
(`slot-plan.md` 1342~1700 구간에 `getBlocker`/`blocker:On` 호출이 없다).
**값 단위 트레이스** — 마운트된 `slot`, `_elements={Fa,Fb}`, `data:Set({b,a,c})`:
1. `i=1 b`: `indexOfRaw(Fb)=2 ~= slotPos 1``rawMove(slot,2,1)` → 규약 4대로
`setLength(slot,1,1,frame)`, `setLength(slot,2,1,frame)` → 각각
`gatedRecompute``blocker:IsOn()` **거짓**, `recomputeBlocker` 거짓 →
**`recompute` 2회**(sum 2, `Length` 불변이라 `Set`은 없음).
2. `i=3 c`: `rawAdd(slot,Fc,3)` → `nativeInsert(frame, getOffsetAt(slot,3)=2,
{Fc})` → `setLength(slot,3,1,frame)`**`recompute` 3회째** → `slot.Length:
Set(3)` → 부모 `gatedRecompute(frame)``recompute(frame)` 1회.
3. `data:Set({b})`: 소멸 루프 `a``rawRemove(slot,2)``spliceArraysDown`
게이트 통과 → `recompute``Length:Set(2)` → 부모 `recompute`; `c` → 같은
경로 → `Length:Set(1)` → 부모 `recompute`. **한 사이클에 `Length:Set` 2회,
부모 `recompute` 2회.**
정합성은 깨지지 않는다(각 `recompute`가 매번 옳은 값을 쓴다). 문제는 (a)
서술과 의사코드가 어긋나고, (b) n개 아이템 사이클에 `recompute` O(n)회 × O(n)
= O(n²) + 부모 캐스케이드가 아이템 수만큼 난다 — 최초 population을 Blocker로
감싼 이유(*"아이템마다 `recompute`가 돌아 O(n²)"*, `slot-plan.md` 1319행)가
재실행엔 적용되지 않은 채 남아 있다.
**처방 갈래**(§4 Q10) — (a) `reconcile` 본문을 `Slot:List``_physicalTarget`
분기와 똑같이 감싼다 — 진입 시 `getBlocker(self):On()`, 끝에
`blocker:OffWithoutEmit()` + `if not bk.recomputeBlocker:IsOn() then
recompute(self, bk) end`. `raw*`의 명시 호출은 이미 `getBlocker(self):IsOn()`
보므로(`H-119`) 추가 배선이 없고, `getOffsetAt`/`physIndex`는 Blocker와 무관하게
정확하다(최초 population과 같은 논증). `updateFn`이 도중에 던지면 Blocker가
켜진 채 남는 것은 `materializeSlotTree`가 이미 감수하는 것과 같은 부류(`pcall`
안 씀). (b) 2344행 서술을 "raw op마다"로 고치고 비용을 문서화. **권고 (a).**
### `H-137` 🟢 — `rawMove`/`rawSwap` 규약 1과 `bk.tokens`/`bk.indexOfToken`
`H-29` 규약 1(2697행)은 `_elements`/`_elemIndex`/`lengthList`/`sourceList`/
`observers`만 치환 대상으로 들고, `H-102`가 splice에 요구한 `bk.tokens`/
`bk.indexOfToken` 갱신(2950행)은 언급이 없다. 트레이스하면 **규약 4가 이동 구간
전 위치에 `setLength`를 다시 태우는 한 정합하다**: 이동 구간 `p`마다
`setLength(p)``oldObserver = bk.observers[p]`(치환으로 옮겨온 남의 observer)를
`unbindLifetime`하고, `token = bk.tokens[p]`(자리 귀속, 치환 안 됨)로 새 클로저를
만들어 `indexOfToken[token] = p`를 다시 쓴다 — 구간 안의 옛 observer는 전부
정확히 한 번 풀린다. 즉 규약 1의 `observers` 치환은 **무의미하고**, 토큰은
치환하면 안 된다(치환해도 `setLength`가 되돌리긴 한다). 규약에 *"`bk.tokens`/
`indexOfToken`은 자리 귀속이라 치환하지 않는다 — 규약 4가 재등록한다"*를
명시하면 닫힌다. 규약 4를 "양끝만"으로 읽으면 깨지므로 "이동 구간 전부"도 같이
못박을 것. (`H-126`과 같은 부류 — `tokens`/`observers`의 자리 귀속을 splice/
move 양쪽에서 한 번에 규정하는 게 낫다.)
### `H-138` 🟢 — 숏핸드 핸들러와 `PropertyHandler`의 매치 구분
`Frame { UICorner = 8 }`에서 `getHandler(inst,"UICorner",8)``UICornerHandler`
고르려면 `PropertyHandler.isHandlable``"UICorner"`를 거부하거나(리플렉션:
Frame의 프로퍼티가 아님) 숏핸드 우선순위가 더 높아야 한다. 어느 쪽인지
`ui-shorthand-plan.md`/`bind-system-plan.md` 어디에도 없다. 리플렉션 거부에
기대면 되긴 하지만, `UIScale`처럼 이름이 우연히 프로퍼티와 겹치는 경우가 생기면
조용히 `PropertyHandler`가 먹는다. 한 줄 명시 권고.
### `H-139` 🟢 — `New` 둘 / `D` 파이프라인 의사코드 부재
`module-lifecycle-plan.md``local function New(): Quad`(모듈 팩토리,
`architecture.md` 확정 13번)와 `bind-system-plan.md``New "Frame" { ... }`
(`D/init.luau`의 인스턴스 생성자)가 같은 이름이다. 패키지가 달라 런타임 충돌은
없지만 문서에서 `New()`/`New "Frame"`이 섞여 나온다. 또 `D.Frame {...}`
실제로 하는 일의 순서 — `Instance.new` → gcconn/gchold(`lifecycle-pattern.md`
(0)) → `flatten`(`modifier-plan.md`) → `Dispatch.drive`(pre-pass 포함) → 반환 —
는 네 문서에 흩어져 있고 한 곳에 의사코드가 없다(값 트레이스는 §5의 `D` 항목 —
산문대로 이어 붙이면 성립한다).
**교차 — `H-125`의 도달 경로 하나 더.** `updateFn`이 중첩 Slot `S``Detach`
뒤 다음 사이클에 `prev`를 돌려주면 `settle``rawAdd(self, S, slotPos, true)`
`attachSlot(S, …)``materializeSlotTree(S, …)`: 첫 줄 `setOffsetSource(self,
slotPos, S.Offset)`이 `S.Offset:Set(newAbs)`를 내는데, 그 시점 `S._baseObserver`
`unmountSlotTree`(`rawDetach` 경유)가 `unbindLifetime`해둔 상태 — `H-125`의 창
그대로다. `:List`에선 사용자가 의도한 정상 기능(`Detach` 캐싱)으로 도달한다.
---
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 |
|---|---|---|---|
| **Q1** | `H-124` `recompute` 루프 순서 | (a) 되감기 판정을 `lengthList[i]` 읽기 앞으로(`continue`) / (b) `i > bk.N` 가드만 | **(a)** — (b)는 되감기 없이 끝나 `H-113`이 닫은 증상을 되살린다 |
| **Q2** | `H-125` 두 필드 초기화 자리 | (a) `materializeSlotTree` 진입부에서 `0, 0` / (b) `unmountSlotTree` 꼬리에서 / (c) `_baseObserver` 바인드·`blocker:On()`을 `setOffsetSource` 앞으로 | **(a)** — 실체화 경계의 명시 불변식. `_baseObserver``0`은 런타임 베이스 변경용으로 유지 |
| **Q3** | `H-126` `spliceArraysUp` 비운 자리 | (a) 요구 목록에 "`observers[index]`/`tokens[index]`를 `nil`로" 명시 / (b) "네 배열 전부 `table.insert`/`remove` 의미론"으로 통일 | **(a)** — 자리표시자 두 줄과 같은 자리에 두 줄 더. 새 결정 아님 |
| **Q4** | `H-127` `EffectHandle:Unsubscribe` | (a) 의사코드 신설: Observer 게이트 위임 **먼저**, 통과 시에만 `_consumeCleanup()`; `Subscribe`/`Weak*`는 Observer 것 재사용 명시 / (b) 산문의 번호 순서만 바꿈 | **(a)** — 네 진입점을 새로 의사코드화한 마당에 Effect만 산문이면 같은 사고가 난다 |
| **Q5** | `H-128` M2×M8 `Ref` | (a) M2 공통 기반에 `Ref` 최소형 체크박스(`.Value`/`.Revision`/`:Set`/`:WeakCallback`/`:Callback`/`:Uncallback`/`isRef`) / (b) M2에선 `Ref` dep을 명시적으로 미루고 M8에 그 테스트 체크박스 | **(a)** — `Effect``isEpoch` 분기를 M2 안에서 한 번은 돌려야 한다 |
| **Q6** | `H-133` `WeakUnsubscribe` 비대칭 | (a) 의도된 관대함으로 명문화 / (b) `WeakSubscribed[self] == nil`도 error | 권고 없음 — 계약 취향. 지금 산문은 (b)처럼 읽히고 코드는 (a)다 |
| **Q7** | `H-130` 폐기 블록 편집 | (a) 그 한 줄을 커밋 전 문구로 되돌리고 "배너 아래는 안 만진다"를 규칙으로 / (b) 블록을 `archive/`로 이동 | **(b)** — 이 블록은 이미 두 번(8라운드 2차 #1·이번) 사고를 냈다 |
| **Q8** | `H-134` `InstanceChildHandler` 부기 | (a) 말단 표에 행 신설 + `setOffsetSource(inst,k,None)`→`setLength(inst,k,1,inst)` + retractor 해제 순서, ROADMAP M5 체크박스에 명시 / (b) `drive``k=number` 일괄 등록(이미 기각된 안) | **(a)** — `H-39`의 "예외 없이" 규칙에 이 핸들러를 넣는 것뿐, 갈래 없음 |
| **Q9** | `H-135` 숏핸드 retractor | (a) `function() end`로 통일, Tween 절 스케치 수정 / (b) `hint == nil` 파괴 유지 + (A)-nil 이중 파괴·(B) 스냅 문서화 | **(a)** — `Relate``inst` weak라 자식 회수는 부모 사망이 맡는다(`UI-11`과 같은 결) |
| **Q10** | `H-136` `:List` 재실행 reconcile | (a) `reconcile` 본문을 배치 Blocker로 감싼다(최초 population과 같은 모양, 끝에 게이트 확인 후 `recompute` 1회) / (b) "한 사이클 한 번만" 서술을 "raw op마다"로 고치고 O(n²) 문서화 | **(a)** — 이미 `raw*` 명시 호출이 `getBlocker(self):IsOn()`을 보므로 추가 배선 없음 |
🟢 `H-129`/`H-131`/`H-132`/`H-137`/`H-138`/`H-139`/`H-140`은 판단 불필요(정정·명시만).
**⚠️ [2026-08-27] Q1~Q3은 이미 확정·반영됐다** — 결정과 근거의 소스는
`-round9-followup.md`이고, 그 처리 중 `H-125`의 피해 범위 정정과 새 발견
`H-140`/`H-141`이 나왔다(위에 반영). Q1은 (a)의 `continue` 형태, Q2는 문항의
(a)/(b)/(c)를 넘어 **`Offset`/`_baseObserver`를 Slot 생성자로 올리고 파괴는
`_destroyed` 플래그가 말하는** 형태로, Q3는 (a)/(b)를 넘어 **`element → index`
맵을 `bk.indexOfElement` 하나로 통일하고 `token`을 폐기**하는 형태로 닫혔다 —
그 결과 **`H-137`은 소멸**했다(토큰이 없어졌으므로).
---
## §5 이상 없다고 확인한 것 (다음 라운드가 다시 파지 않도록)
**A각도 — 겹쳐 읽어 정합했던 조합**:
- **두 필드 분리 × 무효화 4규칙 × `bk.N` 예외 × 2-연산 경로**(4차 HIGH의 그
모양) — 실측 `d11_two_ops.luau`: 커서 `i=3``offset:Set` 안에서
`Remove(1)``Add(s5)`. `Remove`가 두 필드를 `0`으로, `Add`
`setOffsetSource`→`getOffsetAt`이 **`offsetCacheValidUpTo`만** `4`로 올리고
`offsetSetUpTo = 0`은 살아남아 되감기가 `1`부터 다시 돌았다. 최종
`s2 0 / s3 2 / s4 3 / s5 4 / O.Length 5` — 전부 기대값. **분리가 그 버그를
실제로 닫는다.**
- **`H-113` 커서 자리 splice(`j == i < N`)** `d13_splice_at_cursor.luau`:
`i=2`에서 `Remove(2)``offsetSetUpTo = 1 < 2` → 되감기 → 밀려 들어온 `s3`
`Set`을 받는다. `s3 2 / s4 3 / O.Length 4` 정합. (같은 경로의 `i == N`
변형만 `H-124`.)
- **되감기 클램프 `math.max(…, 1)`**`d11`에서 `offsetSetUpTo = 0`으로 떨어진
`i = 1, sum = prefix[1] = 0`으로 정확히 재개.
- **`H-119` 재진입 게이트 7자리** — `rawRemove`/`rawUnmount`/`rawDetach`/
`_baseObserver`/`:List` 활성화 꼬리/`materializeSlotTree` 꼬리/`Dispatch.drive`
꼬리 전부 `blocker:IsOn() or bk.recomputeBlocker:IsOn()`(꼬리 셋은
`recomputeBlocker`만 — 배치 Blocker는 방금 껐으므로 정합). `d10`/`d11`/`d13`에서
`rawRemove`의 건너뛰기가 실제로 걸리고 되감기 신호(`index-1`)가 살아 넘어오는
것 확인.
- **`getBookkeeping` 초기화 열거** — `offsetCache`/두 필드 `0`/`recomputeBlocker`
전부 있고 `bk.N``nil` — 전사물이 그대로 돌았다. `if bk then` 흔적 가드
제거도 `unmountSlotTree`/`destroySlotTree`/`:List` 꼬리/`materialize` 꼬리
전부 반영돼 있다.
- **`Ref` 2-인자 × `Effect``onRefFire`/`onStateFire` ×`guard`** —
`t11_effect_deps.luau`: `Ref` dep이 `fire(ref)``Update(ref)`를 통과해
`Rerun`(미바인드 땐 `canExecute`에 막힘, 바인드 캐치업 `Refresh()`로 1회, 같은
`Set`도 새 리비전이라 1회), 다이아몬드 `A→b, A→c, Effect(fn,b,c)`에서
`A:Set` 한 번에 `fn` 한 번, 혼합 deps(`Ref`+`State`) 독립 발화, 무인자
`state:Observer()`가 3-인자 루프에서 생존, `destroy` 뒤 cleanup 1회·이후 `Set`
무시. 전부 기대값.
- **`Ref:Set` 순서(값→리비전→콜백)·두 테이블 스냅샷·교차 dedup·thread 소진** —
`t13_ref_dedup.luau`: 같은 `fn``:WeakCallback`+`:Callback`으로 걸어도 `Set`
1회에 1번, `:Uncallback` 뒤 0번, thread 대기자가 새 리비전을 보고 소진.
- **fail-fast 3종 매트릭스**`t12_subscribe_failfast.luau`: `Weak→Unsubscribe`
error / `Subscribe→WeakUnsubscribe` error / `Subscribe` 두 번 error / 미구독
`Unsubscribe` error / `Subscribe→Unsubscribe→Subscribe` 재구독 통과 /
`Effect:Subscribe`·`:Unsubscribe`가 내부 Observer를 안 건드리고 dep 발화가
`.Subscribed`로만 켜지고 꺼짐 / 내부 Observer에 범용 `Unsubscribe` → error
(5차 #5가 막으려던 침묵 살해가 실제로 막힌다). 걸린 건 `H-133`의 비대칭뿐.
- **`WeakSubscribe``.Subscribed`를 세움 × `canExecute`** — 위 `t11`의 State
dep 경로가 이걸로 살아 있다(`H-111`의 "전량 침묵"이 실제로 안 난다).
- **사냥 #6 — *idempotent* 네 번째 사본**: `grep -rni idempotent|멱등` 결과
`Subscribe`/`Unsubscribe` 관련은 정정 배너 셋(`lifecycle-pattern.md` 513·516·520,
`source-state-plan.md` 1409~1422, `effect-plan.md` 630)뿐. 나머지 idempotent는
`Blocker`/`Gate`/`RunInit`/`_bindDestroying` 등 다른 대상. **없다.**
- **사냥 #2 — 인용문·절 제목·정정 배너의 옛 이름**: `invalidAfter`가 남은 자리
전부(`dispatch-core-plan.md` 1714·1763·1771·1777·1948·1953, `slot-plan.md`
2925·2933·2943, followup `H-113` 절)가 인용문/절 제목/폐기 서술이고, 살아 있는
코드·표·목록엔 0건. `_observers`도 폐기 배너 안(`effect-plan.md` 476·482·
490~514, 406)에서만 — 단 그 블록 편집은 `H-130`.
- **사냥 #7 — ROADMAP 체크박스 vs `base/`**: M2 `state:Observer(fn)`(3-자리,
`_state`, `H-61` 이름), `Effect` "같이 만들 것"(클로저 둘, `_deps`/`_epochs`/
`_blocker`/`_installed`/`Rerun`), `store:Of`/`CheckReservedKeys<keyof<T>>`/
`__reservedCheck` 셋, `isModifier` 자리·`defaults` 검증, 훅 `guard` 2-인자,
전파 루프 사본 주석, M3 (a)/(b)/(c)의 두 필드·`i-1`·`minPos-1`·`H-119` — 전부
`base/`와 일치. 걸린 건 `H-129`(옛 문장 잔존)와 `H-128`(순서)뿐.
- **`H-118` 재정정 뒤 `gate-plan.md` 5번 × `debounce-throttle-plan.md` 배너** —
두 경로 표가 양쪽에 같은 내용으로 있고 `pass()`/`emit()` 역할이 서로 맞는다.
(게이트 본체는 8라운드 §5대로 다시 안 팠다.)
- **`isModifier` 가드 이동 × `defaults` `isSource` 검증 × `store:Of`** — 세 문서
+ ROADMAP이 "`defaults` 경로에선 안 만든다, `Of`는 만든다, `Source` 생성자
가드가 둘 다 덮는다"로 일치. `component-composition-plan.md``or None`
관용구는 children 배열용이지 `defaults` 값이 아니라 화이트리스트와 무관.
- **`state-epoch-plan.md` §4 카운터 쌍** — 전사물 `core9.luau``State:Get`
그 규칙(`cacheCurrCount ~= cacheTargetCount or Refresh()`)으로 `t11`
다이아몬드를 통과시켰다.
**G각도 — 재확인된 Luau 사실**:
- **`keyof<{}>` + `CheckReservedKeys` — 빈 Store가 깨끗하다**
(`g1b_keyof_empty_clean.luau`, `luau-analyze` exit 0, 진단 0건). `keyof<{}>`
`never`로 타입 함수에 정상 도달하고(`keys:is("never")`로 식별 가능 —
`g1_keyof_empty.luau`에서 `print`로 확인), 유니온이 아니라 단일 타입으로
들어오므로 `is("union")` 분기 없이 `{keys}`로 감싸면 `is("singleton")` 검사가
그냥 거짓 → `types.singleton(true)`. `store-plan.md`의 *"빈 Store는 아직 실측
안 됐다"* 캐비엇과 `STATUS.md` `16`/`21` 지침의 대조군 항목은 **이 실측으로
닫을 수 있다**(음성 대조군 `{ Of = … }`도 같은 파일에서 `"Of" is a reserved
key`로 정확히 걸림). 단, `Source<T>``*error-type*`을 품는 최종형 `T`와의
결합은 8라운드 실측에 의존했다(§6).
- **Luau 함수 타입은 파라미터에 반변**`g2_arity.luau`: `(inst: string,
ref: Ref) -> ()` 자리에 1-인자 람다(무주석/주석 둘 다) 통과, `(number,
string) -> ()`에 `function(x: number)` 통과, 반대(더 받는 함수)는 정확히
에러. `lifecycle-hooks-plan.md`의 주장 그대로.
- **일반화 반복 `for d in t do`는 유효**`H-131`(문서 쪽이 틀림).
- `bit32.bnot(-rev)` 랩은 8라운드 실측에 의존(전사물이 같은 식을 쓰고
`t11`/`t13`의 리비전 판정이 그 위에서 돌았다).
**B각도 — 레인 B가 값으로 돌려 정합했던 것**:
- **그룹 `Attribute` 체인** — `store = Store<<{hp: Source<number>}>>({hp =
Source(100)})`, `Frame { Attribute(store) }` 마운트 → `store.hp:Set(50)`
철거: `AttributeGroupFallbackHandler`(`setOffsetSource(inst,1,None)`/
`setLength(inst,1,0,inst)` 게이트에 막힘 → `groupClaimKeys[(inst,attr)] = 1`
`attr:NameMap()` = `{hp = Source#1}``K1 = groupKey(attr,"hp")`
`Dispatch.process(inst, K1, Source#1, 1)``StoreBind` → 즉시 1회 →
`AttributeKeyFallbackHandler``nameClaims[(inst,"hp")] = K1`
`setAttribute(inst,"hp",100)`). `Set(50)`은 (A) 분기로 `K1` 체인만 돌고 그룹
로직은 안 돈다(*"필드 하나만 바뀌는 흔한 경우"* 서술과 일치).
`retractFrom(inst,1,1)` → claim 반납·`unbindLifetime` 대칭. `State<Attribute>`
새 그룹 객체를 내면 체인 전량 철거 후 `K1'`로 재위임, `nameClaims` 충돌 없음.
`Frame { a, a }``claimed(1) ~= 2` error(`NOOP`·길이 0 등록은 `H-103` 서술대로
남고 무해). plain 필드·`None` 값 경로도 정합.
- **`D` 생성자** — `D.Frame { Name = "x", MouseButton1Click = fn, D.Frame {} }`:
`New<<Frame>> "Frame"``Instance.new` → (0) gcconn/gchold → `flatten`(Modifier
없음) → `Dispatch.drive`(pre-pass 없음 → Blocker On → 단일 `for`: `k=1`
`InstanceChildHandler`(`H-134` 제외 정합) / `"Name"` `PropertyHandler` /
`"MouseButton1Click"` `EventHandler``OffWithoutEmit``recompute(inst)`)
→ 반환. 단일 `for`의 "배열 먼저"가 Luau `next`의 배열 파트 선순회에 의존하는
것은 `F-4-1`이 스파이크 `01` 재작성으로 확인하기로 해둔 자리라 새 항목으로 안
올렸다.
- **숏핸드 → `PropertyHandler`**`(inst,"UICorner")``(child,"CornerRadius")`
**별개 체인**(둘 다 index 1부터), `v:Mapped(toUDim)``Tween` 껍질을 유지한 채
`Value`만 감싸고, PH의 3-상태 슬롯(`nil` → `true``{Tween,Value}`)이
`(child,"CornerRadius")`에 붙어 트윈이 공짜로 따라온다. `UIPadding`은 4개
`(child, prop)` 체인. 재디스패치는 (A) 분기라 `child` 재사용(`H-135`는 철거
쪽 모양만).
- **`:List` reconcile** — `{a,b}``{b,a,c}``{b}``_elements`/`_elemIndex`/
`bk.lengthList`/`sourceList`/`N`/두 필드/`mounted`/`prevKeys`/`slotPos`/
`physIndex` 값으로 전부 적어 돌렸다. `physIndex = getOffsetAt(self,
candidateSlot) - offset:Get() + 1`은 정착된 `1..slotPos`만 합산(아직 처리 안 된
옛 요소는 전부 `≥ slotPos+1`) ✓; 최초 population에서 두 필드가 splice로 0까지
내려가도 `getOffsetAt`이 재부트스트랩 ✓; `rawMove(idx, slotPos)`는 항상
`idx ≥ slotPos` ✓; 소멸 루프의 `indexOfRaw``reindexFrom` 갱신분을 읽어
순서 무관 정확 ✓; 중첩 Slot 요소(`spliceArraysUp` placeholder → `attachSlot`
S 실체화 → `setLength(self,1,S.Length)` 게이트 → 다음 아이템 `physIndex`
`lengthList[1]:Get()`을 라이브로 읽어 2) ✓; `mountSlotTree``acc` 전진 ✓;
`Detach`(메인 루프 → `rawDetach``_detached[key]`, `prevKeys` 유지 → 다음
사이클 `prev` → 재반환은 nop / `prev` 반환은 `rawAdd(…, true)` 재마운트 /
소멸 루프 `Detach`는 홀드, `nil``releaseElement(self, nil, prev, true)`) ✓;
`Owned = false` 래퍼(`sub:Single(v, nil, {Owned=false})`, `data =
state:Compute(function(v) … v:Get() …)`가 `fn(self, …)` 계약 그대로, 고정 키,
`identityUpdateFn``KeyGone` 흡수, 값 교체 `rawReplace(idx, new, false)`
`nativeExtract`, `nil` 전이 `rawUnmount`) ✓; `settle`의 교체+리오더 겹침 순서와
`_elemIndex` 갱신 ✓. 걸린 건 `H-136`(Blocker 부재)과 `H-125`의 도달 경로뿐.
**C각도 — 전사물 자체**: `core9.luau`/`dispatch9.luau`(스크래치패드)는 `base/`
현재 계약을 줄 단위로 옮긴 것이고, 대조군 `d14_baseline.luau`(형제 offset 전파
`O.Length 4 / S.Offset 1 / t2 2`, `t1` 성장 뒤 `t2 4 / O.Length 6`)가 통과한다.
7라운드 전사물의 의도적 차이 셋 중 `PeekDiffers``Peek`으로 표면에 들어왔고,
`box.pos`는 토큰 역참조로 대체됐고, 생명주기 4종은 mock(`{conn = {Connected}}`)
으로 채웠다(`H-97`).
---
## §6 남은 의심 / 못 본 것
**남은 의심**(확신까지 못 간 것):
- **`H-125``State<Slot>` 교체 경로** — 전사물은 `rawUnmount` 상당만 돌렸다.
`SlotHandler.process`의 교체 분기가 `unmountSlotTree`를 거쳐 같은 창을 만드는지
원문에서 확인은 했지만(같은 함수) 값으로 돌리진 않았다.
- **`H-124` (a) 처방의 `rawClear` 결합** — `rawClear`가 splice 취급이라
`index = 1` → 두 필드 `0`이면 되감기 → `i = 1`, `bk.N = 0` → 루프 종료로
깨끗할 것으로 보이나 `rawClear` 의사코드가 없어 손으로만 확인.
- **`getOffsetAt``nil` 가드(`H-106`)가 `H-124` 뒤에 먼저 터질 수 있는가** —
되감기 뒤 `getOffsetAt(i-1)`은 캐시 범위라 안 밟는다고 보지만 `rawSplice`
다중 제거 + 캐시 `index-1`의 조합은 값으로 안 돌렸다.
- **`AttributeGroupHandler`의 (A) 재처리 비용**(H각도) — `State<Attribute>`
emit할 때마다 `setLength(inst,k,0,inst)``gatedRecompute` → steady state에선
`recompute(inst)` 전체 순회가 한 번 돈다(길이 0→0 불변). 정합성 문제는 아니고
`Tag`도 같다 — 비용 서술로만 남긴다.
- **`groupKey` 메모와 `chains[inst][K]`의 빈 배열** — 그룹 값이 살아 있는 동안
이름별 키 객체가 강하게 남고, 철거 뒤 `chains`에 빈 리스트가 남는다.
`inst`/그룹 값이 죽으면 같이 사라지므로 누수는 아니지만 `quad-debug`
`chains`를 덤프할 때 빈 항목이 보인다.
- **`rawMove`/`rawSwap`의 물리 op 인자** — `nativeMove(target, fromOffset,
elements, toOffset)`의 `toOffset`이 "제거 전 좌표"인지 "제거 후 좌표"인지가
`slot-plan.md`에 없다(DOM `insertBefore` 의미론 대비). Roblox 백엔드는 offset을
무시하므로 지금은 관측되지 않는다 — 웹 백엔드가 생길 때 정해야 한다.
- **재실행 reconcile 중 `updateFn`이 던질 때**`H-136` (a)를 채택하면 Blocker가
켜진 채 남는 창이 새로 생긴다(`materializeSlotTree`와 같은 부류). 그 자리가
이미 "실제로 물리면 그때 넣는다"로 확정돼 있어 발견으로 안 올렸다.
- **`WeakUnsubscribe`를 leaf 바인딩된 Observer에 부르는 경우**(`H-133`) —
`.Subscribed = false` 대입만 하고 gcconn 경로는 그대로라 무해하다고 판단했지만,
그 값이 나중에 `:Subscribe()`될 때 `canBound`가 gcconn을 먼저 보므로 여전히
막힌다 — 정합. 다만 "무해"를 발견으로 안 올린 이유는 이것뿐이다.
**못 본 것**(범위 밖 — "감사 통과"로 읽지 말 것):
- **`Gate`/`Blocker`/`Debounce`·`Throttle` 본체** — 8라운드 §5가 닫았고 이
델타는 `gate-plan.md` 5번 산문만 바꿨다. 재트레이싱 안 함.
- **`rawMove`/`rawSwap`/`rawExtract`/`rawSplice`/`rawClear`** — 의사코드가 없어
(`H-29` 규약만) 4번째 무효화 행과 `bk.N` 예외를 **읽기로만** 확인했다. 값
단위는 `rawRemove`/`rawAdd`뿐.
- **`Dispatch.process`/`retractFrom` 체인, `StoreBind`** — 델타가 안 건드렸다.
- **`CheckReservedKeys` × 최종형 `T`(`Source<T>`가 `*error-type*`을 품는 §1②
선언)** — 8라운드 `r8-spike` 실측에 의존. 이번 `g1b``Source` 타입을 단순형
으로 뒀다.
- **`Effect``_bindDestroying` 재바인드(포탈)·`Rerun` 재진입 지연** — `t11`
바인드 1회 + destroy만 돌렸다.
- **Studio 실측 전부**(이 환경 제약).
- **레인 B가 못 본 것**`rawSplice`/`rawClear`/`rawExtract`/`rawSwap` 본체
(의사코드 자체가 없음 — `H-29` 규약만 대조), `Tag` 핸들러 참조 카운트,
`OnChange`/`Event`의 `nil` 전이, `Attribute.Merged`/`Overridden` 합성 규칙,
`D` 생성기의 타입 출력. 전부 문서 정독 수준이고 값을 돌리지 않았다.
---
*스파이크 원본: 세션 스크래치패드 `ref9/`(`core9.luau`·`dispatch9.luau`·
`d10`~`d15`·`t11`~`t13`·`g1`~`g3`). 발견 근거는 위 항목에 코드·출력을 전사해
두어 파일 유실과 무관하게 재현 가능하다. 저장소는 이 파일 말고 아무것도
수정하지 않았다.*

View file

@ -12,7 +12,13 @@
---
## ⭐ 최우선 — **비어 있음** (2026-08-26 갱신)
## ⭐ 최우선 — **비어 있음** (2026-08-27 갱신)
**[2026-08-27] 9라운드 손 트레이싱의 결정 문항 Q4~Q10이 회신 대기입니다 —
M2 착수 게이트는 아닙니다**(🔴 둘은 Q1/Q2로 이미 닫혔고 남은 건 🟡/🟢). 문항과
권고는 `qa-request/pre-implementation-handtrace-round9.md` §4 표, 진행 상태는
`-round9-followup.md`의 진행 표가 소스(여기서 문항을 반복하지 않는다). 다음
세션이 위에서부터 처리한다.
**[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어)
착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정

View file

@ -1925,3 +1925,21 @@ ref 는 그 자체로 epoch임"*), `H-118`은 소유권 문제가 아니라 `gat
문장은 안 읽음; 한 번은 정정 배너 안의 **역사 인용문**까지 치환해 자기모순을
만들었다). 규칙: **전역 치환은 인용문·절 제목·정정 배너를 건드리지 말 것.**
상세는 그 세션 파일의 "검증 (커밋 전)" 절.
## 2026-08-27-01 — 9라운드 손 트레이싱 실행 + Q1~Q3 결정·반영 (`H-124`~`H-141`)
8라운드가 써둔 지시서대로 커밋 `9dd8213`의 델타를 재트레이싱해 발견 18건을
냈고(🔴 둘 다 실측 재현 — `recompute`가 되감기 판정보다 먼저 `lengthList[i]`
읽어 `sum += nil` / 재마운트 시 `_baseObserver`가 unbind 상태라 옛 베이스
캐시), 그중 Q1~Q3를 확정·반영. **Q2는 사용자가 문항을 넘어섰다**`Offset`/
`_baseObserver`를 Slot **생성자**로 올려 첫 마운트/재마운트 분기 자체를 없앴고,
파괴는 `_destroyed` 플래그 하나가 말한다(*"두 일을 겸하는걸 만들다가 사고가
난 적 많아"*). **Q3에서 `token`이 사용자가 정한 적 없는 것으로 드러났다**
2026-08-25 `/code-review`가 발명해 사용자 인용문 옆에 앉아 있던 것(`H-141`);
`element → index``bk.indexOfElement` 하나로 통일, `slot._elemIndex`·토큰
폐기, `setLength` 5번째 인자 `element`. 그 대화에서 메인 세션도 새 개념을 세 번
제안했다 철회했고, `conventions.md`에 *"새 필드·인자·이름·메커니즘은 발견이지
결정이 아니다"* 규칙을 신설했다. 감사 6라운드(1→1→3→1→1→0)로 수렴, `/code-review
high`는 Q4~Q10 반영 뒤로. 결정의 소스는
`qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는
`session/2026-08-27-01-handtrace-round9-q1-q3.md`. **Q4~Q10은 다음 세션.**

View file

@ -0,0 +1,84 @@
# 2026-08-26/27 — 9라운드 손 트레이싱 실행 + Q1~Q3 결정·반영 + 감사 6라운드
**무엇을 했나**: `qa-request/pre-implementation-handtrace-round9-brief.md`(8라운드가
써둔 지시서)대로 커밋 `9dd8213`의 델타를 재트레이싱해 발견 보고
`qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`)를 냈고, 그
§4 문항 중 Q1~Q3를 사용자와 대화형으로 확정해 `base/`·`ROADMAP.md`에 반영, 감사
루프 6라운드(확실 0으로 수렴)까지 돌린 뒤 체크포인트 커밋. **Q4~Q10은 다음
세션**(사용자 판단: *"clear 이후 핸드오버 세션에서 후행 결정을 하는게 맞다"*).
결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`.
세션 도중 모델이 두 번 바뀌었다 — Opus(사용자: *"sonnet 실수가 너무 많아서
감사 루프가 길어져서 오히려 비용이 높아지더라"*) → Fable(아래 "실수" 절).
## 감사 (레인 A/C/G/D 메인, 레인 B는 포크)
- 레인 A는 델타 2180줄 정독 + 원문 절 전문 대조, 레인 C는 7라운드 참조 구현을
현재 계약으로 **다시 전사**(`ref9/core9.luau`·`dispatch9.luau`)해 실행, 레인 B는
같은 컨텍스트를 물려받은 포크에 맡겼다(값 단위 트레이스 4항목).
- 🔴 둘 다 실측 재현: `H-124`(`recompute`가 `lengthList[i]`를 되감기 판정보다
먼저 읽어 커서 뒤 자리 수가 줄면 `sum += nil`, 그 뒤 `recomputeBlocker` 영구
On) / `H-125`(재마운트 시 `_baseObserver`가 unbind 상태라 두 필드가 0으로 안
내려가 옛 베이스의 `offsetCache[1]`을 씀).
- G각도가 **문서 쪽 거짓**을 하나 잡았다 — 7차 code-review의 *"`for d in seen
do`는 유효한 Luau가 아니다"*는 틀렸다(일반화 반복은 런타임·strict 둘 다 통과).
반대로 `keyof<{}>` 빈 Store는 실측 클린이라 8라운드가 남긴 캐비엇을 닫을 수 있다.
## 결정 (원문은 followup, 여기는 흐름)
**Q1** — (a)를 고르면서 사용자가 코드 모양을 정정: 되감으면 `sum``prefix[i]`
덮이니 읽기·누적을 아예 되감지 않을 때만 — `continue` 형태.
**Q2** — 문항의 (a) 진입부 초기화를 사용자가 반대했고(유저 LayoutOrder 체인에
전파가 안 될 것이라는 근거), 실측해보니 **그 근거는 성립하지 않았다**(유저
체인은 요소 인스턴스에 바인드돼 있어 전파된다 — 깨지는 건 부기를 경유하는
중첩 Slot의 `Offset`뿐). 그래도 (a)는 소스 이원화라 기각하고 (c)로 가려는데,
사용자가 한 단계 더 나갔다: *"slot 자체를 생성할 때 offset/observer 이 같이
생성되지 말아야할 이유가 있음?"* → `Offset`/`_baseObserver`를 **생성자**로. 분기
자체가 사라진다. 이어서 `destroySlotTree`가 핸들을 `nil`로 지우던 것에 대해
*"두 일을 겸하는걸 만들다가 사고가 난 적 많아"*(`invalidAfter`) → **`_destroyed`
플래그**, 핸들은 unbind만. 이름은 `_disposed`가 아니다 — *"`dispose` 는 형질이
다른 엔진 요소를 포함할 수 있는 것에 대한 공동 소멸자인 네이밍 … `destroy`
맞아보이고"*. 이중 `dispose`는 no-op. 실측 `d16`에서 none/(a)/(c)/ctor 네 변형
대조.
**Q3** — 표면 증상(`spliceArraysUp`이 비운 자리)을 파다가 `token`이 나왔다.
사용자: *"내가 등장시킨 적 없는 token 이 나와서 당황스러움"*. 추적하니 7라운드
`H-102`의 지시(*"dispatch 로 격상"*)는 요소 → 인덱스 맵을 올리라는 것이었는데,
구현은 `len`을 키로 잡았고 `/code-review`가 그게 유일하지 않다는 걸 잡자 원래
키로 돌아가는 대신 `token = {}`을 발명했다 — 그리고 사용자 인용문 옆에 앉아
확정처럼 읽혔다(`H-141`). 사용자: *"난 층위 상 어떠한 값이든, 마운트된
부기객체 -> index(기여량이 아님) 를 얻고자 했음"*. 확정: `bk.indexOfElement`
하나(`slot._elemIndex` 삭제), `setLength(…, anchor, element)`, 등록은 `setLength`·
이동은 `reindexFrom`·예외 `rawReplace`. `H-137` 소멸.
## 실수 — 같은 종류 셋, 규칙으로 승격
Q3 대화에서 내가 새 개념을 세 번 제안했다가 전부 철회했다: `subject` 인자 /
`observer.pos`·`observer.inst`(**검토 후 안 만들기로 한 `Effect` userdata의
재개방** — 사용자: *"그건 닫은 Effect 의 userdata 허용을 거의 여는 셈이야"*) /
조회 클로저·팩토리(사용자: *"클로저가 필요한 지점으로 안 보여 … elem->index 를
누가 관리하느냐가 어디서 관리하느냐가 명확하지 않아서 자꾸 사고가 나는듯"*).
셋 다 한 번 grep이면 안 냈을 제안이었고, 그중 하나는 **이미 읽은** 제약을
적용 못 한 것이었다. `conventions.md``/code-review` 항목 아래에
*"새 필드·인자·이름·메커니즘은 발견이지 결정이 아니다"* 규칙을 신설했다(사용자:
*"code-review 가 완전 외부자라 우리 대화를 모르기에, 새로운 개념을 창조하려
들 수도 있는 점에 대해서 기술이 필요해보이는데"*). 피드백도 남겼다(`/feedback`).
## 감사 루프 (Q1~Q3 반영분)
`quad-doc-auditor` 한 턴에 하나, 각도 순환: `base/` 정합 → 인덱스 레이어 + 새
문단 자기모순 → archive·reference·audit 인용처 → 앞 수정분 + 두 문서 내부 정합 →
델타 밖 `base/` → 5라운드 수정분. 확실 **1 → 1 → 3 → 1 → 1 → 0**. `base/` 본문
결함은 1라운드 이후 0건이었고, 나머지는 전부 인덱스 레이어·인용처가 델타를 못
따라온 것(8라운드와 같은 분포). 표는 followup의 "감사 루프" 절.
`/code-review high`**아직 안 돌렸다** — Q4~Q10 반영 뒤 한 번에(체크포인트
커밋의 이유: Q4~Q10이 같은 파일을 또 건드려 diff가 섞이면 두 라운드 몫을 구분
못 한다).
## 다음 세션이 할 것
`qa-request/pre-implementation-handtrace-round9-followup.md`의 진행 표가 소스 —
**Q4~Q10**(`H-127`~`H-133`)과 레인 B 몫(`H-134`~`H-136` 🟡, `H-137` 소멸,
`H-138`~`H-140` 🟢)을 발견 문서 §4 표대로 결정 → 반영 → 감사 루프 →
`/code-review high` → 커밋. 이번 세션의 규칙(새 개념은 문항으로)을 지킬 것.

View file

@ -34,6 +34,25 @@
검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동.
- **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가
사본마다 분리된다는 사실(`research/documentation-content-map.md` §4).
- **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q3까지 확정·반영했다 — Q4~Q10 대기.**
발견은 `qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`,
🔴 둘 다 실측 재현), **결정의 소스는 `-round9-followup.md`**(진행 표가
상태의 소스). 반영된 셋: `recompute` 되감기 판정을 `lengthList[i]` 읽기
앞으로 / `Offset`·`_baseObserver`를 Slot 생성자로 + `materializeSlotTree`
순서 + `_destroyed` / `element → index``bk.indexOfElement` 하나로(사용자가
정한 적 없는 `token` 폐기). README 색인은 했고 `/code-review high`·커밋은
Q4~Q10 뒤. 아래는 돌리기 전(2026-08-26) 서술:
지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는
**커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤
`/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도
트레이싱한 적이 없다**(7차 수정분은 리뷰조차 안 됐다). 근거: 그 7패스의
HIGH 추이가 `1→1→0→1→0→3→0`이라 **직전 패스가 조용한 것이 수렴의
증거가 아님**이 같은 세션에 반증됐다(5차 HIGH 0 → 6차 HIGH 3, 전부 5차
수정이 만든 것). 반대로 전수 재트레이싱은 수율이 낮다는 것도 실측됐다 —
8라운드 3차 패스가 `base/`를 완독하고 🔴 0건이었다. **M2 착수 게이트는
아니다**(위 머리말대로 게이트는 0). 돌릴지 말지는 비용 판단이고, 돌린다면
그게 **마지막 종이 라운드**여야 한다는 게 이 지시서의 전제다 — 이후로는
M2 구현 자체가 더 나은 감사 도구다.
아래는 8라운드 이전(2026-08-25) 시점 서술:
**[2026-08-25] 7라운드까지 전부 처리 완료 — 당시 `question.md` 최우선
@ -142,7 +161,7 @@
`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계
결함 둘(`H-1`/`H-2`).
**구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스) 신설로
**구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스; **[2026-08-27]** 9라운드 Q3로 `bk.indexOfElement`에 통합됨) 신설로
`:List``keyIndex`가 단순 키 집합으로 강등, (2) `_mounted`가 "물리
인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`이 신설되어 부기는
실체화 시점부터 항상 수행, (3) `Ref.Callbacks`가 해시맵 셋 + 해제 경로

View file

@ -12,7 +12,8 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
처리 완료 — M2 착수를 막는 항목이 하나도 없다.**
결정의 소스는
`.claude/qa-request/pre-implementation-handtrace-round8-followup.md`
(7라운드 몫은 `-round7-followup.md`)이고 `.claude/question.md` 최우선
(7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은
`-round9-followup.md` — Q1~Q3 반영 완료, Q4~Q10 대기**)이고 `.claude/question.md` 최우선
절은 비어 있다. 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`.

View file

@ -21,6 +21,9 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`,
> 그리고 **[2026-08-26] 8라운드 몫은 `-round8-followup.md`**(7라운드
> 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다.
> **[2026-08-27] 9라운드 몫은 `-round9-followup.md`** — Q1~Q3(`recompute` 되감기
> 순서 / Slot 생성자의 `Offset`·`_baseObserver`·`_destroyed` / `bk.indexOfElement`
> 토큰 폐기)까지 반영됐고 Q4~Q10은 대기 중입니다.
> **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에
> 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐
> 있습니다). M1까지의 산출물은
@ -710,7 +713,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?,element?)`
(**[2026-08-27, 9라운드 Q3]** 5번째 `element` = 그 자리의 `inst|slot`
`gatedRecompute`가 인덱스 대신 이걸 캡처해 **`bk.indexOfElement`**를
조회한다. 옛 `bk.tokens`/`indexOfToken`(사용자가 정한 적 없는 `token = {}`
신원)은 폐기. 상수 길이 자리(`Nil`/`None` 핸들러)는 생략)/
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
**`Dispatch.getOffsetAt(ownerKey,i)`** —
**[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자
@ -724,7 +731,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Offset`이 **영원히 고정**된다(`sum`은 매번 새로 더하므로 위로는 맞고
옆으로만 틀려 알아채기 특히 어렵다):
(a) `getBookkeeping``bk.offsetCache = {}`, **`bk.offsetCacheValidUpTo = 0`,
`bk.offsetSetUpTo = 0`**, `bk.recomputeBlocker = Blocker()`으로
`bk.offsetSetUpTo = 0`**, `bk.recomputeBlocker = Blocker()`,
`bk.indexOfElement = {}`(**[2026-08-27 Q3]**)으로
초기화(`nil` 시작이면 첫 `setOffsetSource``nil` 비교에서 죽는다),
(b) **캐시를 앞으로 당기는 자리**(개수는 `base/dispatch-core-plan.md`
무효화 표가 소스) — `setLength` 본문과 그 자리 length가
@ -1089,10 +1097,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
블록이 이미 머리에 이 파일명을 적어뒀다
- [ ] **⭐ [2026-08-24, 6라운드] 이 마일스톤에서 새로 생긴 필드·헬퍼**
(구현 항목으로 드러나야 놓치지 않는다):
**`slot._elemIndex`**(물리 요소→`_elements` 인덱스 역방향 맵,
`indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**) ·
**`bk.indexOfElement`**(물리 요소→`_elements` 인덱스 역방향 맵,
`indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**. **[2026-08-27, 9라운드
Q3]** 옛 이름 `slot._elemIndex` — 같은 뜻의 맵이 Slot 층과 Dispatch 층에
따로 살던 것을 **Dispatch 부기 하나**로 통일했다, `base/dispatch-core-plan.md`
`setLength` 절) ·
**`reindexFrom(self, from)`**(`_elements`를 시프트하는 **모든** 자리가
부른다 — 실체화 여부와 무관하게 항상) ·
부른다 — 실체화 여부와 무관하게 항상; 갱신 대상은 위 `bk.indexOfElement`) ·
**⭐ [2026-08-27, 9라운드 Q2] 생성자에서 나는 `Offset`/`_baseObserver`**
(`Length`와 같은 자리 — 마운트 시점에 만들면 첫 마운트/재마운트가 갈려
재마운트 캐시가 낡았다, `H-125`. 둘 다 bind/unbind로만 관리하고
제거/생성하지 않는다) · **`slot._destroyed`**(파괴됨은 이 플래그 하나만
말한다 — 핸들을 `nil`로 지워 그 뜻을 겸하게 하지 않는다. 마운트·공개
CRUD·`:List` 진입에서 error level 2, 이중 `dispose`는 no-op,
`base/slot-plan.md`의 "파괴된 Slot은 재사용 불가" 절) ·
**`slot._physicalTarget`**(실체화 시점부터의 생명주기 앵커,
`_mountedInst`는 "마운트됨"만 뜻하게 좁혀졌다) ·
**`collectLeaves(slot)`**(중첩 Slot의 물리 리프 평탄화 —
@ -1121,7 +1139,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**.
반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset
`Source`에 헛된 `:Set()`을 날림. `recompute``sourceList[i]``nil`
이어도 `None`처럼 skip(방어), 해제 시 `slot.Offset = nil`.
이어도 `None`처럼 skip(방어). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]**
여기 한때 *"해제 시 `slot.Offset = nil`"*이 붙어 있었는데 그건 4라운드
`SL-75`/`D-60`이 **폐기**한 문장이다 — `nil`로 갈아치우면 그 Source를
구독 중인 다운스트림이 끊겨 포탈이 깨진다. `slot.Offset`은 생성자에서
나서 Slot과 함께 죽는다(위 `_baseObserver` 항목과 같은 불변식).)
- **소유권 판정을 둘로 분리** — nested(`rawAdd`)는 엄격 `claimOwner`
(같은 owner 재클레임도 error, `Slot{a,a}` 차단, 반환값 없음),
top-level은 `claimOwnerAt(element, inst, k)`(정확히 같은 `(inst,k)`
@ -1211,7 +1233,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`userdata`는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는
`mounted`가 아니라 직전 사이클의 키 집합 `prevKeys` 전체를 순회해야 함
(**[2026-08-24 `H-1`]** 옛 이름은 `keyIndex`였고 인덱스 맵이었으나,
역방향 맵 `slot._elemIndex`가 생기며 **단순 키 집합으로 강등**됐다)
역방향 맵(지금은 `bk.indexOfElement` — 2026-08-27 Q3로 Dispatch 부기에
통일, 그때 이름은 `slot._elemIndex`)이 생기며 **단순 키 집합으로 강등**됐다)
(`userdata`만 살아있는 채로 key가 완전히 사라지는 케이스 커버).
`userdata = userdata or {}` lazy-init 패턴이 Luau 제네릭에서 잘
좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 허용,
@ -1228,9 +1251,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`self._mounted` 확인 후 즉시 활성화)** (2026-08-09 일곱 번째 세션,
`base/slot-plan.md` "`Slot:List(data, updateFn, keyFn?)`"의 "구독 시점" 절)
**`Slot.Offset: Source<number>``Slot.Length`처럼 공개 필드로
노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는
바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션,
`base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절)
노출 — `Length`와 같은 자리, 즉 생성자에서 `Source(0)`으로 만들고 마운트
시점엔 `Dispatch.setOffsetSource`가 그 Source를 **등록만** 한다**
(2026-08-11 세션, `base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은
별개" 절. **[2026-08-27 정정, 9라운드 `H-125`/Q2]** 여기 한때 *"마운트
시점에 `setOffsetSource`가 등록하는 바로 그 Source를 `self.Offset`으로도
저장"*이었다 — 그러면 첫 마운트와 재마운트가 갈려 재마운트 캐시가 낡는다)
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)