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

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 13:00:49 +09:00

883 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 구현 전 손 트레이싱 **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-142` | 🟡→닫힘 | **[2026-08-27 `H-139` 의사코드에서 발견]** `Dispatch.drive`의 해시 파트 순서가 계약이 아니라 `Frame { Parent = x, Size = … }``Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 — 사용자 확정은 순서가 아니라 **props에 `Parent` 금지** | `bind-system-plan.md` × `ROADMAP.md` M5 | 사냥 밖 — 의사코드 작성이 드러냄 | — |
| `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun``fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) → [2026-08-28] 소멸**(10라운드 `H-147`: `fn` 안 자기 해제 자체가 폐기) | — |
| `H-144` | 🟡 | **[2026-08-27 `/code-review high`]** `EffectHandle.Subscribe = Observer.Subscribe` 배정이라 `Unsubscribe`로 cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 **재설치(`Rerun`)가 없다** — leaf 재바인드 경로(`_bindDestroying`의 `not _installed → Rerun`, `H-65`)와 비대칭 | `effect-plan.md` | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) 두 구독 진입점 모두 꼬리; 진입점은 (b) `EffectHandle` 자기 것 — [2026-08-28] `Refresh` 먼저는 폐기(10라운드 `H-151`)** | — |
| `H-145` | 🟡 | **[2026-08-27 `/code-review high`]** 최상위 `SlotHandler` retractor가 `setLength(inst, k, 0)`으로 해제할 때 `bk(inst).indexOfElement[slot]`**안 지워진다**(해제 호출엔 요소가 없고 `setLength``element ~= nil`일 때만 쓴다) — 교체마다 옛 Slot이 부모 `bk`에 강참조로 쌓여 부모가 죽을 때까지 산다(`InstanceChildHandler` 쪽은 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 State라 요소가 필요하다) | `dispatch-core-plan.md` `setLength` × `slot-plan.md` 494 | 리뷰 발견, 처방은 새 메커니즘. **[2026-08-27] 확정 (a) weak-key** | — |
| `H-146` | 🟡 | **[2026-08-27 `/code-review high`]** `H-142`(props에 `Parent` 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 **루트**(`ScreenGui` → `PlayerGui`)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 v1 `Mount(ScreenGui, …)`, `slot-plan.md` 233행의 *"v2는 mount 함수 자체가"*의 그 함수는 어느 표면에도 없다. 부수로 거부 배선의 에러가 일반 매치 실패 메시지(*"provider가 초기화됐는지 확인"*)라 오해를 부른다 | `bind-system-plan.md` `H-142` | 리뷰 발견, 처방은 새 표면. **[2026-08-27] 확정 (a) 루트 예외 + 전용 문구 → [2026-08-28] 둘 다 폐기**(10라운드 `H-148`: 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`) | — |
---
## 상세
### `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의 두 모양 (**[2026-08-27] 🟡→🟢 강등 — 문항의 전제가 틀렸다**, 아래 처방 갈래가 아니라 Tween 절 스케치의 복사 오류 한 줄; `-round9-followup.md` Q9)
**무엇이 문제인가.** `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`은 소멸**했다(토큰이 없어졌으므로).
**⚠️ [2026-08-27 같은 날 후속] Q4~Q8·Q10과 `H-138`·`H-139`도 확정·반영됐다** —
Q4/Q5/Q8/Q10은 권고 (a), Q6은 (a)(의도된 관대함), Q7은 (b)(`archive/`로).
**Q9는 사용자가 문항의 전제를 정정**했다 — 두 "확정"이 경쟁하는 게 아니라
Tween 절 스케치의 `hint == nil` 줄이 `v == nil` 규칙을 잘못 옮긴 복사 오류이고,
(B) 분기의 "트윈이 끊긴다"는 피해가 아니다(자식 파괴가 트윈을 지우는 건 당연).
**[같은 날 확정]** 사용자: *"9 맞음 … retract 는 nop 맞아"*`H-135`는 🟢로.
`H-139` 의사코드를 쓰면서 **`H-142`**가 나왔다(아래). 결정의 소스는
`-round9-followup.md`.
### `H-142` 🟡 — 해시 파트 안의 `Parent` 순서가 미정 (`H-139` 의사코드에서 발견)
`Dispatch.drive`의 본체가 단일 일반화 `for`(`F-4-1`)라 해시 파트 순서는 Luau의
내부 순회 순서다 — 계약이 아니다. `Frame { Parent = x, Size = …, Position = … }`
에서 `Parent` 대입이 다른 프로퍼티보다 **먼저** 올 수 있고, 그러면 부모에 붙은
뒤에 프로퍼티가 바뀐다. 프레임 경계가 안 끼므로 사용자에게 보이는 중간 상태는
없지만 엔진 쪽 레이아웃 재계산이 프로퍼티 수만큼 붙는다. 코퍼스 어디에도
`Parent`의 처리 순서 서술이 없다(`PostRef` 절은 *"자기 `.Parent`는 아직일 수
있다"*라고 부모 쪽 시점만 말한다). 처방 후보는 전부 새 메커니즘이라 정하지
않았다 — (a) `drive``Parent` 키를 루프 뒤로 미룸, (b) 문서화만(사용자가
`Parent`를 별도로 대입), (c) 무시. **사용자 판단.**
**[2026-08-27 확정 — 셋 다 아님, 키 금지]** 사용자: *"Parent대입 자체가 오면 안
돼. 그건 부모에서 할 일이거든."* props에 `Parent`는 올 수 없고(타입 제외 +
`PropertyHandler` 거부 → 기존 no-handler error), 순서 문제는 소멸. 소스는
`-round9-followup.md``H-142` 항목.
---
### `H-143`~`H-146` — 2026-08-27 `/code-review high`가 Q4~Q10 반영분에서 잡은 것 (**[2026-08-27] 전량 확정 — 결정은 `-round9-followup.md`**)
반영 뒤 돌린 `/code-review high`(10건, 검증 12 CONFIRMED)가 낸 것 중 **처방이 새
메커니즘·표면인 넷**. 나머지 여섯(`InstanceChildHandler` retractor의 `Parent = nil`
누락·순서·5번째 인자 / `H-17`에 빈 배열 파트 가드 / `Quad.Ref` 필드 마일스톤 /
Modifier 메소드 목록의 `Parent` 제외 / `EffectHandle` 산문 순서)은 기존 규칙
적용이라 그 자리에서 반영했다(`-round9-followup.md`의 code-review 절).
**`H-143` 🟡 — `fn``self:Unsubscribe()` 뒤 반환 cleanup이 고아.**
`effect-plan.md`가 *"`fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 핸들
표면에 바로 닿는다"*고 허용하는데, `Rerun``_consumeCleanup()``fn` → 반환값을
`_cleanup`에 저장 + `_installed = true`다. `fn` 안에서 `Unsubscribe`가 통과하면
(레지스트리 제거, `_consumeCleanup` no-op — 이미 nil) 그 뒤 `fn`이 돌려준 새
cleanup이 저장되는데, 핸들은 이제 어느 레지스트리에도 없고 leaf도 아니라 재
`Unsubscribe`는 error, `WeakUnsubscribe`는 소진 안 함, dep도 못 깨운다 → 영원히
안 불린다(예: 타이머 정지 cleanup). **갈래**: (a) `Rerun``fn` 반환 뒤
`canExecute(self)`가 거짓이면 반환 cleanup을 **즉시 소진**(저장 안 함) / (b) `fn`
`Unsubscribe`를 금지하고 그 문장을 지움 / (c) 허용하되 "그 실행의 cleanup은
사용자 책임"으로 문서화. **권고 (a)** — 계약(*"끝나는 시점에 마지막 cleanup 정확히
1회"*)을 코드가 지키는 유일한 갈래. 다만 `Rerun` 꼬리에 분기 하나가 는다.
**`H-144` 🟡 — `Unsubscribe` 후 재`Subscribe`에 재설치가 없다.**
`Observer.Subscribe`를 그대로 배정해서 `canBound` 게이트를 통과하면
`.Subscribed = true`만 서고 `fn`은 안 돈다. deps 없는 Effect는 아무도 `Rerun`
못 불러 레지스트리가 살려두는 죽은 핸들(누수), deps 있으면 다음 emit까지 죽어
있다가 조용히 재설치. leaf 재바인드는 `_bindDestroying``not _installed`
`Rerun`한다(`H-65`)라 비대칭. **갈래**: (a) `EffectHandle:Subscribe` 래퍼 —
`Observer.Subscribe(self)``if not self._installed then self:Rerun() end`
(leaf 경로와 대칭) / (b) 소진된 핸들의 재구독을 error / (c) 허용하되 재설치 없음을
문서화. **권고 (a)**`H-65`가 leaf 쪽에 이미 세운 규칙과 같은 모양.
**`H-145` 🟡 — 최상위 Slot 교체 시 `bk(inst).indexOfElement[oldSlot]` 잔존.**
`setLength(inst, k, 0)` 해제엔 요소가 없어 옛 키가 안 지워지고(`setLength`는
`element ~= nil`일 때만 쓴다), `indexOfElement`는 강한 키 맵이라 `Relate(inst)`
부기가 옛 Slot을 부모가 죽을 때까지 붙든다 — Q3가 세운 맵의 부작용이고
`rawReplace`(Slot 층)는 옛 키를 지우지만 inst 층 핸들러는 `bk`에 못 닿는다.
`InstanceChildHandler`는 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`
State라 요소가 필요하다. **갈래**: (a) `indexOfElement`를 **weak-key**로(마운트
중엔 `_elements`/`Relate`가 강하게 잡으므로 사라질 일 없고, 해제 뒤엔 저절로
빠진다) / (b) 해제 호출 `setLength(inst, k, 0, inst, oldSlot)`에도 요소를 넘기면
`setLength``len == 0`일 때 그 키를 지운다(시그니처 의미 확장) / (c) Slot 층처럼
inst 층에도 명시 삭제 API. **권고 (a)** — 코드 한 단어이고 "다른 곳에서 안전하게
유지되는 것은 weak로"(`lifecycle-pattern.md` (0)) 규칙 그대로.
**`H-146` 🟡 — 루트를 붙이는 승인된 경로가 없다.**
`H-142`대로 `.Parent` 대입은 자식을 받는 쪽(`InstanceChildHandler`/Slot `native*`)만
하는데, 부모가 quad 밖인 루트(`ScreenGui` → `PlayerGui`)는 그 어느 쪽도 아니다.
사용자가 `D.ScreenGui { … }`를 만든 뒤 props `Parent`는 error, `Mount`류 API는
없음, 밖에서 `gui.Parent = PlayerGui`는 인용문(*"외부에서 직접 Parent 설정해주지
말것"*)상 금지처럼 읽힌다. 부수로 거부 배선의 에러가 *"no handler … check
quad-roblox provider"*라 provider 설정을 의심하게 만든다. **갈래**: (a) **루트는
예외** — quad가 만든 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서
`.Parent =`로 한다고 명문화(금지되는 건 *quad가 관리하는 자식 자리*에 끼우는 것) /
(b) `Mount(root, parent)`류 표면 신설 / (c) 루트 전용 props 키. 에러 메시지는 어느
갈래든 `PropertyHandler``"Parent"`를 거부할 때 전용 문구를 내는 게 낫다(그건
배선 세부라 갈래와 무관). **권고 (a)** — 새 표면 없이 인용문의 범위만 정확히
적는 것.
## §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`). 발견 근거는 위 항목에 코드·출력을 전사해
두어 파일 유실과 무관하게 재현 가능하다. 저장소는 이 파일 말고 아무것도
수정하지 않았다.*