design: ROADMAP 마일스톤 경계 재편 + 전반 stale 정리

`ROADMAP.md` 전문을 `base/`의 현재 확정과 대조한 전반 점검. 사용자 판단으로
마일스톤을 각주가 아니라 체크박스째 재편했다.

## 마일스톤 이동 (M3/M7 → M2)

`EpochMap.luau` / `state:Gate`+`GateNode` / `Blocker.luau` / `None`+
`Dispatch/None.luau`. 넷 다 M2가 실제로 호출하는데 각주로만 예고돼 있었고,
`GateNode`는 체크박스 자체가 없어 M2를 훑는 구현자에게 항목으로 보이지
않았다. `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던 전례와 같은 처리.
`Blocker`는 `GateNode` 위의 정책으로 얹는다(노드를 다시 만들지 말 것).

##  새 미결 — M2와 M3의 의존이 양방향

이동하다 드러났다. `Dispatch.setLength`가 `State<number>`, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부르며, `Dispatch.drive`
자신도 배치 등록을 Blocker로 게이팅한다 — 즉 M2는 `Source.luau`/`State.luau`
없이 구현이 안 된다. 설계가 아니라 마일스톤 순서 문제라, 선택지 셋((a) 순서
교체 / (b) M2 분할 — 경계선은 `drive` / (c) 유지)을 `question.md` 2번으로
신설하고 유일한 소스로 지정했다. `HUMAN_TODO.md` 11번이 사용자 진입점.

## 모순 정정

- `Dispatch.drive` 순회 구현은 두 패스가 아니라 단일 일반화 `for`(`F-4-1`).
  "배열→해시 먼저"는 그 루프가 지키는 계약. 코퍼스의 "두 패스"는 본체 루프의
  옛 이름으로 정리(시점 표기는 유지, 용어 각주 신설). 따름정리로 "base는
  언어 동작에 안 기댄다"는 서술이 거짓이 됐고, 재작성될 스파이크 `01`이
  검증할 것도 언어 동작 자체로 바뀐다
- 물리 조작 주입 op 이름은 `native*` 확정(옛 가칭 `mountInst`/`unmountInst`/
  `disposeInst` 폐기). 단건 경로 순서는 `setOffsetSource` → `nativeInsert` →
  `setLength` → `recompute` — 역전 배너를 스스로 단 절 안에 옛 "부기 먼저"
  주장이 두 문단 살아 있었다
- M2 첫 체크박스가 하강 diff와 폐기된 옛 모델을 한 불릿에서 둘 다 서술

## 상태 표시

- `[x]`는 "짜야 할 코드"만. 설계 확정은 `### 확정된 것` 절 또는 전용
  불릿으로 분리(M3/M6/M11) — 이제 `[x]`는 M0/M1에만 남는다
- M0에 `### 재검증 대기` 절 신설 — 설계 변경으로 무효화된 스파이크들이
  어느 체크박스에도 없어 잊히기 쉬웠다(현황의 소스는 `luau-test/STATUS.md`)
- 주입 op 개수 하드코딩을 네 문서에서 걷고 `architecture.md`의
  `EngineOps.luau` 줄 하나로 단일화(그 줄에 빠져 있던 시간 op도 채움)

## 검증

`quad-doc-auditor` 10라운드(각도를 매번 바꿔 34건) + 사용자가 돌린
`/code-review high` 9건. `doc-check.py` ERROR 0. 경위와 실측된 실패 패턴은
`.claude/session/2026-08-22-01-roadmap-milestone-review.md`.

Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-22 03:19:13 +09:00
parent 0498816c10
commit 9622ccd3e9
Signed by: qwreey
GPG key ID: D28DB79297A214BD
19 changed files with 858 additions and 322 deletions

File diff suppressed because one or more lines are too long

View file

@ -261,9 +261,10 @@ quad/
│ ├── pesde.toml │ ├── pesde.toml
│ └── src/ │ └── src/
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션) │ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속 │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 │ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate``GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`) │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`) │ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`)
@ -290,8 +291,8 @@ quad/
└── quad-roblox/ └── quad-roblox/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/ └── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백** (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/ ├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)

View file

@ -18,12 +18,17 @@
**store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State **store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State
위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State 위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함.
State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. **⚠️ [2026-08-22 정정] 구현 마일스톤은 M3가 아니라 M2다** — 여기 "State와
같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것"이라고 적혀 있었으나,
`Dispatch.drive`의 배치 등록이 `Blocker`를 호출하므로 "게이팅 먼저" 결정에
따라 `Blocker.luau` 체크박스가 M2로 이동했다. 별도 파일로 두는 것은 그대로.
**그리고 이제 `Blocker`는 바닥부터 짜는 게 아니라 공용 `GateNode`
(`base/gate-plan.md`) 위에 얹는 정책이다** — 노드를 다시 만들지 말 것.
**[2026-08-14 위치 명문화]** `Blocker`는 **quad에서 emit(무효화 신호) 전파를 **[2026-08-14 위치 명문화]** `Blocker`는 **quad에서 emit(무효화 신호) 전파를
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기 지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md` `invalid` 상태를 이유로는 **절대 전파를 접지 않고**(`base/source-state-plan.md`
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된 "전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/ 게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/
debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에 debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에

View file

@ -579,7 +579,8 @@ h.Value:Flush()
> 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고, > 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고,
> 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를 > 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를
> 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건 > 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건
> 알고리즘 + 인터페이스이고, 미배선 상태에선 엔진 op 3개와 같은 관례대로 > 알고리즘 + 인터페이스이고, 미배선 상태에선 `addTag`/`setAttribute` 계열
> 엔진 op과 같은 관례대로
> **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에 > **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에
> "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에 > "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에
> 의존함** — 스로틀만 타이머 없이 되는 게 아님. > 의존함** — 스로틀만 타이머 없이 되는 게 아님.
@ -629,8 +630,12 @@ clearTimeout(handle: Timeout): ()
인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는 인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는
안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉 안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉
varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈. varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈.
- **미주입 백엔드에서는 base 스텁이 명확한 에러** — 엔진 op 3개와 동일한 - **미주입 백엔드에서는 base 스텁이 명확한 에러**`addTag`/`setAttribute`
관례. 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을 계열 엔진 op과 동일한 관례(**[2026-08-22]** 여기 "엔진 op 3개"라고 세어
놨었는데, 정작 이 문서가 추가하는 `setTimeout`/`clearTimeout` 자신이 그
개수를 늘리는 쪽이라 자기모순이었다. 주입 op 전체 목록의 소스는
`base/architecture.md``EngineOps.luau` 줄. **주의**: `native*` 계층은
이 관례의 예외로, 미주입이 에러가 아니라 **조합 폴백**이다). 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
만들어낼 수 없음. 만들어낼 수 없음.
- **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도 - **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도
trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된 trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된
@ -1118,8 +1123,10 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했
막힘). 막힘).
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/ **의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/
`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서 `clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]**
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를 각각 **M2**(게이트/`Blocker`) / **M3**(State 코어) / **M8**(`Ref`)에서
확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" —
`Blocker.luau`가 M2로 옮겨지기 전 기준). **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정 공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정
"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로). "게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로).
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는

View file

@ -414,7 +414,7 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이 해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이
깨진다. 방어는 그대로 `02`/`06` 스파이크의 nil-hole 규율(`None` 관용구)에 깨진다. 방어는 그대로 `02`/`06` 스파이크의 nil-hole 규율(`None` 관용구)에
맡긴다. 맡긴다.
- **⚠️ 스파이크 `01` 재작성 필요** — `luau-test/done/01-two-pass-array-hash-order.luau`가 - **⚠️ 스파이크 `01` 재작성 필요** — `luau-test/rewrite-required/01-two-pass-array-hash-order.luau`가
두 루프 버전이라 지금 계약의 구현과 안 맞는다. 단일 일반화 `for` 버전으로 두 루프 버전이라 지금 계약의 구현과 안 맞는다. 단일 일반화 `for` 버전으로
재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index 재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index
순서"** 를 그대로 확인할 것. 순서"** 를 그대로 확인할 것.
@ -427,7 +427,8 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
**서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이 **서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이
아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가 아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. 프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효.
**[실측 완료, 2026-08-19 M0]** `luau-test/done/01-two-pass-array-hash-order.luau` **[실측 완료, 2026-08-19 M0 — 이후 `F-4-1`로 무효화, 지금은
`rewrite-required/`]** `luau-test/01-two-pass-array-hash-order.luau`
두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저, 두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저,
array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던 array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던
항목은 닫혔다. 항목은 닫혔다.
@ -437,20 +438,32 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건 반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건
의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도 의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도
하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`). 하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`).
**그런데도 base 드라이버가 두 패스를 명시적으로 강제하는 이유는 순서를 **그런데도 base 드라이버가 이 순서를 *계약*으로 들고 있는 이유는 순서를
못 믿어서가 아니라 두 가지다**(위 (1)/(2)번 그대로): 못 믿어서가 아니다** — **⚠️ [2026-08-21 `F-4-1` 이후 정정]** 여기 한때
1. **이식성** — 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 이유가 둘("이식성" + "구분 비용이 이미 듦")이라고 적혀 있었으나, **위
아닌 자료구조로 표현할 수 있고, 그러면 "Lua 테이블의 순회 동작"이라는 `F-4-1` 정정 문단이 이식성 논거를 이미 기각했다**(`flattened`는 항상
근거 자체가 없어진다. 계약을 드라이버가 직접 들고 있어야 한다. 사용자가 Luau로 쓴 평범한 테이블이라, 다른 백엔드가 와도 이 자료구조는
2. **어차피 구분 비용이 이미 든다** — 숫자 키와 문자열 키를 다른 의미로 안 바뀐다 — 백엔드에 따라 달라지는 건 `inst`뿐). 남는 이유는 하나다:
처리해야 하므로, 순서까지 명시적으로 고정하는 건 거의 공짜다. **숫자 키와 문자열 키를 어차피 다른 의미로 처리해야 하므로, 순서까지
계약으로 고정하는 건 거의 공짜다.** 그리고 그 계약을 얻는 데 필요한
구현은 **두 패스가 아니라 한 루프 안의 `type(k) == "number"` 분기**다.
즉 스파이크 `01`이 검증한 건 **"우리가 짠 두 패스 드라이버가 계약대로 **⚠️ [2026-08-22 정정] `F-4-1` 이후로는 "base가 언어 동작에 안 기댄다"고
도는가"**이지 언어 동작이 아니다 — 그 스파이크 주석도 "사용자가 이미 말할 수 없다.** 여기 한때 *"스파이크 `01`이 검증한 건 '우리가 짠 두 패스
REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 드라이버가 계약대로 도는가'이지 언어 동작이 아니다"*, *"base는 이 우연한
… 두 패스를 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다. 동작에 기대지 않고 명시적으로 강제한다"*라고 적혀 있었는데, **단일 일반화
**`nil`-hole로 배열 파트가 통째로 해시 취급이 되는 케이스**(구멍 있는 `for` 구현은 정확히 그 언어 동작에 기대는 구현이다.** 지금 정확한 서술은:
테이블)는 이것과 별개 문제이고 `02`/`06` 스파이크가 담당한다. - base는 배열→해시 순서를 **계약으로 약속**한다(다른 백엔드가 자기
드라이버를 짜도 지켜야 한다).
- quad-base 자신은 그 약속을 **Luau 일반화 `for`의 순회 순서에 기대어**
지킨다 — 그게 `F-4-1`이 확정한 구현이다.
- 그래서 **재작성될 `01`이 검증할 것은 언어 동작 그 자체**다("일반화 `for`
배열 파트 전체를 해시보다 먼저, 배열 안에서는 index 순서로 주는가").
`01`은 두 루프 드라이버를 최소 재현한 것이라 이 질문을 안 물었다.
- **⚠️ 따라서 `nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안
된다** — 구멍이 나면 계약 위반이 아니라 **전제 위반**이라 계약이
지켜줄 수가 없다. 구체적인 깨짐 방식과 방어 위치는 위 "`nil`-hole
위험은 어느 방식이든 동일" 항목이 소스.
### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) ### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정)
@ -554,9 +567,12 @@ end
디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를
뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst` 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`
flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시
파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)` 파트(프로퍼티/이벤트) 나중이라는 **순서 계약대로**`(k,v)`
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체. `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체
**[2026-08-14 아홉 번째 세션] 이 두 패스 앞뒤에 `Ref` 계열 훅 처리가 (**[2026-08-21 `F-4-1`]** 그 계약을 얻는 구현은 두 루프가 아니라
**단일 일반화 `for` + `type(k) == "number"` 분기** — 위 `F-4-1` 정정
문단).
**[2026-08-14 아홉 번째 세션] 이 본체 루프 앞뒤에 `Ref` 계열 훅 처리가
붙음** — 앞에는 `PreRef`/`PostRef`를 한 번에 훑는 pre-pass(`PreRef`는 붙음** — 앞에는 `PreRef`/`PostRef`를 한 번에 훑는 pre-pass(`PreRef`는
그 자리에서 fire, `PostRef`는 이 호출에만 로컬인 `postRefList`에 적재만 그 자리에서 fire, `PostRef`는 이 호출에만 로컬인 `postRefList`에 적재만
하고 둘 다 전용 센티널로 소진), 뒤에는 그 `postRefList`를 순회하며 각 하고 둘 다 전용 센티널로 소진), 뒤에는 그 `postRefList`를 순회하며 각
@ -740,18 +756,28 @@ end
Dispatch 바깥에도 적용됨** — `dispose(value)`(`base/slot-plan.md`)는 Dispatch 바깥에도 적용됨** — `dispose(value)`(`base/slot-plan.md`)는
Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은 Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은
`elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만 `elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만
`disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) — `nativeDispose(element: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
**[2026-08-21 5라운드] 같은 계열로 `mountInst(target, element, index)`/ **[2026-08-21 5라운드, 같은 날 이름 확정] 같은 계열이 `native*` 물리 트리
`unmountInst(element)`가 추가될 예정**(base는 `Parent`를 모른다는 지적에서 조작 계층으로 정리됐다**(base는 `Parent`를 모른다는 지적에서 나옴).
나옴, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절). **이름은 아직 **⚠️ [2026-08-22 정정] 여기 한때 `disposeInst`/`mountInst(target, element,
가칭**이라 이 목록에 정식 등재하는 건 그 확정 시점에 한다 — index)`/`unmountInst(element)`로 적히고 "이름은 아직 가칭이라 정식 등재는
2026-08-14 열 번째 세션에 같은 원칙으로 확정. 확정 시점에 한다"고 미뤄져 있었으나, 이름은 같은 날 `native*`
확정됐다** — `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/
`nativeSwap`/`nativeDispose`. 시그니처와 조합 폴백 규칙의 소스는
`base/slot-plan.md`의 "물리 조작은 주입 op다" 절이고, 주입 op 전체
목록의 소스는 `base/architecture.md`의 소스 트리 안 `EngineOps.luau`
줄이다 — 여기서 다시 나열하지 않는다.
같은 "base 소유 + op 주입" 원칙은 2026-08-14 열 번째 세션에 확정.
- **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은 - **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은
엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작 엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작
(재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) — (재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) —
이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend. 이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend.
**주입되는 엔진 op**: **Tag/Attribute가 쓰는 주입 op**(**⚠️ [2026-08-22] 이건 주입 op *전체
목록*이 아니다** — 이 절이 다루는 Tag/Attribute 경로에 필요한 셋일 뿐이고,
`native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`은 여기 없다. 전체
목록의 소스는 위에서 지정한 `base/architecture.md``EngineOps.luau`
하나다 — 여기에 다시 쌓지 말 것):
```lua ```lua
addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신 addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신
@ -1498,7 +1524,8 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
것뿐, 그래서 별도 방어 로직도 필요 없음. 것뿐, 그래서 별도 방어 로직도 필요 없음.
**⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.** **⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.**
`mountInst`에 물리 삽입 위치를 어떻게 주느냐는 질문에서 결함 둘이 드러났고, 물리 삽입 op(당시 가칭 `mountInst`, 확정 이름은 `nativeInsert`)에 삽입 위치를
어떻게 주느냐는 질문에서 결함 둘이 드러났고,
사용자가 제시한 방향으로 정리됐다: 사용자가 제시한 방향으로 정리됐다:
1. **`sourceList``None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를 1. **`sourceList``None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를
@ -1531,7 +1558,7 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
```lua ```lua
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환. -- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치, -- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — nativeInsert의 삽입 위치,
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다. -- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
function Dispatch.getOffsetAt(ownerKey, at) function Dispatch.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey) local bk = getBookkeeping(ownerKey)
@ -1783,7 +1810,7 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 + 등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 +
`bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면 `bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면
**얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가 **얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가
필요한 쪽(예: `mountInst`의 삽입 위치)은 `getOffsetAt`을 직접 부른다: 필요한 쪽(예: `nativeInsert`의 삽입 위치)은 `getOffsetAt`을 직접 부른다:
```lua ```lua
-- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서 -- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서
@ -1878,12 +1905,16 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임 사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임
순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다. 순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 **⚠️ [2026-08-22 정정] 여기 있던 "동기 순서 — offset 갱신이 마운트보다
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: 먼저 끝나야 함" 문단은 위 역전으로 폐기됐다.** 그 문단은 *"Slot의 `rawAdd`
Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSource`/ 부기를 먼저 완결하고(`setOffsetSource`/`setLength` 등록 → `recompute`) 그
`setLength` 등록 → `recompute` → 뒤 형제 offset 갱신이 동기적으로 여기서 끝남) 다음에 물리 마운트를 호출한다"*고 적어, 바로 위 3번(자기 자리 먼저 →
그 다음에 물리 마운트(`mountInst`)를 호출한다 — 트리에 보이는 시점엔 `nativeInsert` → 뒤를 미는 것 나중)과 **정반대**였다. 실제 의사코드도 3번
다운스트림이 이미 정합적. 쪽이다(`base/slot-plan.md`의 `rawAdd`). 이번 세션이 그 문단의 옛 op 이름만
`native*`로 바꾸고 순서 주장은 지나쳐서 남아 있던 것.
그 문단이 근거로 들던 "한 프레임 순서가 깨진 채 노출될 위험"은 **위
⚠️ 문단이 이미 기각**했다 — 프레임 경계가 안 끼므로 중간 상태 자체가
사용자에게 안 보인다.
**⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd` **⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`
`self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자 `self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자
@ -1900,9 +1931,14 @@ Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSo
순회 쪽뿐이다(위 `Get` 가드 문단). 순회 쪽뿐이다(위 `Get` 가드 문단).
**확정**: `Length`**`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리 **확정**: `Length`**`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리
부기를 등록하고 `recompute`를 한 번 부를 뿐이고, 그게 `Parent` 대입 앞에 부기를 등록하고 `recompute`를 한 번 부를 뿐이다.
오므로 "부기가 물리보다 먼저"는 그대로 지켜진다(사용자 확인: *"recompute 가 **⚠️ [2026-08-22 정정]** 여기 *"그게 `Parent` 대입 앞에 오므로 '부기가 물리보다
length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"*). 먼저'는 그대로 지켜진다"*(+ 사용자 인용 *"recompute 가 length 를 잘 처리해놓고
나서 빈 공간에 들어가므로"*)라고 적혀 있었으나, 그건 **같은 라운드에 역전된
옛 C-7 일반 규칙**을 전제한 서술이다 — 지금 단건 경로는 `setOffsetSource`
`nativeInsert``setLength``recompute``recompute`가 물리 삽입보다
**뒤**다(위 "지금의 계약" 3번). 이 절의 결론(`Length`를 쓰는 주체는
`recompute` 하나뿐)은 그 순서와 무관하게 그대로 유효하다.
의사코드는 `base/slot-plan.md``rawAdd`/`rawReplace`가 소스. 의사코드는 `base/slot-plan.md``rawAdd`/`rawReplace`가 소스.
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 **`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
@ -1913,7 +1949,7 @@ length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: `recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:
DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로,
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 `offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음
(**[2026-08-21 5라운드 재확인]** `mountInst`가 삽입 위치를 받게 된 뒤에도 이 (**[2026-08-21 5라운드 재확인]** `nativeInsert`가 삽입 위치를 받게 된 뒤에도 이
결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면 빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면
백엔드 op이 **아토믹한 최소 단위**여야 한다) — 백엔드 op이 **아토믹한 최소 단위**여야 한다) —

View file

@ -7,7 +7,10 @@ GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부
← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저" ← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저"
결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다 결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다
구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은 구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
`emit(self)` + 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.** 날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.**
(**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나
그건 `Epoch` 일반화 **이전** 표기다 — 지금 싣는 건 떼어낸 `EpochSet`
스냅샷이고 게이트 노드 자신은 안 싣는다, 바로 아래 ⚠️ 문단이 소스.)
**⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고 **⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고
`Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안 `Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안
@ -33,11 +36,17 @@ emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`s
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을 1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정: 전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
**게이팅을 먼저 만든다.** **게이팅을 먼저 만든다.** (이건 그 결정에 이르게 된 *당시* 상태 서술이다 —
**[2026-08-22] 지금은 `Blocker.luau` 자체가 M2에 있다**, 아래 9번.
**⚠️ 다만 그걸로 순환이 닫힌 건 아니다** — `state:Gate`는 State 메소드이고
`GateNode`/`Blocker`는 State 위에 얹히므로, M2로 옮겨도 M2→M3 참조는
그대로 남는다. 그 사실은 `.claude/question.md` 2번이 별도 미결로 다룬다.)
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를 2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용 못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를 `Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다. 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로
**당시** 서술은 "M3에서 Blocker를 만들 때"였다 — **[2026-08-22]** 지금은
`Blocker.luau`도 M2다, 아래 9번).
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가: **사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 *"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
@ -124,8 +133,8 @@ end)
`base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를 `base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를
에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기
위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목. 위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목.
4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수 4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — 흡수 집합
집합.** 문제는 실재했다: 확정 `setup``(emit: () -> ()) -> (() -> ())` 스냅샷.** 문제는 실재했다: 확정 `setup``(emit: () -> ()) -> (() -> ())`
양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부 양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부
`[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무 `[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무
원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21 원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21
@ -272,10 +281,17 @@ end)
- **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가 - **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가
아니다** — 아래 7번 참고. 아니다** — 아래 7번 참고.
9. **M2 범위.** M2에 `Gate`만 넣고 `Blocker`는 M3에 그대로 둘지, 아니면 9. **M2 범위 — [2026-08-22 해소] `Gate``Blocker` 둘 다 M2다.**
`Blocker`까지 같이 앞당길지. `Dispatch.drive`의 배치 등록이 실제로 쓰는 건 `Dispatch.drive`의 배치 등록이 실제로 쓰는 건
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절), **최소한 `blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한
그 세 메서드가 도는 형태까지는 M2에 필요**하다. 그 세 메서드가 도는 형태까지는 M2에 필요한데, 그렇다고 `Blocker`
M3에 남겨두면 M2가 다시 뒤 마일스톤을 참조하게 된다. 같은 날 `ROADMAP.md`
전반 점검에서 `EpochMap.luau`/`GateNode`/`Blocker.luau` **체크박스가 전부
M2로 이동**했다(사용자 판단). `Blocker``GateNode`를 다시 만들지 말고
그 위의 정책으로 얹을 것.
**⚠️ 이 이동이 M2↔M3 의존을 없애지는 않는다** — 셋 다 State 위에
얹히므로 M2는 여전히 `Source.luau`/`State.luau`를 필요로 한다. 마일스톤
경계를 어떻게 그을지는 `.claude/question.md` 2번(사용자 회신 대기).
## 관련 문서 ## 관련 문서

View file

@ -210,7 +210,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
> 이르게 된 조사/논거를 그대로 보존한 것. > 이르게 된 조사/논거를 그대로 보존한 것.
`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절이 계약하는 `base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절이 계약하는
두 패스(배열 파트 먼저, 해시 파트 나중) 기준으로 현재 base가 제공하는 본체 루프의 배열→해시 순서 계약(**[2026-08-22 용어]** 이 문서가 쓰는
"두 패스"는 그 본체 루프의 옛 이름이다 — 구현은 단일 일반화 `for`,
`base/ref-plan.md`의 같은 용어 각주와 `base/dispatch-core-plan.md`
`F-4-1` 정정 문단) 기준으로 현재 base가 제공하는
훅들의 타이밍을 정리하면: 훅들의 타이밍을 정리하면:
| 훅 | 시점 | | 훅 | 시점 |
@ -243,10 +246,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
걸리는 시점"을 `PreRef`와 동일하게 맞춤, 실제 콜백 fire와 시점이 걸리는 시점"을 `PreRef`와 동일하게 맞춤, 실제 콜백 fire와 시점이
갈리는 건 아래 항목뿐). 갈리는 건 아래 항목뿐).
- **`ProcessedPostRefHandler``ProcessedPreRefHandler`와 완전히 - **`ProcessedPostRefHandler``ProcessedPreRefHandler`와 완전히
대칭**: 정상 두 패스`ProcessedPostRef`를 매치해 대칭**: 정상 본체 루프`ProcessedPostRef`를 매치해
`setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를 `setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를
반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는 반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는
소진 전 원본 값이 정상 두 패스의 매치 대상이어야 한다"고 소진 전 원본 값이 정상 본체 루프의 매치 대상이어야 한다"고
잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안 잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안
생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서 생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서
`isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도 `isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도

View file

@ -309,8 +309,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고
실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로
주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에 주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에
엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/ Tag/Attribute용 엔진 op이 추가됨** — `addTag(inst,{string})`/
`setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기 `removeTag(inst,{string})`/`setAttribute(inst,name,v)`(`v==nil`이면 삭제).
**[2026-08-22 정정] 여기 "엔진 op 3개"라고 세어놨으나 그 셋이 전부가
아니다** — 이후 `native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`이
같은 팩토리 뮤테이션 경로에 추가됐다. **주입 op 전체 목록의 소스는
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
세지 않는다. `Tag`/`Attribute`의 부기
알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막 알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막
한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가 한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가
소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/ 소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/
@ -326,8 +331,11 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
"base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반 "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반
원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인 원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인
이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라 이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).**
뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 즉 이 경로에서 백엔드 팩토리가 뮤테이션으로 채우는 건 **핸들러가 아니라
엔진 op 쪽**이다(**[2026-08-22 정정]** 여기 "`addTag`/`removeTag`/
`setAttribute`**만**"이라고 셋으로 못박혀 있었으나 위와 같은 이유로
그 셋이 전부가 아니다). 아직 아무 팩토리도 안 채운 슬롯의
기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한
no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서). no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는 더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는

View file

@ -387,7 +387,7 @@ Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 ch
**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 **children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두
타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md` 타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`
"확정된 디스패치 모델" 절에 "확정된 디스패치 모델" 절에
새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 새로 추가된 순서 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤
어디에 놓느냐가 어디에 놓느냐가
이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기
@ -442,12 +442,22 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저
마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가
동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서
base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 base 드라이버는 위 본체 루프(배열→해시 순서 계약)를 돌기 **전에** 별도의
작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고,
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 본체
패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 루프로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 범위가 좁고, "확정된 디스패치 모델" 절의 배열→해시 순서 계약과 별개로 그
앞에 얹히는 것. 앞에 얹히는 것.
**⚠️ [2026-08-22 용어] 이 문서가 여러 곳에서 쓰는 "두 패스"는 이
본체 루프의 옛 이름이다.** `Dispatch.drive`의 실제 구현은 **단일 일반화
`for` + `type(k) == "number"` 분기** 한 번이고(`F-4-1`,
`base/dispatch-core-plan.md`), "배열 파트 전체가 해시 파트보다 먼저"는
그 루프가 지키는 **계약**이지 루프를 두 번 돈다는 뜻이 아니다. 아래
"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은 **시점** 표기는 그대로
유효하다 — pre-pass와 `postRefList` 소비 루프가 본체 루프의 앞뒤에
붙는다는 뜻이고, 그건 이번 정정과 무관하다. **다만 "두 패스로
처리한다"처럼 구현을 단정하는 용법은 폐기**다.
- **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션 확정, 2026-08-14 - **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션 확정, 2026-08-14
아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로 아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로
보장.** 같은 인스턴스에 `PreRef`가 여럿 있으면, 이 pre-pass는 위 보장.** 같은 인스턴스에 `PreRef`가 여럿 있으면, 이 pre-pass는 위
@ -501,10 +511,10 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음 누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음
(2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를 (2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를
분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler` 분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler`
참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스 참고. (2) 그 다음에야 비로소 평소의 배열→해시 본체 루프
**같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은 **같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은
**정상 `Dispatch.process` 경로를 그대로 탄다**(아래 **정상 `Dispatch.process` 경로를 그대로 탄다**(아래
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스 `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 본체
루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤 루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤
Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정, 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정,
@ -556,7 +566,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
- **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None` - **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`
`Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는 `Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는
전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에 전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에
`Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가 `Dispatch.process` 자체를 절대 안 탄다(본체 루프가
`if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number` `if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number`
조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데, 조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데,
리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State<Slot|None> }` 리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State<Slot|None> }`
@ -594,8 +604,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
문서화까지 검토할 것. 문서화까지 검토할 것.
- **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신, - **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신,
새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).** 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).**
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를 `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 순서로
도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 도는 함수로 확정돼 있으므로(**[2026-08-22 정정]** 여기 "두 패스를 도는
함수"라고 적혀 있었으나 그 순서는 **계약**이고 구현은 단일 일반화
`for`다 — `base/dispatch-core-plan.md``F-4-1` 정정 문단),
그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst, 충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst,
flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미 flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미
다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서
@ -640,7 +653,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
`PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정, `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정,
2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**, 2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**,
위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만 위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만
매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔 매치)에는 다시 노출되지 않게** 하므로(정상 본체 루프 스캔 자체엔
`ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이 `ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이
아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로 아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로
매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로
@ -672,7 +685,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서 슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서
즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인 즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인
PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드" PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드"
Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는 Handler(정상 본체 루프에서 매치)와는 별개 코드 경로 — 이 가드는
pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과. pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과.
- **관용구**: `Slot:List``updateFn`처럼 반복 호출되는 자리에서 - **관용구**: `Slot:List``updateFn`처럼 반복 호출되는 자리에서
`PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에 `PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에
@ -793,7 +806,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
갈리는 건 아래 3번뿐). 갈리는 건 아래 3번뿐).
- `postRefList`**`Relate` 같은 별도 저장소가 아님** — 이 함수 - `postRefList`**`Relate` 같은 별도 저장소가 아님** — 이 함수
콜스택 안에서만 살면 되므로 그냥 로컬 테이블. 콜스택 안에서만 살면 되므로 그냥 로컬 테이블.
2. **정상 두 패스**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef` 2. **정상 본체 루프**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`
소진된 슬롯은 `ProcessedPreRef`**완전히 대칭적으로** 정상 소진된 슬롯은 `ProcessedPreRef`**완전히 대칭적으로** 정상
`Dispatch.process` 경로를 타고 아래 전담 Handler에 매치됨. `Dispatch.process` 경로를 타고 아래 전담 Handler에 매치됨.
3. **두 패스가 끝난 뒤 `Dispatch.drive``postRefList`를 순회하며 각 3. **두 패스가 끝난 뒤 `Dispatch.drive``postRefList`를 순회하며 각

View file

@ -389,8 +389,11 @@ lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy
`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 `Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아
Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는
store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브
`base/blocker-plan.md`가 다룸(State 개발과 같은 `base/blocker-plan.md`가 다룸(**[2026-08-22 정정]** 여기 "State 개발과 같은
마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 마일스톤, `ROADMAP.md` M3에서 함께 구현"이라 적혀 있었으나 `Blocker.luau`
**M2**로 이동했고, 바닥부터 짜는 게 아니라 공용 `GateNode`
(`base/gate-plan.md`) 위의 정책이다 — 마일스톤 소속의 소스는
`blocker-plan.md`의 정정 배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던
초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — 초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 —
`archive/batch-rejected.md` 참고. `archive/batch-rejected.md` 참고.

View file

@ -2,7 +2,12 @@
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 **상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. **채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."* 구현은 **M3(Source/State)**. 채택하면 될것 같아요."*
**구현 마일스톤은 둘로 갈린다** — **[2026-08-22 정정]** 여기 "구현은 M3"라고만
적혀 있었다. 부기 객체 `EpochMap.luau``Epoch` 인터페이스·리비전 갱신
규칙은 **M2**(`GateNode`가 `emitEpochMap`을 쓰므로 — `ROADMAP.md` M2),
그걸 `valueEpochMap`/`emitEpochMap` 둘로 컴포지션하는 **State 본체 통합은
M3**다.
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는 **⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을

View file

@ -10,9 +10,12 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
길게 잡음. 길게 잡음.
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치 **[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(디스패치
엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 엔진)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에 같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ 단 M2 착수를 막는
순서 문제가 하나 열려 있음** — M2↔M3 양방향 의존, 소스는
`.claude/question.md` 2번(설계 결정이 아니라 마일스톤 경계 문제라
"설계 게이트는 없다"는 아래·`todos.md` 서술과 모순되지 않음). 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md` 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -12,7 +12,11 @@
--- ---
## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준) ## ⭐ 최우선 — 설계 결정은 **없음**, 다만 순서 문제 하나 (2026-08-22 갱신)
> **[2026-08-22] 아래 2번(M2/M3 마일스톤 경계)이 M2 착수를 막습니다.**
> 그건 "무엇을 확정할까"가 아니라 "어떤 순서로 짤까"라 성격이 달라서
> 이 절이 아니라 2번에 뒀습니다 — **설계 결정 대기는 여전히 0건**입니다.
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
@ -111,6 +115,41 @@
- `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계 - `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계
선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음.
## 2. M2/M3 마일스톤 경계 — **M2 착수 전에 답이 필요** (2026-08-22 신설)
**M2(디스패치 엔진)와 M3(Store/State/Source)의 의존이 양방향이라,
`ROADMAP.md` 순서대로면 M2를 끝까지 짤 수 없습니다.**
- **M2 → M3 (본체 의존)**: `Dispatch.setLength`
`len: number | State<number>`를, `Dispatch.setOffsetSource`
`Source<number>`를 받고, `recompute``offset:Set()`을 부릅니다
(`base/dispatch-core-plan.md`의 "Length/Offset" 절). 2026-08-22에 M2로
옮긴 `GateNode`/`Blocker`도 State 위에 얹힙니다. 즉 **M2는
`Source.luau`/`State.luau` 없이는 구현이 안 됩니다.**
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
`Dispatch.addHandler` + `Handler.luau` 계약을 씁니다 — 이건 레지스트리
등록 표면만 있으면 되므로 M2 **전체**를 요구하지 않습니다.
**⚠️ 다만 "M2 앞머리 두 항목(`Dispatch/init.luau` + `Handler.luau`)이면
된다"고는 말할 수 없습니다** — `Dispatch/init.luau`에는 `Dispatch.drive`
들어 있고, `dispatch-core-plan.md`가 *"적용 지점 — `Dispatch.drive`
`attachSlot`, 각각 자기 owner 키로 별도 Blocker"*라고 확정해 `drive`
게이팅을 씁니다. 즉 그 첫 항목 자체가 State-free가 아닙니다. 선택지 (b)로
쪼갠다면 `drive`를 어느 쪽에 두느냐가 경계선이 됩니다.
**선택지**:
- **(a) M2와 M3의 순서를 바꾼다** — 반응형(Source/State/EpochMap/Gate/
Blocker)을 먼저 짜고 그 위에 디스패치를 올림. 얕은 쪽(가드 Handler)만
뒤로 미루면 됨. 지금까지의 결정 흐름("게이팅 먼저")과 방향이 같음.
- **(b) M2를 둘로 쪼갠다** — `Dispatch.getHandler`/`process`/`retractFrom`/
`Handler`/`Brand`/`Relate`/`chains`까지가 M2a, State가 필요한
Length/Offset·게이팅은 M3 뒤의 M2b로.
- **(c) 지금 구조를 두고 구현 시 알아서 오간다** — 로드맵은 "순서"가
아니라 "묶음"으로만 읽음.
**[2026-08-22 기준] 이 항목이 M2 착수를 막습니다** — `.claude/todos.md`
0번이 "M2 착수를 막는 설계 항목은 없다"고 하는 것은 **설계** 얘기이고,
이건 설계가 아니라 **순서** 문제라 별개입니다.
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,

View file

@ -168,7 +168,7 @@ R1(부모 등록)까지 따로 떼는 안. `Dispatch.drive`의 최상위 배열
반복된다. 반복된다.
- **이득**: `Dispatch.drive``attachSlot`이 지금 서로 비슷한데 미묘하게 - **이득**: `Dispatch.drive``attachSlot`이 지금 서로 비슷한데 미묘하게
다른 구조(전자는 Blocker + 두 패스, 후자는 Blocker + flush)인 걸 하나로 다른 구조(전자는 Blocker + 본체 루프, 후자는 Blocker + flush)인 걸 하나로
수렴시킬 수 있음. 수렴시킬 수 있음.
- **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득 - **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득
대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**. 대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**.

View file

@ -0,0 +1,234 @@
# 2026-08-22-01 — `ROADMAP.md` 전반 점검: 마일스톤 경계 재편과 stale 정리
사용자 요청: *"마일스톤 전체가 이제 손봐야할 부분이 많다고 생각해. 전반을
확인해볼래?"* — 직전 세션(`2026-08-21-03`)이 `Epoch`/`EpochMap`/`Brand`를
승격하며 커밋 메시지에 마일스톤 관련 항목 셋을 남긴 게 발단.
**이 세션은 커밋하지 않았다** — 사용자 지시(*"이거 다 하고 내가 직접
코드리뷰 트리거해줄게 커밋 하지 말아줘"*). 작업 트리에 17개 파일이 그대로
남아 있고, 사용자가 `/code-review`를 돌린 뒤 그 결과까지 반영해서 커밋하는
게 다음 단계다.
## 1. 무엇을 했나
`ROADMAP.md` 전문(1074줄)을 읽고 `base/`의 현재 확정과 대조했다. 발견을
셋으로 갈라 보고했다 — A(내용 모순), B(순서/소속), C(상태 표시 신뢰성).
사용자가 B와 C-3에 대해 각각 "실제로 옮긴다", "설계 확정은 별도 표기로
분리"를 선택했고 그대로 실행했다.
### A. 모순 — 그대로 읽으면 반대로 구현되는 것
- **M2 첫 체크박스가 자기 자신을 부정**했다. 불릿 머리는 하강 diff인데
꼬리가 *"`Dispatch.process`는 diff를 하지 않음"*(2026-08-13 다섯 번째
세션 모델, **같은 날 열네 번째 세션에 폐기**)으로 끝났다.
- **`Dispatch.drive`의 순회 계약이 옛 버전**이었다. QA 4라운드 `F-4-1`
"두 패스 구현"이 폐기되고 단일 일반화 `for`로 정정됐는데 ROADMAP 두 곳이
안 따라왔다. 더 나쁜 건 **`dispatch-core-plan.md` 자신이 모순**이었다는
것 — `F-4-1` 정정(§390)이 "이식성 논거는 과했다"고 명시적으로 기각한
근거를 34줄 뒤 `D-10` 문단(§440)이 여전히 현재형으로 주장했다.
- **물리 조작 주입 op 이름이 두 벌** 돌았다. `slot-plan.md`/`architecture.md`는
`native*`로 넘어갔는데 ROADMAP M6와 `dispatch-core-plan.md`는 가칭
`mountInst`/`unmountInst`/`disposeInst`를 썼다. 뿌리는
`dispatch-core-plan.md`가 *"이름은 아직 가칭이라 정식 등재하는 건 그
확정 시점에 한다"*로 멈춰 있던 것 — 확정이 났는데 그 문단이 안 열렸다.
- **"주입되는 엔진 op 3개"** 하드코딩. `architecture.md``EngineOps.luau`
줄을 단일 소스로 지정하고 개수를 지웠다.
### B. 순서/소속 — 각주로 덮어온 자리 (사용자 선택: "실제로 옮긴다")
M2가 실제로 필요로 하는 모듈 넷이 전부 뒤 마일스톤에 체크박스로 있었고,
**옮기는 대신 각주가 붙어 있었다**:
| 모듈 | 있던 곳 | 옮긴 곳 |
|---|---|---|
| `GateNode` / `state:Gate` | **체크박스 자체가 없음**(각주 안에만) | M2 |
| `EpochMap.luau` | M3 | M2 |
| `Blocker.luau` | M3 | M2 |
| `None` + `Dispatch/None.luau` | M7 | M2 |
`GateNode`가 특히 나빴다 — M2 체크박스를 훑는 구현자에게 **항목으로 보이지
않았다**. 전례가 있다: `LifetimeHandle` 인터페이스도 같은 이유로 M8→M2
실제 이동을 했었다(각주가 아니라).
### C. 상태 표시
- **M0이 전부 `[x]`인데 검증 스파이크 둘이 무효화**돼 있었고(`01`: 두 패스→
단일 for, `05`: Epoch 리비전 채택), 재작성 작업이 어느 체크박스에도
없었다. `### 재검증 대기` 절을 신설해 7건 + 파일 없는 실측 2건을 모았다.
- **머리 blockquote가 `[2026-08-19 기준]`**에서 멈춰 있었다.
- **`[x]`의 의미가 마일스톤마다 달랐다** — M0/M1은 "실제로 짜서 통과",
M6/M11은 "설계 확정"(코드 0줄). `initValue` 항목은 아예 스코프 배제가
`[x]`였다. 사용자 선택대로 M6/M11에 `### 확정된 것 — 코드 아님, 구현 전
필독` 절을 만들어 10개 항목을 옮기고, 체크박스는 "짜야 할 코드"만 남겼다.
## 2. ⭐ 이동하다 드러난 것 — M2와 M3의 의존이 양방향이다
**이게 이 세션의 실질적 산출물이다.** `EpochMap`/`Gate`/`Blocker`를 M2로
옮기려다 확인한 것:
- **M2 → M3 (본체 의존)**: `Dispatch.setLength``len: number | State<number>`를,
`setOffsetSource``Source<number>`를 받고 `recompute``offset:Set(abs)`/
`ownerKey.Length:Set(sum)`을 부른다(`dispatch-core-plan.md:1279-1280`,
`:1621`). 즉 **M2는 `Source.luau`/`State.luau` 없이는 구현이 안 된다.**
M2로 옮긴 `GateNode`/`Blocker`도 State 위에 얹히므로 마찬가지.
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
`Dispatch.addHandler` + `Handler.luau` 계약을 쓴다 — M2 앞머리 두 항목이면
충족.
선택지 셋((a) M2/M3 순서 교체 / (b) M2를 M2a·M2b로 분할 / (c) 유지)을
`question.md` 2번으로 신설하고 **유일한 소스**로 지정했다. `ROADMAP.md` M2
배너 / `todos.md` 00번 / `CLAUDE.md` / `project-context.md` /
`HUMAN_TODO.md` 11번은 전부 가리키기만 한다.
**중요한 구분**: `todos.md`가 오래 유지해온 "M2 착수를 막는 **설계** 항목은
없다"는 여전히 참이다 — 이건 설계가 아니라 **순서** 문제다. 그 구분을
안 하면 두 서술이 모순으로 읽히므로 다섯 곳 전부에 명시했다.
## 3. 감사 루프 — 8라운드, 30건
`conventions.md`의 절차대로 `quad-doc-auditor`**한 턴에 하나씩**, 라운드마다
각도를 바꿔가며 돌렸다. 발견 추이: **9 → 5 → 7 → 3 → 3 → 1 → 1 → 0**.
라운드별 각도: (1) diff 범위, (2) 인덱스 레이어 + `base/` 프리미티브,
(3) `archive/`·`reference/`·`qa-request/`, (4) **내 수정 자체를 의심**,
(5) 통독 시점 + `architecture.md`/`source-state-plan.md`/`HUMAN_TODO.md`,
(6) 아직 안 본 `base/` 17개 + `session-summary.md`, (7) "이름만 고치고 의미는
안 고친" 각도, (8) 마일스톤 소속 주장 전수.
### ⭐ 이 루프에서 배운 것 — 내가 고치면서 새 결함을 3건 만들었다
1. **하드코딩 "넷"** — 배너에 *"검증 스파이크 **넷**이 재작성 대기"*라고
쓰면서 **같은 문장에서** "개수·현황의 소스는 `STATUS.md`, 여기서 세지
않음"이라 선언했다. 실제로는 7개.
2. **`debounce-throttle-plan.md`의 M3 누락** — "전부 M3/M8"에서 `Blocker`
M2로 옮기다 **State 코어(M3)가 통째로 빠졌다.**
3. **`nativeInsert` 치환 시 순서 주장 미검토** — 3라운드에서
`dispatch-core-plan.md``mountInst``nativeInsert`로 바꾸면서
**이름만 갈고 그 이름이 든 문장이 무슨 주장을 하는지 안 읽었다.**
문단은 역전된 옛 C-7(*"부기를 먼저 완결하고 그 다음 물리 마운트"*)을
현재형으로 주장하고 있었고, 지금 단건 경로는 정반대다
(`setOffsetSource` → `nativeInsert``setLength``recompute`,
`slot-plan.md``rawAdd` 의사코드가 소스). **역전 배너를 스스로 달아둔
바로 그 절 안에** 옛 주장이 살아 있었다.
셋 다 `conventions.md` 핸드오버 체크리스트 2번(*"배너를 달았으면 그 배너가
부정하는 문장을 같은 커밋에서 고쳤는지 확인"*)이 경고하는 패턴이다.
**일괄 치환은 그 자체가 이 실패 모드를 만든다** — 치환한 줄이 속한 문단을
안 읽으면, 옛 주장이 새 어휘를 입고 살아남는다. 앞으로 용어/이름 치환을
할 땐 치환 지점마다 **문단 단위로** 읽을 것.
### 감사자가 잡은 것 중 성격이 두드러진 것
- **`ROADMAP.md` 맨 위 헤드라인 배너에만 M2 블로커 경고가 빠져 있었다** —
`CLAUDE.md`/`project-context.md`/`todos.md`/`question.md` 넷은 다 넣어놓고,
정작 그 넷이 "진행 상황의 소스"로 지목하는 문서의 첫 배너를 빠뜨렸다.
ROADMAP만 읽는 사람이 M2를 바로 시작할 수 있는 상태였다.
- **`architecture.md` 소스 트리에 `EpochMap.luau`가 아예 없었다** — ROADMAP
M2가 "별도 모듈로 낼 것"이라 명시하는데, "다음 세션이 실제로 만들 구조"라
자임하는 정본에서 빠져 있었다.
- **`source-state-plan.md``Blocker`를 "M3에서 함께 구현"이라 서술** —
`blocker-plan.md`엔 정정 배너를 달았는데 정작 **그 사실을 처음 서술한
본문**은 안 고쳤던, 체크리스트 2번 그대로의 사례.
- **`HUMAN_TODO.md`만 인덱스 레이어 동기화에서 빠졌다** — *"`question.md`엔
이제 '결정 대기' 절 자체가 없음"*이라 단정 중이었다.
- **`두 패스`가 코퍼스 전반에 남아 있었다** — `ref-plan.md`가 "본체 루프"로
고친 **같은 문장 안에서** 다시 "두 패스로 처리"라고 썼다. 구현 단정
용법은 전부 "본체 루프"로 바꾸고, `ref-plan.md`/`lifecycle-hooks-plan.md`에
용어 각주를 신설했다. **"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은
*시점* 표기는 의도적으로 남겼다** — `PostRef` 절 제목이기도 하고 인용
대상이라, 바꾸면 절 참조가 깨진다.
## 4. `/code-review high` — 감사 0건 뒤에 9건 더
감사 루프가 0건으로 수렴한 **뒤에** 사용자가 `/code-review high`를 돌렸고
**9건이 더 나왔다. 전부 유효했다.** `conventions.md`가 2026-08-18에 실측으로
기록해둔 것("code-review는 감사자를 대체하지 않는다 — 둘이 보는 축이 다르다")이
이번에도 그대로 재현됐다.
**⭐ 가장 나빴던 것 — 내가 새로 쓴 체크박스가 역전 전 표기를 구현 지시로
복제했다.** M2의 `GateNode` 항목에 *"내보내는 emit은 `emit(self)` + 흡수
집합"*이라 적었는데, 확정 계약은 정반대다 — `state-epoch-plan.md` §5는
*"게이트 노드 자체는 페이로드에 안 싣는다 … 하류는 게이트 identity를 한 번도
안 쓴다"*이고, 넘기는 건 스왑해 떼어낸 `EpochSet` 스냅샷뿐이다. `emit(self)`
`Epoch` 일반화 **이전** 표기인데 `gate-plan.md`의 배너와 4번 제목에 잔존해
있었고, 내가 그걸 그대로 베껴 **M2를 짜는 사람이 게이트 노드를 출처로 싣게
만드는 지시**로 승격시켰다. 뿌리인 `gate-plan.md` 두 곳까지 같이 고쳤다.
나머지 여덟 건의 성격:
- **`F-4-1` 정정이 자기 절 안의 다음 문장을 거짓으로 만들었다.** 구현을 단일
일반화 `for`로 확정해놓고, 아래 문단은 여전히 *"base는 이 우연한 동작에
기대지 않고 명시적으로 강제한다"*고 주장했다. **단일 `for`는 정확히 그
언어 동작에 기대는 구현이다.** 따라서 재작성될 `01`이 검증할 것도 "우리
드라이버가 계약대로 도는가"가 아니라 **언어 동작 그 자체**로 바뀐다. 그리고
`nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안 된다는 따름정리도
같이 적었다.
- **`gate-plan.md`가 마일스톤 순환을 "해소"로 서술**했다. `Blocker`를 M2로
옮겼다고 순환이 닫히지 않는다 — `state:Gate`는 State 메소드라 M2→M3 참조가
그대로 남는다. `question.md` 2번이 인정한 바로 그 사실인데 gate-plan만
읽으면 닫힌 것으로 읽혔다.
- **"M2 앞머리 두 항목이면 충족"이 부정확**했다. `Dispatch/init.luau`
`Dispatch.drive`가 들어 있고 그 자신이 배치 등록을 Blocker로 게이팅한다
(`dispatch-core-plan.md`의 "적용 지점" 확정). 선택지 (b)로 쪼갠다면
`drive`를 어느 쪽에 두느냐가 경계선이 된다 — 사용자가 (a)/(c)를 고를 때
결합도를 실제보다 얕게 볼 뻔했다.
- **"M0 체크박스는 전부 닫혔다"가 같은 편집이 M0에 넣은 미체크 8개와 충돌.**
새로 선언한 `[x]` 규약상 그것들은 실제 열린 작업이다.
- **단일 소스로 지정한 자리가 정작 불완전**했다 — `architecture.md`
`EngineOps.luau` 줄에 `setTimeout`/`clearTimeout`이 없었고, 인접
`RobloxFactory.luau` 줄의 주입 대상 목록도 `native*` 없이 멈춰 있었다.
"개수 하드코딩을 지우고 소스를 하나로"라는 의도가 그 소스가 불완전해서
성립하지 않았다. 소스 쪽을 실제로 채웠다.
- **근거로 제시한 문서를 열면 확인이 안 되는 주장**`None.luau`
소스 트리에 "같은 위치로 있다"고 적었으나 트리엔 `Dispatch/None.luau`
있고 탑레벨 센티널도 `Brand.luau`도 줄이 없었다.
- **새로 세운 `[x]` 규약이 같은 문서에서 이미 깨져 있었다** — M3의
`store.key` 타입 추론 확인이 코드가 아닌데 `[x]`였다. 배너가 위반 사례를
"M6/M11"로 한정 열거해서 감사에서 빠졌다. 규약 문구를 일반화하고 그
항목도 뺐다.
- **재작성 지시가 없는 경로를 가리켰다**`luau-test/done/01-...`인데 실제
파일은 `rewrite-required/`에 있다. 하필 *"⚠️ 스파이크 `01` 재작성 필요"*
지시문 자체가 그랬다.
**이 세션의 교훈을 한 줄로**: 감사 0건은 "코퍼스가 스스로와 정합한다"는
뜻이지 "이번에 새로 쓴 문장이 옳다"는 뜻이 아니다. 특히 **다른 문서에서
문장을 베껴 새 체크박스를 만들 때, 그 문장이 이미 역전된 표기인지를
확인해야 한다** — 감사자는 "베껴온 곳과 원본이 같다"를 보고 정합으로
판정한다(실제로 `emit(self)`가 gate-plan에도 남아 있었으므로 둘은 "일치"했다).
## 5. code-review 뒤 감사 2라운드 — 그리고 실측된 패턴 하나
사용자 요청으로 code-review 수정분에 감사를 두 라운드 더 돌렸다.
- **1라운드 3건, 전부 "직전 수정이 만든 것"**`todos.md``emit(self)`
"사용자 확정"으로 인용하면서 **내가 방금 그 표기를 부정하는 배너를 단
문서**를 근거로 가리키던 것, "주입 op 전체 목록의 소스는 `architecture.md`"라고
선언한 **6줄 아래에 그 목록이 그대로 있던** 것(게다가 `native*`도 시간
op도 없는 불완전한 셋이라, 단일 소스를 세우려던 편집이 오히려 "목록이 두
곳"을 고착시킬 뻔했다), `nil`-hole 문단 중복.
- **2라운드 1건 — 이번엔 내 수정이 만든 게 아니라 앞 라운드들이 놓친 기존
자리였다.** `module-lifecycle-plan.md`가 *"주입 대상 목록에 엔진 op 3개가
추가됨"* + *"`addTag`/`removeTag`/`setAttribute`**만** 채우는 타입 계약"*으로
같은 하드코딩을 들고 있었다. 스윕해보니 `debounce-throttle-plan.md`도 두
곳에서 "엔진 op 3개"를 관례 비유로 쓰고 있었는데, **그 문서 자신이
`setTimeout`/`clearTimeout`을 추가해 그 개수를 늘리는 주체**라 자기모순이었다.
### ⭐ 실측된 패턴 — 개수 하드코딩은 "한 곳을 고치면 끝"이 아니다
"주입 op 3개"라는 같은 사실이 **네 문서**(`ROADMAP.md`, `dispatch-core-plan.md`,
`module-lifecycle-plan.md`, `debounce-throttle-plan.md`)에 흩어져 있었고,
`conventions.md` 체크리스트 4번이 예고한 그대로 전부 갈라져 있었다. 처음
둘만 고치고 "단일 소스로 정리했다"고 선언한 게 이 세션인데, **선언 자체가
나머지 둘을 못 찾았다.** 개수를 지울 땐 그 개수의 **모든 사본**을 grep으로
전수 찾아야 하고, "소스는 X다"라고 선언한 뒤엔 **X가 실제로 완전한지**와
**다른 사본이 남았는지**를 같은 자리에서 확인해야 한다.
## 6. 남은 것
- **`question.md` 2번(M2/M3 마일스톤 경계) 사용자 회신** — M2 착수 전 필요.
`HUMAN_TODO.md` 11번이 사용자용 진입점.
- **사용자가 직접 `/code-review`를 돌린 뒤 그 결과 반영 + 커밋.**
`conventions.md`가 이미 실측으로 기록해둔 대로(2026-08-18) code-review는
감사자가 못 보는 축을 본다 — 이번 변경은 (a) 마일스톤 경계 이동,
(b) 여러 문서에 걸친 용어/이름 일괄 치환, (c) 확정 문서의 순서 계약 정정이라
정확히 그 축에 걸린다.

View file

@ -9,8 +9,9 @@
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서 반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate` **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`, 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`
구현은 M3) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. **[2026-08-22 정정]** 구현 마일스톤은 갈린다: `EpochMap`/`Epoch`
인터페이스는 **M2**, State 본체 통합은 **M3**) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다.
**[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** — **[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** —
부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가 부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가
`Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다 `Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다
@ -21,8 +22,11 @@
열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2). 열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2).
**[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다 **[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로
되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번.
**[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀
있었으나 그건 `Epoch` 일반화 **이전** 표기다 — 게이트 노드 자신은
페이로드에 안 싣는다). 같이
제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 / 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 /
새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤 새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤
`rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) — `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) —
@ -33,9 +37,25 @@
서술**이었고, "빈 배치 emit"은 **아무것도 안 하는 것**으로 확정(그 따름정리로 서술**이었고, "빈 배치 emit"은 **아무것도 안 하는 것**으로 확정(그 따름정리로
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md` `Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md`
순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.** 순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.**
**⚠️ [2026-08-22 신설] 다만 설계가 아니라 *순서*가 막고 있다.**
`ROADMAP.md` 전반 점검에서 **M2와 M3의 의존이 양방향**인 게 드러났다 —
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 `State<number>`/
`Source<number>`를 쓰므로 M2는 `Source.luau`/`State.luau` 없이 구현이
안 되고, 반대로 M3의 `Observer`/`Effect` 동적 경로 가드는 M2의
`Dispatch.addHandler`를 쓴다. 선택지 셋(M2/M3 순서 교체 / M2를 둘로
분할 / 지금 구조 유지)은 **`question.md` 2번이 소스** — 여기서 반복하지
않는다. 같은 점검에서 `EpochMap`/`GateNode`/`Blocker`/`None`이 M3·M7에서
**M2로 실제 이동**했고(각주로만 예고돼 있던 것), `Dispatch.drive`
두 패스가 아니라 단일 일반화 `for`라는 `F-4-1` 정정과 물리 조작 주입 op
이름 `native*` 확정이 `ROADMAP.md`/`dispatch-core-plan.md`에 반영됐다.
M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도
`ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히
`luau-test/STATUS.md`).
그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate` 그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`
생명주기·재진입 계약, M2에 `Blocker`까지 넣을지, 스파이크 `05` 재작성 생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`).
(`luau-test/STATUS.md`). **[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날
`ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다.
**4라운드 — [2026-08-21] 종결.** 문항지는 **4라운드 — [2026-08-21] 종결.** 문항지는
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
@ -111,8 +131,10 @@
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
마일스톤 정합성" 절 참고). 마일스톤 정합성" 절 참고).
**아래는 M3 착수 전에 결론이 필요한 항목 목록** — M0/M2는 막혀 있지 **아래는 M3 착수 전에 결론이 필요한 항목 목록****설계** 게이트
않다(0번 항목 참고). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도 얘기다. M0은 아예 막혀 있지 않고, M2도 설계로는 안 막혀 있으나
**[2026-08-22] 마일스톤 순서 문제 하나가 M2를 막는다**(위 00번의
⚠️ 문단 + `question.md` 2번). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도
⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들 ⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들
(`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키, (`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키,
`SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키 `SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키

View file

@ -1,8 +1,12 @@
# CLAUDE.md # CLAUDE.md
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트. Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치 **[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은
엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md` M2(디스패치 엔진)** — 다만 **⚠️ M2 착수를 막는 항목이 하나 있음**:
M2와 M3의 의존이 양방향이라(M2의 Length/Offset 배관이 `State`/`Source`를
씀) 지금 순서로는 M2를 끝까지 짤 수 없다. 설계 결정이 아니라 **마일스톤
순서** 문제이고, 선택지와 근거의 소스는 `.claude/question.md` 2번(사용자
회신 대기). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`. 항상 루트 `ROADMAP.md`.

View file

@ -6,6 +6,20 @@
올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은 올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은
사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소). 사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소).
## ⭐ 11. **[2026-08-22 신설] M2 착수를 막는 마일스톤 순서 결정** — 사용자 답변 필요
**설계 결정이 아니라 순서 문제**라서 에이전트가 임의로 정하면 안 되는 자리다.
`ROADMAP.md` 전반 점검에서 **M2(디스패치 엔진)와 M3(Store/State/Source)의
의존이 양방향**인 게 드러났다 — M2의 Length/Offset 배관이 `State<number>`/
`Source<number>`를 쓰므로 **M2는 State/Source 없이 구현이 안 되고**, 반대로
M3의 동적 경로 가드는 M2의 `Dispatch.addHandler`를 쓴다(그쪽은 얕음).
**선택지 (a) M2/M3 순서 교체 / (b) M2를 둘로 분할 / (c) 지금 구조 유지**의
근거와 상세는 **`.claude/question.md` 2번이 소스** — 여기서 반복하지 않는다.
답이 나오면 `ROADMAP.md`의 마일스톤 경계와 `question.md` 2번을 같이 닫으면 된다.
---
## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용) ## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용)
Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접 Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접
@ -200,7 +214,9 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준] 마음에 안 드는 것만 답해주면 됨 — **[2026-08-22 갱신] 설계 결정 대기는
여전히 0건**이지만, **마일스톤 순서 결정 하나가 새로 열렸다**(`question.md`
2번, 위 11번 항목이 소스). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준]
`question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 — `question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 —
마지막 남았던 0-W 마지막 남았던 0-W
`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지" `Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지"

View file

@ -7,10 +7,35 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 **2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.** 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
> **✅ [2026-08-19 기준] M0 스파이크 4개 전부 통과, M1 스캐폴딩도 대부분 > **✅ [2026-08-22 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은
> 완료(quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/ > M2(디스패치 엔진)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크
> .luaurc, mock 테스트 하네스, `New()`/`RunInit`/`AddPlugin` 골격 — 아래 > 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그
> M0/M1 체크박스 참고). 다음은 M2(디스패치 엔진) 착수.** M1 착수 도중 > 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. — **⚠️ 다만 M2를 바로 착수하면 안 됩니다: M2와 M3의 의존이
> 양방향이라 이 순서로는 M2를 끝까지 짤 수 없습니다**(설계가 아니라
> 마일스톤 **순서** 문제 — 아래 M2 배너와 `.claude/question.md` 2번,
> 사용자 회신 대기). M1까지의 산출물은
> quad-base/quad-roblox 폴더+pesde.toml, 루트
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
> 재작성 대기 상태입니다** — 아래 "재검증 대기" 절 참고(개수·현황의
> 소스는 `.claude/luau-test/STATUS.md`, 여기서도 그 절에서도 세지 않음).
>
> **[2026-08-22] 이 문서에 이번에 반영된 것**: `Dispatch.drive`가 두
> 패스가 아니라 단일 일반화 `for`(`F-4-1`) / 물리 조작 주입 op 이름이
> `native*`로 확정 / `EpochMap`·`GateNode`·`Blocker`·`None`이 M2로 이동 /
> M2↔M3 의존이 양방향이라는 것(M2 헤더 배너) / `[x]` 표기 의미 분리
> (바로 아래).
>
> **⚠️ `[x]`의 의미** — 체크박스는 **"짜야 할 코드"만** 담습니다.
> "설계가 확정됐다"/"타입을 실측해봤다"는 사실은 체크박스가 아니라 각
> 마일스톤의 **`### 확정된 것`** 절(항목이 여럿일 때) 또는
> **`- **[확정된 것 — 코드 아님]**`** 불릿(하나뿐일 때)에 둡니다. 예전엔
> 둘이 같은 목록에 섞여 있어 `[x]`만 보고는 코드가 있는지 알 수
> 없었습니다 — M0/M1의 `[x]`는 실제 구현이고, M3/M6/M11에 섞여 있던
> `[x]`는 설계 확정·실측이었습니다. **새 항목을 추가할 때도 이 구분을
> 지킬 것.**
> M1 착수 도중
> wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의 > wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의
> `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는 > `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는
> `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서 > `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서
@ -62,9 +87,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 - [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 — 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 —
`luau-test/done/03-recursive-store-bind-dispatch.luau` 통과) `luau-test/done/03-recursive-store-bind-dispatch.luau` 통과)
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 - [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" **계약**이 실제
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
(**[2026-08-21 정정]** 이 계약을 **두 패스로 구현**한다는 서술은
`F-4-1`로 폐기 — 구현은 단일 일반화 `for`, 계약은 그대로. 그래서
두 루프로 짜인 스파이크 `01``rewrite-required/`로 갔다 — 아래
"재검증 대기" 참고)
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션 (2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은
`nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가 `nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가
@ -73,7 +102,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜
`nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션] `nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션]
소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** — 소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** —
정상 두 패스가 그 자리를 `ProcessedPreRefHandler`로 매치해 정상 본체 루프가 그 자리를 `ProcessedPreRefHandler`로 매치해
`Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨 `Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md` (`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드 "Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
@ -97,6 +126,42 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md` 진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`
소스, 여기서 세지 않음). 소스, 여기서 세지 않음).
### 재검증 대기 — 위 `[x]`가 검증했던 스파이크 일부가 무효화됨
**⚠️ [2026-08-22 신설]** M0의 *원래* 체크박스는 전부 닫혔지만, 그 뒤 설계가 바뀌면서
**당시 통과했던 스파이크 몇 개가 지금 계약을 검증하지 않는 상태**가 됐다.
`rewrite-required/`로 되돌아간 것들이고, **어느 마일스톤 체크박스에도
없어서 그냥 잊히기 쉬운 자리**라 여기 모은다. **무엇이 지금 어느 폴더에
있는지의 소스는 항상 `.claude/luau-test/STATUS.md`** — 여기서 세지 않는다.
- [ ] **`01`(props 순회 순서)** — 두 루프로 짜여 있어 지금 계약의 구현
(단일 일반화 `for`, `F-4-1`)과 안 맞음. 재작성하면서 "배열 파트 전체가
해시보다 먼저 + 배열 안에서는 index 순서"를 그대로 확인할 것
- [ ] **`05`(다이아몬드 전파)** — `Epoch` 리비전 비교 채택으로 다이아몬드
Observer가 이제 변경당 **1회**만 울어야 함(옛 "emit은 항상 전파" 모델을
검증 중). `base/state-epoch-plan.md` 기준으로 재작성
- [ ] **`04`(Dispatch 체인 retractFrom)** — 하강 diff 확정으로 무효화.
**M2 착수 시 같이 처리**하는 게 자연스러움
- [ ] **`19`(소유권/참조카운트 Relate 패턴)** — **B 섹션만** 낡음
(공개 `AttributeKey(name)` + 인덱스 1 점유 체크가 폐기되고 그룹 전용
키 + `AttributeKeyHandler`의 이름 claim으로 바뀜, `0-Z` 확정).
A/C 섹션은 손댈 것 없음 — **Attribute 소관이므로 M10 착수 시 같이
처리**(**[2026-08-22 정정]** 여기 "M6"라고 적었으나 B 섹션이 검증하는
건 Slot이 아니라 Attribute 이름 소유권이다)
- [ ] **`22`(Ref/PreRef/PostRef 브랜드)** — `Brand`가 인스턴스 브랜드로
전면 재작성되며 옛 `Brand.set`/`Brand.get` 구현에 의존하던 부분이
깨짐(검증 대상인 `isRef`/`isPreRef` 포함 관계 자체는 그대로).
**M8 착수 시 같이 처리**
- [ ] **`15`(`:Compute` trailing deps 타입팩)** — 이형 다중 deps를 제네릭
타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정).
**M3 착수 시 같이 처리**
- [ ] **`10`(Roblox Studio 확인)** — `bindLifetime`/`canExecute`/
`unbindLifetime` 재정정으로 무효화. **Studio 작업이라
`HUMAN_TODO.md` 1번(계정 분리)이 선행**
- [ ] **아직 파일이 없는 실측 항목**`R-11``table.insert` 구멍 재사용,
**중간 State GC**(`base/source-state-plan.md`, 상류 strong / 하류 weak
불변식 — `.claude/todos.md`가 "M3 착수 전 필요"로 지정)
## M1 — 실제 스캐폴딩 ## M1 — 실제 스캐폴딩
- [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19 - [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19
@ -131,6 +196,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> 분리 신설), 뒤집힌 옛 모델은 > 분리 신설), 뒤집힌 옛 모델은
> `.claude/archive/dispatch-hintvalue-model-reversed.md`. > `.claude/archive/dispatch-hintvalue-model-reversed.md`.
> **⚠️ [2026-08-22] M2는 이름과 달리 "디스패치만"이 아닙니다 — 반응형
> 하부구조 일부가 여기 있습니다.** `EpochMap`/`GateNode`/`Blocker`/`None`
> 체크박스가 M3·M7에서 옮겨왔습니다(각 항목의 이동 표시 참고). 그동안
> 각주로만 예고돼 있어 M2 체크리스트를 훑는 구현자에게 **항목으로 보이지
> 않던** 것을 고친 것이고, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
> 것과 같은 처리입니다.
>
> **⚠️ 다만 이동으로 드러난 더 큰 문제가 하나 남아 있습니다 — M2와 M3의
> 의존이 양방향이라, 이 순서대로면 M2를 끝까지 짤 수 없습니다.**
> 요지만: **M2는 `Source.luau`/`State.luau` 없이는 구현할 수 없고**
> (Length/Offset 배관이 `State<number>`/`Source<number>`를 쓰고,
> `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅합니다),
> 반대 방향은 핸들러 등록 표면만 요구하는 얕은 의존입니다.
> **어느 쪽으로 가를지(순서 교체 / M2 분할 / 유지)는 사용자 결정
> 대기이고, 근거와 선택지의 소스는 `.claude/question.md` 2번입니다** —
> 여기서 반복하지 않습니다.
- [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수 - [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)` 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
@ -140,14 +222,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)` 즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록, (아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)` flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None` `Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None`
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
**[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면 **[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라
`retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로 단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에 base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau
스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process` 테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
diff를 하지 않음 `type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
(`luau-test/STATUS.md`)
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
`process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable` `process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable`
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
@ -179,6 +264,46 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None`
항등 비교 — `brand-plan.md``Brand` 절, 2026-08-07 여덟 항등 비교 — `brand-plan.md``Brand` 절, 2026-08-07 여덟
번째 세션 신설) 번째 세션 신설)
- [ ] **[2026-08-22 M3에서 이동] `EpochMap.luau`** — 재사용 가능한 에포크
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
{[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고
별도 모듈로 낼 것 — `GateNode`(아래)와 `State`(M3)/`Effect`(M3)가
전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와
리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md`
- [ ] **[2026-08-22 M3에서 이동] `state:Gate(setup)` + `GateNode`** —
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을
통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet`
스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
`base/gate-plan.md`. **"게이팅 먼저" 결정으로 M3에서 앞당겨진 항목**
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
- [ ] **[2026-08-22 M3에서 이동] `Blocker.luau`** — 위 `GateNode` 위에
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
(`base/blocker-plan.md`). 아래 `Dispatch.setLength`/`setOffsetSource`의
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M2에 필요**
- [ ] **[2026-08-22 M7에서 이동] `None.luau`(센티널) + `Dispatch/None.luau`
(`NoneHandler` + `NilHandler`)** — `None`은 modifier 전용 값이 아니라
디스패치 배관이라 여기 소속(`architecture.md`의 소스 트리에 있는 건
핸들러 쪽 `Dispatch/None.luau`뿐 — **[2026-08-22] 탑레벨 센티널
`None.luau``Brand.luau`는 그 트리에 아직 줄이 없다**, M2 구현 시
트리도 같이 채울 것).
`NoneHandler`는 배열/해시 구분 없이 `Dispatch.process(inst,k,nil,index+1)`
**재귀만** 하고(선행 `retractFrom` 없음 — 하강 diff), 실제 정리는
`NilHandler`(`isHandlable`이 `type(k) == "number" and v == nil`일 때만
매치하는 말단)가 `Dispatch.setLength(inst,k,0)` +
`Dispatch.setOffsetSource(inst,k,None)` 등록으로 맡는다.
**`Dispatch.drive``None` 스킵 분기는 없다**(반응형 값이 내놓는
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
`inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy
@ -190,8 +315,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
`canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9, 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9 —
`question.md` 2번 — 2026-08-07 네 번째 세션에 반영). 2026-08-07 네 번째 세션에 반영. **[2026-08-22 정정]** 여기 있던
`question.md` 번호 참조는 그 항목이 해소되며 이미 깨져 있었고,
지금 그 번호는 다른 항목이 쓰고 있어서 지웠다).
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 **[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는
`inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음. `inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.
`bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` `bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value`
@ -242,29 +369,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는
`bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가 `bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가
바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker` 바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker`
(`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는 (`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만 lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감.
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는 **[2026-08-22] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은 이제
`Blocker`를 참조하게 됨.** 이 마일스톤에 체크박스로 있다**(위 세 항목) — 예전엔 M3에 있는 걸
**✅ [해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] "게이팅 먼저"로 각주로 가리키기만 했다. 결정 경위("게이팅 먼저", 그리고 앞당기는
결정 — 게이팅을 M2로 앞당긴다**(사용자: *"게이팅 먼저. 게이팅을 대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함"*). 단 `qa-request/pre-implementation-qa-round5-followup.md`
**앞당기는 대상이 `Blocker` 자체가 아니라 그 아래의 공용 `Gate` `base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
노드**로 바뀌었다(같은 라운드 `DT-4` — 시간 기반 게이트는 공개
`Blocker` API 위에 못 얹히므로, emit을 가로채는 노드를 한 겹
일반화해 `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 되게
한다). **[2026-08-21 확정] 그 노드의 표면은 `state:Gate(setup)`
메소드이고 노드 타입 이름은 `GateNode`**(`ComputeNode`와 같은 층위)
— 탑레벨 `Gate(...)` 프리미티브는 안 만든다. 상세와 남은 항목
(생명주기·재진입 계약, M2 범위)은 `base/gate-plan.md`. 최소한
배치 등록이 실제로 쓰는 `On`/`IsOn`/`OffWithoutEmit`이 도는
형태까지는 M2에 필요하다. 경위는
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
정합성" 절과 `pre-implementation-qa-round5-followup.md`.
**[2026-08-21 추가] `GateNode``emitEpochMap`을 쓰므로 `EpochMap.luau`
(M3 목록에 있음)도 M2에 같이 들어와야 한다** — `base/gate-plan.md` 4번,
`base/state-epoch-plan.md` §3.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
@ -333,7 +446,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에 `inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은 걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관 `bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
- [x] `store.key` dot-access 타입 추론 확인 — Luau `type function` - **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를 (`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 `Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]** 번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
@ -354,10 +467,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`luau-test``15-type-compute-trailing-deps-typepack.luau` `luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정) 되면 동종 타입 dep 1개로 한정)
- [ ] **[2026-08-21 5라운드 — 순서 주의]** 아래 `Blocker.luau`가 올라서는 > **[2026-08-22 이동] `EpochMap.luau` / `state:Gate` + `GateNode` /
**공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정, > `Blocker.luau`는 M2로 옮겼습니다** — 셋 다 M2가 실제로 호출하는데
위 M2 각주 + `base/gate-plan.md`). M3에서 `Blocker`를 짤 때는 > 각주로만 예고돼 있어서, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것. > 전례대로 체크박스 자체를 옮겼습니다. M3는 그 위에 State/Source 본체를
> 얹기만 하면 됩니다.
- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의 - [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의
재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`) 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
`invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸 `invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸
@ -371,13 +485,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`). (`luau-test/STATUS.md`).
- [ ] `EpochMap.luau` — 위 부기 객체. `Effect`(M3)와 `GateNode`(M2)도 같은
것을 쓰므로 `State.luau`에 묻지 말고 별도 모듈로 낼 것.
**`GateNode`가 M2로 앞당겨졌으므로 이 모듈도 실제로는 M2에 필요하다**
(위 M2 게이팅 각주).
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive,
State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`" - [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`"
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도 부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
@ -447,7 +554,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.** > **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.**
> `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/ > `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/
> `nativeDispose`(이름 가칭). base가 `Parent`를 모른다는 원칙을 실제로 지키기 > `nativeDispose`. base가 `Parent`를 모른다는 원칙을 실제로 지키기
> 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은 > 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은
> `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만 > `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만
> 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게 > 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게
@ -489,6 +596,142 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나 > "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나
> `nil`임이 계약으로 보장된다는 점만 새로 추가됨. > `nil`임이 계약으로 보장된다는 점만 새로 추가됨.
### 확정된 것 — 코드 아님, 구현 전 필독
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
(**[2026-08-22]**).
- **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
— 에러 조건까지 전부 확정
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)`
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
`IndexOf`는 순수 읽기라 가드 대상 아님).
**[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체
+ 이전 것 **파괴**`Extract`의 파괴 짝, `Remove``Extract`와 같은 축)와
`rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍**
(`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데,
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List``prev`는 전부
**언래핑된 원래 값**이다). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
`question.md` 1번 용어정리 항목은 해소되어
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
Slot 타입.
- **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
raw 요소 항목 참고.
- **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
자신을 owner 키**로 재사용하는 재귀 `attachSlot`으로 최상위/중첩
마운트 통합(새 프리미티브 없음). `Slot.Length`가 raw 개수에서
"요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 `Clear()`
아니라 flat `destroySlotTree`(파괴 walk + `unbindLifetime` walk,
outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리
서브트리만 죽는 경우 명시적 `unbindLifetime` 필요(GC-native 정리의
예외 케이스). DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) —
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록
물리 조작이 **`native*` 주입 op 계층**으로 정리됐고(M5 배너가 목록,
시그니처는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절 —
**[2026-08-22 정정]** 여기 확정 전 가칭 `mountInst`/`unmountInst`/
`disposeInst`가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
`reference/slot-attach-decomposition.md`.
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
- **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
`Add→Remove`와 동일하므로. **`_crudUsed``_listed` 상호 배타
가드 신설** — 기존엔 `:List` 설치 후 수동 CRUD만 막았지 반대(수동
CRUD 후 `:List` 설치)는 안 막아서 `:List`의 reconcile이 기존
요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/
slot-plan.md` "CRUD API 확정" 절).
- **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
dispatch-core-plan.md` "Length/Offset" 절) — `sum` 누적과
`offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던
버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입
방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가져서 nesting만으로는 같은 `bk`가 재진입되는 경로
자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute
도중 같은 Slot의 length에 다시 쓰기)은 `Source⊇State`의 "단방향"
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
- **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
`_elements``None`이 절대 안 들어감(비어있는 nested Slot이 자연히
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
`:Single``updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
### 짜야 할 것
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]** - [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일). - **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 — 비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 —
@ -528,57 +771,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게 내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게
지우는 유일한 경로. 마운트 위치는 `elementOwner`가 이미 알고 있어 지우는 유일한 경로. 마운트 위치는 `elementOwner`가 이미 알고 있어
새 부기 불필요. `isSlot(value)`면 그 경로, 아니면 백엔드가 주입하는 새 부기 불필요. `isSlot(value)`면 그 경로, 아니면 백엔드가 주입하는
`disposeInst(inst): ()`(`addTag`/`removeTag`/`setAttribute`와 같은 `nativeDispose(element): ()`(`addTag`/`removeTag`/`setAttribute`와 같은
"base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임. "base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임
(**[2026-08-22 정정]** 여기 `disposeInst`라 적혀 있었으나 이름은
2026-08-21에 `native*` 계층으로 확정됨 — M5 배너 참고).
**`Observer`/`Effect`는 범위 밖**(GC-native `bindLifetime`/ **`Observer`/`Effect`는 범위 밖**(GC-native `bindLifetime`/
`unbindLifetime`만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째 `unbindLifetime`만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째
세션에 `question.md` 0-B 해소, 정본은 `base/slot-plan.md` 세션에 `question.md` 0-B 해소, 정본은 `base/slot-plan.md`
"`dispose(value)`" 절 "`dispose(value)`" 절
- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
— 에러 조건까지 전부 확정
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)`
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
`IndexOf`는 순수 읽기라 가드 대상 아님).
**[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체
+ 이전 것 **파괴**`Extract`의 파괴 짝, `Remove``Extract`와 같은 축)와
`rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍**
(`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데,
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List``prev`는 전부
**언래핑된 원래 값**이다). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
`question.md` 1번 용어정리 항목은 해소되어
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를 `keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를
그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드 그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드
@ -650,88 +851,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + - [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
- [x] **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
raw 요소 항목 참고.
- [x] **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
자신을 owner 키**로 재사용하는 재귀 `attachSlot`으로 최상위/중첩
마운트 통합(새 프리미티브 없음). `Slot.Length`가 raw 개수에서
"요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 `Clear()`
아니라 flat `destroySlotTree`(파괴 walk + `unbindLifetime` walk,
outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리
서브트리만 죽는 경우 명시적 `unbindLifetime` 필요(GC-native 정리의
예외 케이스). DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) —
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록
물리 조작이 주입 op(`mountInst(target, element, index)`/`unmountInst`/
`disposeInst`)로 정리됐고, 중첩 offset이 부모 베이스를 못 받던 결함과
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
`reference/slot-attach-decomposition.md`.
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
`Add→Remove`와 동일하므로. **`_crudUsed``_listed` 상호 배타
가드 신설** — 기존엔 `:List` 설치 후 수동 CRUD만 막았지 반대(수동
CRUD 후 `:List` 설치)는 안 막아서 `:List`의 reconcile이 기존
요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/
slot-plan.md` "CRUD API 확정" 절).
- [x] **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
dispatch-core-plan.md` "Length/Offset" 절) — `sum` 누적과
`offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던
버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입
방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가져서 nesting만으로는 같은 `bk`가 재진입되는 경로
자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute
도중 같은 Slot의 length에 다시 쓰기)은 `Source⊇State`의 "단방향"
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
- [x] **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
`_elements``None`이 절대 안 들어감(비어있는 nested Slot이 자연히
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
`:Single``updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
## M7 — Modifier ## M7 — Modifier
- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 - [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션
@ -765,26 +884,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번, `archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번,
`brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미 `brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미
구현돼 있어야 함) 구현돼 있어야 함)
- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 > **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/
(이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + > `NilHandler`)는 M2로 옮겼습니다** — `None`은 modifier 전용 값이 아니라
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` > quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도
(`dispatch-core-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 > `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M2의
"이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 > `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다.
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널 > M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를
자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler` > 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md`
쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** > 2-1번).
그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄
(`base/dispatch-core-plan.md`).
**[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive``None` 스킵
분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`
도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고,
실제 정리는 아래 `NilHandler`가 맡는다
- [ ] **[2026-08-18 신설]** `NilHandler``isHandlable`
`type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러.
`Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)`
등록이 이 핸들러의 일이고 재귀는 안 함(`State<Slot|nil>`도 정상
동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`
"`NilHandler`" 절)
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성 - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
@ -808,8 +915,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치 살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치
방지" 절 방지" 절
- [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이 - [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이
`Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프 `Dispatch.drive(inst, flattened)` 자신이 **본체 루프 전에** 배열
전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고 파트를 **한 번** 훑어(**[2026-08-22 정정]** 여기 "두 패스(배열→해시)
루프 전에"라고 적혀 있었으나 본체는 단일 일반화 `for`다 — `F-4-1`), `PreRef`는 그 자리에서 fire하고
`PostRef`는 로컬 `postRefList`에 push만 함(Dispatch.process/getHandler `PostRef`는 로컬 `postRefList`에 push만 함(Dispatch.process/getHandler
우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten 우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten
재호출 가능성과 충돌하므로 기각). **복수 `PreRef`/`PostRef`의 계열 안 재호출 가능성과 충돌하므로 기각). **복수 `PreRef`/`PostRef`의 계열 안
@ -820,9 +928,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
PreRef`류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두 PreRef`류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두
번째 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef`/ 번째 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef`/
`ProcessedPostRef` 처리** — 아래 `Processed*Handler` 항목이 그 자리를 `ProcessedPostRef` 처리** — 아래 `Processed*Handler` 항목이 그 자리를
정상 두 패스로 마저 처리) 정상 본체 루프로 마저 처리)
`base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절 `base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절
- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 두 패스 - [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 본체 루프
`postRefList` 소비 루프 — `PreRef.luau`와 같은 방식(`Ref` 런타임 `postRefList` 소비 루프 — `PreRef.luau`와 같은 방식(`Ref` 런타임
재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store 재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store
타입 차단, `_fired` 1회용 가드). `Dispatch.drive`가 해시 파트까지 타입 차단, `_fired` 1회용 가드). `Dispatch.drive`가 해시 파트까지
@ -914,13 +1022,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
(`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
세션 확정·2026-08-11 아홉 번째 세션 후속(캐시)) 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau` - [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`
주입되는 엔진 op 3개**: `addTag(inst,{string})`/`removeTag(inst,{string})` Tag/Attribute 몫** — `addTag(inst,{string})`/`removeTag(inst,{string})`
(`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제). (`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제).
`RobloxFactory``BaseModule`에 주입(`bindLifetime`/`canExecute`와 `RobloxFactory``BaseModule`에 주입(`bindLifetime`/`canExecute`와
같은 패턴) — 아래 base 핸들러들이 이걸 호출함 같은 패턴) — 아래 base 핸들러들이 이걸 호출함
(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는
엔진 op" 절) 엔진 op" 절). **[2026-08-22 정정] 여기 "주입되는 엔진 op 3개"라고
적혀 있었으나 이 파일이 담는 op은 그 셋이 전부가 아니다** — M5의
`native*` 계층이 같은 파일에 들어오고 백로그의 `setTimeout`/
`clearTimeout`도 예정돼 있다. **주입 op 전체 목록의 소스는
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
세지 않는다.
- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` + - [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` +
이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리
`String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리 `String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리
@ -995,6 +1108,27 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Tween<T>` 래퍼 모델로 전환 — 상세는 `base/tween-plan.md`(전면 `Tween<T>` 래퍼 모델로 전환 — 상세는 `base/tween-plan.md`(전면
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`.
### 확정된 것 — 코드 아님, 구현 전 필독
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
(**[2026-08-22]**).
- **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
- **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
`Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
전부 plain만(State 불가)
- **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
### 짜야 할 것
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ - [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
`TweenBrand`, `Value: T` plain만 받고 State 재귀 없음) `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
- [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존 - [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존
@ -1003,16 +1137,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이
스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만
새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
- [x] **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
- [x] **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
`Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
전부 plain만(State 불가)
- [ ] `quad-roblox/Animate.luau`**시그니처도 이미 확정 완료**(2026-08-12 - [ ] `quad-roblox/Animate.luau`**시그니처도 이미 확정 완료**(2026-08-12
두 번째/세 번째 세션, `base/tween-plan.md`): `Tween` opts(`Value` 제외)를 두 번째/세 번째 세션, `base/tween-plan.md`): `Tween` opts(`Value` 제외)를
`T|State<T>`로 받아 각 필드를 resolve한 뒤 `Tween{...}`을 반환하는 `T|State<T>`로 받아 각 필드를 resolve한 뒤 `Tween{...}`을 반환하는
@ -1020,9 +1144,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
아님, `research/operator-sugar-plan.md` "왜 `:Apply`인가"). `CanAnimate` 아님, `research/operator-sugar-plan.md` "왜 `:Apply`인가"). `CanAnimate`
필드 포함(`false`면 `Tween`으로 안 감싸고 plain 값 그대로). M11은 필드 포함(`false`면 `Tween`으로 안 감싸고 plain 값 그대로). M11은
**구현만** 하면 됨 **구현만** 하면 됨
- [x] **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
## 특정 마일스톤에 안 묶이고 병행 가능 ## 특정 마일스톤에 안 묶이고 병행 가능
- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate` - [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`