quad/.claude/qa-request/pre-implementation-handtrace-round10.md
qwreey ecc6b0e538
qa: 10라운드 후속 H-158~H-162 반영 — :Block 폐기(__apply), _rerunRequired 홀드(_installed 흡수), Claim M5, Void export
- H-158 state:Block → state:Apply(blocker), Blocker:__apply 메소드형(호출 규약 명시)
- H-159 사용자 제안 _rerunRequired 홀드: fire=Update→Rerun, rawRerun이 실행 불가 상태의 요청을 홀드,
  _installed 폐기, Observer 대칭(전파 루프 else + bind/subscribe 1회 발화, 생성자 순서 fn→_subs)
- H-160 홀드로 정정 + "error 나면 그 Effect는 죽는다" 계약 / H-161 Claim M5 스코프 / H-162 Void.luau 잎 모듈
- 감사 7→6→1→0, /code-review high 10건 중 8 반영, 둘(H-163 Slot 내부 Observer×홀드, H-164 emitFrom nil)은 문항으로

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

529 lines
48 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.

# 구현 전 손 트레이싱 **10라운드** — 발견 보고 + 배치 결정 문항지
> **이 파일이 무엇인가**: **[2026-08-28 신설]** 9라운드 후속(`H-143`~`H-146`)
> 반영 뒤 `/code-review high`가 낸 판단 대기 셋(`H-147`~`H-149`)을 씨앗으로,
> 신선한 탐사자가 **M2~M8 전 표면을 광범위하게** 손 트레이싱·실측한 결과를
> 여기 이어 쓴다(`H-150`~). 지시서는 `-round10-brief.md`. **사용자는 §4 표를
> 위에서 아래로 읽고 갈래만 회신한다**(배치). 결정이 나면 `-round10-followup.md`를
> 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의
> "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것.
>
> 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 후속 `H-158`~`H-162`도 같은 날 확정(`H-159`는 `/code-review` 권고 (a) `Refresh` 복원이 아니라 사용자 제안 **`_rerunRequired` 홀드**로). 미결은 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는
> `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존
> 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`의
> 마지막 code-review 절).
## 요약 표
| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |
|---|---|---|---|---|---|
| `H-147` | 🟡 | `Rerun` 꼬리 `wasAlive`가 루프 머리 `_consumeCleanup()` **뒤**에 잡히고 공개 `Rerun()`에 게이트가 없어 **진입 시점에 이미 죽은 핸들**(직전 cleanup이 `self:Unsubscribe()` / 해제 뒤 타이머의 `self:Rerun()`)은 else 분기 → 고아 cleanup + `_installed = true` | `effect-plan.md` `Rerun` | `/code-review` 발견, 처방은 규칙 또는 새 플래그 | `t15`(재현 — §5) |
| `H-148` | 🟡 | `H-146`의 "`Parent` 거부는 **전용 문구**"가 새 메커니즘 — `isHandlable` 거부는 `Dispatch.process`**일반** 매치 실패 문구로 떨어지고 그 자리에 특수 분기는 두지 않기로 확정돼 있다. 사용자 회신에 문구 언급 없음(에이전트 선택이었음) | `bind-system-plan.md` `H-142` 항목, `ROADMAP.md` M5 | `/code-review` 발견, 처방은 새 핸들러 또는 철회 | — |
| `H-149` | 🟡 | `Observer:Subscribe``self:WeakSubscribe()`로 위임하므로 거기 붙인 `error(…, 2)``o:Subscribe()` 경로에선 사용자 호출부가 아니라 `Observer:Subscribe` 본문을 가리킴(`H-104` level 계약 위반). `EffectHandle`은 (b)로 인라인해 문제 없음 | `lifecycle-pattern.md` Observer 네 진입점 | `/code-review` 발견, 처방은 기존 결정("위임하므로 게이트 한 번") 변경 | `t19`(재현 — §5) |
| `H-150` | 🟡 | `Effect._blocker`는 **아무것도 억제하지 않는 죽은 부품**이다 — `fire`의 첫 줄 `canExecute(self)`가 생성자 구간(핸들이 아직 bind/Subscribe 안 됨)의 등록 즉시 1회를 전부 먼저 떨어뜨리고, 생성자 뒤로는 `_blocker`가 다시 `On()`되는 자리가 없다. 그런데 `effect-plan.md`는 억제 주체를 `_blocker`로 서술하고 `gate-plan.md` 7번은 아직 *"자기 내부 플래그로"*(폐기된 `_installing`)라 적혀 있다 | `effect-plan.md` 생성자 / `gate-plan.md` 7번 | 실측 발견, 처방은 부품 제거 또는 서술 정정(사용자 지시로 들어온 부품이라 문항) | `t18` |
| `H-151` | 🟡 | 게이트가 **두 경로에서 우회된다** — (1) `Effect`의 재바인드/재구독 캐치업 `_epochs:Refresh()`는 원천 `Epoch``.Revision`을 직접 읽으므로 중간 `GateNode`가 유보 중이어도 `Rerun`이 돈다, (2) 게이트 없는 형제 dep이 emit하면 `fn`이 돌며 게이트된 dep의 최신값을 `:Get()`으로 그냥 본다. 둘 다 "값은 안 가린다" 계약의 따름정리지만 어디에도 적혀 있지 않다 | `gate-plan.md` / `effect-plan.md` `_bindDestroying` | 실측 발견, 처방은 문서화(권고) 또는 새 메커니즘 | `t16` |
| `H-152` | 🟡 | `gate-plan.md``GateNode` 조립 절(필드 전부를 한 곳에 모은 절)에 **`StateBrand` 등록이 없다.** `_emitDown``isState(sub)`로만 자식 노드를 가르므로 등록을 빠뜨리면 게이트는 `canExecute(sub)`(bind된 적 없음 → 거짓)로 떨어져 **조용히 영영 못 받는다**`:Get()`은 멀쩡하고 통지만 죽는 모드. `brand-plan.md` 스스로 *"어느 브랜드에 등록하는 걸 빠뜨렸나"*를 조용한 버그로 경고한 그 자리 | `gate-plan.md` `GateNode` 조립 / `source-state-plan.md` `_emitDown` | 명시 누락(갈래 없음 — 한 줄 추가) | `t24`(재현) |
| `H-153` | 🟡 | Store 예약 이름(`Of`/`Names`/`__reservedCheck`)의 **런타임 갭**`store:Of("Of")`는 구현 모양에 따라 메소드 함수를 그대로 돌려주거나(그림자=store 자신) 이후 `s:Of`를 가린다(프록시); `Store({ Of = Source(1) })``isSource` 화이트리스트를 **통과**한 뒤 `s:Of(...)`가 *attempt to call a table value*로 죽는다. 타입 함수는 정적 키만 보고 동적 `Of(name)`과 `--!nocheck`엔 손이 안 닿는다(store-plan 자신이 `__reservedCheck`에 대해 절반은 인정) | `store-plan.md` 구현 스케치 / `Of` | 실측 발견, 처방은 **작은 런타임 가드**(새 검사라 문항) | `t23` |
| `H-154` | 🟡 | `InstanceChildHandler`의 retractor에 **같은 값 dedup이 없다**`state<Frame>`이 같은 Frame을 다시 emit하면(spurious) `Parent = nil``Parent = inst`가 실제로 일어나고 `recompute(inst)`가 2회 돈다. 같은 자리의 `SlotHandler`(`slotValue == nextValue → return`)·`RefLeafHandler`(`old ~= v`)·Leaf(`old ~= v`)는 전부 dedup을 두고 있어 이 핸들러만 예외 | `dispatch-core-plan.md` `H-134` 문단 / `ROADMAP.md` M5 | 실측 발견, 처방은 한 줄 가드(동형 선례 있음, dedup 정책이라 문항) | `d23` |
| `H-155` | 🟢 | `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다 — M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip"*(4라운드 `C-6`가 **즉시 error**로 뒤집음), M6 *"`destroySlotTree`가 자식 소유권 반납"*(`C-4`가 되돌림), M6 *"`self._mounted` 확인 후 즉시 활성화"*(6라운드 `H-2``_physicalTarget`으로), M11 3-상태 슬롯 *"`RobloxTween \| true \| nil` … 엔진 객체=활성 트윈"*(tween-plan이 `{Tween, Value}` 테이블로 정정) | `ROADMAP.md` M6/M11 | stale(색인 레이어) | — |
| `H-156` | 🟢 | `debounce-throttle-plan.md` 7절의 `H-32` 문단이 *"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `OffWithoutEmit()`은 보류분을 버리면서 상태도 같이 비운다"*로 남아 있다 — 같은 절 배너가 7라운드 `H-86`(`HasBlockedEmit`은 정책이 읽을 수 없음)·`H-55`(`OffWithoutEmit`만으론 집합이 안 빔, `emit(false)` 필요)로 정확히 그 두 문장을 뒤집어놓고 본문은 안 고쳤다 | `debounce-throttle-plan.md` 7절 | 배너만 달고 본문 미수정 | — |
| `H-157` | 🟢 | `store-plan.md`의 *"빈 Store(`Store<<{}>>()`)는 아직 실측 안 됐다"*는 이제 실측됐다 — 최종형(§1 쪼개기 `Source<T>` + `CheckReservedKeys<keyof<T>>`)에 `Store({} :: {})`를 넣으면 `keyof<{}>`가 에러가 아니라 빈 유니온으로 풀려 **진단 0**, `empty:Names()`/`empty:Of(...)`도 정상 | `store-plan.md` 빈 Store 문단 | 실측 완료(서술 갱신만) | `ty11` |
## 상세
### `H-147` 🟡 — 이미 죽은 핸들에서 `Rerun()`이 불리면 (`H-143` 잔여)
`effect-plan.md` `Rerun`: 루프 머리 `_consumeCleanup()``local wasAlive =
canExecute(self)``fn``if wasAlive and not canExecute(self)`. "실행 중에
죽은" 건 잡지만 **"진입 시점에 이미 죽은"** 건 못 잡는다:
1. 직전 cleanup이 `self:Unsubscribe()`를 부름 → 루프 머리 소진 안에서 죽음 →
`wasAlive` 거짓 → `fn` 실행 → else → 저장 + `_installed = true`. 고아 cleanup,
이후 재`Subscribe`의 `resubscribeTail``_installed` 참을 보고 재설치 생략.
2. `fn``task.delay(1, function() self:Rerun() end)`를 걸고 사용자가 그 전에
`Unsubscribe()`.
생성자 최초 `Rerun`("한 번도 산 적 없음")과 구분할 상태가 없다.
**갈래**: (a) "죽은 핸들에서의 `Rerun()`은 사용자 책임(UB)"으로 문서화 — 새
상태 없음; (1)은 "cleanup 안에서 자기 해제"를 지원 목록에서 빼는 것, (2)는
`Unsubscribe`가 도는 cleanup이 그 예약을 취소해야 하는 것(그게 cleanup의 일).
기존 "error 시 UB / 수렴 책임은 `fn`" 계약과 같은 결 / (b) `_everAlive` 플래그
하나(첫 `canExecute` 참 시점에 세움) → `Rerun` 진입에서 `everAlive and not
canExecute → return`. 필드 하나, 생성자 케이스도 가림 / (c) `wasAlive`
`_consumeCleanup()` **앞**에서 잡기 — (1)만 닫히고 (2)는 그대로.
**권고 (a)** — 두 시나리오 다 "죽은 뒤에도 자기를 부르는 코드"고, `Unsubscribe`
cleanup을 도는 이유가 바로 그런 예약을 거두라는 것.
### `H-148` 🟡 — `H-146`의 "전용 에러 문구"는 새 메커니즘이었다
`PropertyHandler.isHandlable``"Parent"`를 거부하면 나오는 건 `Dispatch.process`
**일반** 매치 실패 문구(*"no handler … check provider"*)이고, 그 자리에 특수
분기는 두지 않기로 확정돼 있다(`dispatch-core-plan.md`의 매치 실패 절). 전용
문구를 내려면 새 자리가 필요하다. 사용자 `H-146` 회신엔 문구 언급이 없었고
에이전트가 "배선 세부"라며 붙였다 — `conventions.md` 2026-08-27 규칙 위반.
**갈래**: (a) 전용 문구 **철회** — 일반 매치 실패 error 그대로, 오해는 사용자
문서("`Parent`는 props에 못 쓴다, 루트는 밖에서")로 흡수. 새 것 0 / (b) **가드
핸들러** — `k == "Parent"`에 매치해 `process`에서 전용 문구로 error. Observer/
Effect의 "동적 경로 가드"(`effect-plan.md`)와 같은 모양이라 새 *종류*는 아니나
핸들러 하나 + 숏핸드보다 위 우선순위 필요 / (c) `Dispatch.process` 매치 실패
문구에 키 이름을 싣기(일반 개선이지만 별도 결정).
**권고 (a)** — 원래 회신의 취지(새 API 없음, 최종 사용자 몫)와 같은 결. M5에서
실제로 밟아 헷갈리면 (b)를 그때 얹는 게 싸다.
### `H-149` 🟡 — `Observer:Subscribe`의 위임 때문에 `level 2`가 quad 내부 줄을 가리킨다
2026-08-27에 `Observer:WeakSubscribe``error``, 2`를 붙였는데(`H-104`
계약), `Subscribe``self:WeakSubscribe()`로 위임하므로 `o:Subscribe()` 경로의
level 2는 **`Observer:Subscribe` 본문**을 가리킨다. 가장 흔한 오용(leaf 바인딩된
Observer에 `o:Subscribe()`)이 quad 내부 줄을 블레임한다. `EffectHandle` 쪽은 (b)로
게이트를 인라인해 문제 없음.
**갈래**: (a) Observer도 `Subscribe`에 게이트·등록 세 줄을 **인라인** — Effect와
같은 모양, level 2 정확. "게이트는 한 번만 돈다 — `Subscribe``WeakSubscribe`
위임"이라는 기존 문장이 "각자 한 번"으로 바뀜. 중복 세 줄은 같은 타입 안이라
dot 호출 로컬 헬퍼로 빼도 됨(가상 디스패치 없음) / (b) 내부 헬퍼
`weakSubscribe(self, level)`로 위임하고 `Subscribe``level 3` — 코퍼스에
`level 3` 선례 없음 / (c) 그대로 두고 `Subscribe` 경로만 블레임이 한 단계 안쪽임을
문서화.
**권고 (a)** — "본문을 섞지 않는다"(2026-08-27 원칙)와 결이 같다.
### `H-150` 🟡 — `Effect._blocker`는 죽은 부품이다 (억제는 `canExecute`가 이미 한다)
**트레이스**`base/effect-plan.md`의 "의사코드 — 생성자" 절 그대로:
`self._blocker:On()` → dep마다 `d:Observer(onStateFire)`(등록 즉시 1회 → `fire`) /
`d:WeakCallback(onRefFire)`(등록 즉시 1회 → `fire`) → `self._blocker:OffWithoutEmit()`
`self:Rerun()`. `fire`의 순서는 `canExecute(self)``_blocker:IsOn()``Update`.
생성자 안의 `self`는 아직 `bindLifetime``Subscribe`도 안 된 핸들이라
`isBoundAlive(self)`가 거짓 → **`canExecute`가 첫 줄에서 전부 떨어뜨린다.**
`_blocker:IsOn()` 줄엔 한 번도 도달하지 않는다. 생성자 뒤로 `_blocker:On()`
다시 부르는 자리는 코퍼스 어디에도 없다(`_bindDestroying`/`Rerun`/네 진입점 전부
확인) — 즉 이 부품은 **어떤 실행 경로에서도 판정에 참여하지 않는다.**
실측(`t18`): `fire` 가드 판정을 훅으로 기록하니 dep 3개짜리 Effect의 생성자에서
`drop:canExecute` 3건, `drop:blocker` **0건**. 바인드 뒤 `Set`은 정상 발화.
**왜 문제인가** — 동작은 안 깨진다. 문제는 **서술**이다:
- `effect-plan.md`*"억제는 사적 `Blocker` 하나가 전담한다"*, `_installing`
플래그가 *"생성자 구간만 덮어 바인드 구간을 놓쳤다"*고 근거를 적어뒀는데,
실제 차단자는 `canExecute`다. 다음 구현자가 이 근거를 믿고 `_blocker`
건드리거나(예: 바인드 구간에도 `On()`을 걸어 "일관되게") 반대로 `canExecute`
줄을 옮기면(예: `Update`를 먼저 해서 리비전을 앞당겨 기록하려고) 그때 처음으로
`_blocker`가 진짜 역할을 갖게 되거나 캐치업이 어긋난다 — 사냥 목록의 *"두 뜻이
같다"*형(문서가 두 장치를 같은 것처럼 서술) 자리다.
- `base/gate-plan.md` 7번은 더 낡았다 — *"`Effect`가 **자기 내부 플래그로** 설치
중 발화를 누르고"*는 7라운드 `H-58`이 폐기한 `_installing` 서술이다(같은
문서의 "확정 구조" 절과 `effect-plan.md`는 이미 `Blocker`로 바뀌어 있다).
**새 메커니즘 여부** — 없음. 제거 쪽이면 필드 하나·줄 셋이 준다.
**갈래**
- (a) **`_blocker`를 제거**하고 생성자 주석을 *"등록 즉시 1회는 `canExecute`
막는다(생성자 안에선 핸들이 아직 bound가 아니므로)"*로 정정. `gate-plan.md`
7번도 같이 정정.
- (b) **유지**(belt-and-braces — 나중에 생성자 안에서 bound가 되는 경로가
생기면 그때 의미를 가짐) + 문서를 *"실제 차단자는 `canExecute`, `_blocker`
예비"*로 정정. `gate-plan.md` 7번 정정은 동일.
- (c) 유지 + `fire`의 순서를 `_blocker:IsOn()``canExecute`로 뒤집어 `_blocker`
실제로 먼저 판정하게(문서와 코드를 맞추는 반대 방향).
**권고: (a).** 근거 — `conventions.md`의 "설계 원칙" 절(가상의 미래 요구까지
방어하지 않는다). (c)는 순서만 바꿀 뿐 `canExecute`가 거짓인 구간과 `_blocker`
켜진 구간이 완전히 겹쳐 여전히 판정 차이가 0이다. 단 **이 부품은 7라운드 `H-58`에서
사용자가 `Blocker`를 쓰라고 직접 지시해 들어온 것**이라(그 인용은
`qa-request/pre-implementation-handtrace-round7-followup.md``H-58`) 탐사자가
임의로 빼지 않고 문항으로 올린다 — 그 지시의 전제("등록 즉시 1회가 `Rerun`
닿는다")가 `canExecute` 첫 줄 때문에 성립하지 않는다는 게 이 발견의 요지다.
**채택 시 고칠 자리**`base/effect-plan.md` 생성자 의사코드·"확정 구조" 절·
`H-58` 정정 문단(`_blocker`/"전담" 서술), `base/gate-plan.md` 7번,
`ROADMAP.md` M2의 `Effect` 항목에 `_blocker`가 있으면 같이.
### `H-151` 🟡 — 게이트는 `Effect` 캐치업과 형제 dep 경로에서 우회된다
**트레이스 (1) — 재바인드 캐치업.** `g = s:Block(b)`, `e = Effect(fn, g)`,
`bindLifetime(inst, e)`. `b:On()``s:Set(2)``g:_receive` → 유보(`fn` 안 돎,
맞다). 이제 `e`를 unbind → 다른 inst에 rebind(포탈) → `_bindDestroying`
`self._epochs:Refresh()`. `_epochs`의 키는 **원천 `Epoch`**(`s`)다 — 생성자가
`TrackFrom(g.valueEpochMap)`으로 `g`가 아는 원천을 그대로 복사했고, `Refresh`
그 원천들의 `.Revision`**직접** 읽는다. `s.Revision`은 이미 바뀌었으므로
`depsChanged = true`**`Rerun` — 게이트가 아직 닫혀 있는데 `fn`이 돈다.**
실측(`t16` 1·2): 게이트 유보 중 재바인드/재구독 → `fn` 1회 실행, 이후 `b:Off()`
flush에서 다시 1회(리비전이 이미 `Refresh`로 갱신됐는데도 flush 배치가 같은
원천을 싣고 오면 `Update`가 변화 없음으로 접어야 하나 — 실측에선 접혔다, 즉
두 번째는 안 돈다. 정확한 횟수는 §7).
**트레이스 (2) — 형제 dep.** `e = Effect(fn, g, u)`(`u`는 게이트 없는 Source).
`b:On()``s:Set(2)`(유보) → `u:Set(9)``u` 경로로 `fire``Update(u)` 참 →
`Rerun``fn` 안에서 `g:Get()`은 **최신값 2**를 준다(게이트는 값을 안 가린다,
`base/gate-plan.md` 확정). 즉 게이트로 "이 값의 변경은 나중에 보겠다"고 했는데
형제가 깨우면 그 값이 그대로 보인다. 실측(`t16` 4).
**왜 문제인가** — 둘 다 코드가 틀린 게 아니라 **계약이 안 적혀 있다.**
`gate-plan.md`는 *정책이 미루는 건 다운스트림 통지뿐*이라고만 하고, "통지가
emit이 아닌 경로(캐치업·형제)로 오면 어떻게 되는가"는 어디에도 없다.
`Debounce`/`Throttle` 사용자는 (1)에서 포탈 한 번에 창을 건너뛰는 실행을 보고,
(2)에서 debounce된 값이 형제 emit마다 새는 걸 본다 — 둘 다 "버그 리포트"로
들어올 모양이다.
**새 메커니즘 여부** — 문서화 갈래는 없음. 막는 갈래는 **새 메커니즘**이다:
(1)을 막으려면 `Refresh`가 원천 `.Revision`이 아니라 **dep 노드의
`emitEpochMap`**을 보고 비교해야 하는데, `EpochMap:Refresh`는 키가 `Epoch`
맵을 라이브로 다시 읽는 연산이라 노드를 거치는 형태가 아니다(`EpochMap`
계약 변경). (2)를 막으려면 `fn` 실행 중 게이트된 dep의 `:Get()`이 유보 전
값을 돌려줘야 하는데 그건 `debounce-throttle-plan.md` 4절이 **철회한 (B)
value-hold**다.
**갈래**
- (a) **문서화**`gate-plan.md`에 *"게이트는 emit 경로만 미룬다. `Effect`
재바인드/재구독 캐치업(`Refresh`)과 게이트 없는 형제 dep의 emit은 게이트를
거치지 않고, 그때 `:Get()`은 최신값을 준다"*를 계약으로 명시.
`debounce-throttle-plan.md` 11절(다른 결정과의 상호작용)의 `Effect` 항목에도
같은 문장.
- (b) (1)만 막기 — `Effect`가 State dep에 대해선 `Refresh` 대신 dep의
`emitEpochMap`을 비교(새 메커니즘, `EpochMap` 계약 변경).
- (c) 둘 다 막기 — value-hold 재개방(이미 기각된 안).
**권고: (a).** 근거 — (b)(c)는 각각 `state-epoch-plan.md``Refresh` 계약과
2026-08-19 `(A) emit-gate` 확정을 되짚어야 하고, 실사용 빈도(게이트된 dep을
가진 Effect가 포탈되는 경우)가 낮다. (a)는 이미 성립하는 사실을 적는 것뿐이다.
**채택 시 고칠 자리**`base/gate-plan.md`(계약 문장),
`base/debounce-throttle-plan.md` 11절, `base/effect-plan.md` `_bindDestroying`
캐치업 주석("dep이 변했으면"의 뜻이 원천 기준임을 한 줄).
### `H-152` 🟡 — `GateNode` 조립 절에 `StateBrand` 등록이 없다
**트레이스**`base/gate-plan.md`의 "`GateNode` 조립" 절은 *"세 문서에 나뉘어
있어 한 곳에서 순서를 볼 수 없었다 … 구현자가 조립을 잘못할 여지를 없애려고
여기 모은다"*며 필드 여섯과 `_receive`/`_flush`를 적는데, **브랜드 등록이 없다.**
`base/source-state-plan.md``_emitDown``if isState(sub) then sub:_receive(from)
elseif canExecute(sub) then …`로 자식 노드를 **`isState`로만** 가른다.
`base/brand-plan.md`는 *"각 타입은 자기 브랜드에만 등록하고, 포함 관계는
predicate 한 곳에 쓴다"*라 `isState`가 참이려면 `GateNode``StateBrand`
등록돼야 한다. 빠뜨리면: 상류 `Set``_emitDown``isState(gate)` 거짓 →
`canExecute(gate)`(게이트는 bind/Subscribe된 적 없음) 거짓 → **조용히 건너뜀**.
`gate:Get()``_hold`를 타고 올라가 최신값을 주므로 값 검사로는 안 잡힌다 —
통지만 죽는다. 실측(`t24`): 같은 메소드·다른 identity로 브랜드만 떼면 하류
Observer 발화 2 → **0**, `Get()`은 3으로 정상.
**왜 지금 잡나**`GateNode``ComputeNode`와 달리 State 생성자를 안 지나고
`Gate(self, setup)`가 직접 조립한다(위 절이 그 조립을 정의한다). 생성자가
공통이면 등록도 공통 자리에 있겠지만, 여기선 조립 절이 곧 생성자라 그 절에
없으면 어디에도 없다. `ROADMAP.md` M2 `GateNode` 항목에도 브랜드 언급이 없다.
사냥 목록 *"의사코드 순서 ≠ 산문"*의 변형 — 산문("게이티드 State 노드")은
State라 하고 의사코드는 State가 되는 줄이 없다.
**새 메커니즘 여부** — 없음(기존 `StateBrand:add(node)` 한 줄).
**갈래** — 없음. 조립 절 필드 목록 첫 줄(또는 `Gate(self, setup)` 본문)에
`StateBrand` 등록을 명시. 같은 절에 *"`isState(gate)`가 참이어야 `_emitDown`
`_receive`로 보낸다"*를 근거로 한 줄.
**채택 시 고칠 자리**`base/gate-plan.md` 조립 절, `ROADMAP.md` M2 `GateNode`
체크박스. 참고 구현 `core10.luau``isState``GateNode` 메타테이블을 **직접
넣어서** 이 문제를 우회하고 있었다(README의 "옮기며 고친 것" 참고).
### `H-153` 🟡 — Store 예약 이름의 런타임 갭 (`Of("Of")` / `defaults.Of`)
**트레이스**`base/store-plan.md`의 구현 스케치: 생성자는
`table.clone(defaults or {})` + `isSource` 순회, `store:Of(name)`은 그림자 테이블에
`Source`를 lazy 생성. 스케치가 *"그림자 테이블"*이라고만 해서 **store 자신인지
별도 테이블인지**가 안 정해져 있다(같은 문서가 `store.key`는 *"평범한 레코드
필드"*라 하므로 store 자신일 가능성이 크다). 두 모양을 다 옮겨 실측(`t23`):
| | (I) 그림자 = store 자신(`__index` 메소드) | (II) 별도 그림자 + 프록시 |
|---|---|---|
| `s:Of("Of")` | **메소드 함수를 그대로 반환**(`Source` 아님), 이후 `Of` 정상 | `Source`를 만들어 그림자에 넣음 → 이후 `s:Of`**그 Source에 가려져** `attempt to call a table value` |
| `s:Names()` | 정상 | `Of`가 사용자 키로 섞여 나옴 |
| `Store({ Of = Source(1) })` | `isSource` 통과 → `s:Of(...)`**`attempt to call a table value`** | (동일) |
타입 쪽 `CheckReservedKeys<keyof<T>>`는 (H)처럼 **정적 키**만 잡는다(`ty11`에서
정확히 잡힘). `Of(name)``name`은 타입에 안 실리고, `--!nocheck`/동적 코드는
`isSource` 화이트리스트(`H-122`)가 있어도 예약 이름을 안 보므로 통과한다.
`store-plan.md``__reservedCheck`에 대해선 *"동적 키는 이름이 타입에 안
실리므로 못 막는다"*고 이미 인정하는데, 같은 문장이 `Of`/`Names` 자신에게도
성립한다는 건 안 적었다.
**왜 문제인가** — 죽긴 죽는데 **자리가 멀다**: `defaults` 예약 이름은 생성
시점이 아니라 첫 `s:Of(...)`에서, 메시지도 예약 이름과 무관한 *call a table
value*다. `H-122``isSource` 화이트리스트를 둔 이유("조용히 받고 첫 `Get`에서
엉뚱한 에러로 죽는다")와 정확히 같은 모양의 구멍이 한 칸 옆에 남아 있다.
**새 메커니즘 여부** — 작은 런타임 검사(예약 이름 셋과 비교) — 새 개념은 아니나
새 검사이므로 문항. `archive/`·"검토 후 안 만들기로 한 것"류에서 런타임 예약 이름
가드를 기각한 기록은 **없다**(`archive/store-value-field-redesign-withdrawn.md`는
타입 함수 범위만 다룸).
**갈래**
- (a) **런타임 가드** — 생성자 `isSource` 순회에 `if RESERVED[k] then error(..., 2)`,
`Of(name)`에 같은 검사(둘 다 `level 2`, 영어 메시지 — `architecture.md` error
계약). 생성 시 1회 + `Of` 호출당 테이블 조회 1회라 hot path 아님.
- (b) 문서화만 — *"예약 이름을 동적으로 쓰면 UB"*.
- (c) 그림자를 (II) 별도 테이블로 못박고 `Of`만 가드(부분).
**권고: (a).** 근거 — `H-122`와 같은 자리·같은 논거(화이트리스트 검증은 이미
있고 비교 대상을 셋 늘리는 것뿐), fail-fast 톤(`Slot` CRUD·`KeyGone`의 선례).
부수로 스케치의 "그림자 = store 자신인가"도 같이 못박을 것(권고: (I) — `store.key`
레코드 필드 계약과 맞고 `Names()`가 메소드를 안 세려면 메소드는 `__index`에).
**채택 시 고칠 자리**`base/store-plan.md` 구현 스케치·`Of` 절·
`__reservedCheck` 주석 (2), `ROADMAP.md` M2 Store 항목.
### `H-154` 🟡 — `InstanceChildHandler`는 같은 Frame 재발행에 물리 detach/attach를 한다
**트레이스**`base/dispatch-core-plan.md``H-134` 문단(그리고 `ROADMAP.md`
M5): `process` = `setOffsetSource(None)``v.Parent = inst``setLength(1)`,
retractor = `v.Parent = nil``setOffsetSource(None)``setLength(0)`. 하강
diff의 (A) 분기는 같은 핸들러면 **무조건** `retractor(v)``process`를 다시
부르므로(`Dispatch 체인` 절), `state<Frame>`이 **같은 Frame**을 다시 emit하면:
`A.Parent = nil``recompute(inst)``A.Parent = inst``recompute(inst)`.
실측(`d23`, round7 `chain.luau` 위에 그대로 옮김): 같은 값 재발행 1회에 `Parent`
대입 2회 + `recompute` 2회. 대조군 `SlotHandler``slotValue == nextValue`
얼리리턴, `RefLeafHandler`/Leaf는 `old ~= v` dedup — `dispatch-core-plan.md`
"Handler 작성 체크리스트" 4번이 *"`Ref`의 spurious 재바인딩 dedup"*을 예로 든다.
**왜 문제인가** — Roblox에서 `Parent = nil``Parent = inst``ChildRemoved`/
`ChildAdded`/`AncestryChanged`를 실제로 쏘고 레이아웃을 한 프레임 흔들 수 있다.
spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18`로 재확인)
때문에 드물지 않다 — `store.child:Set(store.child:Get())` 한 줄이면 난다.
**새 메커니즘 여부** — 없음(선례와 같은 한 줄).
**갈래**
- (a) retractor 첫 줄에 `if nextValue == v then return end`(`SlotHandler`와 동형) —
단 그러면 `process`가 다시 `setOffsetSource(None)`/`Parent = inst`/`setLength(1)`을
하므로 부기는 멱등하고 `Parent` 대입은 같은 값 재대입(엔진 no-op).
- (b) 그대로 둔다 — "dedup은 성능 최적화라 핸들러마다 선택"으로 문서화.
**권고: (a).** 근거 — 같은 값 재발행의 dedup은 `Slot`/`Ref`/Leaf에서 이미 채택된
정책이고, 이 핸들러만 빠진 건 `H-134`가 늦게 생겨서다(설계 차이가 아님).
**채택 시 고칠 자리**`base/dispatch-core-plan.md` `H-134` 문단, `ROADMAP.md` M5
`InstanceChildHandler` 항목.
### `H-155` 🟢 — `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다
넷 다 `base/`가 소스이고 ROADMAP만 안 따라온 것(색인 레이어 stale, 사냥 목록
"배너만 달고 본문 안 고침"의 ROADMAP판):
1. M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip(방어)"*
`base/slot-plan.md`는 4라운드 `C-6`**즉시 `error`**(부기가 깨졌다는 신호).
2. M6 *"`destroySlotTree`가 자식 소유권 반납 + …"*`slot-plan.md``C-4`로 그
수정을 **되돌렸다**(`State<Slot>` 재설정 표의 정정 문단이 명시).
3. M6 *"`self._mounted` 확인 후 즉시 활성화"* — 6라운드 `H-2`로 판정 기준이
`_physicalTarget`.
4. M11 *"3-상태 릴레이션 슬롯(`RobloxTween | true | nil` … 엔진 객체=활성 트윈)"*
`base/tween-plan.md`의 "3-상태 저장" 절은 `{Tween, Value}` **테이블**
(`Tween.Finish`가 목표값을 알아야 해서).
갈래 없음 — ROADMAP 넷을 `base/` 문장으로 교체.
### `H-156` 🟢 — `debounce-throttle-plan.md` 7절 `H-32` 문단이 배너와 모순
같은 절의 무효화 배너는 7라운드 `H-86`(*"읽는 통로는 `HasBlockedEmit`이 아니라
`emit`의 반환값"*)·`H-55`(*"`b:OffWithoutEmit()`만으로는 집합이 안 비므로
`emit(false)`가 필요"*)를 반영했는데, 바로 아래 `H-32` 문단은 여전히
*"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `Trailing = false`
`OffWithoutEmit()`으로 표현되고, 그건 보류분을 버리면서 상태도 같이 비운다"*.
두 문장 다 배너가 뒤집은 것이다. 갈래 없음 — 그 문단을 *"`emit()`의 반환값으로
읽고 `emit(false)`로 버린다"*로 정정.
### `H-157` 🟢 — 빈 Store는 실측됐다
`base/store-plan.md`의 *"빈 Store는 아직 실측 안 됐다 … `keyof<{}>`가 빈
유니온이 되는지 에러가 되는지"*: `ty11`(최종형 — `StateData<T>`/`State<T>`
쪼개기, `Source<T>` 필드, `CheckReservedKeys<keyof<T>>`, 재귀 `Compute -> State<U>`
포함)에 `local empty = Store({} :: {})`를 넣으면 **진단 0**(`keyof<{}>` →
`never`가 아니라 빈 유니온으로 `components()`가 비어 검사가 그냥 통과),
`empty:Names(): {string}`/`empty:Of("dyn"): Source<number>` 정상. 같은 파일에서
키 있는 양성(A·B·C·E) 0건, 음성(D·F·`c4`) 정확히 그 줄, 예약 키(H) 진단 1건,
§1 구멍(G)은 알려진 대로 조용히 통과. 갈래 없음 — 문단을 "실측 완료"로 갱신,
`luau-test/STATUS.md``16`/`21` 재작성 시 대조군 메모는 그대로 유효.
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 |
|---|---|---|---|
| **`H-147`** | 죽은 핸들에서 `Rerun()` | (a) UB로 문서화(cleanup 안 자기 해제는 지원 목록 밖, 타이머 취소는 cleanup의 일) / (b) `_everAlive` 플래그 + 진입 게이트 / (c) `wasAlive`를 소진 앞에서 | **(a)** — 새 상태 없이 기존 UB 계약과 같은 결 |
| **`H-148`** | `Parent` 거부 문구 | (a) 전용 문구 철회, 일반 매치 실패 그대로 / (b) `Parent` 가드 핸들러(동적 경로 가드형) / (c) 일반 문구에 키 이름 | **(a)** — 회신 취지(새 API 없음) 그대로, 필요하면 M5에서 (b) |
| **`H-149`** | Observer `Subscribe` 위임과 `level 2` | (a) `Subscribe`에 게이트·등록 인라인(Effect와 동형) / (b) `level 3` 위임 / (c) 문서화만 | **(a)** — 본문 안 섞기 원칙과 동형 |
| **`H-150`** | `Effect._blocker`(죽은 부품) | (a) 제거 + "억제는 `canExecute`"로 서술 정정 / (b) 유지 + 서술 정정("실제 차단자는 `canExecute`, `_blocker`는 예비") / (c) 유지 + `fire` 순서를 `IsOn``canExecute`로 | **(a)** — 판정 차이 0인 부품, `conventions.md` 설계 원칙. 단 `H-58` 사용자 지시로 들어온 부품이라 문항 |
| **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 |
| **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 |
| **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 |
| **`H-159`** | **[2026-08-28 `/code-review`, 반영 뒤]** `H-151`이 잃은 캐치업 — 바인드 **전**에 온 emit(특히 `Ref`)은 다시 안 온다 | (a) `_bindDestroying`/`resubscribeTail`에 **"묶이는 시점 1회 `Refresh`"**만 되살림(emit 경로의 `_epochs` 갱신은 `H-151`대로 `Update`만) / (b) `Ref` dep만 바인드 시 `.Revision` 대조 / (c) 계약으로 두고 사용자에게 "`Ref`를 dep으로 쓰는 Effect는 그 leaf 뒤에 두라" 문서화 | **(a)** — `H-151`의 근거("다음 emit이 잡는다")가 `Ref`엔 성립하지 않는다; (a)는 `H-151`을 되돌리는 게 아니라 "emit 경로만 미룬다"는 계약과 양립(바인드는 emit 경로가 아님) |
| **`H-160`** | leaf `Destroying` 콜백이 도는 cleanup 안의 `self:Rerun()`/`dep:Set()` — `canExecute`가 아직 참이라 죽는 inst에서 `fn`이 돌고 새 cleanup이 영구 고아 | (a) `rawRerun` 진입에서 `_cleanupRunning`이면 **버린다**(no-op) — "cleanup은 자기 생명주기를 못 바꾼다"의 `Rerun`판 / (b) `Destroying` 콜백이 `_consumeCleanup` **전에** `.Subscribed`류 표식으로 죽음을 먼저 세움(새 상태) / (c) UB 문서화 | **(a)** — 새 상태 없이 기존 플래그 하나로, `Unsubscribe` 경로와 같은 결과 |
| **`H-161`** | `H-148` 이후 **M5에 승인된 루트 부착 경로가 없다** + 여러 스크립트가 같은 `PlayerGui``Claim`하면 이중 claim error / 다중 quad UB라 `Claim`이 자기 동기 사례를 막는다 | (a) `Claim`**M5 스코프**로 당기고(프로바이더 마일스톤이라 자연스러움) `research/existing-mount-plan.md` §5-7·8 갈래를 같이 정한다 / (b) `Claim` 전까지 임시로 `H-146` 루트 예외(밖에서 `.Parent =`)를 M5 한정으로 되살림 / (c) 루트 컨테이너(부기 대상 아님)는 claim 없이 자식만 붙이는 얇은 표면 신설 | **(a)** — 임시 예외는 하루 만에 뒤집힌 것을 되살리는 것이고, (c)는 `Mount` 기각의 재개방. §5-7(다중 스크립트)은 `Claim`의 "전부 매핑" 계약이 **루트 컨테이너에는 안 맞는다**는 신호라 갈래를 그 문서에 적었다 |
| **`H-163`** | **[2026-08-28 `/code-review`, `H-159` 반영 뒤]** Slot 내부 Observer(`_listObserver`·`_baseObserver`)에도 홀드 발화가 걸려 재마운트의 `bindLifetime``materializeSlotTree` **도중** `reconcile`을 동기 실행 → 자리 이중 등록, 중첩 Slot이면 `canBound` error | (a) Slot이 자기 내부 Observer를 다시 묶기 전에 `_rerunRequired`**지운다**(재마운트 캐치업은 `activateList`가 이미 명시적으로 한다 — 이중) / (b) 홀드 발화를 사용자 Observer에만(내부 Observer는 브랜드로 구분 — 새 구분) / (c) 홀드 발화를 `bindLifetime` 안이 아니라 `materializeSlotTree` 끝(`blocker:OffWithoutEmit()` 뒤)으로 미룸 | **(a)** — 새 구분 없이 한 줄, "재마운트 캐치업의 주체는 Slot"이라는 기존 계약 그대로 |
| **`H-164`** | Observer 홀드 발화가 `emitFrom = nil`로 오면 계약("`nil` = 설치 발화")과 구분 불가 — `if emitFrom == nil then initOnly()`로 짠 소비자가 변경을 놓침 | (a) 홀드 시 **마지막 `from`을 보관**(`_rerunRequired = from`, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(`HeldEmit`) / (c) 계약 문구만 "`nil` = 설치 **또는** 묶일 때 캐치업" | **(a)** — Observer는 dedup이 없어 "마지막 출처"가 곧 홀드의 내용; 단 필드가 불리언과 출처를 겸하는 게 원칙(한 필드 두 뜻)에 걸리면 (b) |
갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷),
`H-156`(`H-32` 문단), `H-157`(실측 완료 표기).
**[2026-08-28 추가] `H-159`~`H-161`은 §4 문항 7건을 반영한 뒤 `/code-review high`
낸 10건 중 새 메커니즘·기존 결정 변경인 셋**(나머지 일곱은 반영 — `-round10-followup.md`
마지막 code-review 절). 상세는 아래.
### `H-159` 🟡 — `H-151`이 잃은 캐치업: 바인드 전에 온 emit은 다시 안 온다
`D.Frame { Effect(function(self) if ref.Value then … end end, ref), D.TextButton { ref } }`.
Lua는 배열 원소를 순서대로 평가한다 — `Effect(...)`가 먼저 생성돼 `fn`이 1회 돌고
(`ref.Value == nil`), 그다음 `D.TextButton { ref }``ref:Set(button)``fire`
`canExecute(E)` 거짓(아직 안 묶임) → **`_epochs`를 안 건드리고 버림**. 그 뒤 Frame의
`drive`가 E를 leaf에 묶음 → `_bindDestroying``_installed == true``Rerun` 없음.
`Ref``Set`될 때만 발화하므로 **`fn`은 영원히 버튼을 못 본다**(배열 순서를 바꾸면
된다 — 순서 의존 버그). 같은 구멍이 생성자 안에도 있다: `fn`이 자기 dep을 `Set`하면
그 emit은 `fire` 첫 가드에서 버려지고(`self:Rerun()`처럼 지연되지 않는다) `_installed`
참이 돼 바인드가 재실행하지 않는다. 옛 `_epochs:Refresh()`는 둘 다 잡았다. `H-151`
근거 *"죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로 잡힌다"*는 **다음
emit이 오는 dep**에만 성립한다. 갈래·권고는 §4 표.
### `H-160` 🟡 — leaf `Destroying` 경로의 cleanup은 `canExecute`가 아직 참인 채 돈다
`SignalBehavior = Immediate`: `inst:Destroy()``Destroying``_unbindDestroying()`(
`_destroyConn`만 해제, gcconn은 아직 `.Connected`) → `_consumeCleanup()` → cleanup이
`self:Rerun()`(또는 `dep:Set()``fire`) → `rawRerun(false)`: `_running` 거짓,
`canExecute` **참** → 죽는 inst에서 `fn`이 돌고 `_cleanup = c2`, `_installed = true`;
`_destroyConn`은 이미 nil이라 c2를 소진할 연결이 없고, 나중 포탈 재바인드는
`_installed` 참을 보고 재설치를 건너뛴다(조용히 죽은 Effect). `Unsubscribe()` 경로가
안전한 건 `.Subscribed = false`를 소진 **전에** 세우기 때문 — `Destroying` 경로엔
그 대응물이 없다. `rawRerun` 주석의 *"해제 뒤 cleanup의 재요청 … 정의된 no-op"*은 이
경로에서 거짓. 갈래·권고는 §4 표.
### `H-161` 🟡 — M5에 승인된 루트 부착 경로가 없다 / `Claim`이 자기 동기 사례를 막는다
`H-148``H-146`의 루트 예외를 폐기하고 `Claim`은 "M5 이후" 백로그라, M5(프로바이더·
`D`·`InstanceChildHandler`)가 끝나도 quad가 만든 트리를 `PlayerGui`에 붙이는 승인된
경로가 코퍼스에 없다. 그리고 `research/existing-mount-plan.md`의 *이중 claim은 error /
여러 quad가 한 트리를 claim은 UB / 부기 대상 자식은 전부 매핑* 계약을 그대로 두면
`Shop.client.luau``Inventory.client.luau`가 각각 `Claim(PlayerGui, …)`하는 **가장
흔한 사례가 error**다 — 그 문서 §5엔 이 문항이 없었다(추가: §5-7·§5-8). 갈래·권고는
§4 표.
## §5 이상 없다고 확인한 것
전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/
`dispatch10.luau`, `H-107`~`H-149` 계약으로 갱신) 위에서 값으로 확인한 것. 스파이크
이름은 §7.
- **레인 A — `EffectHandle` 상태 전이표(`t14`).** 네 진입점 × {미바인드/바인드/
파괴 뒤/구독만} × `Rerun` 꼬리 × `_bindDestroying`: (1) 미바인드 `Subscribe`
`fire`가 살아나고 `Unsubscribe` → 죽음, (2) 바인드 중 `WeakUnsubscribe`는 관대
(`H-133`), `Unsubscribe`는 엄격 error, (3) 파괴 뒤 `Subscribe`(재구독)는
`resubscribeTail` 캐치업으로 재설치(`H-144` (b)), (4) 파괴 → 재바인드도 재설치,
`_installed`/`_cleanup` 값이 전이마다 문서 표와 일치. `wasAlive and not canExecute`
즉시 소진(`H-143`)은 `fn` 안에서 자기 inst를 동기 파괴하는 `t22`에서 cleanup이
그 자리에서 1회 돎.
- **`H-147` 재현(`t15`)** — 죽은 핸들에서 `Rerun()`: 케이스 1(직전 cleanup이
`self:Unsubscribe()`) → `fn`이 돌고 고아 cleanup + `_installed = true`;
케이스 2(해제 뒤 타이머의 `Rerun`) 동일; 케이스 3(파괴된 inst에 bound였던 핸들)
`BindData`에 죽은 gcconn이 남아 있어 `wasAlive`가 참 → 즉시 소진으로 구분됨.
고아 cleanup은 나중에 재구독/재바인드가 오면 그때 소진된다(영구 누수는 아님) —
§4 `H-147`의 (a)를 뒷받침.
- **`H-149` 재현(`t19`)** — `o:Subscribe()``error(…, 2)``core10.luau` 내부
줄을 가리킴, `o:WeakSubscribe()`/`Effect` 진입점은 사용자 줄. (a) 인라인 처방으로
사용자 줄이 됨(핸들 쪽 대조).
- **`state:Gate` × 재구독 × `EpochMap` 셋(`t16`)** — 7 시나리오: 유보 중 `Peek`
`emitEpochMap`을 안 덮음, flush가 배치를 unfold해 하류 `Update`가 한 번에 접음,
게이트-게이트 중첩에서 중복 원천이 집합으로 접힘, `emit(false)`가 집합을 비움,
`OffWithoutEmit` 반복에 집합이 단조 증가하지 않음(`H-55`/`H-67`). 우회 둘만
`H-151`로 올렸다.
- **`_hold` 불변식(`t17`)** — 하류가 상류를 강하게 잡고(`s → c1 → c2`, 하류만
참조해도 상류 생존) 상류는 하류를 weak로만(하류 참조를 놓으면 GC, 상류는 살아
있음). 양성/음성 대조.
- **`Source:Set` 같은 값(`t18`)** — 같은 값도 `Revision`을 올리고 emit(확정대로),
`Blocker` 게이팅 중엔 흡수 집합에 1건으로 접힘.
- **Store 최종형 타입(`ty11`, `luau-analyze`)** — `H-157` 참고. `Of<<T>>` 명시
인스턴스화 양성/음성(`c3`/`c4`) 정확.
- **레인 C — 9라운드 결정 재실행.** `H-124`(되감기 판정을 `lengthList[i]` 읽기
앞으로): `d10`/`d11`/`d13`/`d14` 네 케이스 전부 `dispatch10` 위에서 통과(옛
`dispatch9`와 같은 결과). `H-136`(`reconcile` 배치 `ownsGate`): `d21` — 리스트
사이클당 `recompute(L)` 정확히 1회, 물리 op이 최소(리오더 `{b,a,c}`
move 1 + insert 1), 중첩 Slot 요소의 `Length` 전파·리오더 뒤 `Offset`/
`getOffsetAt` 값 정확. `H-145`(`bk.indexOfElement` weak-key): `d20` — 옛 Slot을
언마운트하고 참조를 놓으면 GC 뒤 키가 사라짐, 마운트 중인 키는 `_elements`
잡아 유지. **단** Roblox `Instance`가 weak 키일 때의 거동은 CLI에서 실측
불가(§6).
- **레인 B — `:List` × `Detach` × `KeyGone` × 재등장 × 파괴(`d22`)** — 사라진 키에
`Detach``_detached` 보유·`Length` 감소·물리 extract 1회, 재등장 시 `prev`
재마운트(새로 만들지 않음, `indexOfElement` 갱신), `KeyGone`에 새 값 반환은
error, `destroySlotTree``_detached`까지 dispose, 파괴된 Slot 재마운트 error
(`_destroyed`, 9라운드 Q2).
- **레인 B — 읽기로만 확인(값 트레이스 없음)**: `SlotHandler` 재발행/교체/A→B→A
왕복의 `claimOwnerAt`·`unmountSlotTree`·재마운트 경로, `activateList`
`_detachCleanup` 바인드/언바인드 짝, `settle` 여덟 분기(`Owned` × `Detach` ×
`KeyGone`), `rawMove` 규약 4의 `setLength` 재등록, `H-142` `Parent` 금지 +
`H-146` 루트 예외, `H-138` 숏핸드 우선순위(`StoreBind` → 숏핸드 → `Property`),
`Tag` 참조 카운트, `Attribute` 그룹 전용 키 + `groupClaimKeys` 순서, `Modifier`
flatten 역순, `Tween` 3-상태, `OnChange` `v == nil` 얼리리턴, 훅 슈가 `guard`.
모순은 위 `H-154`~`H-156`뿐.
- **레인 D** — `./scripts/test.sh` **ALL PASS**(`smoke.init`/`mock`/`plugin`,
`luau-analyze` 클린). 커밋된 M1 코드와 `base/`(`RunInit` 멱등·`AddPlugin` mutate·
`@self` require)에 어긋남 없음.
## §6 남은 의심 / 못 본 것
**값으로 안 돌려본 것(읽기만)** — 레인 B 중 `New`/`drive` 파이프라인(`H-139`
의사코드 자체), `Dispatch.process` (A)/(B) 분기 위의 `InstanceChildHandler` 이외
말단(`Tag`/`Attribute`/`OnChange`/`PropertyHandler`+`Tween`), `Modifier` flatten
(`luau-test/done/17`이 별도로 있음), `rawMove`/`rawSwap`(base에 의사코드가 없어
`H-29` 규약을 탐사자가 옮긴 것이라 그 자체가 검증 대상이 아님),
`collectLeaves`/`Extract`/`Splice`의 `native*` 인자, `dispose` 가드,
`Debounce`/`Throttle`(7절이 무효화 배너 아래라 옮길 의사코드가 없음),
`Fallback`/`Traceback`(백로그 `H-26` 그대로), `AttributeGroupHandler` 부분 실패
경로, `PostRef` pre-pass.
**로컬에서 실측 불가** — (1) `bk.indexOfElement`의 weak 키가 Roblox `Instance`
때: `Instance`는 userdata라 weak 키 수거 조건이 Luau 테이블과 다르고
(`Destroy`된 뒤에도 Lua 쪽 참조가 있으면 남음), gcconn 자기순환이 그 참조를 들고
있다 — `d20`은 테이블 키로만 확인했다. (2) `Workspace.SignalBehavior = Deferred`
× `Destroying` cleanup × "지연 창 안에서 재바인드": `_bindDestroying`
`_unbindDestroying()` 선행이 옛 연결을 끊지만, Deferred 큐에 이미 들어간 옛
`Destroying` 콜백이 새 연결 뒤에 도는 순서는 `ref-plan.md`의 실측 계획에만
있고 여기선 볼 수 없다. (3) `Parent = nil``Parent = inst`(`H-154`)가 실제로
`ChildAdded`/`AncestryChanged`를 몇 번 쏘는지.
**남은 의심** — (a) `H-151` (1)에서 캐치업 `Rerun` 뒤 게이트 flush가 같은 원천을
싣고 올 때 `Update`가 접는 건 실측됐지만, **`Ref` dep**(리비전이 `Set`마다
`bnot`으로 감기는 `Epoch`)에서 캐치업과 flush 사이에 `Set`이 한 번 더 오면
접히지 않고 두 번 돈다 — 정상 동작(두 번 바뀌었으니)이지만 `Refresh`가 "변화
있음"만 주고 횟수를 안 주는 게 맞는지는 안 따졌다. (b) `Effect(fn)`(dep 0개)의
`_epochs`가 빈 맵이라 `Refresh()`가 항상 거짓 → 재바인드 캐치업이 `not
_installed`로만 갈리는데, 그 경우 "파괴로 소진 → 재바인드 → 재설치"가 맞는 동작인지
(`OnDestroyed` 슈가가 이 경로로 두 번째 inst에도 cleanup을 달게 됨 — 아마 의도).
(c) `H-148`은 새 증거 없음. (d) 9라운드 §6이 남긴 `H-117`(`Of` 무주석 →
`Source<unknown>`)은 `ty11`에서 주석 있는 경우만 봤다. (e) `luau-test/STATUS.md`
`rewrite-required/` 스파이크는 이번에 다시 안 돌렸다(상태는 그 파일이 소스).
## §7 레인 C 실행 기록
폴더 `audit/handtrace-round10-reference-impl/`(구성·갱신 내역·옮기며 고친 것은
`README.md`가 소스). 참고 구현 `core10.luau`(반응형 코어 — `EpochMap:Peek`,
Observer 3-인자, `Ref` `fn(value, ref)` + `WeakCallback`, `Effect` 생성자
`_blocker`/`fire`/`_bindDestroying`/`Rerun` `H-143` 꼬리/네 진입점 + `resubscribeTail`,
`GateNode` unfold + `emit(commit) -> boolean`, `Blocker:Policy`) /
`dispatch10.luau`(부기 — weak-키 `indexOfElement`, `H-124` `recompute`, 5-인자
`setLength`, Slot 생성자의 `Length`/`Offset`/`_baseObserver`/`_destroyed`,
`materializeSlotTree`/`mountSlotTree`, `raw*`, `:List`/`reconcile` `ownsGate`,
`mountTop` retractor). 전부 `luau <파일>`로 종료 0(2026-08-28 재실행), `ty11`
`luau-analyze`.
| 스파이크 | 무엇 | 결과 |
|---|---|---|
| `t14_effect_state_matrix` | `EffectHandle` 네 진입점 × 상태 전이표 | 문서 표와 일치(§5) |
| `t15_rerun_dead_handle` | `H-147` 케이스 1·2·3 + 대조군 | 재현(§5) |
| `t16_gate_resub_refresh` | 게이트 × 재구독/재바인드 × `Refresh`/`Peek`/`Update` 7건 | 5건 확정대로, 2건 → `H-151` |
| `t17_hold_gc` | `_hold` 불변식 양성/음성 | 확정대로 |
| `t18_blocker_vestigial_and_set_same` | `Effect._blocker` 판정 횟수 / `Source:Set` 같은 값 | `drop:blocker` 0 → `H-150`; 같은 값 emit 확정대로 |
| `t19_observer_level2` | `H-149` blame 줄 | 재현 |
| `t22_fn_destroys_inst` | `fn` 안에서 자기 inst 동기 파괴(`H-143`) | cleanup 즉시 1회 |
| `t23_store_reserved_runtime` | 그림자 (I)/(II) × `Of("Of")` × `defaults.Of` | → `H-153` |
| `t24_gate_brand_missing` | `GateNode` 브랜드 누락 재현 | 발화 2 → 0 → `H-152` |
| `ty11_store_final` | 최종형 Store 타입 A~H + 빈 Store + `Of<<T>>` | 양성 0건·음성 정확 → `H-157` |
| `d10`/`d11`/`d13`/`d14` `_r10` | `H-124` 되감기 네 케이스(round9 스파이크를 `dispatch10`으로) | 전부 통과 |
| `d20_indexOfElement_weak` | `H-145` weak 키 GC | 확정대로(테이블 키 한정) |
| `d21_reconcile_batch` | `H-136` `reconcile` 배치 + 중첩 Slot 리오더 | 사이클당 `recompute(L)` 1 |
| `d22_detach_keygone` | `Detach`/`KeyGone`/재등장/파괴 | 확정대로 |
| `d23_instancechild_spurious` | `InstanceChildHandler` 같은 값 재발행 | `Parent` 2회 → `H-154` |
옮기며 발견한 것(발견 번호 없음, README에 기록): round7/ref9 참고 구현의
`_recompute``fn`의 첫 인자로 **결과 노드**를 넘기고 있었다(base는 리시버의
lazy 핸들) — 그 스파이크들은 첫 인자를 안 써서 무해했지만 `t16`/`t17`은 그
자리에서 무한 재귀했다. 그리고 `core10.luau``isState``GateNode`를 직접
포함하는데, 그게 곧 `H-152`다.