qa: 구현 전 QA 2라운드 — recompute 손 트레이싱으로 RC-1 발견·해결

`:List` reconcile과 dispatch-core-plan.md의 recompute를 실제로 손으로
실행해보다 RC-1(배열 위치가 순차 등록되는 동안 아직 안 채워진 자리를
읽어 산술 에러가 나는 크래시, 정적 자식 2개짜리 Frame도 재현)을 찾았다.
같은 세션 후속 대화에서 사용자가 제시한 Blocker 재사용 게이팅 설계로
해결 — owner별 전용 Blocker가 배치 등록 동안 recompute를 막고,
setOffsetSource는 등록 즉시 앞선 형제 합을 직접 계산하며, attachSlot의
호출 순서(setOffsetSource→실체화→setLength→물리 마운트)도 바로잡고
코루틴 yield 금지 불변식을 명문화했다. Blocker에는 IsOn()/
OffWithoutEmit()이 새로 생겼다.

/code-review high가 이 diff에서 10건을 더 찾아 반영 — D-7 재역전과의
정합성, filter/nil 재역전 반영 누락, PreRef 가드의 typeof(k) 누락,
New()→Quad() 리네임 전파 누락, M6/M8 마일스톤 오기 등. quad-doc-auditor
감사 루프도 여러 라운드 돌려 새 발견 0건까지 수렴시켰다.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-18 22:11:59 +09:00
parent d768e4cbc5
commit a1f948ae34
No known key found for this signature in database
15 changed files with 793 additions and 83 deletions

File diff suppressed because one or more lines are too long

View file

@ -41,6 +41,45 @@
- 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 - 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트
네이밍 인체공학" 절이 소스. 네이밍 인체공학" 절이 소스.
## [해소됨, 2026-08-18 구현 전 QA 2라운드 후속] `RC-1``recompute` 트리거 모델 재설계
`base/dispatch-core-plan.md``recompute`가 배열 위치를 순차 등록하는
동안(`Frame{A,B}`처럼 정적 자식 2개짜리도) 아직 등록 안 된 자리를 `nil`
읽어 산술 에러를 내던 크래시(`qa-request/pre-implementation-qa-round2.md`의
"RC-1" 절에서 손 트레이싱으로 발견) — 같은 날 후속 세션에서 사용자가
Blocker 재사용 설계를 직접 제시해 해결됨.
- **핵심**: owner(물리 `inst` 또는 Slot)마다 `Relate`로 들고 있는 전용
`Blocker`를 배치(`Dispatch.drive`의 배열 파트 순회, `attachSlot`의 자기
`_elements` flush) 시작 시 `On`, `setLength`의 Observer 콜백(등록 즉시
1회 실행 포함)이 `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가
끝나면 `blocker:OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
`setOffsetSource`는 등록되는 그 자리에서 앞선 형제들의 길이 합을 직접
계산해 즉시 `:Set`해서(recompute를 안 기다림) 배치 도중 `:List`
실체화되며 옛 offset을 읽는 문제도 같이 없앰.
- **Blocker에 신설된 API**: `IsOn()`(`IsBlocked` 조회 얇은 래퍼),
`OffWithoutEmit()`(끄되 gated state의 대기 emit은 흘려보내지 않음,
`HasBlockedEmit`은 그대로 리셋) — `state:Block()`을 거치지 않고 직접
쓰는 첫 사례. 처음 요청됐던 `HasBlocked`(Blocker 자신의 새 최상위
플래그)는 논의 중 불필요함이 확인돼 신설 안 함.
- **중첩 Slot마다 별도 Blocker**(부모와 공유 금지, `blocker-plan.md`
재진입 미지원 규칙 그대로), **런타임 단건 `slot:Add()`는 게이팅 불필요**
(이미 안정된 앞선 position만 참조하므로 무관) — 둘 다 사용자가 직접
확인.
- **후속 정정 — `attachSlot` 호출 순서**: 기존 의사코드는 `setLength`
먼저 불렀는데, "호출 순서는 `setOffsetSource``setLength`" 일반
규칙과 어긋나 있었음이 드러남(RC-1로 `setOffsetSource`가 즉시 계산을
하게 되며 순서가 겉으로 드러났기 때문) — Slot의 진짜 `.Length`
`activateList` 실체화 뒤에야 확정되므로 `setOffsetSource` → 실체화 →
`setLength` → 물리 마운트 순으로 바로잡음. 같은 논의에서 **코루틴 yield
금지 불변식**도 확정(`Dispatch.process`/`attachSlot` 호출 체인 도중
yield는 UB).
- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게
만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절.
--- ---
# 확인/결정 필요 목록 # 확인/결정 필요 목록

View file

@ -98,9 +98,11 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도 정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도
프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은
`quad-roblox`가 담당. `quad-roblox`가 담당.
13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox 13. **모듈은 기본 싱글톤, `Quad()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox
프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `Quad()`
추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 추가(**[정정, 2026-08-18 `/code-review high`] 이름은 아래 배너대로
`New()`가 아니라 `Quad()`가 확정 — 이 문장도 그 이름으로 통일**).
**메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라
기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)` 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`
격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가
`BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 `BaseModule` `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 `BaseModule`

View file

@ -112,9 +112,13 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들
아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는
서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가 서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가
초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만 초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만
두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은 두면 됨. 모듈 스코핑(`Quad()`, `base/architecture.md` 13번)과의 관계도
열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도 실은 열려있던 게 아니라 자연히 풀림 — `Quad()`가 생기면 각 인스턴스가
테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요. 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨(**[한정,
2026-08-18 `/code-review high``architecture.md` 13번의 정정과 맞춤]**
단 "자동으로"는 아님 — module-level state를 참조하는 코드들이 모듈
인스턴스를 인자로 받도록 손을 봐야 하는 건 architecture.md 13번의 정정
그대로, 여기서 반복 안 함).
## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) ## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨)

View file

@ -38,7 +38,15 @@ debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸
Blocker() -> blocker -- 생성자 Blocker() -> blocker -- 생성자
blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함 blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함
blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된 blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된
-- onunblock 핸들 전부 실행(순서 무관, idempotent) -- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent)
blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음
-- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이
-- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀.
-- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고),
-- 차이는 넘기는 emit 플래그 하나뿐.
blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는
-- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래
-- "이름 확정" 참고), 호출부 가독성만을 위한 추가.
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을 -- 처음 블록될 때가 아니라) onunblock 핸들을
@ -49,9 +57,14 @@ gated state의 동작:
- 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도. - 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도.
- `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅. - `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅.
- `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과). - `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과).
- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해 - **onunblock 핸들은 이제 `emit: boolean` 인자를 받는다**(`blocker:Off()`/
true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면 `:OffWithoutEmit()`가 공유하는 내부 실행 경로, 2026-08-18 신설) —
아무 것도 안 함(idempotent). `HasBlockedEmit`을 확인해 true면 `emit`이 참일 때만 그제서야 정확히
1회 전파(emit)하고, `emit`이 거짓이면 전파 없이 플래그만 리셋. 이미
`HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함
(idempotent). 즉 `Off()`는 "밀린 전파를 흘려보내며 끈다",
`OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기
상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨.
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서
@ -77,6 +90,28 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번
바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.
## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설)
지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를
경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`
"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다
— 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는
방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end`
형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —
`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두
메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기
위해 `OffWithoutEmit()`을 쓴다**("이 배치가 끝나면 무엇이든 자동으로
흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도
표현). 상세 메커니즘·`Dispatch.setLength`/`setOffsetSource`가 이 Blocker를
어떻게 만들고 어디에 저장하는지는 `base/dispatch-core-plan.md`의 "배치
등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음.
**재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용** — 중첩된 owner
(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를
새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절).
## 이름 확정 ## 이름 확정
- 클래스: `Blocker``Observer`/`Modifier`/`Ref`와 같은 명사-행위자 - 클래스: `Blocker``Observer`/`Modifier`/`Ref`와 같은 명사-행위자
@ -89,6 +124,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** - 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`**
(gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌).
- 메소드: `state:Block(blocker) -> state`. - 메소드: `state:Block(blocker) -> state`.
- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는
얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들"
참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함.
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 즉 `IsBlocked`/
`HasBlockedEmit` 필드는 그대로 유지하고, 별도 `HasBlocked`(Blocker
자신의 새 최상위 플래그)는 **신설하지 않는다**`OffWithoutEmit()`
각 gated state의 기존 `HasBlockedEmit`을 그대로 리셋해주는 것으로
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
"RC-1" 절에 논의 경위 기록).
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 ## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
@ -104,7 +149,22 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면
조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.
## 상태: 핵심 메커니즘+이름 확정. [2026-08-07 기준] 남은 건 문서화뿐 **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다**
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이
경고하는 실패 모드의 구체 사례.
## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐
**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과
`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API
확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과
메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신.
`quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 `quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는
게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용). 게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용).

View file

@ -127,10 +127,20 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
**이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부 **이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부
해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가 해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가
호출되면?" 케이스(`pre-implementation-audit.md` 호출되면?" 케이스(`pre-implementation-audit.md`
1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는 1-4)도 별도 분기 없이 자동으로 해소됨** — **backend가 직접 소유하는
핸들러(`Property`/`Event`/`Slot`류)에 한해** provider 미주입 상태는
결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로 결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로
"매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider "매치 실패"와 정확히 같은 경로로 수렴함. **[한정, 2026-08-18 `/code-review
미주입을 서로 다른 에러 종류로 구분할 필요가 없음. high` — `D-7` 재역전과의 정합성]** `Tag`/`Attribute`처럼 **base가
Fallback Handler를 자기 로드 시점에 스스로 등록하는 것**(위 문단,
"base가 소유하는 핸들러와 주입되는 엔진 op" 절)은 이 일반화의 예외다 —
백엔드가 하나도 없어도 그 Fallback Handler는 이미 레지스트리에 있으므로
**매치는 되고**, 실패는 "매치 실패" 에러가 아니라 그 자리에서 실행되는
주입 op 스텁의 명시적 에러(`addTag가 구현되지 않음...` 류)로 남
— "provider 미주입"과 "매치 실패"가 **에러 경로 자체는 다르지만 둘
다 명확한 에러로 수렴한다"**는 결론은 안 바뀜, 다만 오타 키/미지원
조합과 provider 미주입을 구분할 필요가 없다는 문장은 backend 소유
핸들러에만 해당한다.
- **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + - **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 +
전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로
sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은
@ -459,6 +469,13 @@ end
`PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass `PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass
하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는 하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는
`base/ref-plan.md`의 "`PostRef`" 절. `base/ref-plan.md`의 "`PostRef`" 절.
**[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회
전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에
`Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가
(pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤
`recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/
`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절이 소스.
**진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입
후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을
처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와
@ -511,8 +528,9 @@ function NilHandler.process(inst, k, v, index)
-- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다. -- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다.
-- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가 -- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가
-- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 — -- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 —
-- setLength가 끝에서 recompute를 돌리므로 반대로 하면 죽는 중인 서브트리의 -- setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- Source에 :Set()이 날아간다). [2026-08-18 감사에서 순서 정정] -- 반대로 하면 죽는 중인 서브트리의 Source에 :Set()이 날아간다).
-- [2026-08-18 감사에서 순서 정정]
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0) Dispatch.setLength(inst, k, 0)
return function() end return function() end
@ -581,16 +599,20 @@ end
전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은
더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서
소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`). 소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`).
- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 - **모듈 재생성(`Quad()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히
풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 풀림.**(**[정정, 2026-08-18 `/code-review high`] 헤딩이 옛 이름 `New()`
쓰고 있었음 — `architecture.md` 13번 항목과 같은 이유로 `Quad()`
통일, 아래 "[한정]" 문단이 이미 정확한 이름을 씀**) v1처럼 `require`
감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며
기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가
`BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을
그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에
딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된
것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`Quad()`가
생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로
스코핑됨, 재설계 불필요"). 다중 인스턴스화가 실제로 생기면 그 시점에 스코핑됨" — 단 아래 "[한정]" 문단대로 코드 손질은 필요, 재설계까지는
불필요). 다중 인스턴스화가 실제로 생기면 그 시점에
BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히
같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스
테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.
@ -1084,6 +1106,16 @@ end
중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐. 중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐.
조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서 조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서
`Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것. `Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것.
**9. `process` 안에서(또는 `process`가 부르는 컴포넌트 함수/`updateFn`
안에서) 코루틴 yield 금지(2026-08-18 신설, `/code-review high`로 이
불변식이 "Length/Offset" 절에만 묻혀 있던 걸 발견해 여기로도 끌어올림).**
아래 "Length/Offset" 절의 배치 게이팅(`Blocker`)이 "position이 항상
순서대로, 다른 코드가 끼어들 틈 없이 동기로 처리된다"는 전제 위에
서 있음 — 이 체인 도중 yield가 끼면 같은 owner의 `Blocker`를 다른
코드가 그 사이에 건드릴 수 있어 게이팅 순서 보장이 깨짐. 상세 근거는
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절.
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) ### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, **문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문,
@ -1142,8 +1174,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
`State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state<Slot>` `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state<Slot>`
교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출.
- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source<number>` - **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source<number>`
**스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 **스스로 만들어서** 등록. **[2026-08-18 구현 전 QA 2라운드 후속 —
하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우 `RC-1` 해결]** 예전엔 "Dispatch는 그냥 레지스트리에 넣어두기만 하고
`recompute`가 그 자리에 값을 `:Set()`한다"였는데, 이제 **등록되는 그
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 즉시
`:Set()`한다**(아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의
"`setOffsetSource`의 즉시 계산" 참고) — `recompute`는 이후 값이 바뀔 때
전체를 다시 계산하는 역할로 남는다. Slot이 매치되는 경우
이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래 이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래
참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이 참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이
등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정, 등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정,
@ -1189,8 +1226,9 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
`setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 반대면 위험함**: `setLength`가 끝에서 `gatedRecompute`를 경유해(배치
부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 게이팅 중이 아니면) `recompute`를 돌리므로, 먼저 부르면 그 `recompute`
아직 남아있는 옛 `Source`(지금 막 떼어내는
서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이
캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute` 캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`
`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 `offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이
@ -1229,6 +1267,14 @@ lazy 생성.
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래 의사코드를 배치
등록 중 안전하지 않게 만들던 크래시(`RC-1`)는 해결됨 — 해법은 "배치
등록을 안전하게 만드는 Blocker 게이팅" 절(바로 아래)이 소스, 여기
`recompute` 자체의 코드는 안 바뀜(off-by-one 수정 버전 그대로). 바뀐
건 **언제 호출되는가**뿐 — `setLength`/`setOffsetSource`가 새로 개입한다.
트레이싱 경위·논의 원문은 `qa-request/pre-implementation-qa-round2.md`
"RC-1" 절.
**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 **[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어
있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한 있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한
`offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한
@ -1308,11 +1354,22 @@ vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것.
일어나게 막음. 일어나게 막음.
**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), **`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`),
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: `:Subscribe()` 아님(2026-08-09 여섯 번째 세션).** **[재작성, 2026-08-18
구현 전 QA 2라운드 후속 — `RC-1` 해결]** `setLength`는 더 이상 `recompute`
직접 부르지 않는다 — State든 상수든 항상 아래 `gatedRecompute` 하나를
경유하고, 그 함수가 `blocker:IsOn()`을 확인해 배치 등록 중이면 건너뛴다
(Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이
게이트를 통과한다 — 사용자: *"setLength 는 recompute 를 직접 수행하진
않고, Observer 에서 recompute 를 수행해. 맨 처음 emit 에서도 blocker 가
on 이면 무시하는식"*). `blocker`가 무엇이고 어디서 오는지는 바로 아래
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절 참고 — 이 함수는 그
Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 않음,
그건 호출하는 배치 쪽 책임):
```lua ```lua
function Dispatch.setLength(inst, i, len) function Dispatch.setLength(ownerKey, i, len)
local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
local oldObserver = bk.observers[i] local oldObserver = bk.observers[i]
if oldObserver then if oldObserver then
@ -1322,23 +1379,122 @@ function Dispatch.setLength(inst, i, len)
bk.lengthList[i] = len bk.lengthList[i] = len
if isState(len) then local function gatedRecompute()
local observer = len:Observer(function() if not blocker:IsOn() then
recompute(inst, bk) recompute(ownerKey, bk)
end) end
bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님
bk.observers[i] = observer
end end
recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) if isState(len) then
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
bk.observers[i] = observer
else
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
end
end end
``` ```
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 `:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, `:Unsubscribe()`가 없으면 안 끊기므로 안 맞음. `bindLifetime`/
`inst` 생명주기에 자동 귀속)를 충족. `unbindLifetime`이 이미 이 요구(GC-native, `ownerKey` 생명주기에 자동
귀속)를 충족.
### 배치 등록을 안전하게 만드는 Blocker 게이팅 (2026-08-18, `RC-1` 해결)
**문제 재확인**: `bk.N`(그 owner의 array part 크기)은 배치가 시작되는
시점에 이미 정해져 있는데, `bk.lengthList[1..N]`은 각 position이 처리될
때마다 하나씩 채워진다 — 순차 처리 도중에 `recompute`가 돌면 아직 안
채워진 뒤쪽 position을 `nil`로 읽어 산술 에러가 난다(`Frame{A,B}`처럼
정적 자식 2개짜리도 재현됨, 트레이싱 상세는
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절).
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
직접 쓰는 두 번째 용례" 절 참고.
2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는
`gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉
**배치 도중엔 `recompute`가 단 한 번도 안 돈다**, 그래서
`bk.lengthList`의 빈 자리를 읽을 일 자체가 없다.
3. **`setOffsetSource`는 그동안 손 놓고 있지 않는다 — 등록되는 그
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 `:Set`한다**
(아래 "`setOffsetSource`의 즉시 계산" 참고). 배치가 항상 position을
순서대로(1,2,...,N) 처리하므로, position `i`를 등록하는 시점엔 `1..i-1`
이미 전부 끝나 있어 이 합산이 항상 정확하다. **이게 "recompute를
미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
배치 중이라도 항상 최신값을 보게 됨.
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
재귀 케이스)도 같이 확정시킨다.
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
스킵 — 참여 안 하는 자리는 계산할 게 없음):
```lua
function Dispatch.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source ~= None then
local sum = 0
for j = 1, i - 1 do
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
sum += (if isState(v) then v:Get() else v)
end
if source:Get() ~= sum then
source:Set(sum)
end
end
end
```
이건 `recompute`의 로직을 대체하는 게 아니라 **보완**한다 — 이 즉시
계산은 "지금 막 등록되는 이 position의 초기값"만 맞춰줄 뿐이고, 이후 어느
position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보다 뒤에
있는 모든 position의 offset을 다시 계산해야 하므로 여전히 `recompute`
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
**적용 지점 — `Dispatch.drive``attachSlot`, 각각 자기 owner 키로
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
중첩된 Slot마다 그 Slot 자신의 owner 키로 **별도** Blocker를 새로 만듦,
부모 Blocker 재사용 금지는 `base/blocker-plan.md`의 "재진입" 절 그대로).
**런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 흔한 패턴은
이 게이팅이 필요 없다** — 사용자 확인: *"그건 이미 마운트가 된
이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위
요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서,
setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함"* — 이미
마운트된 배치 밖에서 하나씩 추가되는 position은 그 앞의 모든 position이
이미 안정적으로 등록돼 있어 `nil` 자리를 만들 여지가 없고, 그 owner의
Blocker는 이미 `OffWithoutEmit()`으로 꺼진 채라 `gatedRecompute`
평소처럼 즉시 돈다.
**⚠️ 불변식 — `Dispatch.process`/`attachSlot` 호출 체인 도중에는 코루틴
yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체가
"position이 항상 1,2,...,N 순서대로, 다른 코드가 끼어들 틈 없이 동기로
처리된다"는 전제 위에 서 있다 — 이 체인 도중 어딘가(컴포넌트 함수,
`updateFn`, Handler 등) yield가 끼면, 아직 배치가 안 끝난 owner의 같은
`Blocker`를 다른 코드가 그 사이에 건드릴 수 있어(예: 다른 이벤트 콜백이
같은 owner에 `setLength`를 부르는 것) 배치 도중/직후의 게이팅 순서 보장이
깨진다. 사용자: *"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야
할듯. 안 그럼 꼬이는 문제가 발생하지 않나 생각함"* — 웹 백엔드처럼
`setLength`가 뒤섞이면 특히 골치 아파짐. 새 방어 로직을 넣는다는 뜻이
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 **동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:

View file

@ -174,10 +174,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기
Handler를 추가로 등록할 수 있음 — 상세는 Handler를 추가로 등록할 수 있음 — 상세는
`base/dispatch-core-plan.md`의 같은 절. **중복 호출 `base/dispatch-core-plan.md`의 같은 절. **중복 호출
가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 가드/`Quad()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 —
바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `Quad()`가
생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로
스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 스코핑됨(**[한정, 2026-08-18 `/code-review high`]** 위 "모듈 스코핑" 절의
정정과 맞춰 — "자동으로"는 아니고 module-level state를 참조하는 코드는
손을 봐야 함, 그 손질까지 하고 나면 이 가드 자체는 재설계 불필요라는
뜻). **이 결론이 Dispatch의 handler 레지스트리에도
그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** —
`base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절. `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절.

View file

@ -521,7 +521,8 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
function ProcessedPreRefHandler.process(inst, i, v) function ProcessedPreRefHandler.process(inst, i, v)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가 -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가
-- 끝에서 recompute를 돌리므로(`base/dispatch-core-plan.md`의 해제 순서 계약) -- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- (`base/dispatch-core-plan.md`의 해제 순서 계약)
Dispatch.setOffsetSource(inst, i, None) Dispatch.setOffsetSource(inst, i, None)
Dispatch.setLength(inst, i, 0) Dispatch.setLength(inst, i, 0)
return function() end -- no-op retract, 이 자리는 fire가 끝나 return function() end -- no-op retract, 이 자리는 fire가 끝나
@ -606,9 +607,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함.
전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, 전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isPreRef(v) end, process = isHandlable = function(inst,k,v) return isPreRef(v) end, process =
function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수 function(inst,k,v) error(`PreRef binding should be array index item,
있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)` but got {typeof(k)}`) end }`(**[2026-08-18, `/code-review high`
보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler" 누락 발견 — `PostRef`의 "동적 경로 가드 Handler도 거울상으로 하나 더" 절/
`effect-plan.md`의 "동적 경로 가드" 절과 짝을 맞춤]** 에러 메시지에
실제 `k` 타입을 실을 것) — `k` 타입은 안 가림(숫자든 문자열이든
`isPreRef(v)`만 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만
전담하는 Handler"
패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는 패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는
`HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라 `HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라
`Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서 `Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서

View file

@ -359,8 +359,9 @@ function SlotHandler.process(inst, k, slotValue, index)
-- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음. -- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음.
unmountSlotTree(slotValue) unmountSlotTree(slotValue)
-- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정** -- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정**
-- (setLength가 끝에서 recompute를 돌리므로 offsetSource를 먼저 비워야 -- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고) -- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감,
-- 아래 ⚠️ 절 참고)
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0) Dispatch.setLength(inst, k, 0)
unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝 unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
@ -847,9 +848,10 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
되는 식): 되는 식):
- **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환. - **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환.
"지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면 "지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면
**[정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트됨**(단순 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전
`Visible = false`도 아님 — 위 "구현" 절 `rawUnmount` 참고, 이 — "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible =
문단 작성 당시엔 아직 destroy 모델이었음). 편의상 false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시
반환해야 대신 언마운트+재사용됨). 편의상
`nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 `nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
`nil`/`None` 금지와 안 부딪힘). `nil`/`None` 금지와 안 부딪힘).
@ -974,12 +976,26 @@ end
**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면 **해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면
그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil` 그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil`
반환으로 **[정정, 2026-08-13 3차 감사] 언마운트**되게 함(위 캐비엇대로 반환으로 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로
파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 재역전 — 아래 참고] 언마운트**되게 함(위 캐비엇대로 파괴 아님) — Visible
통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면
존재하지 않음(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음
GC되기 전까지 `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 (애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지
있음 — 위 캐비엇 참고). `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇
참고).
**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale
서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로
쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil`
리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소,
`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로
`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고,
"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면
`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이
절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제
자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은
`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위
"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것.
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — **"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
@ -1457,11 +1473,32 @@ nil/None 금지)는 그대로.
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그
해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래
코드의 `blocker` 관련 줄).
`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와 weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와
중첩 마운트가 완전히 같은 함수 호출**이 됩니다. 중첩 마운트가 완전히 같은 함수 호출**이 됩니다.
**[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음
`setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.**
`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출
순서는 `setOffsetSource``setLength`"** 일반 규칙(해제 시점 계약에서
나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속
어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각
요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset
전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length``activateList`
자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그
전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는
**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야
확정된 값으로 등록) → 물리 마운트**:
```lua ```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position) local function attachSlot(slot, physicalTarget, ownerKey, position)
@ -1475,24 +1512,38 @@ local function attachSlot(slot, physicalTarget, ownerKey, position)
slot._mounted = true slot._mounted = true
slot._mountedInst = physicalTarget slot._mountedInst = physicalTarget
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
local offsetSource = Source(0) local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource) Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
slot.Offset = offsetSource slot.Offset = offsetSource
if slot._listed then if slot._listed then
activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜 activateList(slot, physicalTarget) -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨
end end
-- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에 Dispatch.setLength(ownerKey, position, slot.Length) -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음
-- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님)
-- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush —
-- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의
-- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은
-- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로
-- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
-- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
for i, element in ipairs(slot._elements) do for i, element in ipairs(slot._elements) do
if isSlot(element) then if isSlot(element) then
attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신 attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신
else else
-- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
-- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1)
element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행 element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행
end end
end end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if bk then recompute(slot, bk) end
end end
``` ```
@ -1513,6 +1564,15 @@ end
-- attachSlot될 때 위 flush 루프가 처리 -- attachSlot될 때 위 flush 루프가 처리
``` ```
**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인,
2026-08-18)** — *"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운
개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷
요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안
받고 수행 가능함"* — 이 시점엔 `self`의 Blocker가 이미 flush 배치를
끝내고 `OffWithoutEmit()`으로 꺼져 있고, 새로 등록되는 position보다
앞선 모든 position은 이미 안정적으로 채워져 있어 `nil` 자리가 생길
여지 자체가 없다.
`recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록 `recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록
확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더 확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더
이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested 이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested
@ -1983,9 +2043,12 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
(2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할 (2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할
때는 이 순서를 반드시 지켜야 함**: 때는 이 순서를 반드시 지켜야 함**:
- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute` - `Dispatch.setLength`는 끝에서 `gatedRecompute`를 경유해(배치 게이팅
`sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 중이 아니면) `recompute`를 돌리고, `recompute``sourceList`
(`base/dispatch-core-plan.md` "Length/Offset" 절). 순회하며 각 자리의 `offset:Set(sum)`을 호출함(`base/dispatch-core-plan.md`
"Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히
일어나므로 이 경로에서는 `gatedRecompute`가 거의 항상 즉시 `recompute`
이어짐).
- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 - 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는
시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset
`Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에

View file

@ -71,8 +71,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
`type-recursive-issue-try-callback/` 등). `type-recursive-issue-try-callback/` 등).
- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 - `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
여기 둠(`pre-implementation-qa-round1.md`, 2라운드 예정 — 라운드마다 새 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`
파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 둘 다 **완료** — 라운드마다 새 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
**[2026-08-18 기준] 폴더 자체가 아직 없음**. **[2026-08-18 기준] 폴더 자체가 아직 없음**.
`.claude/archive/`는 원래 같은 취급이었으나 `.claude/archive/`는 원래 같은 취급이었으나
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전

View file

@ -336,7 +336,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
어긋난다**(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이 어긋난다**(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이
필요한 항목. 필요한 항목.
### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ⚠️ 재확인 필요 ### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ✅ 해소(같은 세션 내 재역전)
- **판정**: 아니오(잠정) — 사용자는 **quad-base 로드 시 등록이 맞다고 했었다**고 - **판정**: 아니오(잠정) — 사용자는 **quad-base 로드 시 등록이 맞다고 했었다**고
기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함. 기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함.
@ -359,6 +359,18 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
`archive/tag-attribute-load-time-registration-reversed.md`를 되살릴지 `archive/tag-attribute-load-time-registration-reversed.md`를 되살릴지
아니면 새로 쓸지, (c) `InitNamespace` 거부 원칙과 어떻게 양립시킬지를 아니면 새로 쓸지, (c) `InitNamespace` 거부 원칙과 어떻게 양립시킬지를
같이 적어주시면 좋겠음. 같이 적어주시면 좋겠음.
- **해소(같은 세션 내, 2026-08-18) — (a)/(b)/(c) 전부 답변됨**:
(a) **최종은 로드 시 등록** — "등록 주체는 다시 quad-base 자신이다
(모듈이 자기 레지스트리를 구성하는 시점)"으로 재역전 확정. (b) 새로
안 쓰고 **기존 `archive/tag-attribute-load-time-registration-reversed.md`
재역전 배너만 추가**(그 문서 자체가 "이번에 재역전됐다"는 걸 스스로
알림 — 새 archive 문서 생성 없음). (c) `InitNamespace` 거부 원칙과의
양립: 그 원칙이 금지한 건 "사용자가 수동으로 init을 호출하게 만드는
것"과 "모듈 로드 시 *남의* 상태를 건드리는 것" 둘인데, base가 **자기
모듈 안의 자기 레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니므로
충돌 없음. 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "base가
소유하는 핸들러와 주입되는 엔진 op" 절의 "[재역전, 2026-08-18 구현 전
QA — 사용자 확정]" 배너가 소스.
--- ---

View file

@ -0,0 +1,340 @@
# 구현 전 QA **2라운드** — 의사코드 손 트레이싱
**상태**: **완료 — 핵심 발견(`RC-1`)까지 같은 날 후속 세션에서 해결·
`base/` 반영 완료.** 1라운드는 "확정된 주장이 맞는가"를 물었을 뿐 의사코드를
실제로 실행해보진 않았음(`pre-implementation-qa-round1.md` 맨 아래 "진행
로그" 절). 2라운드는 그 갭을 메우는 작업 — `base/slot-plan.md``:List`
`reconcile``base/dispatch-core-plan.md``recompute`를 구체 시나리오로
직접 손으로 실행해보고, 부수로 `reference/` 인용 정확성과 `ROADMAP.md`
마일스톤 정합성도 훑었다.
**이 문서의 용도**: 1라운드와 달리 "예/아니오 판정" 문항이 아니라 트레이싱
결과 자체가 산출물 — 버그를 찾으면 그 자리에서 기록하고, 방향이 갈리는
것만 사용자에게 물었다. 새로 발견된 결함은 `RC-1` 하나뿐이고, 나머지는
"트레이싱했지만 문제 없음 확인"으로 아래 각 절에 남긴다(재작업 방지용
기록 — `conventions.md`의 "작업이 끝나면(또는 방향이 바뀌면) 항상 자기
문서화" 원칙).
---
## RC-1 — `recompute`의 트리거 모델 자체가 재검토 대상 ✅ 해결(2026-08-18 후속 세션)
**판정**: 트레이싱으로 실제 크래시 경로를 확인 — 사용자도 진단은 맞다고
동의했고, 같은 날 후속 대화에서 **Blocker를 재사용하는 배치 게이팅
설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제
반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는
Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다.
### 발견한 크래시 경로
`base/dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때
순서 보장" 절의 `recompute` 의사코드:
```lua
local function recompute(ownerKey, bk)
local sum = 0
for i = 1, bk.N do
local offset = bk.sourceList[i]
if offset ~= nil and offset ~= None and offset:Get() ~= sum then
offset:Set(sum)
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v) -- ← v가 nil이면 여기서 산술 에러
end
...
end
```
**구체 시나리오 — `Frame{A, B}`(정적 자식 2개, 반응형 없음, `N=2`).**
1. `Dispatch.drive(inst, flattened)`가 배열 파트를 순서대로 순회 —
`bk.N`은 "그 `inst`의 array part 크기" 이므로 순회를 **시작하는 시점에
이미 `2`로 정해져 있음**(같은 절의 "저장 위치" 문단).
2. position 1(`A`)을 담당하는 말단 Handler가 `Dispatch.setLength(inst,1,1)`
부름. `setLength`의 의사코드는 **끝에서 무조건 `recompute(inst,bk)`
호출**(같은 절 "`setLength` 구현" 문단 — "등록 즉시 1회(Observer 자체의
'등록 즉시 1회 실행'과 겹쳐도 무해)").
3. 이 시점의 `recompute` 루프가 `i=1..bk.N(=2)`을 돎 — `i=1`
`bk.lengthList[1]=1`이라 정상. `i=2`는 **position 2(`B`)가 아직 처리되기
전이라 `bk.lengthList[2]``nil`** — `sum += (if isState(v) then
v:Get() else v)`에서 `sum += nil`이 되어 산술 에러로 크래시.
`sourceList`(offset) 쪽은 같은 절에 이미 nil 가드가 있음(*"[방어,
2026-08-13 여섯 번째 세션] `nil`도 같이 배제"*) — `lengthList` 쪽엔 그
대칭 가드가 없다. 정적 자식 2개짜리 `Frame`처럼 **가장 흔한 경우**에서
바로 재현되는 경로라, 별도 `Slot`/반응형 값 없이도 걸림.
### 왜 지금까지 안 잡혔나
기존 `luau-test/` 스파이크 중 이 함수를 다루는 건
`done/20-slot-splice-index-arithmetic.luau` 하나뿐인데, 이건 `Splice`
순수 배열 인덱스 산술(제거/삽입 시 뒤 요소가 몇 칸 밀리는지)만 검증하고
**`Dispatch.drive`가 여러 position을 순차 처리하며 `bk.lengthList`를 점진적으로
채우는 과정 자체는 다루지 않음** — 이 경로를 실행해본 스파이크가 없었다.
같은 함수의 다른 버그(offset이 자기 자신을 포함해 누적되던 off-by-one)는
2026-08-11 세션에 이미 한 번 발견·수정됐지만(같은 문서 "recompute — 매번
전체 순회" 문단), 이번 것은 그와 별개의 문제.
### 사용자 답변 원문 — 진단은 맞다고 확인, 그러나 더 큰 재설계 필요
> setLength/setOffset 자체는 리레이아웃을 트리거하진 않아야한다고 생각함.
> 명시 리트리거를 해야하는게, 이러면 첫 실행에서 계속 recompute 비용이
> 쌓임. 옵져버 생성 시 클로저 위쪽에 init = true 두고 처리할 필요가
> 있는듯 하고, 각각의 process 가 setLength 를 나중에 수행했을 때는
> recompute 를 어떻게 할지 생각해봐야할듯. 등록을 배치로 미룸은 맞는데,
> State<None|Slot> 이 오는건 어쩌냐를 잘 모르겠음. 각각 처음에 바운딩
> 할 땐 그럼 offset/length 어떻게 계산할지는 이것도 아직 모르겠음..
> 생각을 더 해
**애초에 열려 있던 세 갈래**(사용자가 처음엔 하나로 안 좁힘) — (1)
`setLength`/`setOffsetSource` 자체는 `recompute`를 트리거하지 않아야
한다(명시적 리트리거 필요), (2) 등록을 배치로 미루는 방향은 맞지만
`State<Slot|None>`처럼 값이 나중에 도착하는 경우를 어떻게 커버할지 불명,
(3) 최초 바인딩 시점의 offset/length 계산 방식 자체가 안 잡힘 — 아래가
같은 날 후속 대화에서 이 세 갈래를 하나로 합친 결과다.
### 해결 — Blocker 게이팅 (2026-08-18, 같은 날 후속 세션)
**사용자가 직접 제시한 설계 원문**:
> 각각의 length 들을 그냥 받아서 바인딩 하는게 아니야. 각 Frame 에 대한
> Blocker 를 릴레이션으로 가지고 있고 이건 드라이빙 함수가 실행될 때
> 생성돼. Offset/Length 소스가 설정 될 때, 특히 Length 에 있어서는 이
> Frame->Blocker 로 있는걸 얻어와서 한번 적용하고 넣어둬. 그리고 Observer
> callback 에서도 Blocker 가 IsOn 상태면 무시해줘. 드라이브 함수가
> 실행되어 Blocker 가 생성될 때 기본으로 On 을 해줘. 이러면 length 가
> 변경되며 계속 recompute 되지 않아. 그런 다음 OffWithoutEmit 을 해.
> 맨 마지막으로 recompute 를 한번 하면 되는식이야.
>
> 다만, 이러면 초기에 레이아웃이 이상해져. 주의할 점은 setOffsetSource
> 를 하게 되면 이건 자기 자신 위쪽으로 있는 요소들의 length 를 합해서
> 설정해줘야할거야. 이건 첫 for 루프에서도 작동한다고 봄. 즉, 사실 첫
> 루프 상 recompute 자체는 필요 없어. 그리고 setOffsetSource 는 여전히
> slot 의 실체화로 List 가 수행되기 전에 설정되어야하고, length 가 확정
> 된 다음에 다음 요소로 넘어가야해.
**요지 두 갈래로 나뉜다**:
1. **`recompute`를 배치가 끝날 때까지 아예 안 돈다** — owner(inst 또는
Slot)마다 `Relate`로 들고 있는 전용 `Blocker`를 배치 시작 시 `On`,
`setLength`의 Observer 콜백(등록 즉시 1회 실행 포함)이
`blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 끝나면
`OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
2. **`setOffsetSource`는 그 즉시 앞선 형제들의 길이 합을 직접 계산해
`:Set`한다** — recompute를 미루는 것만으로는 "초기 레이아웃이
이상해지는" 문제(배치 도중 `:List`가 실체화되며 옛/기본값 offset을
읽어버림)가 남기 때문에, offset 자체는 즉시 계산으로 옮겨 recompute를
기다리지 않게 함.
**뒤이은 확인 질문 3개와 답변**(`AskUserQuestion`, 같은 세션):
- **중첩된 Slot이 `attachSlot`을 재귀할 때도 각자 별도 Blocker가
필요한가?** → *"예 — 중첩마다 별도 Blocker (권장)"*
`base/blocker-plan.md`의 재진입(네스팅) 미지원 규칙 그대로 적용.
- **런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 경우도
같은 위험이 있는가?** → *"그건 이미 마운트가 된 이후라서 별 상관
없음. 가장 큰 문제는 마운트 중간에 후행 nil 이 있는데 recompute 가
난다는게 문제... 정확한 내 의견은 이래: setLength 는 recompute 를
직접 수행하진 않고, Observer 에서 recompute 를 수행해. 맨 처음 emit
에서도 blocker 가 on 이면 무시하는식. 그럼에도 새로운 개체가 뒤에
붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를
밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고
수행 가능함"* — 크래시는 오직 "N이 미리 정해진 채 배치로 등록"되는
두 자리(`Dispatch.drive`, `attachSlot`의 flush)에서만 나고, 런타임
단건 append는 이미 안정된 앞선 position만 참조하므로 무관함이 확정.
`setLength``recompute`를 직접 안 부르고 Observer 콜백(첫 실행
포함)만을 경유한다는 것도 이 답변에서 확정됨.
- **`IsOn`/`HasBlocked`를 기존 `IsBlocked`/`HasBlockedEmit`과 어떻게
관계지을까?** → *"IsBlocked가 있다면 그냥 두어도 될듯 함.
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 기존 필드는 그대로 두고,
`IsOn()``IsBlocked`를 읽는 얇은 조회 메소드로만 추가. 처음 요청했던
`HasBlocked`(Blocker 자신의 새 최상위 플래그)는 **신설하지 않음**
`OffWithoutEmit()`이 각 gated state의 기존 `HasBlockedEmit`을 그대로
리셋해주는 것으로 충분하다고 판단.
### 후속 정정 — `attachSlot`의 호출 순서가 뒤집혀 있었음 (같은 세션, RC-1 반영 직후)
위 설계를 `attachSlot`에 실제로 반영하는 과정에서 사용자가 직접 짚은
추가 결함: `attachSlot`의 기존 의사코드는 `setLength`를 먼저, `setOffsetSource`
나중에 불렀는데, 이건 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이
이미 확정해둔 **"호출 순서는 `setOffsetSource``setLength`"** 일반
규칙과 어긋나 있었다(RC-1로 `setOffsetSource`가 즉시 계산을 하게 되면서
이 불일치가 드러남 — 그 전엔 둘 다 `recompute`에 얹혀 있어서 순서가
겉으로 안 드러났었다). 사용자 확정 원문:
> length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset
> 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨. 따라서 위가
> 맞음
즉 Slot의 진짜 `.Length``activateList`가 자기 `:List`를 최초
reconcile한 **뒤에야** 확정되므로, 순서는 **`setOffsetSource`(즉시 계산)
→ (Slot이면) `activateList` 실체화 → `setLength`(그제서야 확정된 값으로
등록) → 물리 마운트**여야 한다 — `setLength`를 실체화 전에 부르면 등록
직후 값이 또 바뀌어 전파가 한 번 낭비된다. 평범한 Instance 요소도 같은
순서(`setOffsetSource(None)` → `setLength(1)``Parent` 대입)를 따르며,
이 경로는 기존 의사코드에 아예 안 보이던 것도 이번에 같이 채워짐.
**부수 확정 — 코루틴 yield 금지 불변식.** 사용자가 이 논의 말미에 지적:
> 모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 할듯. 안 그럼
> 꼬이는 문제가 발생하지 않나 생각함
이 배치 게이팅 전체가 "position이 항상 순서대로, 끼어드는 코드 없이
동기로 처리된다"는 전제 위에 있어서, `Dispatch.process`/`attachSlot`
호출 체인 도중 코루틴 yield가 끼면 같은 owner의 Blocker를 다른 코드가
그 사이에 건드릴 수 있다 — 명시적 불변식(UB 선언)으로 문서화하기로 확정.
**반영된 곳**:
- `base/blocker-plan.md``IsOn()`/`OffWithoutEmit()` 신설, onunblock
핸들이 `emit: boolean`을 받도록 변경, `state:Block()` 없이 직접 쓰는
용례 신설, 재진입 규칙에 이 용례의 실제 사례 추가.
- `base/dispatch-core-plan.md` — "Length/Offset" 절에 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절 신설(`setLength`/`setOffsetSource`
재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈), 코루틴
yield 금지 불변식 추가.
- `base/slot-plan.md` — "재귀 메커니즘" 절의 `attachSlot`이 자기 flush
루프를 자기 자신의 Blocker로 감싸도록, 그리고 `setOffsetSource`→`setLength`
순서로 재작성(평범한 Instance 요소의 등록도 명시적으로 채움), 런타임
단건 `Add` 경로는 게이팅 불필요함을 명시.
---
## SL — `base/slot-plan.md``:List` reconcile 트레이싱 — 새 결함 없음, 기존 미해결 갭만 재확인
`base/slot-plan.md`의 "구현" 절(`activateList`/`reconcile` 의사코드)을 아래
시나리오로 손으로 실행:
- **재정렬**(`[A,B,C]` → `[C,A,B]`, 값 동일) — `pos`/`keyIndex` 비교가
`rawMove`를 정확한 절대 위치로 호출, 정상.
- **키 제거**(`[A,B,C]` → `[A,C]`) — 소멸 루프가 `keyIndex`(직전 사이클 전체
key 집합)를 순회해 `B``rawRemove`, 나머지 `pos` 압축도 정상.
- **필터 토글**(`updateFn`이 특정 key에 `nil` 반환) — `rawRemove`(파괴)
경로를 타고, `pos`가 그 키만큼 증가 안 해 뒤 요소가 정상 압축됨.
- **`PopOnly` 반환 후 재등장** — `mounted[key]=nil`이지만 `userdata[key]`
`{old=...}`를 강하게 붙잡아 GC를 막고, 다음 사이클에 `prev=nil`
받은 `updateFn``ud.old`를 그대로 반환하면 `rawAdd`로 재마운트됨 — 문서
서술대로 정상 동작.
- **nested Slot 반환**(`isSlot(result)`) — `pos = candidateIndex - 1 +
result.Length:Get()`으로 다음 형제의 위치가 정확히 밀림, "`:List`의
`index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함" 절의 결론과
일치.
- **중복 key**`seen[key]` 체크가 `updateFn` 호출 *전에* 있어 즉시
`error`, 상태 오염 없음.
**PopOnly 홀드 중 키가 데이터에서 완전히 사라지는 경우**만 트레이싱으로도
재현됨 — 소멸 루프가 `mounted[key]`(이미 `nil`)만 보고 `rawRemove`
건너뛰어, 그 요소가 파괴도 반환도 안 되고 참조만 끊겨 GC된다. 이건 **이미
`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절과 `question.md`
3번(`PopOnly`로 홀드 중이던 요소의 키가 사라지면 어떻게 처분하는가)이
알고 있는 미해결 갭** — 이번 트레이싱은 그 갭이 실제로 재현됨을 손으로
다시 확인했을 뿐, 새 발견이 아니다. 결론은 그대로 `question.md` 3번의
(a)/(b)/(c) 선택지에 맡긴다.
---
## D — `base/dispatch-core-plan.md`의 하강 diff 재디스패치 트레이싱 — 문제 없음
"Dispatch 체인" 절의 `Dispatch.process`/`Dispatch.retractFrom`을 아래
시나리오로 트레이싱:
- **최초 마운트**(`store.key = a`, `a: State<Tag>`) — `StoreBind``index=1`
잡고 `a:Get()`을 들고 `Dispatch.process(inst,k,realv,2)`를 재귀, `chains`
`[1]=StoreBind`, `[2]=TagHandler`가 순서대로 쌓임. 정상.
- **같은 핸들러로 값 교체**(`a`가 새 `Tag`를 내놓음) — `Dispatch.process`
(A) 분기가 인덱스 2 슬롯의 기존 `retractor`에 새 값을 넘기고 클로저를
교체, 인덱스 1은 안 건드림. `chains` 구조·재귀 깊이 전부 문서 서술과
일치.
- **`State<State<Tag>>`** — 안쪽 재귀가 `index+1`이라는 별개 슬롯을 쓰므로
StoreBind 싱글톤이 같은 `(inst,k)`에 두 번 매치돼도 슬롯이 안 겹침 —
"`State<State<T>>`는 정상 지원 대상" 절의 결론과 일치.
새로 발견된 문제 없음.
---
## `reference/` 인용 정확성 — 표본 점검, 불일치 없음
`base/` 문서가 `reference/quad-v1-architecture.md`/`reference/
comparison-fusion-vide.md`를 인용하는 자리 중 표본 3곳을 원문과 대조:
- `base/lifecycle-pattern.md`가 v1의 GC 방지 핫팩을 인용한 자리 — 원문
(`PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 관용구)과
일치.
- `base/slot-plan.md`가 v1 `mount.lua`를 분석한 자리("부모/자식 부기까지
했지만 다중 마운트 방지는 없었음") — 원문(`mount.lua`는 실제로 부모/자식
부기(bookkeeping) + 라이프사이클 파괴까지 담당하는 무거운 모듈)과
정합(다중 마운트 방지 부재는 문서 전체 맥락상 타당한 요약).
- `base/tween-plan.md`가 Fusion의 Tween/Spring을 반면교사로 인용한 자리 —
`reference/comparison-fusion-vide.md`의 해당 문구와 일치.
전수 대조는 아니고(`reference/`를 인용하는 자리 전체는 `base/*.md`에서
`reference/`로 grep한 결과 10여 곳 — 정확한 개수는 grep 결과 자체가
소스), 표본에서 불일치가 안 나와 전수 대조로 확장하지 않음.
## `ROADMAP.md` 마일스톤 정합성 — 1라운드 반영분 확인, 불일치 없음
1라운드에서 뒤집힌 것 중 `ROADMAP.md`가 언급하는 것들이 갱신됐는지 확인:
`NilHandler`/`NoneHandler` 분리(**[정정, 2026-08-18 `/code-review high`]
M2가 아니라 M7 — Modifier 마일스톤에 체크박스가 있음, M2엔 서술 문단
안에서만 이름이 언급될 뿐 별도 체크리스트 항목이 없음**), `PopOnly` 반환
경로(M6), `store:GetDynamic` 탑레벨/콜론 미정 표시(M3), `DI`→`D`
리네임(전역) — 전부 반영 확인됨.
`D-6`(`setLength`/`setOffsetSource` 호출 책임자를 "처음 매치한 Handler"로
서술하던 옛 오류)의 흔적도 `ROADMAP.md`엔 없음(애초에 그 정도 구현
디테일은 `base/`만 갖고 있고 `ROADMAP.md`는 체크박스 수준이라 옮겨붙을
자리가 없었음).
---
## [중간 상태 기록, Blocker 해법 확정 이전] 반영
**⚠️ 이 절은 낡았다 — RC-1이 아직 "미해결"이던 시점(사용자가 "생각을 더
해"라며 방향을 안 정했을 때)에 세워둔 임시 조치 목록이다.** 그 뒤 같은
세션 후속 대화에서 Blocker 게이팅 설계가 확정되며 아래 내용 대부분이
다시 뒤집히거나 대체됐다 — **지금 유효한 반영 목록은 위 "해결 — Blocker
게이팅" 절의 "반영된 곳" 문단이 소스**, 여기서 다시 정리하지 않는다.
`question.md`는 이 중간 단계에서 `RC-1`을 추가했다가 해소 단계에서
다시 뺀 것이라 최종 diff엔 흔적이 안 남는다(2026-08-18 감사에서 확인) —
다음 세션이 "question.md가 바뀌었어야 하는데 안 바뀌었다"고 오해하지
않도록 남겨둠. 아래는 그 시점의 원문 그대로 보존(역사 기록):
- `question.md` 3번에 `RC-1`을 M2 착수 전 결론 필요 항목으로 추가(→ 이후
해소되며 제거, `archive/question-resolved.md`로).
- `.claude/todos.md` 00번(구현 전 QA 결과 요약)의 미해결 목록에 `RC-1`
추가, 머리말을 "M2/M3 착수 전 필요"로 갱신(→ 이후 해소 반영으로 다시
갱신).
- `base/dispatch-core-plan.md`의 "Length/Offset" 절 `recompute`/`setLength`
**의사코드 자체엔 손대지 않음**(nil 가드 같은 국소 수선도 넣지 않기로
함) — 대신 그 절 바로 위에 미해결 배너를 추가(→ 이후 의사코드 자체가
Blocker 게이팅으로 재작성됨).
- `base/slot-plan.md`의 "재귀 메커니즘" 절 서두에 `RC-1` 포인터 추가(→
이후 `attachSlot` 의사코드 자체가 재작성됨).
- `ROADMAP.md` M2/M6 체크박스 3곳에 `RC-1` 경고/각주 추가(→ 이후 해결
표시로 갱신).
- `.claude/README.md`/`project-context.md`의 `qa-request/` 서술을 round2
진행 중 상태로 갱신(→ 이후 완료 상태로 갱신).
---
## 진행 로그
**2라운드(2026-08-18) — `:List` reconcile + `recompute` 손 트레이싱, `reference/`
표본 대조, `ROADMAP.md` 정합성 확인, 그리고 같은 날 후속 세션에서 `RC-1`
해결까지 완료.** 1라운드가 "아직 안 본 것"으로 남겨둔 항목 중:
- `slot-plan.md``:List` 내부 — **트레이싱 완료**(위 "SL" 절).
- `dispatch-core-plan.md``recompute` — **트레이싱 완료, `RC-1` 발견 →
같은 날 Blocker 게이팅 설계로 해결**(위 "해결 — Blocker 게이팅" 절).
- `reference/`**표본 점검 완료**(전수는 아님, 위 참고).
- `research/` 13개(1라운드 기록 당시 11개였으나 같은 날 세션 중
`fastscroll-plan.md`/`spring-plan.md`가 추가돼 지금은 13개 —
정확한 개수는 `research/` 폴더 자체가 소스) — **이번 라운드에서도 제외**,
확정 전 문서라 우선순위 낮음(`.claude/README.md`의 `research/` 표 참고).
- 루트 `ROADMAP.md` 마일스톤 분할 — **확인 완료**(위 "ROADMAP.md 마일스톤
정합성" 절).
**남은 일**: 없음 — `RC-1`까지 닫혀 2라운드 자체가 완료됐다. 3라운드가
필요해지면(예: 이번에 새로 들어간 `attachSlot`/`recompute`/`Blocker`
의사코드를 다시 손으로 트레이싱하는 검증) 새 파일
`pre-implementation-qa-round3.md`를 만들 것.

View file

@ -177,8 +177,10 @@
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata` `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를 `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **M8 착수 전 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
필요**, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절. 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
기본" 절.
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — - **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼

View file

@ -5,17 +5,27 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등). (`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설, 같은 날 반영 완료] 구현 전 QA 결과는 00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2라운드 전부
`base/`에 전부 반영됐다 — 남은 건 아래 "결론 전에 착수 금지" 항목뿐.** `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과(원본 문답과 문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
사용자 답변 원문은 `.claude/qa-request/pre-implementation-qa-round1.md` `.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
소스), 확정으로
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면 반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면
반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다. 반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다.
2라운드는 `:List``reconcile`/`recompute` 같은 확정 의사코드를 실제로
손으로 실행해보는 작업(원본과 진행 로그는
`.claude/qa-request/pre-implementation-qa-round2.md`가 소스) — `recompute`
트레이싱에서 `Frame{A,B}`처럼 정적 자식 2개짜리도 첫 마운트에 크래시하는
경로(`RC-1`)를 찾았고, 같은 날 후속 대화에서 사용자가 직접 제시한
Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다
(`archive/question-resolved.md`의 `RC-1` 절).
**M0/M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다** — 전부 **M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다**(M0/M2는 여전히
`question.md` 3번에 올라가 있고, 각 `base/` 문서에도 ⚠️로 표시돼 있다: 막혀 있지 않음, 0번 항목 참고) — 대부분 `question.md` 3번에도 올라가
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
@ -26,7 +36,10 @@
`:Unsubscribe()` 절) — M3 착수 전 확인. `:Unsubscribe()` 절) — M3 착수 전 확인.
- **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림.
- **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) —
지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. M8 착수 전 필요. 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정,
2026-08-18 `/code-review high``ROADMAP.md`의 M6 PopOnly 체크박스와
대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`)
아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.

View file

@ -184,7 +184,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
(2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset" (2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset"
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소).
**[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음
채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로
해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔
`recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는
`base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker
게이팅" 절.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
@ -416,6 +422,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>` dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ - [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션, `Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12 2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
@ -528,6 +536,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/ 숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 `removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절). 불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로 - [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음). 확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상 `initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상