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

70 KiB
Raw Permalink Blame History

구현 전 손 트레이싱 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 🔴 recomputebk.lengthList[i]되감기 판정보다 먼저 읽는다 — 커서 자리 이후로 자리 수가 줄면 sum += nil 크래시, 그 뒤 recomputeBlocker가 영구 On dispatch-core-plan.md recompute H-113×H-119 겹침 (A) 재현
H-125 🔴 재마운트(포탈/Detach 복귀)에서 setOffsetSourceslot.Offset을 바꾸는 순간 _baseObserverunbind 상태라 두 필드가 0으로 안 내려가고, 꼬리 recompute가 **옛 베이스의 offsetCache[1]**을 그대로 쓴다 slot-plan.md materializeSlotTree×unmountSlotTree H-3×H-119×C-4 겹침 (A) 재현
H-126 🟡 spliceArraysUp이 비운 자리 indexobservers/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()를 부르면(문서가 허용하는 자리) Rerunfn의 반환 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 재바인드 경로(_bindDestroyingnot _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]안 지워진다(해제 호출엔 요소가 없고 setLengthelement ~= 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 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 루트(ScreenGuiPlayerGui)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 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 🔴recomputelengthList[i] 읽기가 되감기 판정보다 앞이다 (A×C)

무엇이 문제인가. 확정 의사코드(dispatch-core-plan.md recompute)의 루프 본문 순서는 이렇다:

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가 "실재하는 재진입 경로"로 확정하고 게이트를 세운 바로 그 상황), rawRemovespliceArraysDownbk.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는 위 순서 그대로):

-- 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)에서 정확히 건너뛰었고 (spliceArraysDownoffsetSetUpTo0으로 당겨둔 것까지 설계대로), 바깥 루프가 lengthList[3](이제 nil)을 읽다 죽었다.

그대로 구현하면. (a) 사용자가 slot.Offset을 관측하는 콜백에서 형제를 지우면 — 코퍼스가 정상 경로로 확정한 재진입 — 익명 산술 에러로 죽고, (b) errorbk.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 ≥ inil이 안 나온다(p > i 제거는 offsetSetUpTop-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을 실제로 넣었다). 그런데 재마운트 순서가:

-- 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)                -- ← 그 뒤에야 깨어난다(바인드는 발화가 아니다)

setOffsetSourcesource:Set(offset)이 전파 루프를 돌 때 _baseObservercanExecute 거짓(gcconn 없음, .Subscribed 없음)이라 조용히 건너뛰어진다 (그게 canExecute 게이트의 정상 동작이다). 두 필드는 옛 값(N_old)에 그대로, offsetCache[1]은 옛 베이스. 이어서 꼬리의 recompute(slot, bk)i = 1에서 getOffsetAt(slot, 1)1 <= offsetCacheValidUpTooffsetCache[1](옛 베이스) 반환 → 자식 offset이 옛 베이스 + 접두합으로 "이미 맞다"고 판정돼 Set이 안 난다. 그 다음 부모의 setLength(ownerKey, position, slot.Length)가 부모 recompute를 돌려도 slot.Offset은 이미 새 값이라 ~= 가드에 걸려 _baseObserver끝내 안 불린다.

H-3 자신이 *"재마운트는 더 나쁘다: bkRelate(slot) 위에 있어 언마운트를 넘어 살아남으므로 offsetCache[1]에 옛 베이스가 남는다"*라고 정확히 경고해뒀는데, 그걸 닫는 유일한 경로가 그 순간 잠들어 있다. 파괴(destroySlotTree) 뒤 재사용은 무사하다 — 거기선 _baseObserver = nil이라 재마운트가 새 Observer를 만들고, 그 "등록 즉시 1회 실행"이 두 필드를 0으로 내린다(가드에 걸려 recompute는 안 하지만 0은 남는다). 깨지는 건 언마운트 보존 경로 (rawUnmount/rawDetachDetach 복귀·State<Slot> 교체·포탈)뿐이다.

어떻게 재현했나 (d12_remount_stale.luau, 같은 전사물):

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가 :ListsettlerawAdd(…, 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를 정하는 것)까지 안 고쳐진다고 적었는데 틀렸다setOffsetSourceSet은 전파 루프를 정상적으로 돌고, 유저 체인의 말단 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)_baseObserver0은 런타임 베이스 변경용으로 그대로 두고, 실체화 경계엔 명시 초기화를 둔다.

H-126 🟡spliceArraysUp이 비운 자리의 observers/tokens (A×C)

무엇이 문제인가. slot-plan.mdspliceArrays* 요구 목록은 비워지는 자리에 대해 lengthList[index] = 0(H-5)과 sourceList[index] = None ("sourceListNone으로 채워지는 것과 대칭")만 명시한다. 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]에 옛 값이 남는다 — 곧 indexindex+1이 같은 Observer·같은 토큰을 가리킨다.

그 뒤 setLength(self, index, …)oldObserver = bk.observers[index]를 보고 unbindLifetime한다 — 그게 이제 index+1(밀려난 요소)의 Observer다. 그리고 indexOfToken[token]index로 덮여 index+1gatedRecompute가 엉뚱한 자리를 무효화한다(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): 요구 목록에 한 줄 — "spliceArraysUpobservers[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를 쓰고(isBoundAliveisObserver(value) or isEffect(value)), effect-plan.mdEffectHandle: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 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없다.

부수로: EffectHandleWeakSubscribe/WeakUnsubscribe가 있는지, Subscribe가 Observer의 것을 그대로 쓰는지(canBound 게이트 포함) 어디에도 없다 — 전사물은 Observer.Subscribe 등을 그대로 물려받는 쪽으로 옮겼고(core9.luau), 그렇게 하면 t12 매트릭스가 전부 기대대로 돈다(§5).

처방(§4 Q4): effect-plan.md에 의사코드 한 블록 — UnsubscribeObserver의 게이트를 먼저(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.luauM8이다. M2 "공통 기반"엔 Brand/Relate/LifetimeHandle만 있고 Ref의 최소형 체크박스가 없다. M8 머리는 *"M2가 이미 이 표면을 전제한다"*라고 인정만 하고, M2는 이 앞으로 참조를 어디서도 말하지 않는다. 8라운드 §5 D각도의 *"앞으로 참조 없음"*은 이 자리를 못 봤다(그때 EffectRef 분기가 H-107로 막 다시 쓰이던 중이었다).

그대로 구현하면. M2 구현자가 EffectRef 분기를 (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 그대로). RefEpoch인 것 자체가 M2의 결정(H-58/H-64/H-70)이라 자연스럽다. (b) M2에선 Ref dep을 명시적으로 미룬다고 적고 M8에 "EffectRef 분기 + 그 테스트"를 체크박스로. 권고 (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 (강한 셋에 걸면 RefEffect를 영원히 붙든다 — 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은 테이블을 호출하려 들어 죽는다"). 실측:

--!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
--!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) 의사코드의 WeakUnsubscribeSubscribed[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.mdH-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)InstanceChildHandlerprocess: child.Parent = inst(물리) — 부기 호출이 명세에 없으므로 없다고 가정. bk(inst) = {lengthList={}, sourceList={}, N=nil, offsetCacheValidUpTo=0, offsetSetUpTo=0} 그대로.
  2. k=2: SlotHandler.processattachSlot(S, inst, inst, 2)materializeSlotTree(S, inst, inst, 2)Dispatch.setOffsetSource(inst, 2, S.Offset)Dispatch.getOffsetAt(inst, 2):
    • offsetCacheValidUpTo == 0offsetCache[1] = 0, offsetCacheValidUpTo = 1
    • at(2) > 1cur = offsetCache[1] = 0; for i = 1, 1: bk.lengthList[1] == nilH-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.drivetype(k)=="number" 분기에서 일괄 등록)은 이미 기각된 안("모든 핸들러가 k=number일 때 처리하도록 두는", 1387행)이라 비권고. 갈래는 사실상 없다 — 확인만.

H-135 🟢 — 숏핸드 retractor의 두 모양 ([2026-08-27] 🟡🟢 강등 — 문항의 전제가 틀렸다, 아래 처방 갈래가 아니라 Tween 절 스케치의 복사 오류 한 줄; -round9-followup.md Q9)

무엇이 문제인가. ui-shorthand-plan.md"vnil인 경우" 절(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 = UICornerHandlerensureManagedChildchild(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 == nildestroyManagedChild 한 번 더 — 이중 파괴(Relate 항목이 이미 nil이면 무해하지만 계약상 두 자리가 같은 일을 한다).
  4. State<State<number>>에서 안쪽이 8(값) ↔ innerState로 바뀌면 index 2의 핸들러가 UICornerHandlerStoreBind로 바뀌어 (B) 분기retractFrom(inst,"UICorner",2)R(nil) → 자식 파괴 → 새 핸들러 설치 → 결국 다시 UICornerHandlerensureManagedChild새 자식 생성(child',"CornerRadius") 슬롯이 nil이라 "첫 세팅은 스냅" 규칙에 걸려 진행 중이던 트윈 문맥이 사라진다. function() end였다면 자식이 Relate에 남아 재사용되고 슬롯도 이어진다.

그대로 구현하면 — 어느 스케치를 옮기느냐에 따라 자식 생존 정책이 갈린다. 두 서술이 같은 문서에서 각각 "확정"이다.

처방 갈래(§4 Q9) — (a) function() end로 통일(자식 제거는 process(nil) 한 경로, Relateinst weak라 부모가 죽으면 같이 사라짐 — UI-11의 논리와 결이 같다). Tween 절의 스케치를 이에 맞춰 고친다. (b) hint == nil 파괴를 유지하고 (A)-nil 이중 파괴와 (B) 깜빡임을 문서화. 권고 (a).

H-136 🟡:List 재실행 reconcile에 배치 Blocker가 없다

무엇이 문제인가. dispatch-core-plan.md 2344행: ":List reconcile에서 Length 갱신 시점: 한 사이클(여러 항목이 한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 한 번만". 그런데 activateListdata: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 1rawMove(slot,2,1) → 규약 4대로 setLength(slot,1,1,frame), setLength(slot,2,1,frame) → 각각 gatedRecomputeblocker: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}): 소멸 루프 arawRemove(slot,2)spliceArraysDown → 게이트 통과 → recomputeLength: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.mdlocal function New(): Quad(모듈 팩토리, architecture.md 확정 13번)와 bind-system-plan.mdNew "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 SDetach한 뒤 다음 사이클에 prev를 돌려주면 settlerawAdd(self, S, slotPos, true)attachSlot(S, …)materializeSlotTree(S, …): 첫 줄 setOffsetSource(self, slotPos, S.Offset)S.Offset:Set(newAbs)를 내는데, 그 시점 S._baseObserverunmountSlotTree(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) — 실체화 경계의 명시 불변식. _baseObserver0은 런타임 베이스 변경용으로 유지
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)EffectisEpoch 분기를 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) drivek=number 일괄 등록(이미 기각된 안) (a)H-39의 "예외 없이" 규칙에 이 핸들러를 넣는 것뿐, 갈래 없음
Q9 H-135 숏핸드 retractor (a) function() end로 통일, Tween 절 스케치 수정 / (b) hint == nil 파괴 유지 + (A)-nil 이중 파괴·(B) 스냅 문서화 (a)Relateinst 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) driveParent 키를 루프 뒤로 미룸, (b) 문서화만(사용자가 Parent를 별도로 대입), (c) 무시. 사용자 판단.

[2026-08-27 확정 — 셋 다 아님, 키 금지] 사용자: "Parent대입 자체가 오면 안 돼. 그건 부모에서 할 일이거든." props에 Parent는 올 수 없고(타입 제외 + PropertyHandler 거부 → 기존 no-handler error), 순서 문제는 소멸. 소스는 -round9-followup.mdH-142 항목.


H-143H-146 — 2026-08-27 /code-review high가 Q4Q10 반영분에서 잡은 것 ([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 🟡fnself: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) Rerunfn 반환 뒤 canExecute(self)가 거짓이면 반환 cleanup을 즉시 소진(저장 안 함) / (b) fnUnsubscribe를 금지하고 그 문장을 지움 / (c) 허용하되 "그 실행의 cleanup은 사용자 책임"으로 문서화. 권고 (a) — 계약("끝나는 시점에 마지막 cleanup 정확히 1회")을 코드가 지키는 유일한 갈래. 다만 Rerun 꼬리에 분기 하나가 는다.

H-144 🟡Unsubscribe 후 재Subscribe에 재설치가 없다. Observer.Subscribe를 그대로 배정해서 canBound 게이트를 통과하면 .Subscribed = true만 서고 fn은 안 돈다. deps 없는 Effect는 아무도 Rerun을 못 불러 레지스트리가 살려두는 죽은 핸들(누수), deps 있으면 다음 emit까지 죽어 있다가 조용히 재설치. leaf 재바인드는 _bindDestroyingnot _installedRerun한다(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) 해제엔 요소가 없어 옛 키가 안 지워지고(setLengthelement ~= nil일 때만 쓴다), indexOfElement는 강한 키 맵이라 Relate(inst) 부기가 옛 Slot을 부모가 죽을 때까지 붙든다 — Q3가 세운 맵의 부작용이고 rawReplace(Slot 층)는 옛 키를 지우지만 inst 층 핸들러는 bk에 못 닿는다. InstanceChildHandler는 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 Length가 State라 요소가 필요하다. 갈래: (a) indexOfElementweak-key로(마운트 중엔 _elements/Relate가 강하게 잡으므로 사라질 일 없고, 해제 뒤엔 저절로 빠진다) / (b) 해제 호출 setLength(inst, k, 0, inst, oldSlot)에도 요소를 넘기면 setLengthlen == 0일 때 그 키를 지운다(시그니처 의미 확장) / (c) Slot 층처럼 inst 층에도 명시 삭제 API. 권고 (a) — 코드 한 단어이고 "다른 곳에서 안전하게 유지되는 것은 weak로"(lifecycle-pattern.md (0)) 규칙 그대로.

H-146 🟡 — 루트를 붙이는 승인된 경로가 없다. H-142대로 .Parent 대입은 자식을 받는 쪽(InstanceChildHandler/Slot native*)만 하는데, 부모가 quad 밖인 루트(ScreenGuiPlayerGui)는 그 어느 쪽도 아니다. 사용자가 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=3offset:Set 안에서 Remove(1)Add(s5). Remove가 두 필드를 0으로, AddsetOffsetSourcegetOffsetAtoffsetCacheValidUpTo 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 → 되감기 → 밀려 들어온 s3Set을 받는다. 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.Nnil — 전사물이 그대로 돌았다. if bk then 흔적 가드 제거도 unmountSlotTree/destroySlotTree/:List 꼬리/materialize 꼬리 전부 반영돼 있다.
  • Ref 2-인자 × EffectonRefFire/onStateFire ×guardt11_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.mdor None 관용구는 children 배열용이지 defaults 값이 아니라 화이트리스트와 무관.
  • state-epoch-plan.md §4 카운터 쌍 — 전사물 core9.luauState: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)] = 1attr:NameMap() = {hp = Source#1}K1 = groupKey(attr,"hp")Dispatch.process(inst, K1, Source#1, 1)StoreBind → 즉시 1회 → AttributeKeyFallbackHandlernameClaims[(inst,"hp")] = K1setAttribute(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" EventHandlerOffWithoutEmitrecompute(inst)) → 반환. 단일 for의 "배열 먼저"가 Luau next의 배열 파트 선순회에 의존하는 것은 F-4-1이 스파이크 01 재작성으로 확인하기로 해둔 자리라 새 항목으로 안 올렸다.
  • 숏핸드 → PropertyHandler(inst,"UICorner")(child,"CornerRadius")별개 체인(둘 다 index 1부터), v:Mapped(toUDim)Tween 껍질을 유지한 채 Value만 감싸고, PH의 3-상태 슬롯(niltrue{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 ✓; 소멸 루프의 indexOfRawreindexFrom 갱신분을 읽어 순서 무관 정확 ✓; 중첩 Slot 요소(spliceArraysUp placeholder → attachSlot → S 실체화 → setLength(self,1,S.Length) 게이트 → 다음 아이템 physIndexlengthList[1]:Get()을 라이브로 읽어 2) ✓; mountSlotTreeacc 전진 ✓; Detach(메인 루프 → rawDetach_detached[key], prevKeys 유지 → 다음 사이클 prev → 재반환은 nop / prev 반환은 rawAdd(…, true) 재마운트 / 소멸 루프 Detach는 홀드, nilreleaseElement(self, nil, prev, true)) ✓; Owned = false 래퍼(sub:Single(v, nil, {Owned=false}), data = state:Compute(function(v) … v:Get() …)fn(self, …) 계약 그대로, 고정 키, identityUpdateFnKeyGone 흡수, 값 교체 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라운드 전사물의 의도적 차이 셋 중 PeekDiffersPeek으로 표면에 들어왔고, box.pos는 토큰 역참조로 대체됐고, 생명주기 4종은 mock({conn = {Connected}}) 으로 채웠다(H-97).


§6 남은 의심 / 못 본 것

남은 의심(확신까지 못 간 것):

  • H-125State<Slot> 교체 경로 — 전사물은 rawUnmount 상당만 돌렸다. SlotHandler.process의 교체 분기가 unmountSlotTree를 거쳐 같은 창을 만드는지 원문에서 확인은 했지만(같은 함수) 값으로 돌리진 않았다.
  • H-124 (a) 처방의 rawClear 결합rawClear가 splice 취급이라 index = 1 → 두 필드 0이면 되감기 → i = 1, bk.N = 0 → 루프 종료로 깨끗할 것으로 보이나 rawClear 의사코드가 없어 손으로만 확인.
  • getOffsetAtnil 가드(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-debugchains를 덤프할 때 빈 항목이 보인다.
  • 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 실측에 의존. 이번 g1bSource 타입을 단순형 으로 뒀다.
  • Effect_bindDestroying 재바인드(포탈)·Rerun 재진입 지연t11은 바인드 1회 + destroy만 돌렸다.
  • Studio 실측 전부(이 환경 제약).
  • 레인 B가 못 본 것rawSplice/rawClear/rawExtract/rawSwap 본체 (의사코드 자체가 없음 — H-29 규약만 대조), Tag 핸들러 참조 카운트, OnChange/Eventnil 전이, Attribute.Merged/Overridden 합성 규칙, D 생성기의 타입 출력. 전부 문서 정독 수준이고 값을 돌리지 않았다.

스파이크 원본: 세션 스크래치패드 ref9/(core9.luau·dispatch9.luau· d10d15·t11t13·g1~g3). 발견 근거는 위 항목에 코드·출력을 전사해 두어 파일 유실과 무관하게 재현 가능하다. 저장소는 이 파일 말고 아무것도 수정하지 않았다.