quad/.claude/qa-request/pre-implementation-handtrace-round6-followup.md
qwreey 19cd046275
design: 손 트레이싱 6라운드 전량 처리·반영 — H-1~H-54 확정, M2/M3 설계 게이트 해소
사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `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
2026-08-24 18:54:14 +09:00

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/ObserverEffectLeafHandlerprocess 맨 앞에서 setOffsetSource(inst,k,None)setLength(inst,k,0). AttributeGroupHandler도 예외로 두지 않는다 — Tag/Attribute를 "Length/Offset 비참여 카테고리"로 재정의하면 bk.N의 의미가 바뀌어 파급이 크다.

H-25New(): Quad가 닫힌 타입

결정: quad-typesQuad를 마일스톤마다 갱신(갈래 2). M2가 Dispatch 필드와 그 타입 재수출을 quad-types에 추가하는 걸 ROADMAP.md M2 체크리스트 항목으로 명시한다. quad-roblox(M5)도 같은 경로로 본다.

H-1keyIndex가 사이클 도중 stale

결정: 사용자 역제안 채택 — 역방향 인덱스 맵을 raw 층에 둔다.

  • :Listk → 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로 막으므로 키 유일성이 보장된다.
  • 부수 효과: :ListkeyIndex가 인덱스 맵에서 단순 키 집합으로 내려앉는다(소멸 루프의 "직전 사이클 키" 용도만 남음) → H-16이 같이 사라진다.

H-2pos(리프 카운터)를 배열 인덱스로 겸용

결정: 카운터를 분리하고, updateFnindexgetOffsetAt으로 구한다. 사용자 원 디자인 초안대로 — activation이 순차이므로 그 시점 length는 확정돼 있다. 이를 성립시키기 위해 초기 population 경로를 같이 고친다:

  • _mounted는 "물리 인스턴스가 있는가"만 가리킨다. rawAdd의 얼리리턴을 native* 호출만 가리는 분기로 좁히고, 부기 (spliceArraysUp/setOffsetSource/setLength/중첩이면 실체화)는 마운트 전에도 수행한다.
  • slot._physicalTarget 신설materializeSlotTree 머리에서 저장. setLength의 앵커/bk.observers 앵커가 마운트 전에도 필요하기 때문. _mountedInst는 지금대로 "마운트됨"만 뜻한다(미실체화/실체화/마운트 3상태).
  • _listedmaterializeSlotTree의 자식 실체화 루프를 건너뛴다activateList가 이미 전부 등록하므로 이중 등록이 된다. :List와 CRUD가 상호배타라 정확하다.
  • blocker:On()activateList 앞으로 옮긴다 — 5라운드 AS-5의 근거("그 안에선 게이팅할 recompute 자체가 안 일어난다")가 사라지므로. population 중 아이템마다 recompute가 도는 O(n²)를 막는다. _baseObserver 생성은 여전히 blocker:On() 뒤.

H-5spliceArraysUpbk.N을 먼저 올려 여는 창

결정: spliceArraysUplengthList[index]에 자리표시자를 채운다. sourceListNone으로 채워지는 것과 대칭. 동기 재진입(ChildAdded)이 그 창에서 sum += nil로 터지던 것이 닫힌다.

H-6unmountSlotTree의 미정의 physicalTarget + offset 어긋남

결정: physicalTargetslot._mountedInst를 로컬로 먼저 뽑아 쓴다 (지우는 대입보다 위에서 읽는 순서 유지). 곁가지는 역순 순회로 변경 — 뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. 백엔드 계약을 좁히지 않아도 닫힌다.

H-7EffectRef 의존성을 뗄 방법이 없음

결정: Ref에 콜백 해제 경로를 추가(갈래 a).

(파생) Ref.Callbacks를 해시맵 셋으로

결정: {[callback/thread] = true}로 변경(사용자 제안). 해제가 O(1)이 되어 H-7이 요구하는 연산과 맞고, type(v) == "thread" 분기는 키 타입 검사로 그대로 성립한다.

  • 같은 콜백/thread의 중복 등록은 dedup을 계약화(여러 번 등록해도 한 번 발화). 해제가 t[fn] = nil 하나로 끝나야 하기 때문.
  • ref-plan.md⚠️ 실측 대상(구멍 있는 테이블에서 #t border가 항상 첫 nil인가 — M0/M8 스파이크)이 폐기된다 — 해시맵엔 border 개념이 없다.
  • table.insert 재사용 근거(R-11)와 "None이 아니라 nil" 논의도 이 자료구조 변경에 맞춰 다시 쓴다.

H-23 — 전파 도중 새 구독자가 붙으면 누락/이중 발화 (실측)

결정: 순회 전 스냅샷(갈래 1). State 구독자 집합과 Ref.Callbacks 둘 다 배열로 복사한 뒤 그 배열을 순회한다. "이번 파동 중에 붙은 구독자는 다음 파동부터 참여"를 계약으로 명시. 스냅샷 사이에 죽은 구독자는 기존 canExecute 게이트가 걸러준다.

H-11Effect의 leaf 사망 cleanup을 부르는 배선이 없음

결정(1번 소항목): EffectHandle 쪽이 자기 bindLifetime 직후에 Destroying을 건다. bindLifetime이 값 타입을 가리지 않는다는 기존 원칙을 유지한다. 2·3번 소항목(=unbindLifetime이 cleanup을 부르는가 / cleanup을 어디 보관하는가)은 아직 미확정.

H-12rawRemove/rawUnmount/rawDetach에 마운트 전 분기 부재

결정: native* 호출(및 _mountedInst를 쓰는 줄)만 _mounted로 가드rawAdd와 대칭을 맞춘다. H-2의 "부기는 마운트 전에도 한다"와 같은 규칙을 네 함수가 공유한다.

H-13Effect(fn, ...deps) 역전이 정본 문서에 미반영

결정: Observer는 기각 유지하되 근거를 새로 쓴다. source-state-plan.md가 승격해둔 일반 원칙(없던 노드를 새로 만들어야 하면 sugar를 안 붙인다)은 :Compute 한정으로 좁히고, Effect의 역전(C-6)을 그 문서에 반영한다. Observer 기각의 새 근거는 "Observer는 리시버 State 하나에 붙는 구독이고, 여럿을 엮는 건 Effect가 대신한다".

H-14Effectfn 시그니처

결정: Effect(fn: (self: Effect) -> (() -> ())?, ...deps).

  • fnself(Effect 핸들) 하나만 받는다. ...deps의존성 선언일 뿐 fn에 안 넘어간다 — dep 값은 사용자가 클로저로 직접 읽는다.
  • 사용자 논거: "Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고, Compute 랑은 완전 다름. 난 그냥 ...deps 넣는게 compute 처럼 그냥 넣을 수 있게 하자는거였을 뿐임."
  • 그 따름정리로 Ref dep의 .Value vs State dep의 :Get() 비대칭 문제가 사라진다(아무것도 안 넘기므로). 문서의 *"trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*와 옛 fn(state) 표기는 둘 다 삭제.

H-17Dispatch.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-19recompute가 두 번 도는 것

결정: setLength에 일임하고 rawAdd/rawReplace의 명시 recompute 호출을 삭제. "자리의 길이가 바뀌면 setLength가 책임지고 recompute를 태운다"로 소스를 하나로 만든다 → H-3invalidAfter 당김도 setLength 한 자리에만 두면 된다.

H-21unwrapElement가 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 updateFnKeyGone을 되돌려 항상 error

결정: identityUpdateFnKeyGone을 흡수(갈래 1) — if item == KeyGone then return nil end. 사용자가 직접 쓴 identity에도 같은 처리가 필요하다는 건 문서로 안내한다.

H-24Tween<T>:Mapped의 재귀 제네릭 타입 누수

결정: 단순 정정. typing-limits.md의 영향 범위에 Tween<T>:Mapped 행을 추가하고, tween-plan.mdMapped 절에 "인라인 제네릭 메소드가 아니라 typeof(named function)으로 선언할 것" 각주. 기존 완화책이 그대로 통한다.

H-26 — 부분 생성 후 예외로 생긴 Instance가 영구히 안 죽음

결정: 둘로 나눈다.

  1. 기본 경로(예외가 밖으로 나가 아무것도 안 그려지는 경우)는 서술 정정으로 끝낸다. 같이 dispatch-core-plan.md에서 잔여 부기가 인스턴스 GC로 정리된다고 적은 문장을 고친다 — gcconn 불멸성과 양립하지 않는 틀린 안전망 주장이다.
  2. Fallback/Traceback 중 생성된 부분 트리는 백로그. 사용자 판단: "의도적으로 error 를 사용하고자 하는 경우 항상 컴포넌트들이 쌓이거든. 이건 후행에서 더 다뤄보도록 백로깅해줘. fallback/traceback 자체가 슈거라서, 그 때 가서 생각해도 될듯."

H-29 — 정의 없는 raw* 다섯

결정: 작성 + collectLeaves(slot) 헬퍼 신설. 재귀 리프 수집 헬퍼를 두고 Move/Swap/Extract/Splice가 공유한다(native*의 "빠지는 요소는 반드시 elements 배열로" 계약을 그대로 지킴). 작성 시 같이 명시할 것: 함께 치환되는 배열(lengthList/sourceList/bk.observers/_elemIndex), bk.N은 안 변함, recomputesetLength에 일임(H-19).

H-32/H-33/H-49Debounce/ThrottleGate의 관계

결정: 정책 합성 구조를 확정.

  • Gate 노드에 Flush/Cancel 표면을 두지 않는다. 확정된 state:Gate(setup)(State 하나만 반환)이 그대로 유지된다.
  • Blocker가 자기 정책을 값으로 낸다 — blocker:Policy(emit) -> onUpstreamEmit. state:Block(b)는 그 위의 얇은 래퍼가 된다.
  • Debounce/Throttle은 emit을 아예 안 쥔다. 자기 Blocker를 사적으로 갖고 언제 On()/Off()할지만 정하며, 실제 발화/보류는 Blocker에 위임한다:
    state:Gate(function(emit)
        local b = Blocker()
        local pass = b:Policy(emit)
        return function()            -- 상류 emit 도착 (동기)
            ...타이머 리셋 / b:On() / b:Off() 시점 판단만...
            pass()                   -- 그 시점 상태로 Blocker가 판정
        end
    end)
    
    동기 실행이라 같은 호출 안에서 정책이 바꾼 Blocker 상태를 pass()가 본다.
  • Blocker는 Debounce 설정당 하나가 아니라 적용 핸들당 하나다 — Debounce{} 커링 결과는 여러 곳에 적용될 수 있으므로 Apply 시점에 생성된다.
  • pending은 Blocker의 HasBlockedEmit으로 흡수한다(중복 상태를 안 만든다). Trailing=falseOffWithoutEmit(), FlushOff()로 매핑되어 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와 :Listsettle이 둘 다 통과하므로 State가 나중에 이상한 값이 되는 경우도 같은 자리에서 걸린다). 사용자 논거: 브랜드 기반 판정은 불가 — "이제 brand 는 각각 따로 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는 의미".
    • 파생: SpliceT | {T} 기각은 유지하되 근거를 정정한다. "근거 정정. 근데 실질적으로 저 splice 는 T 가 slot/state 일 수도 있어서, isSlot/state 도 봐야함. 당연하게도 Splice(..., comp()) 패턴은 흔할 수 있어서 그래. 그리고 여전히 splice 는 소수의 요소를 다루는게 많고, 테이블 생성 비용을 지불할 의미가 없다는 근거는 여전해."
  • H-42문서만 정정. "동기 발화"를 SignalBehavior.Immediate 조건부로 명시(Deferred에선 이 레이스가 없고 PreRef는 무해하게 불필요). 그 뒤의 큰 질문(구조 존속)은 유지로 결론 — Immediate place가 살아있는 한 필요하고 불필요해질 때의 비용이 싸다.
  • H-46top-level Slot.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(OnChangev == 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-16H-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 자체를 읽어야 보이는 종류였다.

  1. rawAdd가 미실체화 가드를 통째로 잃었다(높음). H-2 재작성이 if not self._mounted then return index end를 지우면서 3상태 중 첫 경계에 아무것도 안 뒀다. Slot { frameA } 생성자가 :Add를 부르는데 그건 materializeSlotTree보다 훨씬 먼저라 slot.Offsetnil이고, setLengthgatedRecomputegetOffsetAtownerKey.Offset:Get()에서 즉시 죽는다. 중첩이면 bindLifetime(nil, …)까지 간다. → self._physicalTarget == nil이면 _elements/_elemIndex만 갱신하고 리턴하도록 복원. rawReplace의 미마운트 분기에도 같은 가드를 넣었다. 같이 정리된 것: _elemIndex 갱신을 reindexFrom(self, from) 헬퍼로 떼어 실체화 여부와 무관하게 항상 돌게 했다(spliceArrays*는 부기만 담당).
  2. rawRemove가 마운트 전 창에서 요소를 안 죽인다(높음). H-12nativeRemove_mounted로 가렸는데 nativeRemove가 곧 파괴였다 (백엔드 융합). else 분기가 비어 있어 요소가 _elements에서 빠지고 아무도 안 죽인다 — gcconn 때문에 GC도 안 되므로 영구 누수다. → else nativeDispose(element) 추가(rawReplace가 이미 하던 대로).
  3. _listed 분기가 포탈 재마운트를 깬다(중간). 재마운트에선 activateList_listActivated 멱등 가드에 걸려 앵커만 옮기고 리턴하므로, 자식 루프까지 건너뛰면 보존된 _elements 안의 중첩 Slot이 다시 실체화되지 않는다(_physicalTargetnil인 채 _mounted만 켜지고 _baseObserver가 죽은 옛 target에 매달린다). → 조건을 **slot._listed and not slot._listActivated**로 좁히고, else 가지에서 재마운트 시 activateList(앵커만)를 부른 뒤 루프를 돌게 했다.
  4. H-11의 두 결정이 서로 모순(중간). "bindLifetime은 값 타입을 안 가린다"를 지키기로 해놓고 "unbindLifetimeDestroying을 끊는다"를 같이 정했는데, 후자는 정확히 그 분기를 요구한다. 게다가 *"EffectHandle 쪽이 자기 bindLifetime 직후에 건다"*는 그 호출부가 실재하지 않는다 (핸들은 남이 자기를 bind하는 걸 관측 못 한다). Effect 바인드 경로가 둘이라(leaf 핸들러 / activateList_detachCleanup 직접 바인드) 핸들러 층에 둬도 안 덮인다. → 재결정(사용자, 2026-08-24): bindLifetime/unbindLifetimeisEffect를 보고 직접 처리한다. "Destroying 자체가 엔진이 아는 요소이기 때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 때문에, bind 상 옵져버 목록을 가져와 자신이 재귀하고, bindLifetime 이 처리하는게 나아보입니다." 근거로 들었던 "게이트는 값 타입을 안 가린다"는 인용이 틀렸다 — 그 절은 canBound 판정이 두 진입점에서 같다는 얘기지 부수 배선 얘기가 아니고, 실제로 그 함수는 이미 _observers로 cascade한다. 의사코드는 base/lifecycle-pattern.md에 반영.
    • 같은 자리에서 사용자가 추가 지적: EffectRef 콜백도 canExecute를 거쳐야 한다. 해제 경로만으로는 창이 남는다 — unbindLifetime으로 조용히 끊긴 상태(포탈 언마운트)는 Destroying이 안 도는데도 canExecute가 거짓이다. 해제는 누수를, 게이팅은 발화를 막는다. → Effect가 거는 Ref 콜백은 본문 맨 앞에서 canExecute(handle)를 확인하고 거짓이면 리턴한다. 이로써 두 dep 경로가 같은 게이트를 공유한다. (H-7 처리 때 "그 대안은 채택하지 않았다"고 적었던 서술을 정정했다 — 택일이 아니라 둘 다 필요하다.)
  5. Block/Gate 동치 표기가 호출 불가(중간). state:Gate(b:Policy)는 문법 오류이고 state:Gate(b.Policy)는 언바운드 메소드라 emitself 자리에 들어가 게이트가 영영 안 열린다. → state:Gate(function(emit) return b:Policy(emit) end)로 두 문서 정정.
  6. settle의 교체 분기가 새 자리로 안 옮긴다(낮음). rawReplace는 자리를 유지하는데 slotPos를 무시해서, 값 교체와 리오더가 같은 사이클에 겹치면 그 요소만 옛 자리에 남는다. → 교체 후 idx ~= slotPosrawMove.
  7. prevKeys[key] = truesettle 뒤에 있어 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-typesQuad 갱신을 M3/M6/M7/M8/M10에도 항목화(확정이 "마일스톤마다"였는데 M2에만 있었다).