From 9622ccd3e93a4dcdb6ea13a700aa3b948368ff86 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 22 Aug 2026 03:19:13 +0900 Subject: [PATCH] =?UTF-8?q?design:=20ROADMAP=20=EB=A7=88=EC=9D=BC=EC=8A=A4?= =?UTF-8?q?=ED=86=A4=20=EA=B2=BD=EA=B3=84=20=EC=9E=AC=ED=8E=B8=20+=20?= =?UTF-8?q?=EC=A0=84=EB=B0=98=20stale=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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`, `setOffsetSource`가 `Source`를 받고 `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 --- .claude/README.md | 10 +- .claude/base/architecture.md | 9 +- .claude/base/blocker-plan.md | 11 +- .claude/base/debounce-throttle-plan.md | 17 +- .claude/base/dispatch-core-plan.md | 112 ++-- .claude/base/gate-plan.md | 36 +- .claude/base/lifecycle-hooks-plan.md | 9 +- .claude/base/module-lifecycle-plan.md | 16 +- .claude/base/ref-plan.md | 39 +- .claude/base/source-state-plan.md | 7 +- .claude/base/state-epoch-plan.md | 7 +- .claude/project-context.md | 9 +- .claude/question.md | 41 +- .../reference/slot-attach-decomposition.md | 2 +- .../2026-08-22-01-roadmap-milestone-review.md | 234 ++++++++ .claude/todos.md | 38 +- CLAUDE.md | 8 +- HUMAN_TODO.md | 18 +- ROADMAP.md | 557 +++++++++++------- 19 files changed, 858 insertions(+), 322 deletions(-) create mode 100644 .claude/session/2026-08-22-01-roadmap-milestone-review.md diff --git a/.claude/README.md b/.claude/README.md index 40cdd68..8e49008 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -49,15 +49,15 @@ | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | -| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | +| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | | `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | -| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-22 정정]** 구현은 M3가 아니라 **M2**이고, 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | | `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`) | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | @@ -71,8 +71,8 @@ | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | -| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **남은 것은 생명주기·재진입 계약과 M2 범위**(`Gate`만 vs `Blocker`까지). 구현은 M2 | -| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** 구현은 M3 | +| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **남은 것은 생명주기·재진입 계약뿐**(**[2026-08-22]** 미결이던 "M2 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 M2**로 해소). 구현은 M2 | +| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-22 정정]** 구현 마일스톤은 둘로 갈린다 — 부기 객체 `EpochMap.luau`와 `Epoch` 인터페이스·리비전 갱신은 **M2**(`GateNode`가 씀), State 본체 통합은 **M3** | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 6ea59d8..320d226 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -261,9 +261,10 @@ quad/ │ ├── pesde.toml │ └── src/ │ ├── 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 그대로 반환 -│ ├── 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`) │ ├── 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`) @@ -290,8 +291,8 @@ quad/ └── quad-roblox/ ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── src/ - ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) - ├── 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" 절) + ├── 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은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[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 그대로 재사용) ├── Handlers/ │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 6e7b44a..60a3694 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -18,12 +18,17 @@ **store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State 위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State -온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 -State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. +온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. +**⚠️ [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(무효화 신호) 전파를 지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기 -`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md` +`invalid` 상태를 이유로는 **절대 전파를 접지 않고**(`base/source-state-plan.md` "전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된 게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/ debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에 diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index c3b6fc6..0ac34be 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -579,7 +579,8 @@ h.Value:Flush() > 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고, > 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를 > 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건 -> 알고리즘 + 인터페이스이고, 미배선 상태에선 엔진 op 3개와 같은 관례대로 +> 알고리즘 + 인터페이스이고, 미배선 상태에선 `addTag`/`setAttribute` 계열 +> 엔진 op과 같은 관례대로 > **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에 > "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에 > 의존함** — 스로틀만 타이머 없이 되는 게 아님. @@ -629,8 +630,12 @@ clearTimeout(handle: Timeout): () 인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉 varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈. -- **미주입 백엔드에서는 base 스텁이 명확한 에러** — 엔진 op 3개와 동일한 - 관례. 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을 +- **미주입 백엔드에서는 base 스텁이 명확한 에러** — `addTag`/`setAttribute` + 계열 엔진 op과 동일한 관례(**[2026-08-22]** 여기 "엔진 op 3개"라고 세어 + 놨었는데, 정작 이 문서가 추가하는 `setTimeout`/`clearTimeout` 자신이 그 + 개수를 늘리는 쪽이라 자기모순이었다. 주입 op 전체 목록의 소스는 + `base/architecture.md`의 `EngineOps.luau` 줄. **주의**: `native*` 계층은 + 이 관례의 예외로, 미주입이 에러가 아니라 **조합 폴백**이다). 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을 만들어낼 수 없음. - **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도 trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된 @@ -1118,8 +1123,10 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했 막힘). **의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/ -`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서 -확정되는 것들이라 그 이후 언제든 얹을 수 있다. **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를 +`clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]** +각각 **M2**(게이트/`Blocker`) / **M3**(State 코어) / **M8**(`Ref`)에서 +확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" — +`Blocker.luau`가 M2로 옮겨지기 전 기준). **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를 공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정 "게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로). 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index a7fa837..46098fb 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -414,7 +414,7 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이 깨진다. 방어는 그대로 `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` 버전으로 재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index 순서"** 를 그대로 확인할 것. @@ -427,7 +427,8 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 **서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이 아니라 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 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던 항목은 닫혔다. @@ -437,20 +438,32 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건 의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도 하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`). - **그런데도 base 드라이버가 두 패스를 명시적으로 강제하는 이유는 순서를 - 못 믿어서가 아니라 두 가지다**(위 (1)/(2)번 그대로): - 1. **이식성** — 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 - 아닌 자료구조로 표현할 수 있고, 그러면 "Lua 테이블의 순회 동작"이라는 - 근거 자체가 없어진다. 계약을 드라이버가 직접 들고 있어야 한다. - 2. **어차피 구분 비용이 이미 든다** — 숫자 키와 문자열 키를 다른 의미로 - 처리해야 하므로, 순서까지 명시적으로 고정하는 건 거의 공짜다. + **그런데도 base 드라이버가 이 순서를 *계약*으로 들고 있는 이유는 순서를 + 못 믿어서가 아니다** — **⚠️ [2026-08-21 `F-4-1` 이후 정정]** 여기 한때 + 이유가 둘("이식성" + "구분 비용이 이미 듦")이라고 적혀 있었으나, **위 + `F-4-1` 정정 문단이 이식성 논거를 이미 기각했다**(`flattened`는 항상 + 사용자가 Luau로 쓴 평범한 테이블이라, 다른 백엔드가 와도 이 자료구조는 + 안 바뀐다 — 백엔드에 따라 달라지는 건 `inst`뿐). 남는 이유는 하나다: + **숫자 키와 문자열 키를 어차피 다른 의미로 처리해야 하므로, 순서까지 + 계약으로 고정하는 건 거의 공짜다.** 그리고 그 계약을 얻는 데 필요한 + 구현은 **두 패스가 아니라 한 루프 안의 `type(k) == "number"` 분기**다. - 즉 스파이크 `01`이 검증한 건 **"우리가 짠 두 패스 드라이버가 계약대로 - 도는가"**이지 언어 동작이 아니다 — 그 스파이크 주석도 "사용자가 이미 - REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 - … 두 패스를 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다. - **`nil`-hole로 배열 파트가 통째로 해시 취급이 되는 케이스**(구멍 있는 - 테이블)는 이것과 별개 문제이고 `02`/`06` 스파이크가 담당한다. + **⚠️ [2026-08-22 정정] `F-4-1` 이후로는 "base가 언어 동작에 안 기댄다"고 + 말할 수 없다.** 여기 한때 *"스파이크 `01`이 검증한 건 '우리가 짠 두 패스 + 드라이버가 계약대로 도는가'이지 언어 동작이 아니다"*, *"base는 이 우연한 + 동작에 기대지 않고 명시적으로 강제한다"*라고 적혀 있었는데, **단일 일반화 + `for` 구현은 정확히 그 언어 동작에 기대는 구현이다.** 지금 정확한 서술은: + - 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로 정정) @@ -554,9 +567,12 @@ end 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 - 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 - `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체. - **[2026-08-14 아홉 번째 세션] 이 두 패스 앞뒤에 `Ref` 계열 훅 처리가 + 파트(프로퍼티/이벤트) 나중이라는 **순서 계약대로** 각 `(k,v)`에 + `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체 + (**[2026-08-21 `F-4-1`]** 그 계약을 얻는 구현은 두 루프가 아니라 + **단일 일반화 `for` + `type(k) == "number"` 분기** — 위 `F-4-1` 정정 + 문단). + **[2026-08-14 아홉 번째 세션] 이 본체 루프 앞뒤에 `Ref` 계열 훅 처리가 붙음** — 앞에는 `PreRef`/`PostRef`를 한 번에 훑는 pre-pass(`PreRef`는 그 자리에서 fire, `PostRef`는 이 호출에만 로컬인 `postRefList`에 적재만 하고 둘 다 전용 센티널로 소진), 뒤에는 그 `postRefList`를 순회하며 각 @@ -740,18 +756,28 @@ end Dispatch 바깥에도 적용됨** — `dispose(value)`(`base/slot-plan.md`)는 Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은 `elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만 - `disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) — - **[2026-08-21 5라운드] 같은 계열로 `mountInst(target, element, index)`/ - `unmountInst(element)`가 추가될 예정**(base는 `Parent`를 모른다는 지적에서 - 나옴, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절). **이름은 아직 - 가칭**이라 이 목록에 정식 등재하는 건 그 확정 시점에 한다 — - 2026-08-14 열 번째 세션에 같은 원칙으로 확정. + `nativeDispose(element: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) — + **[2026-08-21 5라운드, 같은 날 이름 확정] 같은 계열이 `native*` 물리 트리 + 조작 계층으로 정리됐다**(base는 `Parent`를 모른다는 지적에서 나옴). + **⚠️ [2026-08-22 정정] 여기 한때 `disposeInst`/`mountInst(target, element, + index)`/`unmountInst(element)`로 적히고 "이름은 아직 가칭이라 정식 등재는 + 확정 시점에 한다"고 미뤄져 있었으나, 이름은 같은 날 `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·시그널 같은 엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작 (재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) — 이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend. -**주입되는 엔진 op**: +**Tag/Attribute가 쓰는 주입 op**(**⚠️ [2026-08-22] 이건 주입 op *전체 +목록*이 아니다** — 이 절이 다루는 Tag/Attribute 경로에 필요한 셋일 뿐이고, +`native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`은 여기 없다. 전체 +목록의 소스는 위에서 지정한 `base/architecture.md`의 `EngineOps.luau` 줄 +하나다 — 여기에 다시 쌓지 말 것): ```lua addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신 @@ -1498,7 +1524,8 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 ` 것뿐, 그래서 별도 방어 로직도 필요 없음. **⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.** -`mountInst`에 물리 삽입 위치를 어떻게 주느냐는 질문에서 결함 둘이 드러났고, +물리 삽입 op(당시 가칭 `mountInst`, 확정 이름은 `nativeInsert`)에 삽입 위치를 +어떻게 주느냐는 질문에서 결함 둘이 드러났고, 사용자가 제시한 방향으로 정리됐다: 1. **`sourceList`의 `None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를 @@ -1531,7 +1558,7 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 ` ```lua -- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환. --- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치, +-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — nativeInsert의 삽입 위치, -- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다. function Dispatch.getOffsetAt(ownerKey, at) local bk = getBookkeeping(ownerKey) @@ -1783,7 +1810,7 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser 등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 + `bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면 **얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가 -필요한 쪽(예: `mountInst`의 삽입 위치)은 `getOffsetAt`을 직접 부른다: +필요한 쪽(예: `nativeInsert`의 삽입 위치)은 `getOffsetAt`을 직접 부른다: ```lua -- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서 @@ -1878,12 +1905,16 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임 순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다. -**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 -실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: -Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSource`/ -`setLength` 등록 → `recompute` → 뒤 형제 offset 갱신이 동기적으로 여기서 끝남) -그 다음에 물리 마운트(`mountInst`)를 호출한다 — 트리에 보이는 시점엔 -다운스트림이 이미 정합적. +**⚠️ [2026-08-22 정정] 여기 있던 "동기 순서 — offset 갱신이 마운트보다 +먼저 끝나야 함" 문단은 위 역전으로 폐기됐다.** 그 문단은 *"Slot의 `rawAdd`는 +부기를 먼저 완결하고(`setOffsetSource`/`setLength` 등록 → `recompute`) 그 +다음에 물리 마운트를 호출한다"*고 적어, 바로 위 3번(자기 자리 먼저 → +`nativeInsert` → 뒤를 미는 것 나중)과 **정반대**였다. 실제 의사코드도 3번 +쪽이다(`base/slot-plan.md`의 `rawAdd`). 이번 세션이 그 문단의 옛 op 이름만 +`native*`로 바꾸고 순서 주장은 지나쳐서 남아 있던 것. +그 문단이 근거로 들던 "한 프레임 순서가 깨진 채 노출될 위험"은 **위 +⚠️ 문단이 이미 기각**했다 — 프레임 경계가 안 끼므로 중간 상태 자체가 +사용자에게 안 보인다. **⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`가 `self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자 @@ -1900,9 +1931,14 @@ Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSo 순회 쪽뿐이다(위 `Get` 가드 문단). **확정**: `Length`는 **`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리 -부기를 등록하고 `recompute`를 한 번 부를 뿐이고, 그게 `Parent` 대입 앞에 -오므로 "부기가 물리보다 먼저"는 그대로 지켜진다(사용자 확인: *"recompute 가 -length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"*). +부기를 등록하고 `recompute`를 한 번 부를 뿐이다. +**⚠️ [2026-08-22 정정]** 여기 *"그게 `Parent` 대입 앞에 오므로 '부기가 물리보다 +먼저'는 그대로 지켜진다"*(+ 사용자 인용 *"recompute 가 length 를 잘 처리해놓고 +나서 빈 공간에 들어가므로"*)라고 적혀 있었으나, 그건 **같은 라운드에 역전된 +옛 C-7 일반 규칙**을 전제한 서술이다 — 지금 단건 경로는 `setOffsetSource` → +`nativeInsert` → `setLength` → `recompute`라 `recompute`가 물리 삽입보다 +**뒤**다(위 "지금의 계약" 3번). 이 절의 결론(`Length`를 쓰는 주체는 +`recompute` 하나뿐)은 그 순서와 무관하게 그대로 유효하다. 의사코드는 `base/slot-plan.md`의 `rawAdd`/`rawReplace`가 소스. **`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 @@ -1913,7 +1949,7 @@ length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동 `recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, `offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 -(**[2026-08-21 5라운드 재확인]** `mountInst`가 삽입 위치를 받게 된 뒤에도 이 +(**[2026-08-21 5라운드 재확인]** `nativeInsert`가 삽입 위치를 받게 된 뒤에도 이 결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면 백엔드 op이 **아토믹한 최소 단위**여야 한다) — diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 4dbca3a..332bc91 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -7,7 +7,10 @@ GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 ← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저" 결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다 구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은 -날 `emit(self)` + 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.** +날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.** +(**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나 +그건 `Epoch` 일반화 **이전** 표기다 — 지금 싣는 건 떼어낸 `EpochSet` +스냅샷이고 게이트 노드 자신은 안 싣는다, 바로 아래 ⚠️ 문단이 소스.) **⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고 `Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안 @@ -33,11 +36,17 @@ emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`s 1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을 전제하므로 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 위에는 시간 기반 게이트를 못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용 - `Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를 - 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다. + `Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를 + 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로 + **당시** 서술은 "M3에서 Blocker를 만들 때"였다 — **[2026-08-22]** 지금은 + `Blocker.luau`도 M2다, 아래 9번). **사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가: *"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 @@ -124,8 +133,8 @@ end) `base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기 위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목. -4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수 - 집합.** 문제는 실재했다: 확정 `setup`은 `(emit: () -> ()) -> (() -> ())`라 +4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — 흡수 집합 + 스냅샷.** 문제는 실재했다: 확정 `setup`은 `(emit: () -> ()) -> (() -> ())`라 양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부 `[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무 원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21 @@ -272,10 +281,17 @@ end) - **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가 아니다** — 아래 7번 참고. -9. **M2 범위.** M2에 `Gate`만 넣고 `Blocker`는 M3에 그대로 둘지, 아니면 - `Blocker`까지 같이 앞당길지. `Dispatch.drive`의 배치 등록이 실제로 쓰는 건 - `blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절), **최소한 - 그 세 메서드가 도는 형태까지는 M2에 필요**하다. +9. **M2 범위 — [2026-08-22 해소] `Gate`와 `Blocker` 둘 다 M2다.** + `Dispatch.drive`의 배치 등록이 실제로 쓰는 건 + `blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한 + 그 세 메서드가 도는 형태까지는 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번(사용자 회신 대기). ## 관련 문서 diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index a2871d0..9989829 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -210,7 +210,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 > 이르게 된 조사/논거를 그대로 보존한 것. `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와 시점이 갈리는 건 아래 항목뿐). - **`ProcessedPostRefHandler`는 `ProcessedPreRefHandler`와 완전히 - 대칭**: 정상 두 패스가 `ProcessedPostRef`를 매치해 + 대칭**: 정상 본체 루프가 `ProcessedPostRef`를 매치해 `setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를 반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는 - 소진 전 원본 값이 정상 두 패스의 매치 대상이어야 한다"고 + 소진 전 원본 값이 정상 본체 루프의 매치 대상이어야 한다"고 잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안 생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서 `isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 2a60ea1..0e66db0 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -309,8 +309,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에 - 엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/ - `setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기 + Tag/Attribute용 엔진 op이 추가됨** — `addTag(inst,{string})`/ + `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로 옮겨오면서, 엔진에 실제로 손대는 마지막 한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/ @@ -326,8 +331,11 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반 원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인 이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라 - 누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 - 뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 + 누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** + 즉 이 경로에서 백엔드 팩토리가 뮤테이션으로 채우는 건 **핸들러가 아니라 + 엔진 op 쪽**이다(**[2026-08-22 정정]** 여기 "`addTag`/`removeTag`/ + `setAttribute`**만**"이라고 셋으로 못박혀 있었으나 위와 같은 이유로 + 그 셋이 전부가 아니다). 아직 아무 팩토리도 안 채운 슬롯의 기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서). 더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 7b1fb39..670f371 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -387,7 +387,7 @@ Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 ch **children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절에 -새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) +새로 추가된 순서 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 어디에 놓느냐가 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 @@ -442,12 +442,22 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 - base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 + base 드라이버는 위 본체 루프(배열→해시 순서 계약)를 돌기 **전에** 별도의 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, - 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 - 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 - 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 + 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 본체 + 루프로 처리하면 됨 — 이 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 아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로 보장.** 같은 인스턴스에 `PreRef`가 여럿 있으면, 이 pre-pass는 위 @@ -501,10 +511,10 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음 (2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를 분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler` - 참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + 참고. (2) 그 다음에야 비로소 평소의 배열→해시 본체 루프가 **같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은 **정상 `Dispatch.process` 경로를 그대로 탄다**(아래 - `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스 + `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 본체 루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤 Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정, @@ -556,7 +566,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store - **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`도 `Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는 전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에 - `Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가 + `Dispatch.process` 자체를 절대 안 탄다(본체 루프가 `if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number` 조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데, 리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State }` @@ -594,8 +604,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 문서화까지 검토할 것. - **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신, 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).** - `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를 - 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 + `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 순서로 + 도는 함수로 확정돼 있으므로(**[2026-08-22 정정]** 여기 "두 패스를 도는 + 함수"라고 적혀 있었으나 그 순서는 **계약**이고 구현은 단일 일반화 + `for`다 — `base/dispatch-core-plan.md`의 `F-4-1` 정정 문단), + 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst, flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 @@ -640,7 +653,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정, 2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**, 위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만 - 매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔 + 매치)에는 다시 노출되지 않게** 하므로(정상 본체 루프 스캔 자체엔 `ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이 아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 @@ -672,7 +685,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서 즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인 PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드" - Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는 + Handler(정상 본체 루프에서 매치)와는 별개 코드 경로 — 이 가드는 pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과. - **관용구**: `Slot:List`의 `updateFn`처럼 반복 호출되는 자리에서 `PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에 @@ -793,7 +806,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 갈리는 건 아래 3번뿐). - `postRefList`는 **`Relate` 같은 별도 저장소가 아님** — 이 함수 콜스택 안에서만 살면 되므로 그냥 로컬 테이블. -2. **정상 두 패스**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`로 +2. **정상 본체 루프**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`로 소진된 슬롯은 `ProcessedPreRef`와 **완전히 대칭적으로** 정상 `Dispatch.process` 경로를 타고 아래 전담 Handler에 매치됨. 3. **두 패스가 끝난 뒤 `Dispatch.drive`가 `postRefList`를 순회하며 각 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 256e64a..08db352 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -389,8 +389,11 @@ lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy `Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 -`base/blocker-plan.md`가 다룸(State 개발과 같은 -마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 +`base/blocker-plan.md`가 다룸(**[2026-08-22 정정]** 여기 "State 개발과 같은 +마일스톤, `ROADMAP.md` M3에서 함께 구현"이라 적혀 있었으나 `Blocker.luau`는 +**M2**로 이동했고, 바닥부터 짜는 게 아니라 공용 `GateNode` +(`base/gate-plan.md`) 위의 정책이다 — 마일스톤 소속의 소스는 +`blocker-plan.md`의 정정 배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던 초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — `archive/batch-rejected.md` 참고. diff --git a/.claude/base/state-epoch-plan.md b/.claude/base/state-epoch-plan.md index b4e968e..ec2e405 100644 --- a/.claude/base/state-epoch-plan.md +++ b/.claude/base/state-epoch-plan.md @@ -2,7 +2,12 @@ **상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 **채택 확정**됨 — *"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`의 "전파 모델 확정" 절을 대체하는 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을 diff --git a/.claude/project-context.md b/.claude/project-context.md index 5682ee8..0e5183e 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -10,9 +10,12 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 길게 잡음. -**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치 -엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 -같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에 +**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(디스패치 +엔진)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 +같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ 단 M2 착수를 막는 +순서 문제가 하나 열려 있음** — M2↔M3 양방향 의존, 소스는 +`.claude/question.md` 2번(설계 결정이 아니라 마일스톤 경계 문제라 +"설계 게이트는 없다"는 아래·`todos.md` 서술과 모순되지 않음). 저장소 루트에 `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가 diff --git a/.claude/question.md b/.claude/question.md index 23d98d4..f15eb19 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,7 +12,11 @@ --- -## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준) +## ⭐ 최우선 — 설계 결정은 **없음**, 다만 순서 문제 하나 (2026-08-22 갱신) + +> **[2026-08-22] 아래 2번(M2/M3 마일스톤 경계)이 M2 착수를 막습니다.** +> 그건 "무엇을 확정할까"가 아니라 "어떤 순서로 짤까"라 성격이 달라서 +> 이 절이 아니라 2번에 뒀습니다 — **설계 결정 대기는 여전히 0건**입니다. > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 @@ -111,6 +115,41 @@ - `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`를, `Dispatch.setOffsetSource`가 + `Source`를 받고, `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. 낮은 우선순위 — 열려 있지만 급하지 않음 - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, diff --git a/.claude/reference/slot-attach-decomposition.md b/.claude/reference/slot-attach-decomposition.md index 7cc5905..c3441ab 100644 --- a/.claude/reference/slot-attach-decomposition.md +++ b/.claude/reference/slot-attach-decomposition.md @@ -168,7 +168,7 @@ R1(부모 등록)까지 따로 떼는 안. `Dispatch.drive`의 최상위 배열 반복된다. - **이득**: `Dispatch.drive`와 `attachSlot`이 지금 서로 비슷한데 미묘하게 - 다른 구조(전자는 Blocker + 두 패스, 후자는 Blocker + flush)인 걸 하나로 + 다른 구조(전자는 Blocker + 본체 루프, 후자는 Blocker + flush)인 걸 하나로 수렴시킬 수 있음. - **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득 대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**. diff --git a/.claude/session/2026-08-22-01-roadmap-milestone-review.md b/.claude/session/2026-08-22-01-roadmap-milestone-review.md new file mode 100644 index 0000000..e14800e --- /dev/null +++ b/.claude/session/2026-08-22-01-roadmap-milestone-review.md @@ -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`를, + `setOffsetSource`가 `Source`를 받고 `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) 확정 문서의 순서 계약 정정이라 + 정확히 그 축에 걸린다. diff --git a/.claude/todos.md b/.claude/todos.md index 774e8b2..fdbcdc5 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -9,8 +9,9 @@ 반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서 **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`는 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 - 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`, - 구현은 M3) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. + 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md` + — **[2026-08-22 정정]** 구현 마일스톤은 갈린다: `EpochMap`/`Epoch` + 인터페이스는 **M2**, State 본체 통합은 **M3**) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. **[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** — 부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가 `Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다 @@ -21,8 +22,11 @@ 열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2). **[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 - 되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 - 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 + 되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로 + 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번. + **[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀 + 있었으나 그건 `Epoch` 일반화 **이전** 표기다 — 게이트 노드 자신은 + 페이로드에 안 싣는다). 같이 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 / 새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤 `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) — @@ -33,9 +37,25 @@ 서술**이었고, "빈 배치 emit"은 **아무것도 안 하는 것**으로 확정(그 따름정리로 `Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md`의 순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.** + + **⚠️ [2026-08-22 신설] 다만 설계가 아니라 *순서*가 막고 있다.** + `ROADMAP.md` 전반 점검에서 **M2와 M3의 의존이 양방향**인 게 드러났다 — + `Dispatch.setLength`/`setOffsetSource`/`recompute`가 `State`/ + `Source`를 쓰므로 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`의 - 생명주기·재진입 계약, M2에 `Blocker`까지 넣을지, 스파이크 `05` 재작성 - (`luau-test/STATUS.md`). + 생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`). + **[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날 + `ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다. **4라운드 — [2026-08-21] 종결.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 @@ -111,8 +131,10 @@ 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 정합성" 절 참고). - **아래는 M3 착수 전에 결론이 필요한 항목 목록** — M0/M2는 막혀 있지 - 않다(0번 항목 참고). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도 + **아래는 M3 착수 전에 결론이 필요한 항목 목록** — **설계** 게이트 + 얘기다. M0은 아예 막혀 있지 않고, M2도 설계로는 안 막혀 있으나 + **[2026-08-22] 마일스톤 순서 문제 하나가 M2를 막는다**(위 00번의 + ⚠️ 문단 + `question.md` 2번). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도 ⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들 (`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키, `SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키 diff --git a/CLAUDE.md b/CLAUDE.md index 09dde0e..e476229 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,12 @@ # CLAUDE.md Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트. -**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치 -엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`도 +**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은 +M2(디스패치 엔진)** — 다만 **⚠️ M2 착수를 막는 항목이 하나 있음**: +M2와 M3의 의존이 양방향이라(M2의 Length/Offset 배관이 `State`/`Source`를 +씀) 지금 순서로는 M2를 끝까지 짤 수 없다. 설계 결정이 아니라 **마일스톤 +순서** 문제이고, 선택지와 근거의 소스는 `.claude/question.md` 2번(사용자 +회신 대기). 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 63a9f3f..0b6a0c4 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -6,6 +6,20 @@ 올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은 사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소). +## ⭐ 11. **[2026-08-22 신설] M2 착수를 막는 마일스톤 순서 결정** — 사용자 답변 필요 + +**설계 결정이 아니라 순서 문제**라서 에이전트가 임의로 정하면 안 되는 자리다. +`ROADMAP.md` 전반 점검에서 **M2(디스패치 엔진)와 M3(Store/State/Source)의 +의존이 양방향**인 게 드러났다 — M2의 Length/Offset 배관이 `State`/ +`Source`를 쓰므로 **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로 연결 (테스트 자동화용) Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접 @@ -200,7 +214,9 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 -마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준] +마음에 안 드는 것만 답해주면 됨 — **[2026-08-22 갱신] 설계 결정 대기는 +여전히 0건**이지만, **마일스톤 순서 결정 하나가 새로 열렸다**(`question.md` +2번, 위 11번 항목이 소스). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준] `question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 — 마지막 남았던 0-W `Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지" diff --git a/ROADMAP.md b/ROADMAP.md index 1fa2673..1e59d5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,10 +7,35 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 **2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.** -> **✅ [2026-08-19 기준] M0 스파이크 4개 전부 통과, M1 스캐폴딩도 대부분 -> 완료(quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/ -> .luaurc, mock 테스트 하네스, `New()`/`RunInit`/`AddPlugin` 골격 — 아래 -> M0/M1 체크박스 참고). 다음은 M2(디스패치 엔진) 착수.** M1 착수 도중 +> **✅ [2026-08-22 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은 +> M2(디스패치 엔진)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크 +> 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그 +> 절이 소스). 그건 아직 열린 작업이므로 "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.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는 > `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서 @@ -62,9 +87,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 — `luau-test/done/03-recursive-store-bind-dispatch.luau` 통과) -- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 +- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" **계약**이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 + (**[2026-08-21 정정]** 이 계약을 **두 패스로 구현**한다는 서술은 + `F-4-1`로 폐기 — 구현은 단일 일반화 `for`, 계약은 그대로. 그래서 + 두 루프로 짜인 스파이크 `01`이 `rewrite-required/`로 갔다 — 아래 + "재검증 대기" 참고) (2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 `nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가 @@ -73,7 +102,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 `nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션] 소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** — - 정상 두 패스가 그 자리를 `ProcessedPreRefHandler`로 매치해 + 정상 본체 루프가 그 자리를 `ProcessedPreRefHandler`로 매치해 `Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨 (`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md` "Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드 @@ -97,6 +126,42 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 진행 — **[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 — 실제 스캐폴딩 - [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19 @@ -131,6 +196,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > 분리 신설), 뒤집힌 옛 모델은 > `.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`/`Source`를 쓰고, +> `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅합니다), +> 반대 방향은 핸들러 등록 표면만 요구하는 얕은 의존입니다. +> **어느 쪽으로 가를지(순서 교체 / M2 분할 / 유지)는 사용자 결정 +> 대기이고, 근거와 선택지의 소스는 `.claude/question.md` 2번입니다** — +> 여기서 반복하지 않습니다. + - [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)` @@ -140,14 +222,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)` (아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, - flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 + flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`에 `Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). - **[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면 - 그 `retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로 - 재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에 - 스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process`는 - diff를 하지 않음 + **[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라 + 단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히 + base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau + 테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는 + `type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의 + `F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로 + 못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기 + (`luau-test/STATUS.md`) - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ `process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도 `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 @@ -179,6 +264,46 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 비교 — `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()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy @@ -190,8 +315,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 - 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9, - `question.md` 2번 — 2026-08-07 네 번째 세션에 반영). + 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9 — + 2026-08-07 네 번째 세션에 반영. **[2026-08-22 정정]** 여기 있던 + `question.md` 번호 참조는 그 항목이 해소되며 이미 깨져 있었고, + 지금 그 번호는 다른 항목이 쓰고 있어서 지웠다). **[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음. `bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽 @@ -242,29 +369,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 `bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가 바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker` - (`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는 - 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만 - 호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에 - 있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는 - `Blocker`를 참조하게 됨.** - **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] "게이팅 먼저"로 - 결정 — 게이팅을 M2로 앞당긴다**(사용자: *"게이팅 먼저. 게이팅을 - base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함"*). 단 - **앞당기는 대상이 `Blocker` 자체가 아니라 그 아래의 공용 `Gate` - 노드**로 바뀌었다(같은 라운드 `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. + (`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를 + lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는 + 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. + **[2026-08-22] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은 이제 + 이 마일스톤에 체크박스로 있다**(위 세 항목) — 예전엔 M3에 있는 걸 + 각주로 가리키기만 했다. 결정 경위("게이팅 먼저", 그리고 앞당기는 + 대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는 + `qa-request/pre-implementation-qa-round5-followup.md`와 + `base/gate-plan.md`가 소스 — 여기서 반복하지 않는다. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 @@ -333,7 +446,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관 -- [x] `store.key` dot-access 타입 추론 확인 — Luau `type function` +- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function` (`WrapStore`/`ProcessStoreType`)으로 `Store`가 `T`의 각 필드를 `Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]** @@ -354,10 +467,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정) -- [ ] **[2026-08-21 5라운드 — 순서 주의]** 아래 `Blocker.luau`가 올라서는 - **공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정, - 위 M2 각주 + `base/gate-plan.md`). M3에서 `Blocker`를 짤 때는 - 그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것. +> **[2026-08-22 이동] `EpochMap.luau` / `state:Gate` + `GateNode` / +> `Blocker.luau`는 M2로 옮겼습니다** — 셋 다 M2가 실제로 호출하는데 +> 각주로만 예고돼 있어서, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던 +> 전례대로 체크박스 자체를 옮겼습니다. M3는 그 위에 State/Source 본체를 +> 얹기만 하면 됩니다. - [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`) — `invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸 @@ -371,13 +485,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 (`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)`" 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 @@ -447,7 +554,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.** > `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/ -> `nativeDispose`(이름 가칭). base가 `Parent`를 모른다는 원칙을 실제로 지키기 +> `nativeDispose`. base가 `Parent`를 모른다는 원칙을 실제로 지키기 > 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은 > `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만 > 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게 @@ -489,6 +596,142 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나 > `nil`임이 계약으로 보장된다는 점만 새로 추가됨. +### 확정된 것 — 코드 아님, 구현 전 필독 + +아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로 +두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다 +(**[2026-08-22]**). + +- **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 + 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ + dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State`도 + 이때 확정(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()` 제네릭화, 요소 + 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 + 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ + Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 + error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = + Slot<>`(**[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`, 자기 참조 제네릭은 실측 필요). + `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을 실제 `
` 중첩으로 + 매핑하는 안은 기각(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`/`Source`도 Slot 요소로 허용** + (2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는 + 실제 타입은 `T | State | Source`(임의 깊이 조합 가능). + **[정정] 최초 검토한 "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` 요소(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 결정 전부, 구현 전 필독]** - **`State` 교체 = 파괴가 아니라 언마운트**(`state`와 동일). 비파괴 경로 `unmountSlotTree`를 `destroySlotTree`와 별도로 구현 — @@ -528,57 +771,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게 지우는 유일한 경로. 마운트 위치는 `elementOwner`가 이미 알고 있어 새 부기 불필요. `isSlot(value)`면 그 경로, 아니면 백엔드가 주입하는 - `disposeInst(inst): ()`(`addTag`/`removeTag`/`setAttribute`와 같은 - "base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임. + `nativeDispose(element): ()`(`addTag`/`removeTag`/`setAttribute`와 같은 + "base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임 + (**[2026-08-22 정정]** 여기 `disposeInst`라 적혀 있었으나 이름은 + 2026-08-21에 `native*` 계층으로 확정됨 — M5 배너 참고). **`Observer`/`Effect`는 범위 밖**(GC-native `bindLifetime`/ `unbindLifetime`만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째 세션에 `question.md` 0-B 해소, 정본은 `base/slot-plan.md` "`dispose(value)`" 절 -- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 - 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ - dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State`도 - 이때 확정(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()` 제네릭화, 요소 - 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 - 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ - Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 - error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = - Slot<>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 — - 옛 `question.md` 1번 용어정리 항목은 해소되어 - `archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한 - Slot 타입. - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, `keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를 그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드 @@ -650,88 +851,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — `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`, 자기 참조 제네릭은 실측 필요). - `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을 실제 `
` 중첩으로 - 매핑하는 안은 기각(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`/`Source`도 Slot 요소로 허용** - (2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는 - 실제 타입은 `T | State | Source`(임의 깊이 조합 가능). - **[정정] 최초 검토한 "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` 요소(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 - [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 @@ -765,26 +884,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번, `brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미 구현돼 있어야 함) -- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 - (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + - 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` - (`dispatch-core-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 - "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 - 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널 - 자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`가 - 쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** — - 그냥 `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`도 정상 - 동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`의 - "`NilHandler`" 절) +> **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/ +> `NilHandler`)는 M2로 옮겼습니다** — `None`은 modifier 전용 값이 아니라 +> quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도 +> `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M2의 +> `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다. +> M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를 +> 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md` +> 2-1번). - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` @@ -808,8 +915,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치 방지" 절 - [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이 - `Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프 - 전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고 + `Dispatch.drive(inst, flattened)` 자신이 **본체 루프 전에** 배열 + 파트를 **한 번** 훑어(**[2026-08-22 정정]** 여기 "두 패스(배열→해시) + 루프 전에"라고 적혀 있었으나 본체는 단일 일반화 `for`다 — `F-4-1`), `PreRef`는 그 자리에서 fire하고 `PostRef`는 로컬 `postRefList`에 push만 함(Dispatch.process/getHandler 우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 충돌하므로 기각). **복수 `PreRef`/`PostRef`의 계열 안 @@ -820,9 +928,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 PreRef`류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두 번째 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef`/ `ProcessedPostRef` 처리** — 아래 `Processed*Handler` 항목이 그 자리를 - 정상 두 패스로 마저 처리) + 정상 본체 루프로 마저 처리) — `base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절 -- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 두 패스 뒤 +- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 본체 루프 뒤 `postRefList` 소비 루프 — `PreRef.luau`와 같은 방식(`Ref` 런타임 재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store 타입 차단, `_fired` 1회용 가드). `Dispatch.drive`가 해시 파트까지 @@ -914,13 +1022,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시)) -- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau` — - 주입되는 엔진 op 3개**: `addTag(inst,{string})`/`removeTag(inst,{string})` +- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`의 + Tag/Attribute 몫** — `addTag(inst,{string})`/`removeTag(inst,{string})` (`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제). `RobloxFactory`가 `BaseModule`에 주입(`bindLifetime`/`canExecute`와 같은 패턴) — 아래 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<>(name)` + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 `String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리 @@ -995,6 +1108,27 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Tween` 래퍼 모델로 전환 — 상세는 `base/tween-plan.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`/ `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음) - [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 @@ -1003,16 +1137,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 스냅(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 두 번째/세 번째 세션, `base/tween-plan.md`): `Tween` opts(`Value` 제외)를 `T|State`로 받아 각 필드를 resolve한 뒤 `Tween{...}`을 반환하는 @@ -1020,9 +1144,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 아님, `research/operator-sugar-plan.md` "왜 `:Apply`인가"). `CanAnimate` 필드 포함(`false`면 `Tween`으로 안 감싸고 plain 값 그대로). M11은 **구현만** 하면 됨 -- [x] **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정** - (2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님 - ## 특정 마일스톤에 안 묶이고 병행 가능 - [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`로