- EmitReceive: State:_emitDown은 sub:_receive(from)만, Observer:_receive가 canExecute 판정·_rerunRequired 홀드 (사용자 지시 — 계층간 지식 분리), State:Observer(fn) 생성자 순서(플래그 참 → fn 1회 → 내림 → _subs) - H-163 (a′): _listObserver 재마운트 바인드는 materializeSlotTree 꼬리(트리 확정 뒤)에서 재마운트일 때만, 홀드가 있었으면 reconcile 1회 (감사가 잡은 첫 마운트 이중 bind·reconcile 미실행 결함 정정) - H-164 (c): emitFrom == nil = 출처 없음(설치 또는 캐치업), from 보관 기각 - 감사 4→3→2→2(각도 교체, 마지막 둘은 표현), /code-review 8 반영분 포함 Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
49 KiB
구현 전 손 트레이싱 10라운드 — 발견 보고 + 배치 결정 문항지
이 파일이 무엇인가: [2026-08-28 신설] 9라운드 후속(
H-143H-146) 반영 뒤/code-review high가 낸 판단 대기 셋(H-147H-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-158H-162도 같은 날 확정(H-159는/code-review권고 (a)Refresh복원이 아니라 사용자 제안_rerunRequired홀드로). 미결은research/existing-mount-plan.md§5의 갈래들뿐(개수는 거기가 소스).H-147H-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). "실행 중에
죽은" 건 잡지만 "진입 시점에 이미 죽은" 건 못 잡는다:
- 직전 cleanup이
self:Unsubscribe()를 부름 → 루프 머리 소진 안에서 죽음 →wasAlive거짓 →fn실행 → else → 저장 +_installed = true. 고아 cleanup, 이후 재Subscribe의resubscribeTail이_installed참을 보고 재설치 생략. 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.md7번은 더 낡았다 — *"Effect가 자기 내부 플래그로 설치 중 발화를 누르고"*는 7라운드H-58이 폐기한_installing서술이다(같은 문서의 "확정 구조" 절과effect-plan.md는 이미Blocker로 바뀌어 있다).
새 메커니즘 여부 — 없음. 제거 쪽이면 필드 하나·줄 셋이 준다.
갈래
- (a)
_blocker를 제거하고 생성자 주석을 *"등록 즉시 1회는canExecute가 막는다(생성자 안에선 핸들이 아직 bound가 아니므로)"*로 정정.gate-plan.md7번도 같이 정정. - (b) 유지(belt-and-braces — 나중에 생성자 안에서 bound가 되는 경로가
생기면 그때 의미를 가짐) + 문서를 *"실제 차단자는
canExecute,_blocker는 예비"*로 정정.gate-plan.md7번 정정은 동일. - (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.md11절(다른 결정과의 상호작용)의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.mderror 계약). 생성 시 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판):
- M6 "
recompute는sourceList[i]가nil이어도None처럼 skip(방어)" —base/slot-plan.md는 4라운드C-6로 즉시error(부기가 깨졌다는 신호). - M6 "
destroySlotTree가 자식 소유권 반납 + …" —slot-plan.md는C-4로 그 수정을 되돌렸다(State<Slot>재설정 표의 정정 문단이 명시). - M6 "
self._mounted확인 후 즉시 활성화" — 6라운드H-2로 판정 기준이_physicalTarget. - 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 ✅ (a) → (a′) |
[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) → (a′) — (a)의 전제("재마운트 캐치업은 activateList가 이미 한다")는 감사 2라운드가 반증(그 분기는 앵커만 옮긴다) → 트리 확정 뒤 끄고 묶고 홀드가 있었으면 reconcile 1회. 소스는 -round10-followup.md |
H-164 ✅ (c) — 문항 전제 정정 |
Observer 홀드 발화가 emitFrom = nil로 오면 계약("nil = 설치 발화")과 구분 불가 — if emitFrom == nil then initOnly()로 짠 소비자가 변경을 놓침 |
(a) 홀드 시 마지막 from을 보관(_rerunRequired = from, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(HeldEmit) / (c) 계약 문구만 "nil = 설치 또는 묶일 때 캐치업" |
(c) — 문항 전제 정정: 홀드 발화는 출처 있는 통지가 아니라 "묶였으니 값을 읽어라"라 설치 발화와 같은 종류 — nil = 출처 없음(설치 또는 캐치업). (a)의 from 보관은 사용자 기각(여러 홀드가 오면 앞 것이 날아감, 보관할 이유 없음). 소스는 -round10-followup.md |
갈래 없는 것(회신 불필요, 반영만): 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은 나중에 재구독/재바인드가 오면 그때 소진된다(영구 누수는 아님) — §4H-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.indexOfElementweak-key):d20— 옛 Slot을 언마운트하고 참조를 놓으면 GC 뒤 키가 사라짐, 마운트 중인 키는_elements가 잡아 유지. 단 RobloxInstance가 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-142Parent금지 +H-146루트 예외,H-138숏핸드 우선순위(StoreBind→ 숏핸드 →Property),Tag참조 카운트,Attribute그룹 전용 키 +groupClaimKeys순서,Modifierflatten 역순,Tween3-상태,OnChangev == nil얼리리턴, 훅 슈가guard. 모순은 위H-154~H-156뿐. - 레인 D —
./scripts/test.shALL PASS(smoke.init/mock/plugin,luau-analyze클린). 커밋된 M1 코드와base/(RunInit멱등·AddPluginmutate·@selfrequire)에 어긋남 없음.
§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다.