사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.
**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).
## 구조가 바뀐 것 넷
- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
`getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다
요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).
## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿
`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.
## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건
**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.
**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).
**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.
## 백로그 하나
`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.
**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
26 KiB
6라운드 손 트레이싱 — 처리 결과
상태: [2026-08-24] 전량 처리·반영 완료. 사용자와 대화형으로 하나씩
결정했고(H-1~H-54), 그 결정을 각 base/ 문서에 전부 반영했다.
이 문서는 결정과 근거의 기록이고, 지금 유효한 설계는 항상 base/가 소스다.
원 발견 보고는 pre-implementation-handtrace-round6.md.
반영된 문서: slot-plan.md / dispatch-core-plan.md / source-state-plan.md /
effect-plan.md / ref-plan.md / gate-plan.md / blocker-plan.md /
debounce-throttle-plan.md / tag-plan.md / attribute-plan.md /
modifier-plan.md / onchange-plan.md / tween-plan.md / typing-limits.md /
fallback-plan.md / quad-types-plan.md / store-plan.md /
module-lifecycle-plan.md / architecture.md, 그리고 루트 ROADMAP.md와
.claude/todos.md / luau-test/STATUS.md.
A. 처리 완료 (결정됨)
H-39 — 말단 핸들러 4종의 setLength/setOffsetSource 미등록
결정: 넷 다 똑같이 등록(갈래 1 + 2-(a)). TagHandler/
AttributeGroupHandler/RefLeafHandler/ObserverEffectLeafHandler의
process 맨 앞에서 setOffsetSource(inst,k,None) → setLength(inst,k,0).
AttributeGroupHandler도 예외로 두지 않는다 — Tag/Attribute를 "Length/Offset
비참여 카테고리"로 재정의하면 bk.N의 의미가 바뀌어 파급이 크다.
H-25 — New(): Quad가 닫힌 타입
결정: quad-types의 Quad를 마일스톤마다 갱신(갈래 2). M2가
Dispatch 필드와 그 타입 재수출을 quad-types에 추가하는 걸 ROADMAP.md
M2 체크리스트 항목으로 명시한다. quad-roblox(M5)도 같은 경로로 본다.
H-1 — keyIndex가 사이클 도중 stale
결정: 사용자 역제안 채택 — 역방향 인덱스 맵을 raw 층에 둔다.
:List는k → realElem만 들고, 배열 인덱스를 직접 들지 않는다.slot._elemIndex신설(realElem → index),_elements와 같은 수명. 자리를 밀고 당기는 모든 연산(spliceArraysUp/Down,rawMove,rawSwap,rawReplace,rawAdd)이 같이 갱신한다. detach된 요소는_elements밖이므로 맵에서도 빠진다.- raw*의 index 시그니처는 유지(5라운드 결정 그대로). 대신
indexOfRaw(self, element)가 선형 탐색이 아니라 이 맵 조회가 되고, "폴백이 아니라 기본 경로"로 승격된다. - 근거: 층 분리가 오히려 깨끗해지고(raw*가
:List클로저를 안 봄), 시프트는 이미 O(n)이라 맵 갱신이 점근 비용을 안 올리며, 조회가 O(1)이 된다.claimOwnerAt이 같은 요소의 이중 배치를 이미 error로 막으므로 키 유일성이 보장된다. - 부수 효과:
:List의keyIndex가 인덱스 맵에서 단순 키 집합으로 내려앉는다(소멸 루프의 "직전 사이클 키" 용도만 남음) →H-16이 같이 사라진다.
H-2 — pos(리프 카운터)를 배열 인덱스로 겸용
결정: 카운터를 분리하고, updateFn의 index는 getOffsetAt으로 구한다.
사용자 원 디자인 초안대로 — activation이 순차이므로 그 시점 length는
확정돼 있다. 이를 성립시키기 위해 초기 population 경로를 같이 고친다:
_mounted는 "물리 인스턴스가 있는가"만 가리킨다.rawAdd의 얼리리턴을native*호출만 가리는 분기로 좁히고, 부기 (spliceArraysUp/setOffsetSource/setLength/중첩이면 실체화)는 마운트 전에도 수행한다.slot._physicalTarget신설 —materializeSlotTree머리에서 저장.setLength의 앵커/bk.observers앵커가 마운트 전에도 필요하기 때문._mountedInst는 지금대로 "마운트됨"만 뜻한다(미실체화/실체화/마운트 3상태)._listed면materializeSlotTree의 자식 실체화 루프를 건너뛴다 —activateList가 이미 전부 등록하므로 이중 등록이 된다.:List와 CRUD가 상호배타라 정확하다.blocker:On()을activateList앞으로 옮긴다 — 5라운드AS-5의 근거("그 안에선 게이팅할 recompute 자체가 안 일어난다")가 사라지므로. population 중 아이템마다 recompute가 도는 O(n²)를 막는다._baseObserver생성은 여전히blocker:On()뒤.
H-5 — spliceArraysUp이 bk.N을 먼저 올려 여는 창
결정: spliceArraysUp이 lengthList[index]에 자리표시자를 채운다.
sourceList가 None으로 채워지는 것과 대칭. 동기 재진입(ChildAdded)이
그 창에서 sum += nil로 터지던 것이 닫힌다.
H-6 — unmountSlotTree의 미정의 physicalTarget + offset 어긋남
결정: physicalTarget은 slot._mountedInst를 로컬로 먼저 뽑아 쓴다
(지우는 대입보다 위에서 읽는 순서 유지). 곁가지는 역순 순회로 변경 —
뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. 백엔드 계약을
좁히지 않아도 닫힌다.
H-7 — Effect의 Ref 의존성을 뗄 방법이 없음
결정: Ref에 콜백 해제 경로를 추가(갈래 a).
(파생) Ref.Callbacks를 해시맵 셋으로
결정: {[callback/thread] = true}로 변경(사용자 제안). 해제가 O(1)이
되어 H-7이 요구하는 연산과 맞고, type(v) == "thread" 분기는 키 타입
검사로 그대로 성립한다.
- 같은 콜백/thread의 중복 등록은 dedup을 계약화(여러 번 등록해도 한 번
발화). 해제가
t[fn] = nil하나로 끝나야 하기 때문. ref-plan.md의 ⚠️ 실측 대상(구멍 있는 테이블에서#tborder가 항상 첫nil인가 — M0/M8 스파이크)이 폐기된다 — 해시맵엔 border 개념이 없다.table.insert재사용 근거(R-11)와 "None이 아니라nil" 논의도 이 자료구조 변경에 맞춰 다시 쓴다.
H-23 — 전파 도중 새 구독자가 붙으면 누락/이중 발화 (실측)
결정: 순회 전 스냅샷(갈래 1). State 구독자 집합과 Ref.Callbacks
둘 다 배열로 복사한 뒤 그 배열을 순회한다. "이번 파동 중에 붙은
구독자는 다음 파동부터 참여"를 계약으로 명시. 스냅샷 사이에 죽은 구독자는
기존 canExecute 게이트가 걸러준다.
H-11 — Effect의 leaf 사망 cleanup을 부르는 배선이 없음
결정(1번 소항목): EffectHandle 쪽이 자기 bindLifetime 직후에
Destroying을 건다. bindLifetime이 값 타입을 가리지 않는다는 기존
원칙을 유지한다. 2·3번 소항목(=unbindLifetime이 cleanup을 부르는가 /
cleanup을 어디 보관하는가)은 아직 미확정.
H-12 — rawRemove/rawUnmount/rawDetach에 마운트 전 분기 부재
결정: native* 호출(및 _mountedInst를 쓰는 줄)만 _mounted로 가드해
rawAdd와 대칭을 맞춘다. H-2의 "부기는 마운트 전에도 한다"와 같은 규칙을
네 함수가 공유한다.
H-13 — Effect(fn, ...deps) 역전이 정본 문서에 미반영
결정: Observer는 기각 유지하되 근거를 새로 쓴다. source-state-plan.md가 승격해둔 일반 원칙(없던 노드를 새로
만들어야 하면 sugar를 안 붙인다)은 :Compute 한정으로
좁히고, Effect의 역전(C-6)을 그 문서에 반영한다. Observer 기각의 새 근거는
"Observer는 리시버 State 하나에 붙는 구독이고, 여럿을 엮는 건 Effect가 대신한다".
H-14 — Effect의 fn 시그니처
결정: Effect(fn: (self: Effect) -> (() -> ())?, ...deps).
fn은self(Effect 핸들) 하나만 받는다....deps는 의존성 선언일 뿐fn에 안 넘어간다 — dep 값은 사용자가 클로저로 직접 읽는다.- 사용자 논거: "Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게
맞아보이고, Compute 랑은 완전 다름. 난 그냥
...deps넣는게 compute 처럼 그냥 넣을 수 있게 하자는거였을 뿐임." - 그 따름정리로
Refdep의.Valuevs State dep의:Get()비대칭 문제가 사라진다(아무것도 안 넘기므로). 문서의 *"trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*와 옛fn(state)표기는 둘 다 삭제.
H-17 — Dispatch.drive의 Blocker 범위
결정: "drive 전체"로 넓혀 적고 계약화. F-4-1의 단일 일반화 for에선
배열 파트 종료 시점이 관측되지 않고 postRefList 소비는 애초에 해시 파트보다
뒤이므로, 실제 범위를 문장에 맞춘다. 추가로 PostRef 콜백은 게이트가 켜진
채로 실행되며, 그 안에서 일어난 Length 변화는 직후의 명시적 recompute 한
번으로 정리된다를 계약으로 명시(지금은 우연히 맞고 있을 뿐).
H-18 + H-45 — attribute 이름을 그룹 사이에서 옮길 때 emit 순서 의존
결정: UB로 못박는다. 사용자 논거: "state 로 그 경로가 열리는데
어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이 없음. 외부에서
먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임. 애초에 싱크이기도
하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한 예외들이 생기고
오버 엔지니어링으로 보임." H-45(단건 AttributeKey ↔ 그룹)도 같은 결정으로
닫힌다.
H-19 — recompute가 두 번 도는 것
결정: setLength에 일임하고 rawAdd/rawReplace의 명시 recompute 호출을
삭제. "자리의 길이가 바뀌면 setLength가 책임지고 recompute를 태운다"로
소스를 하나로 만든다 → H-3의 invalidAfter 당김도 setLength 한 자리에만
두면 된다.
H-21 — unwrapElement가 Instance에서 크래시
결정: isSlot 가드(갈래 1). if isSlot(el) then return el._wrapped or el end return el. 래퍼는 항상 Slot이고 isSlot은 Brand의 weak-key 조회라
Instance/userdata/number 전부에 안전하다.
H-22 — 기본 identity updateFn이 KeyGone을 되돌려 항상 error
결정: identityUpdateFn이 KeyGone을 흡수(갈래 1) —
if item == KeyGone then return nil end. 사용자가 직접 쓴 identity에도 같은
처리가 필요하다는 건 문서로 안내한다.
H-24 — Tween<T>:Mapped의 재귀 제네릭 타입 누수
결정: 단순 정정. typing-limits.md의 영향 범위에 Tween<T>:Mapped 행을
추가하고, tween-plan.md의 Mapped 절에 "인라인 제네릭 메소드가 아니라
typeof(named function)으로 선언할 것" 각주. 기존 완화책이 그대로 통한다.
H-26 — 부분 생성 후 예외로 생긴 Instance가 영구히 안 죽음
결정: 둘로 나눈다.
- 기본 경로(예외가 밖으로 나가 아무것도 안 그려지는 경우)는 서술 정정으로
끝낸다. 같이
dispatch-core-plan.md에서 잔여 부기가 인스턴스 GC로 정리된다고 적은 문장을 고친다 — gcconn 불멸성과 양립하지 않는 틀린 안전망 주장이다. Fallback/Traceback중 생성된 부분 트리는 백로그. 사용자 판단: "의도적으로 error 를 사용하고자 하는 경우 항상 컴포넌트들이 쌓이거든. 이건 후행에서 더 다뤄보도록 백로깅해줘. fallback/traceback 자체가 슈거라서, 그 때 가서 생각해도 될듯."
H-29 — 정의 없는 raw* 다섯
결정: 작성 + collectLeaves(slot) 헬퍼 신설. 재귀 리프 수집 헬퍼를 두고
Move/Swap/Extract/Splice가 공유한다(native*의 "빠지는 요소는 반드시
elements 배열로" 계약을 그대로 지킴). 작성 시 같이 명시할 것: 함께 치환되는
배열(lengthList/sourceList/bk.observers/_elemIndex), bk.N은 안 변함,
recompute는 setLength에 일임(H-19).
H-32/H-33/H-49 — Debounce/Throttle과 Gate의 관계
결정: 정책 합성 구조를 확정.
Gate노드에Flush/Cancel표면을 두지 않는다. 확정된state:Gate(setup)(State 하나만 반환)이 그대로 유지된다.Blocker가 자기 정책을 값으로 낸다 —blocker:Policy(emit) -> onUpstreamEmit.state:Block(b)는 그 위의 얇은 래퍼가 된다.Debounce/Throttle은 emit을 아예 안 쥔다. 자기Blocker를 사적으로 갖고 언제On()/Off()할지만 정하며, 실제 발화/보류는 Blocker에 위임한다:
동기 실행이라 같은 호출 안에서 정책이 바꾼 Blocker 상태를state:Gate(function(emit) local b = Blocker() local pass = b:Policy(emit) return function() -- 상류 emit 도착 (동기) ...타이머 리셋 / b:On() / b:Off() 시점 판단만... pass() -- 그 시점 상태로 Blocker가 판정 end end)pass()가 본다.- Blocker는
Debounce설정당 하나가 아니라 적용 핸들당 하나다 —Debounce{}커링 결과는 여러 곳에 적용될 수 있으므로 Apply 시점에 생성된다. pending은 Blocker의HasBlockedEmit으로 흡수한다(중복 상태를 안 만든다).Trailing=false는OffWithoutEmit(),Flush는Off()로 매핑되어H-32의 새는 경로가 구조적으로 사라진다.- 합성 순서(누가 상류인가)는 손으로 중첩해 표현한다 —
state:Gate(p1, p2, ...)가변인자 슈가는 두지 않는다. - 기각된 대안(기록): "블로커를 바깥에 중첩"(
blocker:Policy(debounceOnEmit))은 unblock 시 흘러나온 emit이 디바운스 창을 새로 시작시켜 창이 안 끝난다.
H-38 — reconcile의 예외 원자성
결정: 키 집합을 증분적으로 갱신(마지막 일괄 교체 폐지). settle 직후에
키를 넣고 소멸 루프에서 뺀다. 계약은 "reconcile은 원자적이지 않지만 중단된
지점까지는 정합하다". H-31(duplicate key)의 선행 검증 패스와 함께 그 실패
모양을 닫는다.
H-39~H-42, H-46, H-52 (4차)
H-40— 빠진 가드 넷을 전부 의사코드에 반영. 타입 검증은 화이트리스트로 뒤집되 base가T를 아는 방식이 아니라 백엔드 주입으로 한다:isInst를 백엔드 계약(주입 op)에 추가하고 quad-roblox가typeof(v) == "Instance"로 채운다. 판정 순서는isSlot→ 구조 처리 /isState→ 래퍼 Slot으로 풀어 재귀 / 그 외 →isInst(v)가 거짓이면 error. 관문은wrapElement하나 (공개 CRUD와:List의settle이 둘 다 통과하므로 State가 나중에 이상한 값이 되는 경우도 같은 자리에서 걸린다). 사용자 논거: 브랜드 기반 판정은 불가 — "이제 brand 는 각각 따로 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는 의미".- 파생:
Splice의T | {T}기각은 유지하되 근거를 정정한다. "근거 정정. 근데 실질적으로 저 splice 는 T 가 slot/state 일 수도 있어서, isSlot/state 도 봐야함. 당연하게도Splice(..., comp())패턴은 흔할 수 있어서 그래. 그리고 여전히 splice 는 소수의 요소를 다루는게 많고, 테이블 생성 비용을 지불할 의미가 없다는 근거는 여전해."
- 파생:
H-42— 문서만 정정. "동기 발화"를SignalBehavior.Immediate조건부로 명시(Deferred에선 이 레이스가 없고PreRef는 무해하게 불필요). 그 뒤의 큰 질문(구조 존속)은 유지로 결론 — Immediate place가 살아있는 한 필요하고 불필요해질 때의 비용이 싸다.H-46— top-levelSlot.luau신설(다른 값 타입과 대칭,slot-plan.md가 이미 적어둔 파일명과도 일치).Dispatch/Slot.luau는 핸들러/부기만.H-52— 대칭적으로 가드 추가(type(k)=="number"+ 동적 경로 가드),RefLeafHandler가 2026-08-18에 받은 수정과 같은 모양.
일괄 단순 정정 (사용자 확인: "전부 그대로 반영")
H-3(캐시 무효화 3규칙을 setLength/splice/_baseObserver에 실제 배치) ·
H-4(bk 스펙에 offsetCache/invalidAfter 추가, 0/{} 초기화 명시) ·
H-8(_observers 배열화 + cascade/Subscribe/Unsubscribe 순회) ·
H-9(withheld 스왑도 weak-key로) · H-10(sum 주석 정정) ·
H-20(process(inst, k, v, index) 표기 통일) · H-27(OnChange의 v == nil
얼리리턴) · H-28+H-43(dispose의 소유권 가드를 isSlot 분기 밖으로) ·
H-30+H-31(선행 일괄 검증 패스) · H-34+H-44(native* 계약 보강 —
Roblox 백엔드는 nativeMove/nativeSwap을 덮어써야 함, ROADMAP M6 줄 갱신) ·
H-35(ProcessedModifierHandler 의사코드 + 색인 두 곳) · H-36 · H-37 ·
H-41(groupClaimKeys 배선) · H-47~H-51 · H-53 · H-54.
B. 답할 게 없는 항목
H-15— 2차 패스에서 이미 철회(오탐).H-16—H-1해법의 부수로 소멸(keyIndex가 인덱스 맵이 아니게 됨).H-2의 크래시 주장 정정 — 3차 패스의 실측(table.insert(t, 0, x)는 Luau에서 안 터짐)대로 "크래시"가 아니라 "조용한 영구 고아"로 본문을 고친다. 결론과 처방은 그대로.
C. 백로그로 넘긴 것
H-26의 2번 —Fallback/Traceback중 생성된 부분 트리의 회수. 그 둘이 슈가라 구현 시점에 다룬다.
D. 반영 후 /code-review high (2026-08-24) — 7건, 전부 유효
반영을 커밋하기 전에 사용자가 /code-review high를 돌렸고 7건이 나왔으며
전부 유효했다. 그중 셋(1·2·3)은 이번 반영이 새로 만든 회귀다 — 감사자가
보는 축(코퍼스 정합성)으로는 안 보이고 diff 자체를 읽어야 보이는 종류였다.
rawAdd가 미실체화 가드를 통째로 잃었다(높음).H-2재작성이if not self._mounted then return index end를 지우면서 3상태 중 첫 경계에 아무것도 안 뒀다.Slot { frameA }생성자가:Add를 부르는데 그건materializeSlotTree보다 훨씬 먼저라slot.Offset이nil이고,setLength→gatedRecompute→getOffsetAt의ownerKey.Offset:Get()에서 즉시 죽는다. 중첩이면bindLifetime(nil, …)까지 간다. →self._physicalTarget == nil이면_elements/_elemIndex만 갱신하고 리턴하도록 복원.rawReplace의 미마운트 분기에도 같은 가드를 넣었다. 같이 정리된 것:_elemIndex갱신을reindexFrom(self, from)헬퍼로 떼어 실체화 여부와 무관하게 항상 돌게 했다(spliceArrays*는 부기만 담당).rawRemove가 마운트 전 창에서 요소를 안 죽인다(높음).H-12가nativeRemove를_mounted로 가렸는데nativeRemove가 곧 파괴였다 (백엔드 융합).else분기가 비어 있어 요소가_elements에서 빠지고 아무도 안 죽인다 — gcconn 때문에 GC도 안 되므로 영구 누수다. →else nativeDispose(element)추가(rawReplace가 이미 하던 대로)._listed분기가 포탈 재마운트를 깬다(중간). 재마운트에선activateList가_listActivated멱등 가드에 걸려 앵커만 옮기고 리턴하므로, 자식 루프까지 건너뛰면 보존된_elements안의 중첩 Slot이 다시 실체화되지 않는다(_physicalTarget이nil인 채_mounted만 켜지고_baseObserver가 죽은 옛 target에 매달린다). → 조건을 **slot._listed and not slot._listActivated**로 좁히고,else가지에서 재마운트 시activateList(앵커만)를 부른 뒤 루프를 돌게 했다.H-11의 두 결정이 서로 모순(중간). "bindLifetime은 값 타입을 안 가린다"를 지키기로 해놓고 "unbindLifetime이Destroying을 끊는다"를 같이 정했는데, 후자는 정확히 그 분기를 요구한다. 게다가 *"EffectHandle쪽이 자기bindLifetime직후에 건다"*는 그 호출부가 실재하지 않는다 (핸들은 남이 자기를 bind하는 걸 관측 못 한다).Effect바인드 경로가 둘이라(leaf 핸들러 /activateList의_detachCleanup직접 바인드) 핸들러 층에 둬도 안 덮인다. → 재결정(사용자, 2026-08-24):bindLifetime/unbindLifetime이isEffect를 보고 직접 처리한다. "Destroying 자체가 엔진이 아는 요소이기 때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 때문에, bind 상 옵져버 목록을 가져와 자신이 재귀하고, bindLifetime 이 처리하는게 나아보입니다." 근거로 들었던 "게이트는 값 타입을 안 가린다"는 인용이 틀렸다 — 그 절은canBound판정이 두 진입점에서 같다는 얘기지 부수 배선 얘기가 아니고, 실제로 그 함수는 이미_observers로 cascade한다. 의사코드는base/lifecycle-pattern.md에 반영.- ⭐ 같은 자리에서 사용자가 추가 지적:
Effect의Ref콜백도canExecute를 거쳐야 한다. 해제 경로만으로는 창이 남는다 —unbindLifetime으로 조용히 끊긴 상태(포탈 언마운트)는Destroying이 안 도는데도canExecute가 거짓이다. 해제는 누수를, 게이팅은 발화를 막는다. →Effect가 거는Ref콜백은 본문 맨 앞에서canExecute(handle)를 확인하고 거짓이면 리턴한다. 이로써 두 dep 경로가 같은 게이트를 공유한다. (H-7처리 때 "그 대안은 채택하지 않았다"고 적었던 서술을 정정했다 — 택일이 아니라 둘 다 필요하다.)
- ⭐ 같은 자리에서 사용자가 추가 지적:
Block/Gate동치 표기가 호출 불가(중간).state:Gate(b:Policy)는 문법 오류이고state:Gate(b.Policy)는 언바운드 메소드라emit이self자리에 들어가 게이트가 영영 안 열린다. →state:Gate(function(emit) return b:Policy(emit) end)로 두 문서 정정.settle의 교체 분기가 새 자리로 안 옮긴다(낮음).rawReplace는 자리를 유지하는데slotPos를 무시해서, 값 교체와 리오더가 같은 사이클에 겹치면 그 요소만 옛 자리에 남는다. → 교체 후idx ~= slotPos면rawMove.prevKeys[key] = true가settle뒤에 있어H-38계약이 약해진다(낮음).settle이 커밋 후 던지면 그 키가 집합에 안 들어가 다음 사이클 소멸 루프가 못 물어 영구 고아가 된다 —H-38이 고치려던 바로 그 모양. →settle앞으로.
교훈(기록): conventions.md가 "/code-review는 감사자를 대체하지 않는다"
라고 적어둔 그대로였다. 이번엔 감사자를 돌리기 전에 code-review가 먼저
돌았는데, 잡힌 7건 중 셋이 "이번 diff가 만든 새 결함"이라 코퍼스 정합성
각도로는 애초에 안 보이는 것들이었다.
E. 반영 후 quad-doc-auditor 감사 루프 (2026-08-24) — 9라운드, 34건
/code-review high 뒤에 감사 루프를 돌렸다. conventions.md의 절차대로
한 턴에 하나씩(병렬 금지) 돌리고 라운드마다 각도를 바꿨으며, 새 발견이
0건인 라운드가 나올 때까지 반복했다. 라운드별 발견: 5 → 7 → 2 → 2 → 3 →
9 → 2 → 4 → 0.
| 라운드 | 각도 | 발견 |
|---|---|---|
| 1 | base/ 문서들끼리 정합성(옛 주장 잔존) |
5 |
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS/archive/reference) | 7 |
| 3 | 이번에 새로 쓴 서술 자체의 내부 모순·인용 검증·정의/사용 일치 | 2 |
| 4 | 이번에 안 바뀐 문서가 조용히 틀려졌는가 | 2 |
| 5 | 어휘 추적 — 뜻이 바뀐 낱말을 옛 뜻으로 쓰는 산문 | 3 |
| 6 | ROADMAP.md의 미완료 체크박스를 구현자 눈으로 |
9 |
| 7 | 6라운드 픽스 검증 + agents/tools/followup 사각 |
2 |
| 8 | 완결성 — "N개 중 일부만 고친 자리" 세기 | 4 |
| 9 | 수렴 확인 + 전 코퍼스 스윕 | 0 |
각도를 바꾼 게 실제로 작동했다. 가장 많이 잡은 6라운드(9건)는 문서
정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은
라운드였고, 그때 ROADMAP.md의 미완료 항목들이 대거 stale인 게 드러났다 —
M6엔 폐기된 pos 공식이 "확정"으로 남아 있었고, M2엔 접두합 캐시 무효화
계약이 통째로 없었으며, M8은 bindLifetime이 *"둘만 한다"*고 적어 H-11의
Effect 분기와 직접 모순이었다. 말단 핸들러 4종의 부기 의무(H-39)는 어느
체크박스에도 없었다.
반복해서 드러난 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만
고쳤다". 1라운드는 배너를 달아놓고 그 배너가 부정하는 위쪽 문장을 안 고친
것(핸드오버 체크리스트 2번), 2라운드는 base/ 19개를 바꾸고 .claude/README.md를
한 줄도 안 고친 것(체크리스트 6번), 7·8라운드는 그 README를 고칠 때 11개 행
중 6개만 고치고 다섯을 빠뜨린 것이었다. 8라운드를 아예 완결성 축(새 이름
하나당 나타나야 할 자리 넷 — 정의 문서/소스 트리/ROADMAP 체크박스/README 행 —
을 열거해 세기)으로 잡은 게 그래서였고, 실제로 4건이 더 나왔다.
같이 채운 절차적 공백 둘: conventions.md가 요구하는
session/2026-08-24-01-handtrace-round6-resolution.md 원문과
session-summary.md 항목을 남겼다(설계 결정이 대량으로 오간 세션인데 기록이
없었다). 그리고 이 감사에서 파생된 새 결정이 셋 있어 base/에 함께 반영했다 —
주입 op onDestroying(inst, fn) 신설(_bindDestroying 의사코드를 쓰다
드러남, base는 Instance를 모르므로), EffectHandle의 필드 다섯
(_destroyConn/_refDeps/_refCallbacks 등), quad-types의 Quad 갱신을
M3/M6/M7/M8/M10에도 항목화(확정이 "마일스톤마다"였는데 M2에만 있었다).