diff --git a/.claude/README.md b/.claude/README.md index 34cb0e8..5dcfd33 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -33,20 +33,21 @@ | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | -| `bind-system-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`를 이미 캡처)임을 명문화 | +| `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`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md` | +| `bind-system-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` | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `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/bind-system-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/bind-system-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**(시그니처/범위는 `question.md` 0-B로 열림) | +| `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**(시그니처/범위는 `question.md` 0-B로 열림) **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로 | | `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 우선순위를 쓰는 이유) | | `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)과 함께 개발. 메커니즘+이름 확정 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | | `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` 아님) | -| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/bind-system-plan.md` "Dispatch 체인" 절 참고 | -| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 | +| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드) | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 | | `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 `false`를 넣으면 disconnect. 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | | `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 10종 branded 타입 전부로 일반화. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **분리는 순수 이동** | | `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`는 사용자가 직접 처리(에이전트 범위 제외) | @@ -71,7 +72,6 @@ | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | -| `dispatch-redispatch-diff-plan.md` | **[2026-08-13 여섯 번째 세션 신설]** 현행 `hintValue`가 "곧 디스패치될 raw 값"이라 `None` 센티널/`State`/`Tween` 래퍼가 그대로 넘어가 말단 핸들러의 `isX(hint)` 가드를 거짓으로 만들고 깜빡임/재생성 방지를 조용히 끄는 결함을 재현·확인(사용자 제기). **채택 모델**: 래핑 핸들러의 `retractFrom` 선행 호출을 폐기하고 `Dispatch.process` 안에서 **핸들러를 먼저 비교** — 같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출, 다르면 그 자리부터 전량 철거. 이걸로 힌트 타입이 구조적으로 보장되고 깊은 체인의 힌트 유실도 사라짐(각 레벨이 자기 재프로세스에서 자기 힌트를 받으므로). `oldValue` 전달/`HandlerChanged` 마커는 둘 다 불필요로 판명 (클로저가 이미 old를 캡처, `chains`에 더할 건 비교용 `handler` 하나). **남은 열린 항목은 Attribute 이름 소유권 하나** — `question.md` 0-Z | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 @@ -95,6 +95,7 @@ | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | | `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | +| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | ## 참고 diff --git a/.claude/research/dispatch-redispatch-diff-plan.md b/.claude/archive/dispatch-hintvalue-model-reversed.md similarity index 82% rename from .claude/research/dispatch-redispatch-diff-plan.md rename to .claude/archive/dispatch-hintvalue-model-reversed.md index 95b0a1b..5e7271d 100644 --- a/.claude/research/dispatch-redispatch-diff-plan.md +++ b/.claude/archive/dispatch-hintvalue-model-reversed.md @@ -1,16 +1,42 @@ -# 재디스패치 = 하강 diff — `retractFrom` 선행 폐기, 핸들러 비교를 클로저 호출 앞에 (설계안) +# [역전됨] 재디스패치 = "철거 후 재구축" + `hintValue` 힌트 모델 -**상태**: research — 2026-08-13 여섯 번째 세션에 사용자가 제기하고 방향을 -제시, 같은 세션 후속 라운드에서 모델이 거의 확정됨. **`base/` 반영 전 -남은 열린 항목은 아래 5절의 하나뿐**(Attribute 이름 소유권). 그 하나만 -정해지면 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ -`attribute-plan.md`를 한 번에 옮기면 됨. +**상태**: 역전됨 — 2026-08-13 여섯 번째 세션에 사용자가 결함을 제기하고 +방향을 제시, 같은 날 열네 번째 세션에 마지막 열린 항목(Attribute 이름 +소유권)까지 확정되며 **`base/dispatch-core-plan.md`로 전면 반영 완료**. +이 문서는 그때까지 research/ 아래 dispatch-redispatch-diff-plan이었던 설계안 +원문이고, **지금 유효한 모델은 `base/dispatch-core-plan.md`의 "Dispatch +체인" 절** — 여기 남기는 이유는 "왜 힌트 모델을 버렸는가"의 재현 사례와 +근거가 통째로 보존될 가치가 있어서(같은 함정을 다시 설계하지 않도록). -> **문서 이력**: 최초엔 dispatch-hint-to-oldvalue-plan(현재 없는 옛 파일명)으로 -> "힌트 대신 `oldValue`를 넘기자"는 보완안을 담고 있었으나, 사용자가 -> **"이전 값인 oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?"** -> 라고 지적해 그 보완안이 통째로 불필요함이 드러남(아래 3-2) — 파일명도 -> 바꿈. +## 뒤집힌 옛 모델의 골자 (원문 보존) + +```lua +-- 옛 계약: 래핑 핸들러가 재-dispatch 전에 자기 아래를 먼저 철거하고, +-- 그때 "곧 디스패치될 raw 값"을 힌트로 실어 보냄. +function StoreBind.process(inst, k, state, index) + local observer = state:Observer(function() + local realv = state:Get() + Dispatch.retractFrom(inst, k, index + 1, realv) -- 선행 철거 + 힌트 + Dispatch.process(inst, k, realv, index + 1) + end) + ... +end + +function Dispatch.retractFrom(inst, k, index, v) -- 4-인자(마지막이 힌트) + ... + retractor(if i == index then v else nil) -- 힌트는 직속 1단계에만 + ... +end +``` + +옛 모델이 같이 요구하던 규칙들(전부 지금은 폐기): +- **`isX(hintValue)` 가드 필수** — 힌트의 타입이 계약으로 보장되지 않아서. +- **`hintValue`는 직속 위임 1단계에만 보장, 깊은 인덱스는 항상 `nil`** — + 그래서 `State>`류에서 깜빡임 방지가 조용히 꺼짐. +- **`Dispatch.process`의 점유 체크(이미 점유된 인덱스면 즉시 error)** — + Attribute 이름 소유권이 이 부수 효과에 얹혀 있었음. + +## 아래는 역전을 이끈 분석 원문 (당시 `research/` 문서 그대로) ## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨) @@ -267,3 +293,21 @@ Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없 **요약**: 배너를 달고 있는 파일 = 반영 대상. 위 7개(`bind-system-plan`/ `tag-plan`/`slot-plan`/`attribute-plan`/`architecture`/`ROADMAP`/ `ref-plan`)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것. + + +--- + +## 최종 결말 (2026-08-13 열네 번째 세션) + +- **5절의 열린 항목은 (a)가 아니라 그 변형으로 확정됨** — 사용자가 제시한 + **그룹 전용 키(`GetKey`, 비공개) + `AttributeKeyHandler`의 이름 claim**. + (a)(그룹 안에 이름별 claimant `Relate`)를 그대로 쓰면 **그룹↔직접 쓰기 + 충돌을 못 잡는다**는 게 트레이싱으로 드러났기 때문 — 두 경로가 만나는 + 유일한 지점인 말단 핸들러에서 `k`가 같은 객체라 소유자를 구분할 수 없음. + 근거와 최종 의사코드는 `base/attribute-plan.md` "이름 소유권" 절. +- **`Dispatch.retractFrom`은 4-인자에서 3-인자가 됐음** — 값을 넘기는 + 경로가 `Dispatch.process`의 "같은 핸들러" 분기 하나로 통일되면서, 외부가 + 힌트를 만들어 넣을 자리 자체가 없어짐(옛 결함이 구조적으로 재발 불가). +- 6절의 반영 목록(7개 문서 + `bind-system-plan.md` 2단계 분할)은 같은 + 세션에 전부 수행됨 — 디스패치 코어는 `base/dispatch-core-plan.md`로 + 분리되며 재작성됐다. diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 4b01f9f..4382b73 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -104,7 +104,17 @@ API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정 정상 nilable 사용례까지 막음), `16`(`type function` API 불일치). 전부 `audit/luau-test-first-run-2026-08-13.md`. -### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관) +### 0-Z. ~~⭐ 최우선~~ **[해소됨, 2026-08-13 열네 번째 세션]** — Attribute 이름 소유권을 무엇으로 판정할 것인가 (2026-08-13 여섯 번째 세션 신설) + +> **결론**: 후보 (a)가 아니라 그 변형 — **그룹 전용 키(비공개 `GetKey`) +> + `AttributeKeyHandler`의 이름 claim**. 사용자가 `Attribute:GetKey(name)` +> 아이디어를 제시했고, 트레이싱 결과 (a)만으로는 **그룹↔직접 쓰기 충돌을 +> 못 잡는다**는 게 드러나(두 경로가 만나는 말단 핸들러에서 공개 키는 +> 같은 객체라 소유자 구분 불가) 전용 키가 필요함이 확인됨. 전용 키가 +> 교차 오염을 구조적으로 없애고, claim이 남은 충돌을 즉시 error로 +> 드러냄. `GetKey`는 공개 API로 내지 않음(같은 키가 두 자리에 놓이는 +> 0-W류 갭을 원천 차단). 정본은 `base/attribute-plan.md` "이름 소유권" +> 절. **아래는 해소 전 원문.** **이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의 재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면 @@ -138,21 +148,29 @@ Attribute가 직접 해야 함.** claimant 단방향이면 충분할 것이라는 방향. - 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler` 체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`, - 분석은 `research/dispatch-redispatch-diff-plan.md` 5절. + 분석은 `archive/dispatch-hintvalue-model-reversed.md` 5절. **대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에 국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 + 교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장, (c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대. -### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**) +### 0-A. ~~`hintValue` 폐기 → process 하강 중 핸들러 비교~~ **[해소됨, 2026-08-13 열네 번째 세션 — base 반영 완료]** (2026-08-13 여섯 번째 세션) + +> **결론**: 모델 그대로 채택되어 `base/dispatch-core-plan.md`(같은 세션에 +> `bind-system-plan.md`에서 분리 신설)로 전면 반영. 부수적으로 +> `Dispatch.retractFrom`이 4-인자에서 **3-인자**가 됐음(값을 넘기는 경로가 +> `Dispatch.process`의 "같은 핸들러" 분기 하나로 통일되어, 외부가 힌트를 +> 만들어 넣을 자리 자체가 사라짐). 배너를 달고 있던 7개 문서 전부 갱신 +> 완료. 뒤집힌 옛 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`. +> **아래는 해소 전 원문.** **검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.** 힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/ `Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의 `isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐** (정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은 -`research/dispatch-redispatch-diff-plan.md`. +`archive/dispatch-hintvalue-model-reversed.md`. **후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`을 선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저 @@ -172,7 +190,7 @@ Attribute가 직접 해야 함.** **실행 규모**: `base/`의 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ `attribute-plan.md` 의사코드 재작성 + `architecture.md` 소스트리 서술과 `ROADMAP.md` M2/M4/M6/M10 체크리스트 — 0-Z 하나만 정해지면 한 번에 옮기면 -됨(어디를 어떻게 고칠지는 `research/dispatch-redispatch-diff-plan.md` 6절에 +됨(어디를 어떻게 고칠지는 `archive/dispatch-hintvalue-model-reversed.md` 6절에 파일별로 적어둠 — 뒤 둘은 2026-08-13 7차 감사에서 그 목록에 빠져 있던 걸 발견해 추가). **그때까지 `base/`의 현행 `hintValue` 서술이 유효** — 아직 안 옮겼다는 걸 잊고 base만 읽으면 옛 모델로 구현하게 되니 주의. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 41ef8f4..98a8c9c 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -1,11 +1,12 @@ # quad-v2 전체 아키텍처 (현재 상태 요약) -> **⚠️ [2026-08-13 4차 감사에서 발견] 이 문서는 세션 시작 시 가장 먼저 -> 읽는 진입점인데, 아래 소스 트리의 `chains`/`retractFrom`(Dispatch/init.luau -> 항목)과 `retractFrom`에 재귀 위임(Attribute.luau 항목) 서술은 현행 -> (교체 예정) 재-dispatch 모델을 전제로 쓰여 있음 — `question.md` **0-Z** -> 해소 전엔 그대로 구현하면 옛 모델로 짜게 됨. 상세는 -> `base/bind-system-plan.md` 최상단 배너, `research/dispatch-redispatch-diff-plan.md`. +> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델(0-A)/Attribute 이름 +> 소유권(0-Z) 확정·반영 완료 — 아래 소스 트리도 갱신됨.** 이전 ⚠️ 배너가 +> 경고하던 "옛 모델로 짜게 됨" 위험은 해소. 디스패치 코어는 +> `base/bind-system-plan.md`에서 **`base/dispatch-core-plan.md`로 +> 분리**됐고, `Tag`/`Attribute`는 알고리즘까지 quad-base로 재배치됐음 +> (엔진 op만 주입). 뒤집힌 옛 모델은 +> `archive/dispatch-hintvalue-model-reversed.md`. **상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트 전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은 @@ -78,7 +79,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 방지, 비용은 무시 가능한 수준으로 확인됨). 8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 - 구현(`base/bind-system-plan.md`). + 구현(`base/dispatch-core-plan.md`). 9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`에 연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더 @@ -106,7 +107,7 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler 레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy` 마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는 - `base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. + `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. 14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 @@ -135,6 +136,15 @@ RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서 **pluggable 디스패치 엔진 자체도 "인터페이스"로 base가 소유**한다(엔진마다 큰 구현을 중복하지 않기 위함 — rbvm이 relation을 하나로 통합하려 했던 것과 같은 동기). `quad-roblox`는 그 인터페이스의 **실제 구현체**만 제공. +**[2026-08-13 열네 번째 세션 확장] 이 원칙은 "값 타입"이 아니라 "부기가 +엔진 지식을 요구하는가"로 가른다** — `Tag`의 참조 카운트와 `Attribute`의 +이름 claim/그룹 위임은 엔진과 무관한 순수 부기라 **핸들러째로 quad-base**, +백엔드는 `addTag`/`removeTag`/`setAttribute` 세 op만 주입한다(웹에도 +`className`/`data-*`라는 대응물이 있어서, 그러지 않으면 같은 알고리즘이 +백엔드마다 복제됨). 반대로 Property/Event/OnChange는 Reflection·시그널 +자체가 로직이라 그대로 백엔드 소속 — 상세 기준과 op 시그니처는 +`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 +op" 절. ``` quad/ @@ -148,15 +158,19 @@ quad/ │ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── 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`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) -│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`, `Tag`와 동형) — `SetAttribute` 글루는 quad-roblox Handlers/Attribute.luau(`base/attribute-plan.md`, 2026-08-11 아홉 번째 세션). 단일 키(`AttributeKey<>`)는 값 타입 레이어 없이 quad-roblox 단독 소속(아래) +│ ├── 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`) +│ ├── AttributeKey.luau # 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치) │ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ -│ │ ├── init.luau # process 엔진(반환값=retract 클로저), isHandlable 우선순위 스캔, `chains`(inst,k별 인덱스 배열)+`retractFrom`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계) +│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) +│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). HANDLER_PRIORITY_FALLBACK으로 등록(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) +│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절) +│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) @@ -166,15 +180,13 @@ quad/ └── quad-roblox/ ├── wally.toml └── src/ - ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) + ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/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이면 삭제) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). 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`) │ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) - │ ├── AttributeKey.luau # 단일 키(`AttributeKey<>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절) - │ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process — 이름 집합 diff만 자체 로직(반환 클로저에 캡처, 별도 Relate 불필요), 실제 `SetAttribute`/구독은 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`base/attribute-plan.md`) - │ ├── Tag.luau # CollectionService 글루만(process, 반환 클로저가 정리 담당) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) ├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`) @@ -225,7 +237,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확 전용 소유물인가?"로 물으면 됨 — 그렇다면 대문자(생성자/메서드/그 타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진 소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는 - `base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/ + `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/ `base/store-semantics.md`의 "세 번째 카테고리 — Handler" 절 참고. ## 코드 스타일 — Luau 문법 관례: `if-then-else`/`const` (2026-08-12 세션 신설) @@ -234,7 +246,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확 간주해 `and`/`or`로 "고치지" 말 것.** 2021년 10월 Luau에 정식 도입된 표현식 문법(공식 릴리스 노트: ) — `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이 -falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의 +falsy(`nil`/`false`)여도 정확하게 동작함(`dispatch-core-plan.md`의 `Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12 세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`만 쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그 @@ -265,8 +277,8 @@ property bag + property별 변경 시그널 정도만 흉내내고, `IsA()`/클 Studio/엔진 없이 테스트(Vide가 실제로 이렇게 CI에 물려놓음) — Fusion처럼 Studio 안에서만 도는 방식은 채택 안 함. 근거: quad-base 코어(Store/State/ Source/Modifier/Slot, 디스패치 엔진)는 이미 `inst`를 `any`로 취급하고 -Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`bind-system-plan.md` -"inst가 항상 Roblox Instance일 필요는 없음" 절), mock이 실제 Roblox 충실도를 +Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`dispatch-core-plan.md` +"확정된 디스패치 모델" 절의 "`inst`가 항상 살아있는 엔진 객체일 필요는 없음"), mock이 실제 Roblox 충실도를 가질 이유가 없음. **스코프는 "정적 디버깅"으로 한정** — **사용자 확정**: mock으로 확인하려는 @@ -316,5 +328,5 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 -(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/ -`modifier-plan.md`/`component-composition-plan.md`). +(`bind-system-plan.md`/`dispatch-core-plan.md`/`module-lifecycle-plan.md`/ +`slot-plan.md`/`modifier-plan.md`/`component-composition-plan.md`). diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 3f69c1a..266a806 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -1,27 +1,31 @@ # Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브 -> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 -> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 -> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 -> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 -> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 -> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** -> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 -> 구현하면 옛 모델로 짜게 됨** — 반드시 -> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. +> **✅ [2026-08-13 열네 번째 세션] `question.md` 0-Z(이름 소유권) 확정 + +> 하강 diff 재디스패치 반영 완료 — 이 문서는 이제 최신 모델을 서술합니다.** +> 이전 ⚠️ 배너가 예고하던 교체가 전부 끝났음: (1) 그룹은 **자기 전용 키**로 +> 위임하고(비공개 `GetKey`), 이름 소유권은 **`AttributeKeyHandler`의 이름 +> claim**이 판정(아래 "이름 소유권" 절), (2) `retractFrom` 선행 호출은 +> 폐기되고 `Dispatch.process`가 핸들러를 먼저 비교 +> (`base/dispatch-core-plan.md`), (3) **값 타입·알고리즘 전부 quad-base +> 소속으로 재배치**되고 `setAttribute` op만 백엔드가 주입(아래 "패키지 +> 배치" 절). 뒤집힌 옛 모델 원문은 +> `archive/dispatch-hintvalue-model-reversed.md`. **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는 전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서 `retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/ "그룹 `Attribute(...)`" 절이 최신**). **[2026-08-13 세션, 하루 안에서 -두 차례 재설계]** 그룹/직접 쓰기 이름 충돌 방지 방식이 `rawNew`+`owners` -수동 레지스트리 → `AttributeGroupKeyHandler` 체크포인트(`Dispatch. -processAs`/`retractSelfAndUnder`) → **최종적으로 `Dispatch` 자체의 -인덱스 기반 재설계(`base/bind-system-plan.md` "Dispatch 체인" 절)에 -올라타 체크포인트도 필요 없어짐**(공개 `AttributeKey(name)`으로 항상 -인덱스 1에 직접 위임, 점유 체크가 소유권 충돌 감지를 대신함) — 아래 -"이름 소유권"/"메커니즘" 절이 최신, 중간 버전은 `archive/ -checkpoint-handler-pattern-reversed.md`에 보존. **[2026-08-11 아홉 번째 세션 추가]** +네 차례 재설계 — 지금은 마지막 것이 확정]** 그룹/직접 쓰기 이름 충돌 +방지 방식이 `rawNew`+`owners` 수동 레지스트리 → `AttributeGroupKeyHandler` +체크포인트(`Dispatch.processAs`/`retractSelfAndUnder`) → 공개 +`AttributeKey(name)`+Dispatch 점유 체크 → **최종적으로 그룹 전용 키 +(비공개 `GetKey`) + `AttributeKeyHandler`의 이름 claim**(2026-08-13 +열네 번째 세션, `question.md` 0-Z 확정). 세 번째 안이 다시 뒤집힌 +이유는 하강 diff 재디스패치에선 **두 그룹이 똑같이 `StoreBind`로 보여 +"같은 핸들러"로 판정**되므로 점유 체크가 성립하지 않기 때문 — 아래 +"이름 소유권"/"메커니즘" 절이 최신, 중간 버전들은 `archive/ +checkpoint-handler-pattern-reversed.md`와 +`archive/dispatch-hintvalue-model-reversed.md`에 보존. **[2026-08-11 아홉 번째 세션 추가]** Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)` 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 `Attribute<>` → `AttributeKey<>`로 리네임(잠정 확정 — 최종 이름은 @@ -119,100 +123,129 @@ end "a"`가 외부에 관찰되는 것도 의도적으로 허용 가능한 동작(사용자 확인). `base/onchange-plan.md` "확정" 절 참고. -### 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) +### 메커니즘, `None`, 반환 클로저 — 전부 확정 (2026-08-07 여덟 번째 세션, 2026-08-13 열네 번째 세션에 이름 claim 추가) 타입 파라미터화 이름과 무관하게 런타임 동작은 확정: -- `process(inst, k, v, index)` — `inst:SetAttribute(name, v)`가 사실상 +- `process(inst, k, v, index)` — `setAttribute(inst, name, v)`가 사실상 전부, **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반 프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`의 - 가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`을 - "그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None → - nil` 재디스패치(`base/bind-system-plan.md`의 `None` 센티널 절)가 - 도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name, - nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 - 수동으로 찾아 지우는" 로직조차 필요 없음. -- **반환하는 클로저는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번 - 불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라 - "항상"** — 일반 프로퍼티 핸들러(반환 클로저가 완전 무조건 no-op, - `bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와 - 완전히 같은 성격으로 재정정. **`AttributeKeyHandler`가 반환하는 - 클로저는 `SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 - 유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가 스스로 - `nil`로 바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가 - `SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1) - 이 클로저 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`의 - "이 클로저는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는 - 성격의 코드가 됨, (2) 그룹이 survivor 이름에 재위임할 때 그 시점에 - `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 `a→nil→b` 깜빡임 - 위험(사용자 지적) — 클로저가 완전 no-op이면 이 경로 자체가 물리적으로 - 없어짐. + 가장 깔끔한 사례** — 주입 op의 계약 자체가 `setAttribute(inst,name,nil)` + = "그 Attribute 엔트리를 지운다"이므로(Roblox `SetAttribute`의 네이티브 + 동작 그대로, `base/dispatch-core-plan.md` "base가 소유하는 핸들러와 + 주입되는 엔진 op" 절), `None → nil` 재디스패치(같은 문서의 `None` + 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이** 그대로 + 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 수동으로 찾아 + 지우는" 로직조차 필요 없음. +- **반환하는 클로저는 `setAttribute`를 절대 호출하지 않음** — attribute를 + 지우는 유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가 + 스스로 `nil`로 바뀌든) 뿐(2026-08-12 세션 후속 확정, 그대로 유지). + **[2026-08-13 열네 번째 세션] 다만 "완전 no-op"은 더 이상 아님** — 아래 + "이름 소유권" 절의 이름 claim을 반납하는 한 줄이 들어감. 엔진에 대한 + 부작용은 여전히 0이고(관측 가능한 attribute 값은 안 건드림), 반납하는 + 건 순수 부기라 아래 "`a→nil→b` 깜빡임" 위험도 그대로 없음. 이전 + 버전("이름이 사라질 때(`v==nil`)만 클로저가 `SetAttribute(name,nil)`을 + 호출")이 기각된 이유는 그대로 유효: (1) 클로저 안에 관측 가능한 + 부작용이 생겨 "이 클로저는 구조적 팝만" 일반 규칙과 어긋남, + (2) 그룹이 survivor 이름에 재위임할 때 `SetAttribute`가 잘못 끼어들어 + `a→nil→b` 깜빡임이 생길 수 있었음. - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` 값도 받음). -### 이름 소유권 — 그룹/직접 쓰기 충돌 방지 (2026-08-12 열 번째 세션, 2026-08-13 다섯 번째 세션 전면 재정정) +### 이름 소유권 — 그룹 전용 키 + 이름 claim (2026-08-12 열 번째 세션 신설, 2026-08-13 열네 번째 세션 확정 = `question.md` 0-Z) -**문제**: `AttributeKey(name)`이 이름별 weak 캐시로 항상 같은 객체를 -리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용(아래 "메커니즘" -절)하다 보니, **서로 다른 원래 위치(해시파트 직접 쓰기 `[AttributeKey -"name"]=value` vs 배열파트 `Attribute(store)`, 또는 서로 다른 두 -`Attribute(...)` 그룹)가 같은 이름을 동시에 관리하려 하면 정확히 같은 -`(inst, k=AttributeKey(name))` 자리로 수렴해 조용히 마지막 쓰기가 -이기는 충돌이 생김.** Modifier 필드는 정적이라 override로 이미 해소되지만 -(같은 해시 키는 한 Modifier 안에 하나뿐), 그룹의 이름 집합은 런타임에 -동적이라 이 해소망 밖에 있음. +**문제**: 같은 attribute 이름을 **서로 다른 두 자리가 동시에 관리**할 수 +있음 — 해시파트 직접 쓰기 `[AttributeKey "name"] = value` vs 배열파트 +`Attribute(store)`, 또는 서로 다른 두 `Attribute(...)` 그룹. Modifier +필드는 정적이라 override로 이미 해소되지만(같은 해시 키는 한 Modifier +안에 하나뿐), 그룹의 이름 집합은 런타임에 동적이라 그 해소망 밖에 있음. -**[역사, 2026-08-13 세션 안에서 두 번 뒤집힘]** 첫 버전(`rawNew`로 그룹 -전용 키를 만들고 `owners` Relate로 이름별 소유권을 수동 추적)은 "그룹이 -이름을 놓았다 나중에 같은 그룹이 그 이름을 다시 포함하면 자기 자신과 -충돌"하는 실제 버그가 있었음(소유권 반납이 `process`의 `v==nil` 분기에만 -있어서, 그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 소유권 -기록이 안 지워짐). 두 번째 버전(`AttributeGroupKeyHandler`라는 스캔 -불가 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push, 소유권 충돌을 -Dispatch의 재진입 가드에 얹어 감지)은 이 버그를 고치긴 했으나, 같은 날 -다섯 번째 세션에 `Dispatch` 자체가 `chains`를 핸들러 identity가 아니라 -**인덱스**로 추적하도록 재설계되며(`base/bind-system-plan.md` "Dispatch -체인" 절) 체크포인트가 하던 일 자체가 통째로 불필요해짐 — 원문·역전 -이유는 `archive/checkpoint-handler-pattern-reversed.md`. +**왜 Dispatch가 대신 잡아줄 수 없는가**: 옛 모델에선 그룹이 공개 +`AttributeKey(name)`(이름별 weak 캐시라 항상 같은 객체)으로 위임했고, +같은 `(inst,k)` 인덱스 1을 두 소유자가 노리면 `Dispatch.process`의 점유 +체크가 error를 냈음. **하강 diff 재디스패치에선 이게 성립하지 않음** — +그룹 A가 등록해둔 인덱스 1의 핸들러는 `StoreBind`이고 그룹 B가 넘기는 +`sourceB`도 `StoreBind`에 매치되므로 **"같은 핸들러"로 판정되어 조용히 +갈아탐**. 게다가 나중에 A의 클로저가 자기 이름들을 `retractFrom`할 때 +**B의 바인딩을 대신 철거**함(교차 오염) — 예전의 "조용한 last-write-wins"가 +그대로 돌아옴. -**최종(세 번째 버전) — 체크포인트도 `owners`도 없이, 항상 인덱스 1부터 -직접 위임.** **[캐비엇, 2026-08-13 여섯 번째 세션] 여기서 "최종"은 *현행 -`hintValue` 모델 기준*이고, 이 주제 자체가 `question.md` **0-Z**로 다시 -열려 있음** — 문서 상단 ⚠️ 배너가 예고하는 "하강 diff" 재디스패치 모델에선 -그룹 A/B가 둘 다 `StoreBind`로 보여 "같은 핸들러"로 판정되므로 **아래 점유 -체크만으로는 그룹↔그룹 충돌을 못 잡음**(`research/dispatch-redispatch-diff-plan.md` -5절). 0-Z가 정해지면 이 절이 네 번째 버전으로 다시 갱신됨. 그룹이 이름마다 공개 `AttributeKey(name)`으로 그냥 -`Dispatch.process(inst, key, source, 1)`를 부르면 끝 — **"인덱스 1이 이미 -점유돼 있는가"라는 `Dispatch.process` 자신의 점유 체크가 소유권 충돌 -감지를 그대로 대신함**: 다른 그룹이나 직접 쓰기가 이미 그 이름을 -점유했다면, 이 호출은(체크포인트를 거칠 필요도 없이) 곧바로 "이미 -점유됨" error를 냄. `AttributeKeyHandler`도 다시 완전 무상태로 되돌아감: +**확정된 해법(2026-08-13 열네 번째 세션) — 두 조각**: + +1. **그룹은 이름마다 자기 전용 키를 쓴다(비공개 `GetKey`)**. 그룹 값 + 객체별·이름별로 메모이즈된 `AttributeKey`를 만들어 그걸로 위임 → + 그룹 A와 그룹 B와 직접 쓰기가 **서로 다른 체인**을 갖게 되어, 한 + 소유자의 철거가 다른 소유자의 바인딩을 건드리는 **교차 오염이 + 구조적으로 불가능**해짐. +2. **이름 자체의 소유권은 `AttributeKeyHandler`가 이름 claim으로 판정**. + `(inst, name) → 지금 그 이름을 잡고 있는 키 객체`를 `Relate` 하나로 + 들고, 다른 키 객체가 같은 이름에 들어오면 **즉시 error**. + +**왜 이 조합인가 — 대안 (a)(그룹 안에 이름별 claimant `Relate`)로는 부족**: +(a)는 그룹↔그룹은 잡지만 **그룹↔직접 쓰기를 못 잡음**. 직접 쓰기는 그룹 +코드를 아예 안 지나가서 그룹 쪽 레지스트리에 등록될 자리가 없고, 두 +경로가 실제로 만나는 유일한 지점인 `AttributeKeyHandler`에서는 공개 키를 +쓰는 한 `k`가 **같은 객체**라 소유자를 구분할 방법이 원천적으로 없기 +때문. 전용 키가 있어야 비로소 "이 이름을 지금 누가 잡고 있나"를 **키 +identity 하나로** 판정할 수 있고, 그러면 claim 로직이 그룹을 알 필요도 +없어짐(그룹인지 직접 쓰기인지 구분 안 함 — 소유자 종류가 늘어도 그대로 +동작). 후보 (b)(UB로 두고 문서화만)는 증상이 "조용한 오작동+교차 오염"이라 +비권장으로 기각, (c)(`Dispatch`에 claimant 일반화)는 "Dispatch는 diff만 +한다"는 방향과 어긋나 기각. ```lua --- AttributeKeyHandler(quad-roblox) — 완전 무상태, 소유권 추적 없음 +-- AttributeKeyHandler(quad-base) — 이름 claim만 자기 상태로 가짐 +local nameClaims = Relate() -- {[inst(weak)] = {[name] = key(strong)}} + function AttributeKeyHandler.process(inst, k, v, index) - inst:SetAttribute(k.Name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일 - return function() end -- 지울 게 없음 — SetAttribute는 오직 process(inst,k,nil)로만 + local claims = nameClaims:GetStrong(inst) + if not claims then + claims = {} + nameClaims:SetStrong(inst, claims) + end + local cur = claims[k.Name] + if cur ~= nil and cur ~= k then + error(`attribute "{k.Name}" is already bound by another owner`) + end + claims[k.Name] = k + + setAttribute(inst, k.Name, v) -- v가 nil이든 아니든 무조건(위 "메커니즘" 절) + + return function() + -- 엔진 부작용 없음 — claim 반납만. "내가 실제로 물러날 때만" 지움 + -- (`dispatch-core-plan.md` "Handler 작성 체크리스트" 4번). + if claims[k.Name] == k then + claims[k.Name] = nil + end + end end ``` +- **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는 + `slot.retractor(v)` → `h.process(...)` 순서이고, 핸들러가 바뀌는 경우는 + `retractFrom` → `process` 순서(`base/dispatch-core-plan.md` "Dispatch + 체인" 절) — 어느 경로든 **옛 claim 반납이 새 claim보다 먼저**라 자기 + 자신과 충돌하는 일이 없음. `5 → None → 5`처럼 체인 깊이가 오가는 + 경우도 인덱스가 바뀌는 자리에서 `retractFrom`이 먼저 돌므로 동일. +- **`GetKey`는 공개 API가 아님(사용자 확정)** — 그룹 값 객체 바깥으로 + 키를 반출하면, 사용자가 그 키를 다른 자리에 다시 놓아 **같은 키가 두 + 자리에서 수렴**할 수 있고 그건 claim(키 identity 기준)으로도 잡히지 + 않음(0-W "같은 `Ref` 객체 이중 배치"와 같은 형태의 갭). 비공개로 + 두면 이 경로 자체가 없음 — 구현상으로도 그룹 핸들러 안의 + `Relate<그룹 값 → {[name] = key}>` 또는 클로저 캡처면 충분하고, base의 + `Attribute` 값 객체 공개 표면에는 아무것도 안 늘어남. +- **에러 메시지는 도메인 언어로** — 옛 모델의 일반 점유 error("이 인덱스는 + 이미 점유돼 있음")와 달리 여기선 이름을 그대로 찍을 수 있음. + `base/dispatch-core-plan.md`가 "상세 에러가 필요하면 호출부가 도메인 + 언어로 다시 던지는 건 자유"라고 남겨둔 자리를 이게 채움. - **직접 리터럴 쓰기**(`[AttributeKey<> "name"] = value`)는 공개 - `AttributeKey(name)`을 그대로 씀, 정상 스캔으로 바로 - `AttributeKeyHandler`에 도달(인덱스 1) — 한 Modifier 안에 같은 해시 - 키가 중복될 수 없어 이 경로 자체의 claimant는 항상 유일. 그룹이 - 이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의 - `Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error — - 그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘. -- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.process`만으로 - 위임하고(철거는 반환 클로저가 전담) — 전용 키 객체도, 소유권 레지스트리도 - 필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1). **`process`가 - 위임 직전에 `retractFrom`을 부르면 안 된다는 게 이 절이 성립하는 - 전제** — 그러면 점유 여부와 무관하게 인덱스 1이 비워져 아래 점유 - 체크가 무력화됨(2026-08-13 감사에서 실제 그렇게 적혀 있던 걸 정정, - "메커니즘" 절 참고). -- **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리 - base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽 - `Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름. + `AttributeKey(name)`을 그대로 씀 — 한 Modifier 안에 같은 해시 키가 + 중복될 수 없어 이 경로 자체의 소유자는 항상 유일하고, 그룹이 이미 그 + 이름을 잡고 있으면 claim이 즉시 error. +- **`Tag`와의 대조** — `Tag`는 같은 이름을 여러 위치가 공유하는 게 + **의도된 동작**(웹 `className` 합집합)이라 참조 카운트로 가고, Attribute는 + 값이 하나뿐이라 겹침이 곧 충돌이라 claim으로 감. 두 정책의 차이는 + 자원의 성질에서 나옴(`base/tag-plan.md`). ## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설) @@ -254,20 +287,18 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 Store 기각과 안 부딪히나" 참고. -### 메커니즘 — 항상 인덱스 1부터 기존 단일 키 경로에 직접 위임 +### 메커니즘 — 그룹 전용 키로 단일 키 경로에 위임 (2026-08-13 열네 번째 세션 확정) **[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler, Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독" -이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유 -자체가 없어짐 — 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을 -새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로 -재귀 위임** — `None`/store-bind 전부 이미 확정된 단일 키 메커니즘을 -100% 재사용, 중복 구현 없음. **[전면 재정정, 2026-08-13 다섯 번째 세션] -`rawNew(name)` 그룹 전용 키 → `AttributeGroupKeyHandler` 체크포인트로 -두 번 거쳐온 위임 메커니즘이, `Dispatch`의 인덱스 기반 재설계로 다시 -한번 단순화됨 — 이제 그냥 공개 `AttributeKey(name)`으로 인덱스 1에 -직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/ -checkpoint-handler-pattern-reversed.md`): +이었으나, 위 "동등성" 절의 이름별 캐시가 확정되며 그 회피 이유 자체가 +없어짐 — 그래서 그룹 Handler는 **자기만의 set/구독 로직을 새로 만들지 +않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로 재귀 위임**한다 +(`None`/store-bind 전부 이미 확정된 단일 키 메커니즘을 100% 재사용, 중복 +구현 없음). **위임에 쓰는 키만 세 번 바뀌었고 지금은 그룹 전용 키** +(`rawNew(name)` 전용 키 → `AttributeGroupKeyHandler` 체크포인트 → 공개 +`AttributeKey(name)`+점유 체크 → **비공개 `GetKey`(그룹 값 객체별·이름별 +메모이즈) + 이름 claim**) — 경위와 근거는 위 "이름 소유권" 절. **그룹의 `process`** — 다른 모든 핸들러와 똑같은 4-인자 계약 `process(inst, k, v, index)`를 따름(`k`는 이 그룹 값이 놓인 array-part @@ -275,68 +306,60 @@ checkpoint-handler-pattern-reversed.md`): 다른 것이라 헷갈리지 말 것**. 2026-08-13 감사 전까지 이 자리 시그니처가 `process(inst, index, v)` 3-인자로 적혀 있었고 배열 위치를 하필 `index`로 부르고 있어서, 코퍼스에서 유일하게 계약과 안 맞는 핸들러였음). 반환하는 -클로저가 "이 호출이 등록한 이름 집합"을 직접 캡처 — **별도 `Relate` -불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의 -이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가 -없어짐): - -**[정정, 2026-08-13 감사] `process`는 `retractFrom`을 부르지 않는다 — -철거는 전적으로 반환 클로저 몫.** 최초 작성본은 `process` 안에서 이름마다 -`Dispatch.retractFrom(inst, key, 1, source)`를 먼저 부른 뒤 `process`를 -불렀는데, 이러면 **인덱스 1을 누가 점유했든 무조건 비워버리므로 뒤따르는 -`Dispatch.process`가 점유 error를 낼 수가 없음** — 위 "이름 소유권" 절이 -이 재설계의 핵심 근거로 내세운 "점유 체크가 소유권 충돌 감지를 그대로 -대신함"이 그룹↔그룹 사이에서 전혀 작동하지 않았음(그룹 B가 그룹 A의 -바인딩을 조용히 파괴하고 이김 — 이 절이 없앴다고 선언한 바로 그 -last-write-wins). 클로저가 자기가 등록한 이름 전부를 책임지고 걷어내면 -`process`는 순수하게 `Dispatch.process`만 부르면 되고, 점유 체크가 다시 -살아남: +클로저가 "이 호출이 등록한 키 목록"을 직접 캡처: ```lua function AttributeGroupHandler.process(inst, k, v, index) - local names = {} + local keys = {} for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장 - -- 공개 캐시 키 그대로(그룹 전용 키 불필요), 항상 인덱스 1부터 위임 — - -- 이미 다른 그룹/직접 쓰기가 그 이름을 점유 중이면 여기서 즉시 점유 error - Dispatch.process(inst, AttributeKey(name), source, 1) - names[name] = true + -- 그룹 전용 키(비공개) — 공개 AttributeKey(name)이 아님. + -- 같은 그룹 값 객체 + 같은 이름이면 항상 같은 키 객체가 나와야 함. + local key = groupKey(v, name) + Dispatch.process(inst, key, source, 1) -- 다른 키로 위임이므로 항상 인덱스 1 + keys[key] = true end return function() - -- 이 그룹이 등록했던 이름 전부를 철거 — 생존/소멸 구분 없이 균일. - -- hintValue를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시 - -- 등록하는 순서라(StoreBind가 retractFrom → process 순서를 보장), + -- 이 그룹이 등록했던 것 전부를 철거 — 생존/소멸 구분 없이 균일. + -- 인자(새 값)를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시 + -- 등록하는 순서라(Dispatch가 retractor → process 순서를 보장), -- "다음 값에 이 이름이 있나"를 미리 알 필요가 없음. - for name in pairs(names) do - Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙) + for key in pairs(keys) do + Dispatch.retractFrom(inst, key, 1) end end end ``` +- **`groupKey(v, name)`는 그룹 값 객체별·이름별 메모이즈** — 같은 그룹 + 값이 재프로세스될 때 같은 키가 나와야 claim이 자기 자신과 안 부딪힘. + 구현은 `Relate<그룹 값 → {[name] = key}>` 하나면 충분하고, **공개 + API로 노출하지 않음**(위 "이름 소유권" 절). 그룹 값 객체 자체가 바뀌면 + (`State`가 새 객체를 emit) 새 키가 나오는데, 그때는 옛 + 클로저가 먼저 전부 철거하므로 claim 충돌이 없음. - **생존 이름도 매 사이클 철거→재등록됨(의도된 트레이드오프)** — 비용은 그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회 - 실행"이 같은 값으로 `SetAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미 + 실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미 "값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목) - 결이 같고, `SetAttribute`는 같은 값 재기록이 관측상 무해함. **`Tag`식 - `hintValue == v` 조기 반환을 여기에 넣으면 안 됨** — 클로저가 아무것도 - 안 걷어낸 상태로 다음 `process`가 같은 이름에 다시 인덱스 1을 잡으려 - 들어 자기 자신에게 점유 error를 냄. + 결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이 + 그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`만 + 불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴 + 위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또 + 들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는 + 실제로 비용이 문제될 때 재검토. - **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가 - `names` 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진 - 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없어짐(이전 버전이 - `newNames`를 계산하던 로직 자체가 불필요). + 자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진 + 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음. - **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md` "하드 경계" 절)과 어긋나고, `source`가 `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음. -- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract`는 `SetAttribute`를 +- **[확정, 2026-08-12 세션 후속, 사용자 결정] 클로저는 `setAttribute`를 절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.** - 그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가 - 통째로 사라지든(컴포넌트 언마운트 등, `v`가 더 이상 Attribute가 아님) - 프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지 - 않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와 - 무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라", + 그룹에서 이름이 조용히 빠지든, 그룹 바인딩 자체가 통째로 사라지든 + (컴포넌트 언마운트 등) 프레임워크가 자동으로 `setAttribute(inst,name,nil)`을 + 대신 불러주지 않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 + Destroy와 무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라", `ref-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은 "Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로 조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가 @@ -349,40 +372,31 @@ end 그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을 나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라 모호하지 않음, 지금은 범위 밖(백로그). -- **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면 - 안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지 - 않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던 - `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 - `Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가 - 됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 - 문제). 그래서 반환하는 클로저는 **자기가 등록했던 이름 전부**에 대해 - `Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`을 부름 - (**[정정, 2026-08-13 감사]** 원래는 "사라진 이름에 한해"였으나, 그 - 선별을 하려면 `process` 쪽이 생존 이름을 `retractFrom`으로 강제 - 회수해야 해서 점유 체크가 무력화됐음 — 위 "메커니즘" 절 참고. 전부 - 걷어내고 새 `process`가 다시 등록하는 쪽이 점유 체크를 살리면서 - 코드도 더 단순함) — - **`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을 - 트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙) - 그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`는 - 여기서도 절대 안 일어남 — 위 "명시적 None으로만 지운다" 원칙과 안 - 부딪힘. +- **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면 안 됨.** + 그룹이 더 이상 관리하지 않는 이름의 체인을 그대로 두면 그 키에 걸려있던 + `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 `Source`가 + 바뀔 때마다 계속 `setAttribute`를 쏘는 실제 리소스 누수가 됨(이건 + "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 문제). + 그래서 반환하는 클로저는 자기가 등록했던 키 전부에 대해 + `Dispatch.retractFrom(inst, key, 1)`을 부름 — **`Dispatch.process`는 + 절대 안 부르므로**(이 클로저 안에서 새 등록을 트리거하는 건 체인 추적을 + 꼬는 UB, `dispatch-core-plan.md` 일반 규칙) 그 키 아래가 전부 자기 + 자신의 클로저만 타고 끝나 `setAttribute`는 여기서도 절대 안 일어남 — + 위 "명시적 None으로만 지운다" 원칙과 안 부딪힘. - **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는 안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린 단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로 - `SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로). + `setAttribute(inst,"foo",v)`를 호출(그룹 재진입 없이 그 경로 스스로). **`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** — - 키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎. + 키 집합이 안 바뀌는 한 그룹 로직 자체가 안 돎. -**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 위임하고, -클로저가 같은 집합을 걷어내는 것"뿐**(**[정정, 2026-08-13 감사]** 예전엔 -"이름 집합 diff"라고 적었으나, 위 재정정으로 diff 자체가 없어짐 — -`process`는 새 집합을 전부 등록, 클로저는 옛 집합을 전부 철거) — 실제 -`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키 -경로가 그대로 담당. `TagHandler`가 `CollectionService` 호출을 직접 하는 -것과 달리, 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만 -"이미 완성된 재사용 가능한 단일 키 경로"가 있어서 가능한 차이 — Tag는 -애초에 이름 하나짜리 단일 키 대응물이 없음). +**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 자기 전용 +키로 위임하고, 클로저가 같은 키들을 걷어내는 것"뿐** — 실제 +`setAttribute` 호출/`None` 처리/store-bind 구독/이름 claim은 전부 단일 키 +경로가 담당. `TagHandler`가 `addTag`/`removeTag`를 직접 호출하는 것과 +달리 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만 "이미 +완성된 재사용 가능한 단일 키 경로"가 있어서 가능한 차이 — Tag는 애초에 +이름 하나짜리 단일 키 대응물이 없음). ### `Attribute.Merged`가 레이어드 Store 기각과 안 부딪히나 — 점검, 안 부딪힘 @@ -402,30 +416,60 @@ end 기각 사유(범용 컨테이너의 암묵적 런타임 폴백)가 이 케이스엔 적용 안 됨 — 새 primitive 추가에 문제없음. -### 패키지 배치 — Tag와 동일 원칙 +### 패키지 배치 — 값 타입도 알고리즘도 quad-base, 주입되는 건 `setAttribute` 하나 (2026-08-13 열네 번째 세션 전면 재배치) -값 타입+API(`Attribute(...)`/`Merged`)는 quad-base(엔진 무관, 순수 데이터+ -연산). `SetAttribute` 실제 호출 글루만 quad-roblox. +**[전면 재배치, 2026-08-13 열네 번째 세션, 사용자 판단]** 예전엔 값 +타입/API만 quad-base이고 **단일 키 `AttributeKey`와 두 Handler는 통째로 +quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 마지막 한 줄 +(`inst:SetAttribute`)뿐이고, 이름 claim·그룹 위임·`None` 처리·이름별 weak +캐시는 전부 순수 부기임. 웹에도 대응물이 있으므로(`data-*`) 그 배치대로면 +**같은 소유권 알고리즘을 백엔드마다 재구현**하게 됨 — `architecture.md`의 +"엔진마다 큰 구현을 중복하지 않기 위해 디스패치 엔진을 base가 인터페이스로 +소유한다"는 원칙에 정면으로 어긋남. -## 패키지 배치 (단일 키 `AttributeKey`) +**확정된 배치**: -UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 -포함, 별도 opt-out 패키지로 안 쪼갬. +| 무엇 | 어디 | +|---|---| +| 그룹 값 타입+API(`Attribute(...)`/`Merged`/`:NameMap`) | quad-base | +| 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시 | quad-base | +| 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) | quad-base | +| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로 등록 | +| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) | +| **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 | + +- **왜 타입 패밀리만 갈리는가**: Roblox attribute가 받는 타입 집합 + (`Color3`/`UDim2`/`CFrame`/`Instance` 등)은 엔진 고유 어휘라 base가 알 + 수 없음. 반대로 string/number/boolean은 어느 백엔드에나 있으므로 base에 + 둔다. "이 값이 이 백엔드에서 표현 가능한가"라는 **검증도 base가 아니라 + 주입된 `setAttribute`의 몫** — base는 값을 그대로 흘려보냄. +- **백엔드가 통째로 다르게 하고 싶으면** 평범한 우선순위로 자기 핸들러를 + 등록하면 됨(base 것은 최하위 밴드라 자동으로 짐) — 상세는 + `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 + op" 절. `Tag`도 정확히 같은 구조(`base/tag-plan.md`). +- 단일 키를 별도 opt-out 패키지로 쪼개지 않는다는 기존 판단은 그대로 + (UICorner 숏핸드/Tween/Tag와 같은 결) — 다만 "어느 패키지의 코어인가"가 + quad-roblox에서 quad-base로 바뀐 것. ## 열린 질문 (`.claude/question.md`에도 취합) -- **[2026-08-13 3차 감사에서 보강] `hintValue`/`retractFrom` 재-dispatch - 메커니즘은 `question.md` **0-Z**(이 문서 자신의 이름 소유권 결정) - 해소 대기 중** — 이 문서 최상단 배너와 "이름 소유권" 절에 이미 - 명시돼 있지만, 이 목록에는 빠져 있어서 여기만 훑는 독자가 놓치기 - 쉬움. `research/dispatch-redispatch-diff-plan.md` 먼저 읽을 것. +- **[해소, 2026-08-13 열네 번째 세션] 이름 소유권(`question.md` 0-Z)과 + 하강 diff 재디스패치(0-A)는 확정·반영 완료** — 위 "이름 소유권"/ + "메커니즘" 절이 정본, 뒤집힌 옛 모델은 + `archive/dispatch-hintvalue-model-reversed.md`. +- **[열림, 사소함, 2026-08-13 열네 번째 세션 신설] `Attribute.Merged`에서 + 두 Store가 같은 이름을 가지면 지금은 조용히 하나가 이김** — + `:NameMap()` 평탄화가 dispatch 이전 단계라 위 이름 claim이 못 잡는 + 자리. 이름 겹침을 error로 잡는 게 이 문서의 다른 결정들과 결이 같고 + 구현도 싸지만(합성 시점 1회 체크), "Merged는 뒤가 이긴다"를 의도된 + override로 볼 여지도 있어서 사용자 확인 대기 — `question.md` 3번. - **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은 `Attribute`, 단일 키는 `AttributeKey<>`로 코드/문서 전체 통일해서 - 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/ - `DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 + 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/ + `Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 대기열에 있음, 나중에 한꺼번에 재검토. - **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도 - `SetAttribute(name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" + `setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" 절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은 모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 `None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 — diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 570398b..1594a86 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1,35 +1,20 @@ -# Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨) +# Bind 시스템 — 반응형 값 조합과 Store/State/Source 온톨로지 (base로 승격됨) -> **📄 [2026-08-13 아홉 번째 세션] 이 문서는 분할 중입니다 — 1단계 완료.** -> 2989줄까지 불어나 사람이 검토할 수 없고 한 곳의 실수가 미치는 범위가 -> 너무 크다는 사용자 지적으로 쪼개는 중. **1단계로 분리된 것(내용/결정은 -> 하나도 안 바뀜, 순수 이동)**: +> **📄 [2026-08-13 열네 번째 세션] 분할 완료 — 이 문서는 이제 "반응형 +> 코어"만 담습니다.** 2989줄까지 불어나 사람이 검토할 수 없다는 사용자 +> 지적으로 시작된 분할이 2단계로 끝났음(내용/결정은 이동 자체로는 안 +> 바뀜): > -> | 나간 것 | 어디로 | -> |---|---| -> | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | -> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | -> | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | +> | 나간 것 | 어디로 | 단계 | +> |---|---|---| +> | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | 1단계(9차 세션) | +> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | 1단계 | +> | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | 1단계 | +> | **디스패치 코어**(핸들러 계약 / 디스패치 모델 / `chains`·`retractFrom` / 체크리스트 / Length·Offset) | **`base/dispatch-core-plan.md`** | **2단계(14차 세션)** | > -> **2단계(예정, 0-Z 반영과 같은 패스에서 할 것)**: 아직 여기 남아있는 -> **디스패치 코어**(핸들러 계약 / 확정된 디스패치 모델 / Dispatch 체인 / -> Handler 작성 체크리스트 / Length/Offset, ~1000줄)와 **반응형 코어** -> (`:With`+`:Compute` / Store·State·Source 온톨로지, ~950줄)를 각각 -> 별도 문서로. **지금 안 쪼갠 이유**: 디스패치 코어는 0-Z 확정 시 -> 어차피 전면 재작성 대상이라, 지금 옮기면 같은 텍스트를 두 번 만지고 -> 인바운드 참조(~37곳)도 두 번 고쳐야 함 — 재작성하는 그 패스에서 파일을 -> 가르는 게 총 변경량과 실수 위험이 모두 작음. 1단계가 인바운드 참조 -> 12곳으로 끝난 것과 대조됨. - -> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 -> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 -> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 -> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 -> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 -> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** -> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 -> 구현하면 옛 모델로 짜게 됨** — 반드시 -> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. +> 2단계를 9차 세션이 미뤄뒀던 이유는 "0-A/0-Z 확정 시 그 텍스트가 어차피 +> 전면 재작성 대상이라 같은 패스에서 갈라야 총 변경량·위험이 작다"였고, +> 실제로 14차 세션에 재작성과 분할을 같이 처리함. **상태**: base — 핵심 디스패치 모델(`process` + 그가 반환하는 retract 클로저, 핸들러 3종 계약 — 2026-08-13 다섯 번째 세션에 별도 `retract` @@ -45,1079 +30,17 @@ array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` (레지스트리)와 Fusion/Vide 비교는 `reference/comparison-fusion-vide.md` 참고. -## 문제 - -v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 -숫자 키(children/style) vs 문자열 키(prop/event) vs `__type` 태그 테이블 -(register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 -(`[Attribute "X"]`, `[Tag ""]`, `PropertyChangedEvent ""` 등)를 추가하려면 이 -중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조. - -## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것) - -**[전면 재정정, 2026-08-13 다섯 번째 세션] `process`/`retract` 2-메소드 -계약에서 `process`가 자기 retract 클로저를 반환하는 1-메소드 계약으로 -전환.** 계기와 근거는 아래 "Dispatch 체인" 절 참고 — 이 절은 바뀐 최종 -계약만 서술. - -핸들러는 다음 3개를 제공하는 등록 가능한 객체: - -- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value - 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — - tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/ - CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 - 적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 - 유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음 - (2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** — - `process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는 - 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) - `isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에 - 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 - 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 - 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — - 사용자 판단으로 확정. **[2026-08-13 세션] 생략 불가, 항상 정의할 - 것** — 같은 날 네 번째 세션에서 한때 "생략하면 스캔 불가시 체크포인트 - 핸들러"로 확장했으나, 다섯 번째 세션(아래 "Dispatch 체인" 절)의 인덱스 - 기반 재설계로 그 용도(`AttributeGroupKeyHandler`류 마커) 자체가 - 없어져 이 확장도 같은 세션 안에서 신설·철회가 끝나 archive 이전 없이 - 이 한 줄로만 기록. -- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 - action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. -- `process(inst, key, value, index): (hintValue) -> ()` — 실제 처리 - 수행(아래 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고) 하고, - **자기 자신이 방금 벌인 일을 무르는 1-인자 클로저를 반환**. - v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할 + 예전의 `retract` - 필드가 여기로 합쳐짐(**[전면 재정정, 2026-08-13 다섯 번째 세션]**, - 계기·근거는 아래 "Dispatch 체인" 절). **반환값 생략 불가 — 정리할 - 게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** — - `Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가 - 나중에 정확히 이 클로저 하나만 호출해서 정리하므로(예전 "retract 필드 - 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴). **[정정, 2026-08-13 7차 - 감사] 생략했을 때 실제로 벌어지는 일**: `list[index] = nil`이 되어 - **배열에 구멍이 뚫림** — `#list`가 Lua 명세상 정의되지 않게 되고 - (`retractFrom`의 순회 시작점이 어긋남), 그 자리가 비어 보이므로 - `Dispatch.process`의 점유 체크도 통과해버려 **소유권 충돌 감지가 조용히 - 꺼짐**. 옛 서술("`attempt to call a nil value`로 크래시")은 부정확했음 — - `retractFrom`이 `if retractor then` 가드로 넘기고 있어서 크래시조차 안 - 났음. 그 가드를 즉시 error로 바꿔 계약 위반이 실제로 드러나게 함. - **핸들러가 직접 자기 자신의 하위 위임(재귀 `Dispatch.process`로 만든 - 것들)까지 클로저 안에서 다시 정리할 필요는 없음** — `Dispatch. - retractFrom`의 순회 구조 자체가 항상 깊은 인덱스부터 먼저 정리하고 - 나서 얕은 인덱스로 올라오므로, 이 클로저가 불릴 시점엔 자기보다 - 아래(자기가 만들어낸 하위 위임)는 이미 전부 정리된 뒤임(아래 - "Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접 - 자원**(Observer 구독 등)만 정리하면 됨. - -디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, -첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 -열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 -반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(`.claude/initreq/tbox/ -src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 -실패 시에만 클로저 호출. - -**우선순위 동률/매치 실패 처리 — 확정(2026-08-12 열일곱 번째 세션, -`pre-implementation-audit.md` 1-3/1-4 해소).** - -- **동률(같은 `priority` 값)에 대한 tiebreak 규칙은 강제하지 않는다.** - "등록 순서가 이긴다" 같은 규칙을 강제하면 `NoneHandler`/`StoreBind`처럼 - 이미 서로 `isHandlable`이 안 겹치는 내장 핸들러에까지 전부 그 규칙을 - 지켜가며 순서를 신경 써야 하고, 나중에 서드파티 핸들러가 늘어나면 더 - 골치아파짐(사용자 판단). 대신 **목적별로 이름 붙은 우선순위 상수** - (`HANDLER_PRIORITY_HIGH`/`HANDLER_PRIORITY_NORMAL`/`HANDLER_PRIORITY_LOW` - 등, 여전히 열린 숫자 공간 위의 편의 상수라 `HANDLER_PRIORITY_HIGH + 1`처럼 - 세밀 조정도 가능)를 제공해 애초에 동률이 잘 안 나오게 유도 — "우선순위 - 밴드 + 오프셋"은 여러 업계에서 이미 흔한 패턴. 실제로 동률이 나면 그건 - 대개 핸들러 설계 실수라, 강제 규칙보다 아래 디버그 가시성으로 대응하는 - 쪽이 맞음. -- **매치 실패(`isHandlable`을 만족하는 핸들러가 하나도 없음)는 조용한 - 무시 없이 즉시 `error`.** 에러 메시지엔 값의 `Brand`(있으면)와 - `typeof(v)`를 함께 출력하고, "quad-roblox 등 필요한 provider가 - 초기화됐는지 확인하라"는 안내만 덧붙임 — 그 이상의 특수 분기는 두지 - 않음(다른 라이브러리에서도 흔한 "매치 실패=에러" 패턴 그대로). - **이걸로 `module-lifecycle-plan.md`의 "provider가 아직 주입 안 된 - 상태에서 dispatch가 호출되면?" 케이스(`pre-implementation-audit.md` - 1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는 - 결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로 - "매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider - 미주입을 서로 다른 에러 종류로 구분할 필요가 없음. -- **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + - 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 - sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 - 두 핸들러가 등록되면 콘솔에 경고를 찍고, `Dispatch.listHandlers()`류 - 함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함. - 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 - M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 - `quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의, - 라이브러리 자체에 내장된 개발자 편의 기능. - -## 확정된 디스패치 모델: `process(inst, k, v, index) -> retractor` - -**사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함.** 아래가 -실제로 구현할 모양. **[전면 재정정, 2026-08-13 다섯 번째 세션]** 이 절은 -원래 `process(inst,k,v)`/`retract(inst,k,v)` 별개 2-메소드로 서술돼 -있었으나, `chains`를 핸들러 **객체 identity**가 아니라 **인덱스**로 -추적하는 재설계(아래 "Dispatch 체인" 절)와 함께 `process`가 자기 -retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절의 예시/규칙은 -전부 새 모델로 갱신됨, 옛 2-메소드 버전은 `archive/`로 옮기지 않고 이 -정정 표시로만 남김(오늘 하루 안에서 신설→재정정이 끝났기 때문). - -- 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고 - 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 - 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 - 대상"을 비동기로 기다릴 필요 자체가 없음(`ref-plan.md`의 Ref 절 참고 — Ref는 - 다른 이유로 존재). - - **보강(2026-08-04)**: `inst`가 항상 살아있는 엔진 객체(Roblox Instance)일 - 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 - (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 - 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 - 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — - base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 - 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. -- `process(inst, k, v, index)` — 우선순위 순으로 등록된 핸들러를 스캔, - `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당하고 - 자기 retract 클로저를 반환. **이 "스캔+실행" 오케스트레이터는 - `Dispatch.process`로, 순수 스캔 부분은 `Dispatch.getHandler`로 이름이 - 공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이 - 절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. **`index`가 - 뭔지·왜 필요한지는 아래 "Dispatch 체인" 절 참고** — 요약하면 같은 - `(inst,k)` 안에서 "지금 몇 번째로 겹쳐 위임됐는지"를 나타내는 정수로, - 핸들러 객체 identity 대신 이 숫자로 체인 위치를 추적함. -- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 - `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — - `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: - 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — - 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 - 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 - 그 시점엔 그냥 `Connected`를 보고 무시(no-op). - 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. - 3. **재귀 호출 전에 먼저 `Dispatch.retractFrom(inst, k, index + 1, realv)`를 - 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고 - `Dispatch.process(inst, k, realv, index + 1)`를 재귀 호출**(정확한 - 메커니즘은 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션에 처음 - 확정, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재정정 — 오케스트레이터 - 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 - 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 - 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 - 아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상 - 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. - `realv`가 또 State/Source(`State>`)여도 이제 **자연스럽게 - 처리됨** — 안쪽 재귀는 `index+1`이라는 별개 슬롯을 쓰므로 바깥 - StoreBind의 슬롯(`index`)과 절대 안 겹침(아래 "Dispatch 체인" 절의 - `State>` 재정정 참고, 예전엔 이게 UB였음). - **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind - 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 - 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며(`research/ - tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 - 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 - 패턴의 남은 대표 예시. -- **`process`가 반환하는 retractor(`(hintValue) -> ()`)** (이전 초안의 - "cleanup"/별도 `retract` 필드, 이름 변경 근거는 `base/lifecycle-pattern.md` - 참고, 별도 필드에서 반환값으로 합쳐진 경위는 위 "핸들러 계약" 절 — - 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 - 이전 처리를 갈아치우는" 시나리오에만 존재** — 인스턴스/바인드 전체가 - Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의 - "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). - - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 - 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만 - 반환하면 됨. - - **[정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에 계약 - 자체가 클로저로 바뀌며 서술 갱신] 이 클로저는 "핸들러 타입이 바뀔 - 때만" 불리는 게 아니라, store 바인드가 재발행될 때마다(값이 뭐로 - 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터 - 말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건** - `Dispatch.retractFrom(inst,k,index+1,realv)`를 불러 자기 밑에 쌓인 - 클로저들을 꼬리부터 전부 부른 뒤에야 `Dispatch.process`가 다시 - 매치를 시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract - 없이 process가 diff"라는 이전 서술은 틀렸음** — 그 오류의 상세 - 경위(`Tag`의 옛 `assert(v==nil)`을 액면 그대로 믿고 거꾸로 일반 - 규칙을 추론한 것)는 `archive/retract-always-fires-reversed.md` 참고, - 지금은 결론만 인용. - - **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이 - 없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가 - 없음) 반환하는 클로저가 사실상 no-op일 뿐, "타입이 안 바뀌면 아예 - 안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러 - 위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를 - 공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는, - 이 클로저가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`hintValue`)과 - 사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 힌트 - 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 — - `Tag`의 `Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. - **`hintValue`를 반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 - 그 자체일 수 있음) — 새 핸들러를 짤 때 이 클로저 안에서 `hintValue`의 - 타입을 방어적으로 확인할 것. - - **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개 - "반환한 클로저가 이전 기여를 걷어내고(실제 해제는 힌트로 skip - 가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 — - `process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 클로저가 - 이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`의 - 그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도 - 패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md` - "이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은 - `slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고. - - **[일반 규칙] 클로저의 `hintValue`는 타입을 보장 안 함 — 내용(메소드/ - 필드)을 보려면 반드시 `isX(hintValue)` 가드부터.** identity/nil만 - 비교하면 가드가 필요 없지만(`Ref`/`Slot`/`AttributeKey`가 이 경우), - 내용을 실제로 들여다봐야 하면(`TagHandler`의 `newv:Contains(name)`처럼) - 그 전에 반드시 `isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 - 그 자리 핸들러 *타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 - 호출해 크래시함. - - **[일반 규칙] 이 클로저 안에서 `Dispatch.process`를 부르는 것은 - UB — `Dispatch.retractFrom`이 체인을 걷는 도중의 트래킹이 꼬임.** - 이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새 - 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹 - 로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는 - 순서로만 일어나야 함. **`Dispatch.retractFrom`은 "다른 키에 대해서만" - 허용** — `Attribute` 그룹이 자기가 위임했던 `AttributeKey(name)`들을 - 걷어내는 게 정확히 이 경우. **같은 `(inst,k)`에 대해 이 클로저 안에서 - `retractFrom`을 부르는 것도 `process`와 똑같이 금지(UB)** — 지금 - 돌고 있는 바깥 `retractFrom`의 루프가 `#list`를 이미 캡처한 채 - 꼬리부터 내려오는 중이라, 그 도중에 같은 list를 다시 훑으면 같은 - retractor가 두 번 불리거나 건너뛰어짐(2026-08-13 감사에서 명시화 — - 원래는 "다른 키에 대해"라는 괄호로만 암시돼 있었음). - - **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할 - 필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`의 - 순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로 - 올라오므로 자동으로 해결됨(재귀/래핑 핸들러가 다단으로 겹쳐도 - 각 클로저는 자기 자신의 자원만 책임지면 전체 cascade가 저절로 됨 — - 2026-08-08 세 번째 세션에 확정된 "다단 체인 자동 전파" 성질이 인덱스 - 모델에서도 그대로 유지, 오히려 더 단순해짐). - - Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가 - 소비하는 값-레벨 래퍼(`Tween`)라 매치되는 핸들러가 항상 - PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은 - PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, - `archive/tween-special-bind-key-reversed.md`). -- **핸들러 내부 상태 저장 — 클로저로 충분한 것과 `Relate`가 필요한 것을 - 구분할 것.** "이 `process` 호출이 만든 걸 나중에 정리하는" 단발성 - handoff는 이제 클로저의 업밸류 캡처만으로 충분(예: Observer 객체를 - 로컬 변수로 만들고 그대로 반환 클로저가 캡처) — 예전처럼 `Relate`에 - 저장했다가 나중에 다시 조회할 필요가 없어짐(**[2026-08-13 다섯 번째 - 세션, 이 문단 재작성]**). `Relate`가 여전히 필요한 경우는 **여러 번의 - 독립적인 `process`/클로저 호출을 가로질러 누적되는 상태**뿐 — - `Tag`의 `tagNameMap`(여러 위치가 같은 이름을 공유), `Attribute`의 - 이름 소유권처럼 "이 `(inst,k)` 하나의 클로저 수명을 넘어서는" 정보만 - `local relate = Relate()`(모듈 톱레벨, `relate:SetStrong(inst,k,v)`/ - `:GetStrong(inst,k)`)로 저장. `base/lifecycle-pattern.md`의 - `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 - 다르니 별도 `Relate()` 인스턴스) — 이건 "언제까지 실행돼도 되는지"를 - 묻는 것이라 애초에 클로저 수명과 무관한, 계속 남는 질문이라 그대로 - `Relate` 기반. -- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot - 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 - 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. -- **일반적인 무한루프 방어(사이클 감지 등)는 하지 않기로 확정(2026-08-04, - 로드맵 인수인계 라운드)**: 우선순위 스캔+재귀 `process` 구조 자체는 핸들러가 - 규율을 안 지키면(예: 값을 좁히지/변형하지 않고 같은 값을 그대로 다시 - `process`에 넘김) 무한루프에 빠질 수 있음 — 하지만 이건 base가 방어 로직을 - 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 - 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 - 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 - 프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼 - 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 - 아니라고 판단해 별도 가드 없이 진행. - -- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 - 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, - 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau - 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 - 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, - `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, - **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 - 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. - 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 - 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 - 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 - 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 - 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 - 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 - 먼저 처리된다는 게 base 자체의 보장**이 됨 — `ref-plan.md`의 "Ref 일반화" 절 - 뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 - Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 - 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 - 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). - -### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) - -`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 -지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 -`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 -층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 -`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** - -```lua -NoneHandler.priority = <매우 높음> -NoneHandler.isHandlable(inst, k, v) = (v == None) -function NoneHandler.process(inst, k, v, index) - Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스 - return function() end -- 자기 자신은 아무 상태도 없어 no-op -end -``` - -- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히 - 다른 개념이라 혼동하지 말 것: `isHandlable(inst,k,v)`는 KV 매치 predicate - (핸들러 계약 3종 중 하나, 이 절에서 다루는 것 — 예전엔 `(k,v)` 2-인자에 - 4종 계약이었으나 각각 2026-08-07 여덟 번째/2026-08-13 다섯 번째 세션에 - 바뀜, 이 문단만 갱신에서 누락돼 있던 걸 같은 날 감사에서 발견), `canExecute`는 인자로 받은 특정 - 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 - 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — - KV 매치와 무관. - **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 - `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" - 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" - 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 - 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 - 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 - 경로가 다르다는 점에 유의. - `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 - `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 - 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. - `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 - 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 - 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — - `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 - 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. -- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 - 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 - "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 - 로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을 - 명시적으로 분리: - - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ - `priority`), 부작용 없음. - - `Dispatch.process(inst,k,v,index)` — 오케스트레이터: 그 인덱스가 이미 - 점유돼 있으면 즉시 error(핸들러 호출 전에 걸러짐) → 아니면 - `getHandler` 호출 → 매치된 핸들러의 `.process`를 불러 그 반환값 - (retractor 클로저)을 `(inst,k)` 체인의 그 인덱스에 저장. **"이전 - 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` 자신의 일이 - 아님** — 재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch - 전에 스스로 `Dispatch.retractFrom(inst, k, index + 1, newV)`를 먼저 - 불러 자기 밑을 정리하는 책임을 짐(정확한 메커니즘·기각된 대안은 - 아래 "Dispatch 체인" 절 참고 — 전역 소유자 슬롯 하나로 diff하는 - 안은 래핑 핸들러에서 깨져서 기각됨). - - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 - 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 - 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ - OnChangeHandler/UICornerHandler/TagHandler/AttributeHandler 등)은 - 팩토리가 `BaseModule`을 - 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 - 같은 패턴, 새 메커니즘 아님). - - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, - `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — - 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 - 소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소. - - **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로 - 확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base - 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 - 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 - flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 - 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 - `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체. - **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 - 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 - 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 - 무관하게 항상 1부터"(아래 "Dispatch 체인" 절)라는 규칙의 가장 기본 - 사례. 같은 키가 두 번 나올 수 없는 테이블 순회라 여기서 점유 충돌이 - 날 일도 없음(단, 그룹 `Attribute`가 배열 파트에서 먼저 점유해둔 - 이름을 해시 파트 직접 쓰기가 다시 건드리면 그건 정상적으로 점유 - error — `base/attribute-plan.md` "이름 소유권" 절). -- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 - "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 - 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 - 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 - `base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`. - `None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, - 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI - shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가 - State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반 - 반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. - **구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입 - 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — - PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 - 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). -- **반환하는 retractor는 여기서 할 일이 없음** — `NoneHandler`는 `v==None`을 - 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`을 - 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 - 전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티 - 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에 - 쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위 - "핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음. -- **[해소됨, 2026-08-08 세 번째 세션, 2026-08-13 다섯 번째 세션에 - 인덱스 기반으로 재정정]** "이 키를 지금 누가 담당 중인가" bookkeeping — - `pre-implementation-audit.md` 우선순위1 "이전에 실제로 매치됐던 핸들러 - 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch 체인" 절의 - `chains`/`Dispatch.retractFrom`로 구체화됨 — `NoneHandler`의 재귀 - 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 - 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). - -### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) - -`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ -`Modifier`처럼 생성자가 있는 프리미티브(예: `Dispatch()`로 인스턴스를 여러 개 -만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 -바로 닿는 flat 탑레벨 함수) 유지로 확정**: - -- **재귀 재-dispatch가 요구하는 필연** — `NoneHandler`/`Dispatch/ - StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, - realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 - 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ - lifecycle-pattern.md`)과 똑같이 require 한 번으로 바로 닿는 안정된 - 전역이어야 함 — 인스턴스화 가능한 프리미티브로 만들면 모든 Handler - 등록/호출 경로에 Dispatch 핸들을 인자로 계속 실어날라야 하는 스레딩 - 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐. -- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 - 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** - (`isHandlable`/`priority`/`process`(반환값 포함) 시그니처만 있는 순수 - leaf, Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 - **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 - 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` - (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` - (재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 - Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. - 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 - 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 - 완전히 끝나있어 부트스트랩 문제도 없음. -- **quad-base 자신의 기본 핸들러도 같은 레지스트리를 씀** — `NoneHandler`, - `Dispatch/StoreBind.luau`("범용, 엔진 무관")뿐 아니라, children 배열 - 숫자 슬롯에 `Ref`/`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 - Handler도 여기 속함(`inst`를 `any`로 취급, 엔진 특정 API 불필요 — - `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 - 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, - `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 - Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 - 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 - 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 - 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 - 소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`). -- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 - 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 - 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 - 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 - `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 - 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 - 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 - 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 - 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 - 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule - 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 - 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 - 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. - -### Dispatch 체인 — 인덱스 기반 재추적, `Dispatch.retractFrom` (2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 전면 재설계) - -**[전면 재설계, 2026-08-13 다섯 번째 세션]** 이 절은 원래 `chains`를 -핸들러 **객체 identity**로 추적했으나(옛 버전은 `archive/ -checkpoint-handler-pattern-reversed.md`에 그 위에 얹혔던 체크포인트 -패턴과 함께 보존), 그 표현 자체가 `State>`를 UB로 만든 -근본 원인이었음이 드러나 **재귀 깊이를 나타내는 정수 인덱스**로 -추적하도록 다시 설계됨. 계기: `AttributeGroupHandler`의 소유권 충돌 -버그를 고치려고 체크포인트 핸들러 패턴을 얹었다가, 사용자가 "그 문제도 -결국 identity 기반 추적이 원인 아니냐"고 되짚으면서 인덱스 기반으로 -가면 체크포인트 없이도 같은 문제가 풀리고 `State>` UB 자체도 -없앨 수 있다는 게 같은 세션 안에서 확인됨. - -**문제(원래 동기, 여전히 유효)**: `NoneHandler`/`StoreBind`처럼 자기 -`process` 안에서 `Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 -핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 -하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 -Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 -두고 서로 덮어씀**. 처음 검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 -이 이유로 기각됨. - -**해법 — Dispatch가 `(inst,k)`별로 인덱스 배열을 소유, 각 슬롯엔 그 -`process` 호출이 반환한 retractor 클로저만 저장**: - -```lua --- Dispatch/init.luau -local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = retractor}(strong)}} -local NOOP = function() end - -function Dispatch.process(inst, k, v, index) - -- [순서 주의, 2026-08-13 감사] 아래 두 줄(list 확보 + chains 등록)은 반드시 - -- h.process 호출 *전에* 끝나야 함 — h.process가 내부에서 재귀 - -- Dispatch.process(inst,k,...,index+1)를 부르는 게 정상 경로이고(StoreBind/ - -- NoneHandler), 그때 chains에 이 list가 아직 안 들어가 있으면 재귀 호출이 - -- `or {}`로 자기만의 새 테이블을 만들어 저장해버린 뒤 바깥이 그걸 덮어써서 - -- 하위 위임 retractor가 통째로 유실됨(최초 마운트에서 항상 발생). - local list = chains:GetStrong(inst, k) - if not list then - list = {} - chains:SetStrong(inst, k, list) - end - if list[index] ~= nil then - error("Dispatch: (inst,k)의 이 인덱스는 이미 점유돼 있음 — 먼저 retract할 것") - end - -- 점유 마커를 먼저 박는 이유: (1) h.process가 재귀하는 동안 list가 구멍 - -- 없는 시퀀스로 유지돼야 `#list`가 정의됨(hole 있는 테이블의 `#`는 Lua가 - -- 보장 안 함), (2) 핸들러가 같은 index로 재진입하는 버그도 이 가드에 걸림. - list[index] = NOOP - local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error - list[index] = h.process(inst, k, v, index) -- 반환값 = retractor 클로저, 생략 불가 -end - -function Dispatch.retractFrom(inst, k, index, v) - -- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리. - local list = chains:GetStrong(inst, k) - if not list then return end - for i = #list, index, -1 do - local retractor = list[i] - -- [정정, 2026-08-13 7차 감사] 예전엔 `if retractor then ... end` 가드였으나, - -- 그 가드에 걸리는 경우는 **핸들러가 계약을 어기고 nil을 반환한 것뿐**이고 - -- (정상 경로는 process가 항상 NOOP 마커를 먼저 박음) 조용히 넘기면 이미 - -- 배열에 구멍이 난 뒤라 `#list`도 점유 체크도 같이 망가짐 — 즉시 error가 맞음. - if retractor == nil then - error("Dispatch: 인덱스 " .. i .. "에 retractor가 없음 — 핸들러가 반환을 생략했음") - end - retractor(if i == index then v else nil) - list[i] = nil - end -end -``` - -- **점유 체크는 핸들러를 부르기 전에, Dispatch가 직접 함** — 매치된 - 핸들러의 `.process`가 실제 부작용(`SetAttribute`, Observer 구독 등)을 - 일으키기 *전에* 걸러야, "일단 실행해보고 나중에 되돌리는" 낭비/깜빡임이 - 없음. 충돌이 나면 핸들러 코드는 한 줄도 안 돔. **에러 메시지가 - 도메인 특화(예: "attribute 이름 foo 충돌")로 상세하지 않은 건 의도된 - 트레이드오프** — 에러가 나는 순간 이미 처리는 패닉 상태이고 그 이후의 - 정합성은 애초에 관리 대상이 아니므로(2026-08-04 "일반적 무한루프 방어 - 안 함"과 같은 결), 상세한 설명을 위해 먼저 실행해보는 비용을 들일 - 이유가 없음. 필요하면 호출부(`AttributeGroupHandler` 등)가 이 에러를 - 잡아 도메인 언어로 다시 던지는 건 자유(강제 안 함). -- **인덱스의 의미 — 재귀 깊이, 서로 다른 키는 항상 1부터**: 같은 키에서 - 값이 한 겹 더 반응형으로 감싸져 재귀하면(`StoreBind`가 `realv`를 들고 - 다시 `Dispatch.process`를 부르는 경우) `index+1`을 넘김. **다른 - 키로 위임할 때는 그 키의 재귀 깊이와 무관하게 항상 `1`부터 시작** — - `chains[inst][key2]`는 `chains[inst][key1]`과 완전히 별개의 배열이라 - 연속성이 필요 없음(예: `Attribute` 그룹이 `(inst,index)`에서 - `(inst,AttributeKey(name))`으로 위임할 때). **시작 인덱스는 0이 아니라 - 1** — Luau `ipairs`/`#`(배열 part 순회)는 1부터 연속된 정수 키를 - 전제하므로(quad 자신이 "props 순회 순서" 절에서 이 관례에 의존), 0을 - 쓰면 그 항목이 `ipairs` 순회에서 조용히 빠지고 `quad-debug`가 나중에 - `chains`를 그대로 순회해서 보여주려는 계획과도 부딪힘. -- **`handler.process(inst,k,v,index)`를 `Dispatch.process`를 거치지 않고 - 직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 - 것.** 이유: 점유 체크·`chains` 저장 bookkeeping이 `Dispatch.process` - 내부에만 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 - 활성화됐는데도 체인에 안 올라가 — 나중에 `retractFrom`이 이 핸들러의 - 존재를 몰라 정리가 영영 안 되거나(리소스 누수), 반대로 같은 인덱스를 - 다른 핸들러가 또 점유 시도해 정합성이 깨짐. -- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractFrom(inst, - k, index + 1, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV, - index + 1)`를 부름** — "내 바로 아래부터 전부 정리하고 새로 위임". - 자기 자신(`index`)은 그대로 살아있으므로 자기 자신의 retractor는 이 - 시점에 안 불림 — 자기가 완전히 사라질 때(더 바깥의 `retractFrom`이 - 자기 인덱스까지 포함해서 부를 때)만 불림. -- **개별 핸들러의 retractor는 더 이상 자기 위임 대상을 수동으로 안 - 쫓아가도 됨** — `retractFrom`이 꼬리(가장 깊은 인덱스)부터 목표 - 인덱스까지 한 번의 루프로 순서대로 정리해주므로, A→B→C처럼 몇 단계든 - 각 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨. 자기 - 자신을 포함해서 지우고 싶으면(`Attribute` 그룹처럼 위임한 것 전체를 - 통째로 걷어내고 싶은 경우) 호출자가 자기 자신의 인덱스를 그대로 - 넘기면 되고, 자기 아래만 지우고 싶으면(`StoreBind`가 자기 구독은 - 유지한 채 하위만 갈아치우는 경우) `index+1`을 넘기면 됨 — **"미만"과 - "이하"를 별도 함수로 안 쪼개고 호출자가 넘기는 인덱스 하나로 통일** - (옛 `retractUnder`/`retractSelfAndUnder` 두 함수가 이걸로 하나가 됨, - `archive/checkpoint-handler-pattern-reversed.md` 참고). -- **`process`가 반환하는 retractor는 여전히 `hintValue` 1-인자** — 드롭하자는 - 제안이 대화 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 - 하는 핸들러가 있어서, `base/tag-plan.md` 참고). `Tag`는 오히려 - 이 힌트를 반드시 봐야 하는 대표 사례다: 이 클로저는 store 재발행마다 - (핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler`가 반환한 - 클로저는 `hintValue`가 여전히 그 이름을 `Contains`하는지 확인해 실제 - 엔진 `RemoveTag` 호출만 skip한다 — 상세는 `base/tag-plan.md` "메커니즘" - 절 참고. `hintValue`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 - 있어도 됨" 정도로 이해할 것. -- **`hintValue`는 "직속 위임 1단계"에만 보장됨 — 깊은 인덱스는 항상 `nil` - (2026-08-13 여섯 번째 세션 명시화).** 위 `retractFrom` 의사코드의 - `retractor(if i == index then v else nil)`이 그대로 계약임: 힌트를 받는 - 건 **호출자가 지목한 `index` 자리 하나뿐**이고, 그보다 깊은(꼬리 쪽) - 인덱스들은 전부 `nil`을 받음. 결과: - - **`State`/`State`/`State`(한 겹)은 힌트가 확실히 - 전달됨** — StoreBind가 인덱스 1, 실제 핸들러가 인덱스 2이고 - StoreBind는 `retractFrom(inst,k,index+1=2,realv)`를 부르므로 - `i == index`가 정확히 그 핸들러에 걸림. 즉 `Tag`의 `Contains` 힌트, - `Ref`/`Slot`의 identity 비교 같은 깜빡임/재생성 방지가 **흔한 - 경로에서는 유실 없이 동작**함. - - **두 겹 이상(`State>` 등)에서 바깥이 재발행하면 안쪽 - 핸들러는 `nil` 힌트를 받음** — 구조적으로 불가피(바깥 단계는 안쪽 - State가 결국 어떤 값을 내놓을지 모름). 동작은 정상이지만 힌트 기반 - 최적화만 꺼짐. - - **[검토 후 기각] `chains`에 핸들러도 같이 저장해두고, 깊은 인덱스의 - 핸들러가 새 값에 매치될 핸들러와 같으면 값을 한 겹 풀어 힌트로 - 내려보내는 안**(사용자 제시). 두 가지 이유로 기각: (1) `process`와 - retractor 호출이 **1:1이 아님** — 전체 철거(`retractFrom(inst,k,1,nil)`) - 처럼 뒤따르는 `process`가 아예 없는 경로가 정상적으로 존재해서, 그 - 경우엔 애초에 내려보낼 "새 값"이 없음(사용자 스스로 지적한 한계). - (2) 더 근본적으로, 값을 한 겹 풀려면 teardown 도중에 - `innerState:Get()`을 **투기적으로** 호출해야 하는데 — State는 - pull-recompute 계약(`base/store-semantics.md`)이라 이 호출이 실제 - 재계산을 유발할 수 있고, 곧이어 `process`가 다시 `:Get()`을 부르면 - 같은 사이클에 이중 계산이 됨. 또 그렇게 얻은 값은 어디까지나 추정이라 - 실제 `process` 시점의 값과 다를 수 있음("값 비교/캐싱 금지" 원칙과 - 같은 결). **대신 채택할 방향은 값 층에서의 평탄화**(`State>` - → `State` 콤비네이터, `research/operator-sugar-plan.md` 백로그) — - 체인을 한 겹으로 유지하면 위 첫 번째 항목의 보장이 그대로 적용되므로 - Dispatch 층에 특수 배관을 넣을 이유가 없어짐. -- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 - B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜 - 무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가 - 나므로 애초에 일어날 수 없는 구조 — 값에 별도 플래그를 심어 의도적으로 - 순환을 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, - 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 - 같은 결로 UB 취급. -- **`State>`는 더 이상 UB가 아님 — 정상 지원 대상으로 재정정 - (2026-08-13 다섯 번째 세션).** 원래(같은 날 두 번째 세션) `store.key - = a`(State), `a:Get() = b`(State)일 때 같은 `StoreBind` 싱글톤이 같은 - `(inst,k)`에 identity로 두 번 매치돼 `retractUnder`의 cutoff 계산이 - 안쪽 자신을 잘못 retract하는 실제 버그(체인 파손, 구독이 등록 직후 - 스스로 끊김)로 재현돼 "같은 (inst,k)에 같은 핸들러 객체가 이미 있으면 - 즉시 error" 가드로 막았었음(당시 고정, `archive/ - checkpoint-handler-pattern-reversed.md`가 인용하는 옛 코드 참고). 이 - 버그의 근본 원인은 "핸들러당 최대 한 번씩만 그 키에서 호출됨"을 - 객체 identity로 강제하려 한 것 — 인덱스 기반으로 바꾸면 `a`를 처리하는 - StoreBind는 인덱스 N, `a:Get()`(=`b`)을 처리하는(같은 싱글톤이지만) - StoreBind는 인덱스 N+1을 써서 **애초에 슬롯이 안 겹침** — 같은 핸들러 - 객체가 같은 키에서 여러 번 매치되는 것 자체가 문제가 아니게 됨. - 임의 깊이의 `State>>`도 그냥 인덱스가 계속 늘어날 - 뿐 정상 동작 — 유일하게 남는 UB는 위 "순환" 항목(값이 결국 순환 참조를 - 이뤄 무한히 깊어지는 경우)뿐. -- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 - 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 - 미래의 재바인드는 `Dispatch.retractFrom(inst, k, 1, newV); - Dispatch.process(inst, k, newV, 1)` 두 줄로 "이 키의 체인을 통째로 갈아 - 끼우기"가 자연스럽게 됨. `research/debug-tooling-plan.md`의 "무엇이 - 무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — - quad-debug 착수 시점에 새로 설계할 필요 없음. - -### Handler 작성 체크리스트 — 새 모델에서 실제로 반복된 실수들 (2026-08-13 여섯 번째 세션 신설) - -**왜 이 절이 있는가**: 인덱스 기반 재설계 직후 작성된 의사코드 -(`Dispatch` 자신, `Ref`, `Tag`, `Slot`, `Attribute`)에서 **같은 세션 -안에 버그 4건**이 나왔고, 그중 셋이 서로 다른 문서에 있으면서도 -**같은 종류의 착각**에서 나왔음. 새 Handler를 짜거나 기존 걸 고칠 때 -이 목록을 먼저 훑을 것 — 전부 "그럴듯해 보이는데 틀린" 것들이라 -리뷰로 잡기 어렵다. - -**1. 클로저는 early-return해도 체인에서 *소비*된다.** -`Dispatch.retractFrom`은 저장된 retractor를 호출하고 **항상** -`list[i] = nil`로 지움 — 그 클로저가 `hintValue == v`로 아무 일도 안 하고 -돌아왔더라도 마찬가지. 그러므로: -- **매 `process` 호출은 "이 자리를 무르는 책임"을 온전히 새로 짊어진 - 클로저를 반환해야 한다.** "이번엔 내가 실제로 한 일이 없으니 no-op을 - 돌려주자"는 거의 항상 버그 — 다음 사이클에 진짜 교체가 올 때 정리할 - 주체가 사라짐. (`SlotHandler`에서 실제로 이 함정에 빠졌었음.) -- 반대로 "아무 일도 안 했으니 무를 것도 없다"가 **진짜로** 맞으려면, - 그 자리가 무를 자원을 애초에 아무도 안 갖고 있어야 함(일반 - PropertyHandler처럼). - -**2. `process`가 재귀 위임 전에 `retractFrom`을 부르는 건 *같은 키에 -자기 아래*일 때만이다.** -`StoreBind`가 `retractFrom(inst,k,index+1,realv)`를 부르는 건 자기가 -직전 사이클에 만든 하위 체인을 자기가 치우는 것 — 정당함. 반면 **다른 -키로 위임하면서 그 키의 인덱스 1을 미리 `retractFrom`으로 비우는 것은 -거의 항상 버그**: 그 자리를 누가 점유했든 지워버리므로 -`Dispatch.process`의 점유 체크(= 소유권 충돌 감지)가 통째로 무력화됨. -`AttributeGroupHandler`가 정확히 이걸로 그룹↔그룹 충돌을 조용히 -넘기고 있었음. **다른 키의 정리는 그 키를 등록했던 클로저가 한다.** - -**3. `hintValue`는 `nil`이 아니고, 타입도 보장 안 되고, 깊은 인덱스엔 -안 온다.** 셋 다 별개의 함정: -- `nil`이라고 가정하고 `assert(v == nil)`을 쓰면 안 됨(이미 한 번 전면 - 정정된 이력, `archive/retract-always-fires-reversed.md`). -- 내용(메소드/필드)을 보려면 `isTag(hintValue)` 같은 가드부터 — - 그 자리 핸들러 *타입*이 바뀌는 경우 엉뚱한 값이 옴. -- 깊이 2 이상에는 `nil`이 옴(위 "`hintValue`는 직속 위임 1단계에만 - 보장됨" 항목) — 힌트가 있으면 최적화, 없으면 정직하게 전부 정리하는 - 코드여야 함. 힌트를 **정확성**의 근거로 삼으면 안 됨. - -**4. "이전 값"을 알고 싶으면 클로저 캡처, "여러 위치/사이클을 가로지르는 -누적 상태"만 `Relate`.** 이 경계를 헷갈리면 양방향으로 틀림: -- 불필요한 `Relate`: `process`가 만든 걸 그 클로저가 정리하는 단발성 - handoff는 upvalue 캡처로 끝(옛 `kSlotMap`/`kTagMap`이 이걸로 삭제됨). -- 부족한 `Relate`: `Tag`의 `tagNameMap`(여러 위치가 한 이름을 공유), - `Ref`의 spurious 재바인딩 dedup처럼 **자기 클로저 수명 밖의 정보**는 - 캡처로 대체 불가. -- 그리고 `Relate`에 쓴 걸 클로저에서 지울 땐 **"내가 실제로 물러날 - 때만"** 지울 것 — 조건 밖에서 무조건 지우면 dedup이 무력화됨 - (`RefLeafHandler`가 정확히 이 버그였음). - -**5. `Dispatch`를 통해서만 진입한다.** -`handler.process(...)`를 직접 부르면 점유 체크와 `chains` 기록이 통째로 -빠져 나중에 정리가 안 되거나 정합성이 깨짐(위 "Dispatch 체인" 절). -마찬가지로 클로저 안에서는 `Dispatch.process` 금지, **같은 키**에 대한 -`Dispatch.retractFrom`도 금지(진행 중인 루프가 `#list`를 이미 캡처). - -**6. 인덱스는 "같은 키 안의 재귀 깊이"다.** -같은 키로 재귀하면 `index + 1`, **다른 키로 위임하면 그 키에서 다시 -`1`부터**, `Dispatch.drive`의 최초 진입도 `1`. 배열 파트의 위치(`k`)와 -이 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를 -`index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음. - -**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil`을 -반환하면 `list[index]`에 **구멍이 뚫려** `#list`가 정의되지 않게 되고 -(순회 시작점이 어긋남) 그 자리가 비어 보여 **점유 체크까지 조용히 -통과** — 즉 소유권 충돌 감지가 꺼짐. **[정정, 2026-08-13 7차 감사]** -예전엔 이 항목이 "`attempt to call a nil value`로 크래시"라고 적혀 -있었으나 `retractFrom`이 `if retractor then`으로 넘기고 있어 크래시조차 -안 나는 게 실제였음 — 지금은 `retractFrom`이 즉시 error를 냄(위 -"Dispatch 체인" 절 의사코드). - -### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) - -**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, -2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적 -자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상 -Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가 -자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은 -Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을 -만들어서 막다른 길. - -**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각 -구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지 -기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가 -`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라, -이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이 -필요 없음(이미 있는 store-bind 재실행 패턴 재사용). - -**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: - -```lua -Dispatch.setLength(inst, i, len: number | State) -Dispatch.setOffsetSource(inst, i, offset: Source | None) -``` - -**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 — -`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을 -재사용해 **Slot 자신을 owner 키로 써서 같은 두 함수를 한 번 더 -부르면, 최상위(Dispatch.drive의 리터럴 배열)와 중첩(Slot이 자기 -자신의 요소들에 대해)이 완전히 같은 메커니즘으로 재귀됨** — 새 함수를 -만들 필요 없음. 상세 재귀 흐름(Slot-in-Slot)은 `base/slot-plan.md`의 -"Slot-in-Slot 중첩" 절 참고, 이 문서는 그 절이 재사용하는 `recompute` -자체만 다룸(아래). - -- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의 - 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 - `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, - 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 - store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` - 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ - Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 - 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 - 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 - 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 - `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` - 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. -- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 - **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 - 하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우 - 이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래 - 참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이 - 등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정, - 2026-08-11 세션] 예전엔 이 Source를 "Handler가 자기 원소(들)의 - `LayoutOrder` 바인딩에 그대로 쓴다"고 서술했었는데 — 폐기.** Slot이 - 마운트한 원소에 `LayoutOrder`를 자동으로 덮어쓰면 (a) 사용자가 그 - 원소 자신의 프로퍼티로 `LayoutOrder`를 이미 지정해도 조용히 씹히는 - 매직이 되고, (b) `LayoutOrder`는 애초에 Roblox 전용 프로퍼티라 그 - 지식이 `Dispatch/Slot.luau`(엔진 무관) 층위로 새는 레이어링 위반이기도 - 함. 이제 `Offset`은 `Slot.Offset`으로 공개 노출만 되고, 각 원소의 - `LayoutOrder`(또는 웹의 CSS `order`)를 실제로 계산해 세팅하는 건 - `updateFn`(또는 수동 Slot 사용자)의 몫 — `updateFn`은 `index`를 raw - number로만 받고(`Slot.Length`/`item`과 같은 원칙, `:List`가 반응형을 - 강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를 - 만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼 - 써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의 - `Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 - 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 - 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 - 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 - 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 - "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 - 길이 합계와 실제 순서 계산이 어긋남). - -**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` -→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** -별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 -반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 -부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 -서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 -캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의 -`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 -틀려지는 문제는 아니지만(자기 length가 줄어도 자기 offset은 그대로라 -갱신될 일 자체가 없음) **invalid한 Source가 순회 대상에 남아있는 것 -자체가 위험**하므로 순서를 계약으로 고정. 상세·부수 방어 조치는 -`base/slot-plan.md`의 "구현상 바뀌어야 하는 것" 절 참고. - -**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB -(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — -이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 -이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. - -**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 -`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 -최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 -lazy 생성. - -**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 -원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) -`nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 -구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) -— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef -pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(`ref-plan.md`의 "PreRef" 절의 -"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 -되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef -pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 -하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 -`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 -자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 -여부 구별과 접근 비용" 쪽. - -**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: - -**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 -있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한 -뒤 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 -개수"가 아니라 **자기 자신을 포함한** 누적합이 되고 있었음 — 예를 -들어 `Frame{Slot1}` 하나뿐이어도(앞에 아무것도 없는데) `Slot1.Offset`이 -`Slot1.Length`가 되어버려 `index+offset` 공식이 어긋남. 순서를 -뒤집어(offset 먼저 Set, 그 다음에 자기 기여도를 sum에 누적) 수정 — -지금까지 실제 Luau로 돌려본 적이 없어 아무도 못 잡았던, Length/Offset -메커니즘 자체의 버그(오늘 논의한 중첩 기능과는 별개). - -**[검토했다가 기각, 2026-08-11 세션] 재진입 방지 가드 — 불필요함이 -재추적으로 확인됨.** 처음엔 recompute 도중 재귀 호출이 들어오는 경우를 -대비해 `_recomputing`/`_dirty` 플래그로 방어하는 안을 검토했으나, 실제 -호출 경로를 다시 추적한 결과 **각 Slot이 `Relate(자기 자신)`으로 독립된 -`bk`를 갖기 때문에, 중첩된 Slot의 Length 변경이 상위로 전파되는 경로는 -항상 서로 다른 `bk`를 거쳐 지나감** — 부모의 `recompute(parent, parentBk)`가 -자식의 `bk`를 건드리지 않고, 자식의 `recompute(child, childBk)`도 부모의 -`bk`를 안 건드림. 즉 **nesting이 있다는 사실만으로는 같은 `(ownerKey,bk)`가 -재진입되는 경로 자체가 없음** — "중첩 Slot이 있으면 항상 dirty가 켜진다"는 -초기 우려는 틀렸고, 가드 자체가 불필요한 걸로 확인됨. 진짜 재진입은 -`updateFn` 같은 부작용이 recompute 도중 **같은** Slot에 다시 `Add`/`Remove`를 -거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된 -"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로 -간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음. -**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입 -가드 없음.** - -**이 케이스를 명시적으로 UB로 명명(2026-08-11 세션, 사용자 제안)** — -`Source`가 `State`를 "단방향"으로만 만족한다는 이미 확정된 원칙 -(`base/store-semantics.md` "Source가 State를 만족함" 절 — 파생값이 -자기 upstream Source로 거꾸로 쓰기를 하지 않는다는 것)과 **같은 카테고리의 -위반**이라는 게 근거: `recompute`가 만드는 `offset`/`Length`는 전부 -`lengthList`(그 Slot의 upstream 입력)에서 파생된 다운스트림 값인데, -계산 도중 촉발된 부작용이 **자기 자신의 `lengthList` 입력을 다시 -mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `Set`을 -가하는 것"이 UB인 것과 동일한 이유로, "recompute 도중 발생한 부작용이 -같은 Slot의 length에 다시 쓰기를 가하는 것"도 UB로 문서화 — 새 원칙이 -아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한 -것뿐, 그래서 별도 방어 로직도 필요 없음. - -```lua -local function recompute(ownerKey, bk) - local sum = 0 - for i = 1, bk.N do - local offset = bk.sourceList[i] - -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 - -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. - -- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상 - -- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는 - -- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도 - -- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로. - if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set - offset:Set(sum) - end - local v = bk.lengthList[i] - sum += (if isState(v) then v:Get() else v) - end - if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then - ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스 - end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절) -end -``` - -**`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11 -세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는 -`offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게 -0에서 시작함 — `updateFn`의 `index`(로컬 위치, 1-based Lua 관례)와 -`index + offset` 공식으로 섞이는 게 의도된 것이지 인덱싱 불일치가 -아님. `LayoutOrder` 자체도 0/음수가 허용되는 값이라 최종 결과에도 -문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치 -vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. - -전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 -길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 -캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, -`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 -일어나게 막음. - -**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), -`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: - -```lua -function Dispatch.setLength(inst, i, len) - local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 - - local oldObserver = bk.observers[i] - if oldObserver then - unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨 - bk.observers[i] = nil - end - - bk.lengthList[i] = len - - if isState(len) then - local observer = len:Observer(function() - recompute(inst, bk) - end) - bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 - bk.observers[i] = observer - end - - recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) -end -``` - -`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 -본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 -같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 -끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, -`inst` 생명주기에 자동 귀속)를 충족. - -**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 -실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: -Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder -갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 -트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` -자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 -`Get` 가드와 같은 원칙을 호출부에서도 적용). - -**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 -한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 -도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. - -**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ -`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: -DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, -`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — -quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, -`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 -물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 -동일, backend Handler의 "무엇을 하는가"만 다름. - -**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**: -`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된 -개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`가 -읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을 -실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`는 -자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔 -`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자 -몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가 -채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source`. - -**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11 -세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가 -`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를 -`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을 -`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로, -`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md` -참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를 -구성해야 함 — 아무것도 안 하면 그냥 `LayoutOrder`가 안 바뀔 뿐. - -`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 -해소됨 — 상세는 그 문서 참고. - -**동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 `state`류 -store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Offset` -카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind -프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를 -호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는 -부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent = -parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래 -추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 -만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB. -`Slot`이든 `state`이든 둘 다 이미 이 두 API를 정확히 호출하는 -유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절 -참고) — 새 경로를 만들 필요 없이 "동적 자식은 반드시 이 둘 중 하나를 -거쳐야 한다"는 규칙만 문서화하면 됨. - -## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 - -사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 -재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음." - -**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 -다른 핸들러와 동일한 `isHandlable`/`priority`/`process`(반환값 포함) 계약을 -따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 -value)로 `Dispatch.process(inst,k,realv,index+1)`를 재귀 호출"하는 식으로 -구현됨. 이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해 -동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 -직결. **[2026-08-13 세션, 두 차례 정정]** 이 "동일한 재귀적 디스패치로 -처리 가능"은 처음엔 값이 또 State/Source면(`State>`) 같은 -핸들러가 같은 `(inst,k)`에 identity로 두 번 push돼 체인이 파손되는 실제 -버그로 낙관적으로 틀린 서술임이 드러났었으나(같은 날 두 번째 세션), 같은 -날 다섯 번째 세션에 `chains`를 핸들러 identity가 아니라 재귀 깊이 -인덱스로 추적하도록 재설계되며 **다시 맞는 서술로 돌아옴** — `realv`가 -또 State면 `index+1`이라는 별개 슬롯을 쓰므로 identity 충돌 자체가 없어짐 -(위 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고). - -**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 -확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 -서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 아래 -"`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 -구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: - -```lua --- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state, index) -function StoreBind.process(inst, k, state, index) - local observer = state:Observer(function() - local realv = state:Get() - Dispatch.retractFrom(inst, k, index + 1, realv) -- 내 바로 아래부터 정리 - Dispatch.process(inst, k, realv, index + 1) -- 새로 위임(체인에 push) - end) - bindLifetime(inst, observer) - return function() - -- 자기 자신의 자원(Observer 구독)만 정리 — observer는 위 클로저가 - -- upvalue로 이미 캡처하고 있어 별도 Relate 저장/조회가 필요 없음 - -- (2026-08-13 다섯 번째 세션, 계약이 클로저 반환으로 바뀌며 단순화됨). - unbindLifetime(inst, observer) - end -end -``` - -**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 -아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가 -아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/ -`:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 -최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 — -"leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면** -`:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든) -`bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그 -자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중 -바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, -`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). - -- **반환하는 클로저가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — - 위임 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractFrom`이 자기 - 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 - 클로저는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 - `event-plan.md`의 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 - 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). - **[2026-08-13 다섯 번째 세션] 별도 `Relate`가 더 이상 필요 없음** — - `observer`는 `process` 안의 로컬 변수를 반환 클로저가 upvalue로 그대로 - 캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가 - 나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위 - "핸들러 계약"/"핸들러 내부 상태 저장" 절 참고). -- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 - 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 - `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 - 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로 - 세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효. -- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 - 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 - 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). - -Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 -필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 -자연스럽게 맞음 — `base/slot-plan.md` 참고. +## 디스패치 코어 — 전용 문서로 분리됨 (2026-08-13 열네 번째 세션) + +핸들러 계약(`isHandlable`/`priority`/`process`), 확정된 디스패치 모델, +`None` 센티널, `Dispatch`가 탑레벨 싱글톤인 이유, `chains` 인덱스 체인과 +`Dispatch.retractFrom`, Handler 작성 체크리스트, Length/Offset(형제 순서 +보장), "store 바인드는 래핑" 결론은 **`base/dispatch-core-plan.md`로 +분리**됐음 — 이 문서가 2989줄까지 불어나 사람이 검토할 수 없다는 지적으로 +시작된 분할의 2단계(1단계는 `ref-plan.md`/`event-plan.md`/`brand-plan.md`). +**같은 세션에 0-A/0-Z 확정으로 그 텍스트를 어차피 전면 재작성했기 때문에, +재작성과 분할을 한 패스에서 같이 처리함**(9차 세션이 "같은 텍스트를 두 번 +만지지 않기 위해" 의도적으로 미뤄둔 계획 그대로). ## Store가 Store를 저장 가능한가 @@ -1141,8 +64,9 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State>`)는 한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error 하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반 -재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상**(위 -"Dispatch 체인" 절의 `State>` 재정정 참고) — "신경 안 씀"의 +재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상** +(`base/dispatch-core-plan.md`의 "Dispatch 체인" 절 참고 — 열네 번째 +세션의 하강 diff로 깜빡임 방지 힌트까지 깊은 체인에서 유지됨) — "신경 안 씀"의 의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로 다시 한번 바뀜. @@ -1788,7 +712,7 @@ Fusion식 eager 노드·생성순 정렬은 안 만듦** "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind - pluggable 핸들러(위 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 + pluggable 핸들러(`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 받는 즉시 자기가 알아서 `Get()`을 호출해 pull하는 방식으로 충분함 — State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 필요 없음. @@ -2141,8 +1065,8 @@ State/스트림)를 다뤘던 이전 시도가 있어 조사함 — **확인된 "핸들러 계약" 절과 모순돼 있던 걸 같은 날 리뷰에서 발견]** 예전엔 `process`(구 `bind`) + `retract`(구 `cleanup`) 4종이었으나, `retract`가 별도 필드에서 **`process`의 반환값(retractor 클로저)** 으로 합쳐짐 — - 이름과 개념은 그대로 유효하고 자리만 옮겨온 것(위 "핸들러 계약" 절이 - 정본). + 이름과 개념은 그대로 유효하고 자리만 옮겨온 것(`base/dispatch-core-plan.md`의 + "핸들러 계약" 절이 정본). - **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ lifecycle-pattern.md`). - **Ref**: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 @@ -2256,15 +1180,13 @@ vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작 ## 남은 열린 질문 (`.claude/question.md`에도 취합) -> **⚠️ [2026-08-13 7차 감사 캐비엇, 13차 세션 갱신] 아래 "전부 확정됨"은 -> 2026-08-13 **이전** 기준.** 지금 이 문서의 계약 중 **하나가 실제로 -> 열려 있음** — `question.md` **0-Z**: 이 문서 최상단 ⚠️ 배너가 예고하는 -> `hintValue`/`retractFrom` 재-dispatch 모델 교체. "API 표면 이름"이 -> 아니라 **핵심 계약**이므로, 아래 목록만 보고 "이름만 남았다"고 읽지 말 것. -> -> (여기 같이 적혀 있던 **0-Y**(콜백의 lazy 핸들 계약)는 **해소됨** — -> 계약은 그대로 유지로 확정, 남은 건 Luau 자체의 한계라 우리가 할 게 -> 없음. `base/typing-limits.md` 참고.) +> **✅ [2026-08-13 열네 번째 세션 갱신] 여기 열려 있던 계약 질문은 전부 +> 해소됐음.** `0-Z`/`0-A`(재-dispatch 모델 교체)는 확정되어 +> `base/dispatch-core-plan.md`로 반영됐고 — 그 계약은 이제 이 문서 소관도 +> 아님(2단계 분할로 나갔음) —, `0-Y`(콜백의 lazy 핸들 계약)도 열세 번째 +> 세션에 "계약 유지, 남은 건 Luau 자체의 한계"로 해소됨 +> (`base/typing-limits.md`). 아래 목록은 그래서 다시 **순수 이름 문제**만 +> 남은 상태. 이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State 쓰기 금지/Slot 생존 확인 → dot-access 타입 추론/인스턴스·이벤트 네이밍/ diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md new file mode 100644 index 0000000..ac9b375 --- /dev/null +++ b/.claude/base/dispatch-core-plan.md @@ -0,0 +1,1214 @@ +# 디스패치 코어 — Handler 계약 / Dispatch 체인 / 재디스패치 하강 diff + +**상태**: base — 2026-08-13 열네 번째 세션에 `bind-system-plan.md`에서 +분리(2단계 분할). 같은 세션에 `question.md` **0-A/0-Z**가 확정되어 +**재디스패치 모델이 "철거 후 재구축"에서 "하강 diff"로 전면 교체**됐고, +그 재작성과 분할을 한 패스에서 같이 처리했음(같은 텍스트를 두 번 만지지 +않기 위해 9차 세션이 의도적으로 미뤄뒀던 것 — 경위는 아래 "재디스패치 +모델의 역사" 절, 뒤집힌 옛 모델 원문은 +`archive/dispatch-hintvalue-model-reversed.md`). + +**이 문서가 담는 것**: 핸들러 계약 / 확정된 디스패치 모델 / `None` 센티널 / +`Dispatch`가 프리미티브가 아닌 이유 / `chains` 인덱스 체인과 +`Dispatch.retractFrom` / Handler 작성 체크리스트 / Length·Offset(형제 +순서 보장) / store 바인드가 래핑이라는 결론. + +**여기 없는 것**: `:With`/`:Compute` 등 반응형 값 조합과 Store/State/Source +온톨로지는 `base/bind-system-plan.md`, 개별 핸들러의 도메인 로직은 +`base/tag-plan.md`/`attribute-plan.md`/`slot-plan.md`/`ref-plan.md`/ +`event-plan.md`, 런타임 판별은 `base/brand-plan.md`. + +## 문제 + +v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 +숫자 키(children/style) vs 문자열 키(prop/event) vs `__type` 태그 테이블 +(register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 +(`[Attribute "X"]`, `[Tag ""]`, `PropertyChangedEvent ""` 등)를 추가하려면 이 +중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조. + +## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것) + +**[전면 재정정, 2026-08-13 다섯 번째 세션] `process`/`retract` 2-메소드 +계약에서 `process`가 자기 retract 클로저를 반환하는 1-메소드 계약으로 +전환.** 계기와 근거는 아래 "Dispatch 체인" 절 참고 — 이 절은 바뀐 최종 +계약만 서술. + +핸들러는 다음 3개를 제공하는 등록 가능한 객체: + +- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value + 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — + tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/ + CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 + 적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 + 유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음 + (2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** — + `process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는 + 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) + `isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에 + 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 + 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 + 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — + 사용자 판단으로 확정. **[2026-08-13 세션] 생략 불가, 항상 정의할 + 것** — 같은 날 네 번째 세션에서 한때 "생략하면 스캔 불가시 체크포인트 + 핸들러"로 확장했으나, 다섯 번째 세션(아래 "Dispatch 체인" 절)의 인덱스 + 기반 재설계로 그 용도(`AttributeGroupKeyHandler`류 마커) 자체가 + 없어져 이 확장도 같은 세션 안에서 신설·철회가 끝나 archive 이전 없이 + 이 한 줄로만 기록. +- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 + action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. +- `process(inst, key, value, index): (nextValue: any?) -> ()` — 실제 처리 + 수행(아래 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고) 하고, + **자기 자신이 방금 벌인 일을 무르는 1-인자 클로저를 반환**. + v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할 + 예전의 `retract` + 필드가 여기로 합쳐짐(**[전면 재정정, 2026-08-13 다섯 번째 세션]**, + 계기·근거는 아래 "Dispatch 체인" 절). 그 인자는 **`nil`(단순 철거) + 이거나, 같은 핸들러가 곧바로 처리할 새 값**이라는 게 계약 — 코퍼스 + 전반에서 이 인자를 `hintValue`라고 부르는데 이는 타입이 보장되지 않던 + 옛 모델에서 온 이름이고, 지금은 "힌트"가 아니라 보장된 값임에 유의 + (이름 자체는 `question.md` 용어 정리 대기열). **반환값 생략 불가 — + 정리할 게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** — + `Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가 + 나중에 정확히 이 클로저 하나만 호출해서 정리하므로(예전 "retract 필드 + 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴). **생략했을 때 실제로 + 벌어지는 일**: 그 자리 슬롯이 완성되지 못해 `#list`가 Lua 명세상 + 정의되지 않게 되고(`retractFrom`의 순회 시작점이 어긋남) 체인 추적이 + 통째로 깨짐 — 그래서 `Dispatch.process`가 반환값 `nil`을 즉시 error로 + 잡음(2026-08-13 7차 감사에서 조용히 삼키던 가드를 error로 바꾼 것, + 하강 diff 모델에서도 그대로 유지). + **핸들러가 직접 자기 자신의 하위 위임(재귀 `Dispatch.process`로 만든 + 것들)까지 클로저 안에서 다시 정리할 필요는 없음** — `Dispatch. + retractFrom`의 순회 구조 자체가 항상 깊은 인덱스부터 먼저 정리하고 + 나서 얕은 인덱스로 올라오므로, 이 클로저가 불릴 시점엔 자기보다 + 아래(자기가 만들어낸 하위 위임)는 이미 전부 정리된 뒤임(아래 + "Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접 + 자원**(Observer 구독 등)만 정리하면 됨. + +디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, +첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 +열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 +반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(`.claude/initreq/tbox/ +src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 +실패 시에만 클로저 호출. + +**우선순위 동률/매치 실패 처리 — 확정(2026-08-12 열일곱 번째 세션, +`pre-implementation-audit.md` 1-3/1-4 해소).** + +- **동률(같은 `priority` 값)에 대한 tiebreak 규칙은 강제하지 않는다.** + "등록 순서가 이긴다" 같은 규칙을 강제하면 `NoneHandler`/`StoreBind`처럼 + 이미 서로 `isHandlable`이 안 겹치는 내장 핸들러에까지 전부 그 규칙을 + 지켜가며 순서를 신경 써야 하고, 나중에 서드파티 핸들러가 늘어나면 더 + 골치아파짐(사용자 판단). 대신 **목적별로 이름 붙은 우선순위 상수** + (`HANDLER_PRIORITY_HIGH`/`HANDLER_PRIORITY_NORMAL`/`HANDLER_PRIORITY_LOW` + 등, 여전히 열린 숫자 공간 위의 편의 상수라 `HANDLER_PRIORITY_HIGH + 1`처럼 + 세밀 조정도 가능)를 제공해 애초에 동률이 잘 안 나오게 유도 — "우선순위 + 밴드 + 오프셋"은 여러 업계에서 이미 흔한 패턴. 실제로 동률이 나면 그건 + 대개 핸들러 설계 실수라, 강제 규칙보다 아래 디버그 가시성으로 대응하는 + 쪽이 맞음. +- **`HANDLER_PRIORITY_FALLBACK` — 최하위 밴드, "base가 제공하되 백엔드가 + 덮어쓸 수 있는" 핸들러의 자리 (2026-08-13 열네 번째 세션 신설, 사용자 + 제안).** **base 소속 핸들러가 전부 여기 오는 게 아님에 주의** — + `StoreBind`/`NoneHandler`/`Leaf`처럼 디스패치 골격 자체인 것들은 여전히 + 높은 우선순위여야 함(`StoreBind`가 프로퍼티 세터보다 먼저 매치돼야 + 반응형 값이 언랩됨). `Tag`/ + `Attribute`처럼 **알고리즘은 엔진 무관이라 base가 소유하고 실제 효과만 + 주입받는** 핸들러(아래 "base가 소유하는 핸들러와 주입되는 엔진 op" 절)는 + 이 밴드에 등록한다. 그러면 특정 백엔드가 그 키/값을 자기 방식으로 + 통째로 다르게 처리하고 싶을 때 **그냥 평범한 우선순위로 자기 핸들러를 + 하나 더 등록하면 언제나 이김** — base 쪽을 비활성화하거나 등록 순서를 + 신경 쓸 필요가 없음(사용자 표현: "위에서 처리되면 상관 없게 잘 + 처리되니까"). base 핸들러가 실제로 매치되는 건 "아무도 그 자리를 안 + 가져간 경우"뿐이므로, 주입 op이 없는 백엔드에서의 실패도 이 자리에서 + 명확한 에러 하나로 수렴함(같은 절 참고). +- **매치 실패(`isHandlable`을 만족하는 핸들러가 하나도 없음)는 조용한 + 무시 없이 즉시 `error`.** 에러 메시지엔 값의 `Brand`(있으면)와 + `typeof(v)`를 함께 출력하고, "quad-roblox 등 필요한 provider가 + 초기화됐는지 확인하라"는 안내만 덧붙임 — 그 이상의 특수 분기는 두지 + 않음(다른 라이브러리에서도 흔한 "매치 실패=에러" 패턴 그대로). + **이걸로 `module-lifecycle-plan.md`의 "provider가 아직 주입 안 된 + 상태에서 dispatch가 호출되면?" 케이스(`pre-implementation-audit.md` + 1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는 + 결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로 + "매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider + 미주입을 서로 다른 에러 종류로 구분할 필요가 없음. +- **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + + 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 + sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 + 두 핸들러가 등록되면 콘솔에 경고를 찍고, `Dispatch.listHandlers()`류 + 함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함. + 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 + M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 + `quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의, + 라이브러리 자체에 내장된 개발자 편의 기능. + +## 확정된 디스패치 모델: `process(inst, k, v, index) -> retractor` + +**사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함.** 아래가 +실제로 구현할 모양. **[전면 재정정, 2026-08-13 다섯 번째 세션]** 이 절은 +원래 `process(inst,k,v)`/`retract(inst,k,v)` 별개 2-메소드로 서술돼 +있었으나, `chains`를 핸들러 **객체 identity**가 아니라 **인덱스**로 +추적하는 재설계(아래 "Dispatch 체인" 절)와 함께 `process`가 자기 +retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절의 예시/규칙은 +전부 새 모델로 갱신됨, 옛 2-메소드 버전은 `archive/`로 옮기지 않고 이 +정정 표시로만 남김(오늘 하루 안에서 신설→재정정이 끝났기 때문). + +- 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고 + 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 + 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 + 대상"을 비동기로 기다릴 필요 자체가 없음(`ref-plan.md`의 Ref 절 참고 — Ref는 + 다른 이유로 존재). + - **보강(2026-08-04)**: `inst`가 항상 살아있는 엔진 객체(Roblox Instance)일 + 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 + (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 + 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 + 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — + base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 + 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. +- `process(inst, k, v, index)` — 우선순위 순으로 등록된 핸들러를 스캔, + `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당하고 + 자기 retract 클로저를 반환. **이 "스캔+실행" 오케스트레이터는 + `Dispatch.process`로, 순수 스캔 부분은 `Dispatch.getHandler`로 이름이 + 공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이 + 절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. **`index`가 + 뭔지·왜 필요한지는 아래 "Dispatch 체인" 절 참고** — 요약하면 같은 + `(inst,k)` 안에서 "지금 몇 번째로 겹쳐 위임됐는지"를 나타내는 정수로, + 핸들러 객체 identity 대신 이 숫자로 체인 위치를 추적함. +- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 + `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — + `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: + 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — + 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 + 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 + 그 시점엔 그냥 `Connected`를 보고 무시(no-op). + 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. + 3. **`realv`를 들고 `Dispatch.process(inst, k, realv, index + 1)`를 재귀 + 호출 — 선행 철거는 하지 않음**(**[정정, 2026-08-13 열네 번째 세션]** + 옛 모델은 이 자리에서 `Dispatch.retractFrom(inst,k,index+1,realv)`를 + 먼저 불렀으나 하강 diff로 폐기됨. 정확한 메커니즘은 아래 "Dispatch + 체인" 절, 2026-08-08 세 번째 세션에 처음 확정, 2026-08-13 다섯 번째 + 세션에 인덱스 기반으로 재정정 — 오케스트레이터 + 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 + 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 + 아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상 + 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + `realv`가 또 State/Source(`State>`)여도 이제 **자연스럽게 + 처리됨** — 안쪽 재귀는 `index+1`이라는 별개 슬롯을 쓰므로 바깥 + StoreBind의 슬롯(`index`)과 절대 안 겹침(아래 "Dispatch 체인" 절의 + `State>` 재정정 참고, 예전엔 이게 UB였음). + **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind + 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며(`research/ + tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 + 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 + 패턴의 남은 대표 예시. +- **`process`가 반환하는 retractor(`(hintValue) -> ()`)** (이전 초안의 + "cleanup"/별도 `retract` 필드, 이름 변경 근거는 `base/lifecycle-pattern.md` + 참고, 별도 필드에서 반환값으로 합쳐진 경위는 위 "핸들러 계약" 절 — + 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 + 이전 처리를 갈아치우는" 시나리오에만 존재** — 인스턴스/바인드 전체가 + Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의 + "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). + - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 + 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만 + 반환하면 됨. + - **[정정 이력, 2026-08-12 열한 번째 → 2026-08-13 다섯 번째 → 열네 번째 + 세션] 이 클로저는 "핸들러 타입이 바뀔 때만" 불리는 게 아니라, store + 바인드가 재발행될 때마다(값이 뭐로 바뀌든) 항상 불림** — 다만 **누가 + 부르는지가 열네 번째 세션에 바뀌었음**: 옛 모델에선 `StoreBind`가 + 재-dispatch 전에 무조건 `retractFrom`을 때려서 자기 밑을 통째로 + 비웠고, 지금은 `Dispatch.process`가 핸들러를 비교해 **같으면 그 자리 + 클로저에 새 값을 넘기고(아래를 안 건드림), 다르면 그 자리부터 아래를 + 전량 철거**함(아래 "Dispatch 체인" 절 (A)/(B) 분기). **호출 빈도는 + 그대로, 아래 체인이 매번 통째로 재구축되지 않는다는 점만 달라짐** — + 그래서 깜빡임 방지가 깊은 체인에서도 유지됨. 한때 "핸들러가 안 + 바뀌면 retract 없이 process가 diff"라고 적혀 있던 서술은 그때도 + 틀렸고 지금 모델과도 다름(지금은 **핸들러가 안 바뀌어도 클로저는 + 불리되, 그 클로저가 새 값을 받아 스스로 전이를 처리**함) — 옛 오류의 + 상세 경위는 `archive/retract-always-fires-reversed.md`. + - **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이 + 없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가 + 없음) 반환하는 클로저가 사실상 no-op일 뿐, "타입이 안 바뀌면 아예 + 안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러 + 위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를 + 공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는, + 이 클로저가 매번 불려도 **"이전 값이 지금 들어오는 새 값과 사실상 + 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 인자로 받은 새 + 값으로 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 — + `Tag`의 `Contains` 비교, `Ref`/`Slot`의 identity 비교가 그 예. + **인자를 반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 + 자체일 수 있음). 반대로 **타입은 이제 보장됨** — 값이 넘어오는 건 + 같은 핸들러로 재프로세스될 때뿐이라 그 값은 정의상 자기 + `isHandlable`을 만족함(아래 "Dispatch 체인" 절). + - **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개 + "반환한 클로저가 이전 기여를 걷어내고(실제 해제는 힌트로 skip + 가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 — + `process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 클로저가 + 이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`의 + 그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도 + 패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md` + "이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은 + `slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고. + - **[일반 규칙, 2026-08-13 열네 번째 세션에 폐지] 옛 "클로저 인자는 + 타입 보장이 안 되니 `isX(hintValue)` 가드부터" 규칙은 없어졌음** — + 그 규칙은 힌트가 `None`/`State`/`Tween` 래퍼로 오염될 수 있던 옛 + 철거-선행 모델을 메우던 임시방편이었고, 하강 diff에선 오염 경로 자체가 + 구조적으로 없음(아래 "Dispatch 체인" 절). 지금 필요한 구분은 + **`nil`이냐 아니냐 하나뿐**. 방어 가드를 남겨둬도 무해하지만 죽은 + 코드이고, 반대로 **그 가드가 있어야만 정확한 코드는 이제 없음**. + - **[일반 규칙] 이 클로저 안에서 `Dispatch.process`를 부르는 것은 + UB — `Dispatch.retractFrom`이 체인을 걷는 도중의 트래킹이 꼬임.** + 이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새 + 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹 + 로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는 + 순서로만 일어나야 함. **`Dispatch.retractFrom`은 "다른 키에 대해서만" + 허용** — `Attribute` 그룹이 자기가 위임했던 `AttributeKey(name)`들을 + 걷어내는 게 정확히 이 경우. **같은 `(inst,k)`에 대해 이 클로저 안에서 + `retractFrom`을 부르는 것도 `process`와 똑같이 금지(UB)** — 지금 + 돌고 있는 바깥 `retractFrom`의 루프가 `#list`를 이미 캡처한 채 + 꼬리부터 내려오는 중이라, 그 도중에 같은 list를 다시 훑으면 같은 + retractor가 두 번 불리거나 건너뛰어짐(2026-08-13 감사에서 명시화 — + 원래는 "다른 키에 대해"라는 괄호로만 암시돼 있었음). + - **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할 + 필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`의 + 순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로 + 올라오므로 자동으로 해결됨(재귀/래핑 핸들러가 다단으로 겹쳐도 + 각 클로저는 자기 자신의 자원만 책임지면 전체 cascade가 저절로 됨 — + 2026-08-08 세 번째 세션에 확정된 "다단 체인 자동 전파" 성질이 인덱스 + 모델에서도 그대로 유지, 오히려 더 단순해짐). + - Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)라 매치되는 핸들러가 항상 + PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은 + PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, + `archive/tween-special-bind-key-reversed.md`). +- **핸들러 내부 상태 저장 — 클로저로 충분한 것과 `Relate`가 필요한 것을 + 구분할 것.** "이 `process` 호출이 만든 걸 나중에 정리하는" 단발성 + handoff는 이제 클로저의 업밸류 캡처만으로 충분(예: Observer 객체를 + 로컬 변수로 만들고 그대로 반환 클로저가 캡처) — 예전처럼 `Relate`에 + 저장했다가 나중에 다시 조회할 필요가 없어짐(**[2026-08-13 다섯 번째 + 세션, 이 문단 재작성]**). `Relate`가 여전히 필요한 경우는 **여러 번의 + 독립적인 `process`/클로저 호출을 가로질러 누적되는 상태**뿐 — + `Tag`의 `tagNameMap`(여러 위치가 같은 이름을 공유), `Attribute`의 + 이름 소유권처럼 "이 `(inst,k)` 하나의 클로저 수명을 넘어서는" 정보만 + `local relate = Relate()`(모듈 톱레벨, `relate:SetStrong(inst,k,v)`/ + `:GetStrong(inst,k)`)로 저장. `base/lifecycle-pattern.md`의 + `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 + 다르니 별도 `Relate()` 인스턴스) — 이건 "언제까지 실행돼도 되는지"를 + 묻는 것이라 애초에 클로저 수명과 무관한, 계속 남는 질문이라 그대로 + `Relate` 기반. +- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot + 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 + 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. +- **일반적인 무한루프 방어(사이클 감지 등)는 하지 않기로 확정(2026-08-04, + 로드맵 인수인계 라운드)**: 우선순위 스캔+재귀 `process` 구조 자체는 핸들러가 + 규율을 안 지키면(예: 값을 좁히지/변형하지 않고 같은 값을 그대로 다시 + `process`에 넘김) 무한루프에 빠질 수 있음 — 하지만 이건 base가 방어 로직을 + 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 + 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 + 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 + 프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼 + 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 + 아니라고 판단해 별도 가드 없이 진행. + +- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 + 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, + 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 + 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, + `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, + **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 + 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. + 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 + 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 + 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 + 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 + 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 + 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨 — `ref-plan.md`의 "Ref 일반화" 절 + 뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 + 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 + 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). + +### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) + +`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 +지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 +`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 +층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 +`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** + +```lua +NoneHandler.priority = <매우 높음> +NoneHandler.isHandlable(inst, k, v) = (v == None) +function NoneHandler.process(inst, k, v, index) + Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스 + return function() end -- 자기 자신은 아무 상태도 없어 no-op +end +``` + +- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히 + 다른 개념이라 혼동하지 말 것: `isHandlable(inst,k,v)`는 KV 매치 predicate + (핸들러 계약 3종 중 하나, 이 절에서 다루는 것 — 예전엔 `(k,v)` 2-인자에 + 4종 계약이었으나 각각 2026-08-07 여덟 번째/2026-08-13 다섯 번째 세션에 + 바뀜, 이 문단만 갱신에서 누락돼 있던 걸 같은 날 감사에서 발견), `canExecute`는 인자로 받은 특정 + 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 + 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — + KV 매치와 무관. + **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 + `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" + 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" + 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 + 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 + 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 + 경로가 다르다는 점에 유의. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 + `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — + `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. +- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 + 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 + "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 + 로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을 + 명시적으로 분리: + - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ + `priority`), 부작용 없음. + - `Dispatch.process(inst,k,v,index)` — 오케스트레이터: `getHandler`로 + 새 핸들러를 고른 뒤 **그 인덱스에 이미 있던 핸들러와 비교** → 같으면 + 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`를 다시 불러 + 자리를 교체, 다르면 그 자리부터 아래를 전량 철거하고 새로 설치. 즉 + **"이전 핸들러와 다르면 철거"라는 diff는 `Dispatch.process` 자신의 + 일**(**[정정, 2026-08-13 열네 번째 세션]** 옛 모델에선 반대로 래핑 + 핸들러가 재-dispatch 전에 스스로 `retractFrom`을 부르는 책임을 졌고, + 그게 힌트 오염의 원인이었음 — 아래 "Dispatch 체인" 절). + - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 + 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 + 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ + OnChangeHandler/UICornerHandler/TagHandler/AttributeHandler 등)은 + 팩토리가 `BaseModule`을 + 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 + 같은 패턴, 새 메커니즘 아님). + - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — + 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 + 소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소. + - **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로 + 확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base + 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 + flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 + 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 + `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체. + **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 + 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 + 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 + 무관하게 항상 1부터"(아래 "Dispatch 체인" 절)라는 규칙의 가장 기본 + 사례. 같은 키가 두 번 나올 수 없는 테이블 순회라 이 루프 자신이 + 한 키를 두 번 여는 일은 없음(다만 그룹 `Attribute`가 배열 파트에서 + 이미 관리 중인 *이름*을 해시 파트 직접 쓰기가 다시 건드리는 건 + 별개 문제이고, 그건 Attribute 자신의 이름 claim이 즉시 error로 + 잡음 — `base/attribute-plan.md` "이름 소유권" 절). +- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 + "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 + 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 + 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 + `base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`. + `None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, + 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI + shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가 + State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반 + 반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. + **구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입 + 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — + PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 + 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). +- **반환하는 retractor는 여기서 할 일이 없음** — `NoneHandler`는 `v==None`을 + 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`을 + 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 + 전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티 + 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에 + 쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위 + "핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음. +- **[해소됨, 2026-08-08 세 번째 세션, 2026-08-13 다섯 번째 세션에 + 인덱스 기반으로 재정정]** "이 키를 지금 누가 담당 중인가" bookkeeping — + `pre-implementation-audit.md` 우선순위1 "이전에 실제로 매치됐던 핸들러 + 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch 체인" 절의 + `chains`/`Dispatch.retractFrom`로 구체화됨 — `NoneHandler`의 재귀 + 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 + 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). + +### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) + +`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ +`Modifier`처럼 생성자가 있는 프리미티브(예: `Dispatch()`로 인스턴스를 여러 개 +만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 +바로 닿는 flat 탑레벨 함수) 유지로 확정**: + +- **재귀 재-dispatch가 요구하는 필연** — `NoneHandler`/`Dispatch/ + StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, + realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 + 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ + lifecycle-pattern.md`)과 똑같이 require 한 번으로 바로 닿는 안정된 + 전역이어야 함 — 인스턴스화 가능한 프리미티브로 만들면 모든 Handler + 등록/호출 경로에 Dispatch 핸들을 인자로 계속 실어날라야 하는 스레딩 + 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐. +- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 + 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** + (`isHandlable`/`priority`/`process`(반환값 포함) 시그니처만 있는 순수 + leaf, Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 + **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 + 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` + (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` + (재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 + Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. + 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 + 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 + 완전히 끝나있어 부트스트랩 문제도 없음. +- **quad-base 자신의 기본 핸들러도 같은 레지스트리를 씀** — `NoneHandler`, + `Dispatch/StoreBind.luau`("범용, 엔진 무관")뿐 아니라, children 배열 + 숫자 슬롯에 `Ref`/`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 + Handler도 여기 속함(`inst`를 `any`로 취급, 엔진 특정 API 불필요 — + `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 + 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, + `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 + Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 + 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 + 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 + 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 + 소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`). +- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 + 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 + 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 + 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 + `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 + 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 + 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 + 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 + 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 + 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule + 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 + 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 + 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. + +### base가 소유하는 핸들러와 주입되는 엔진 op (2026-08-13 열네 번째 세션 신설) + +**원칙**: 핸들러를 base와 backend 중 어디에 둘지는 "이 키/값이 엔진 +개념인가"가 아니라 **"이 핸들러가 하는 부기(bookkeeping)가 엔진 지식을 +요구하는가"**로 가른다. 부기가 순수하면 **알고리즘은 base가 소유하고, +엔진에 실제로 손대는 마지막 한 줄만 함수로 주입**받는다. + +- **base 소유 + op 주입**: `Tag`(위치별 참조 카운트), `Attribute` 단일 + 키/그룹(이름 claim, 그룹→단일 키 위임, `None` 처리). 둘 다 웹에도 + 대응물이 있고(`className`, `data-*`) 부기 로직이 엔진과 무관해서, + 백엔드마다 재구현하면 **같은 참조 카운트/소유권 알고리즘이 통째로 + 복제**됨 — `architecture.md`의 "엔진마다 큰 구현을 중복하지 않기 위해 + 디스패치 엔진을 base가 인터페이스로 소유한다"는 원칙이 그대로 적용되는 + 자리(2026-08-13 열네 번째 세션, 사용자 판단으로 재배치). +- **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은 + 엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작 + (재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) — + 이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend. + +**주입되는 엔진 op(base는 시그니처만 소유, 실제 구현은 +`RobloxFactory(BaseModule)`류가 뮤테이션으로 주입 — `bindLifetime`/ +`canExecute`와 완전히 같은 패턴, 새 메커니즘 아님)**: + +```lua +addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신 +removeTag(inst: any, names: {string}): () +setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름을 지움 +``` + +- **왜 vararg가 아니라 `{string}`인가**: 호출자는 항상 quad 자신이고 + 넘기는 것도 "이번 사이클에 추가/제거된 이름 집합"이라 테이블이 자연 + 단위임. vararg로 두면 `table.unpack(t)`가 **인자 목록 tail 위치일 + 때만** 완전히 펼쳐진다는 Lua 문법 제약에 걸리고(대량 이름에서 unpack + 한계도 있음), 이건 이미 `Tag:Added`가 vararg → `string | {string}`로 + 되돌아갔던 것과 **같은 이유**(`base/tag-plan.md`). 배치 호출 자체는 + 테이블로도 그대로 되므로 웹의 className 일괄 갱신 요구도 충족됨. +- **`setAttribute(inst, name, nil)`이 "지운다"는 의미**인 건 Roblox + `SetAttribute`의 네이티브 동작과 일치하고, 다른 백엔드는 자기 방식으로 + 매핑하면 됨(웹이면 `removeAttribute`). base 쪽 규칙 — "Attribute는 오직 + 명시적 `None`/`nil`로만 지워진다"(`base/attribute-plan.md`) — 은 그대로. +- **미주입 백엔드의 실패 모드**: base가 이 핸들러들을 + `HANDLER_PRIORITY_FALLBACK`(위 "핸들러 계약" 절)으로 등록하므로, + 백엔드가 자기 핸들러를 따로 등록했다면 그쪽이 항상 이기고 base 것은 + 아예 안 불림. 아무도 안 가져갔는데 op도 주입 안 된 백엔드라면 그때 + **"이 백엔드는 `addTag`를 구현하지 않음"** 같은 명확한 에러를 내는 게 + base 기본 스텁의 역할 — "매치 실패=error"(위 절)와 층위만 다른, 같은 + 성격의 즉시 실패. +- **타입 패밀리는 백엔드 몫**: `AttributeKey<>` 제네릭 생성자와 + 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) + 까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인 + 패밀리는 그 백엔드(quad-roblox의 `D`/`DI` 층)가 자기 것으로 추가함 — + "이 값이 이 백엔드에서 표현 가능한가"라는 검증도 base가 아니라 주입된 + `setAttribute`의 몫(`base/attribute-plan.md` "패키지 배치" 절). + +### Dispatch 체인 — 인덱스 기반 추적, 재디스패치는 하강 diff (2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 인덱스화, 같은 날 열네 번째 세션 하강 diff로 전면 교체) + +**[전면 교체, 2026-08-13 열네 번째 세션 — `question.md` 0-A/0-Z 확정]** +이 절은 원래 **"래핑 핸들러가 재-dispatch 전에 자기 아래를 먼저 +`retractFrom`으로 철거한다"**는 모델이었으나, 그 모델은 철거 시점에 +넘기는 힌트(`hintValue`)의 **타입이 계약으로 보장되지 않는다**는 실제 +결함이 있었음(`None` 센티널이나 `State`/`Tween` 래퍼가 그대로 말단 +핸들러에 도착해 `isTag(hint)` 가드를 거짓으로 만들고 깜빡임/재생성 +방지를 조용히 끔). 지금은 **철거 선행을 폐기하고 `Dispatch.process`가 +핸들러를 먼저 비교하는 "하강 diff"** 모델 — 뒤집힌 옛 모델의 원문·재현 +사례·역전 근거는 `archive/dispatch-hintvalue-model-reversed.md`. + +**문제(원래 동기, 여전히 유효)**: `NoneHandler`/`StoreBind`처럼 자기 +`process` 안에서 `Dispatch.process(inst,k,realv,...)`를 다시 부르는 래핑 +핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 +하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 +Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 +두고 서로 덮어씀**. 처음 검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 +이 이유로 기각됨. + +**해법 — Dispatch가 `(inst,k)`별로 인덱스 배열을 소유, 각 슬롯엔 그 +`process` 호출을 담당한 **핸들러**와 그가 반환한 **retractor 클로저**를 +같이 저장**(핸들러를 같이 저장하는 게 하강 diff의 유일한 추가 저장분 — +"이전 값"은 클로저가 이미 upvalue로 알고 있으므로 따로 저장 안 함): + +```lua +-- Dispatch/init.luau +local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = {handler, retractor}}(strong)}} +local NOOP = function() end + +function Dispatch.process(inst, k, v, index) + -- [순서 주의] list 확보 + chains 등록은 반드시 h.process 호출 *전에* 끝나야 함 — + -- h.process가 내부에서 재귀 Dispatch.process(inst,k,...,index+1)를 부르는 게 + -- 정상 경로이고(StoreBind/NoneHandler), 그때 chains에 이 list가 아직 안 들어가 + -- 있으면 재귀 호출이 `or {}`로 자기만의 새 테이블을 만들어 저장해버린 뒤 바깥이 + -- 그걸 덮어써서 하위 위임 retractor가 통째로 유실됨(최초 마운트에서 항상 발생). + local list = chains:GetStrong(inst, k) + if not list then + list = {} + chains:SetStrong(inst, k, list) + end + + local slot = list[index] + local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error + + if slot ~= nil and slot.handler == h then + -- (A) 같은 핸들러 — 아래를 안 건드리고, 이 자리 클로저에 새 값을 넘겨 + -- 스스로 전이를 처리하게 한 뒤 같은 자리를 새 클로저로 교체. + -- v는 getHandler가 h를 골랐다는 사실만으로 h.isHandlable(inst,k,v)를 만족함이 보장됨. + slot.retractor(v) + slot.retractor = NOOP -- 이미 소비된 클로저가 두 번 불릴 여지를 없앰 + -- (h.process가 재귀하는 동안 잠깐 열려 있는 구간) + local retractor = h.process(inst, k, v, index) + if retractor == nil then + error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") + end + slot.retractor = retractor + else + -- (B) 다른 핸들러(또는 빈 자리) — 이 자리부터 아래를 전부 철거하고 새로 설치. + Dispatch.retractFrom(inst, k, index) + -- 점유 마커를 먼저 박는 이유: h.process가 재귀하는 동안 list가 구멍 없는 + -- 시퀀스로 유지돼야 `#list`가 정의됨(hole 있는 테이블의 `#`는 Lua가 보장 안 함). + list[index] = { handler = h, retractor = NOOP } + local retractor = h.process(inst, k, v, index) + if retractor == nil then + error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") + end + list[index] = { handler = h, retractor = retractor } + end +end + +function Dispatch.retractFrom(inst, k, index) + -- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리. + -- 힌트는 항상 nil — "뒤따르는 process가 없는 단순 철거"가 이 함수의 유일한 용도. + local list = chains:GetStrong(inst, k) + if not list then return end + for i = #list, index, -1 do + local slot = list[i] + if slot == nil then + error("Dispatch: 인덱스 " .. i .. "에 슬롯이 없음 — 배열에 구멍이 뚫렸음") + end + slot.retractor(nil) + list[i] = nil + end +end +``` + +- **래핑 핸들러는 재-dispatch 전에 아무것도 철거하지 않는다 — 그냥 아래로 + 내려보낸다.** `StoreBind`/`NoneHandler`가 하는 일은 이제 한 줄: + ```lua + Dispatch.process(inst, k, realv, index + 1) -- 선행 retractFrom 없음 + ``` + 전이 판정은 그 재귀 호출 안에서 `Dispatch.process`가 스스로 함(위 (A)/(B) + 분기). **이게 이 모델의 전부** — "누가 무엇을 언제 철거하는가"라는 질문이 + 래핑 핸들러들에서 Dispatch 한 곳으로 옮겨갔음. +- **retractor가 받는 값의 타입이 계약으로 보장됨.** 클로저에 `nil`이 아닌 + 값이 넘어가는 건 **오직 (A) 분기, 즉 새 값이 같은 핸들러에 매치될 때뿐**이고, + "같다"는 판정 자체가 `getHandler(inst,k,v) == slot.handler`이므로 그 `v`는 + 정의상 그 핸들러의 `isHandlable`을 만족함. 즉 말단 핸들러는 **`nil` 여부만 + 구분하면 되고**, 옛 모델이 요구하던 `isX(hintValue)` 방어 가드는 필요 + 없어짐(옛 규칙은 힌트의 타입 미보장을 메우던 임시방편이었음). +- **깊은 체인에서도 힌트가 안 사라짐** — 힌트를 위에서 아래로 실어 + 보내는 게 아니라 **각 레벨이 자기 재프로세스에서 자기 힌트를 받기** + 때문. `State>`에서 바깥이 새 inner State를 내놓아도 인덱스 2는 + StoreBind끼리 같으니 자기 클로저가 구독을 갈아타고, 재위임으로 내려간 + 인덱스 3은 TagHandler끼리 같으니 **진짜 `Tag` 객체를 힌트로 받아** + `Contains` skip이 정상 동작함. 옛 모델의 "깊이 2 이상에선 힌트가 `nil`, + 구조상 불가피" 캐비엇은 **철거 선행 모델에서만 불가피했던 것**이라 같이 + 없어짐. +- **두 종류의 retract가 계약상 갈림**(사용자 정리: "새 프로세싱으로 인한 + retract처리와, 단순 retract는 다르다"): + - **단순 retract**(언마운트/전체 철거, `Dispatch.retractFrom`): 뒤따르는 + `process`가 없음. 인자는 항상 `nil`. 핸들러는 자기 기여를 무조건 전부 + 걷어냄. + - **재프로세싱**(`Dispatch.process`의 (A) 분기): 그 자리 클로저가 **새 + 값을 인자로** 받고, 곧바로 같은 핸들러의 `process`가 다시 불림. + - **그래서 `Dispatch.retractFrom`은 3-인자다** — 옛 모델의 4번째 인자 + (`v`, 힌트)는 "철거 직후 이 값이 올 것"을 알려주려던 것인데, 새 + 모델에서 값을 넘기는 경로가 (A) 분기 하나로 통일되면서 **외부에서 + 힌트를 만들어 넣을 자리 자체가 없어짐**. 옛 결함(래퍼/센티널이 힌트로 + 새는 것)이 구조적으로 재발할 수 없는 이유이기도 함. +- **`inst`에 실제 부작용을 가하는 것은 말단 핸들러뿐 — 중간(래핑) 노드는 + 순수 언랩만 한다**(사용자 명시, 새 제약이 아니라 이미 성립하던 성질의 + 계약 승격): + + | 핸들러 | 위치 | `inst` 부작용 | + |---|---|---| + | `StoreBind` | 중간 | 없음(구독 + 재위임만) | + | `NoneHandler` | 중간 | 없음(재위임만) | + | `PropertyHandler` | 말단 | 프로퍼티 세팅 | + | `TagHandler` | 말단 | `addTag`/`removeTag` | + | `AttributeKeyHandler` | 말단 | `setAttribute` | + | `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) | + | `SlotHandler` | 말단 | 마운트/언마운트 | + | `RefLeafHandler` | 말단 | `Ref:Set` | + | `UICornerHandler` | 말단 | 자식 Instance 생성/제거 | + + 이 계약이 필요한 이유: (A) 분기는 **아래를 안 건드린 채** 중간 노드만 + 갈아치우므로, 중간 노드가 `inst`에 직접 손을 댔다면 그 흔적을 지울 + 주체가 없어짐. +- **재위임하는 핸들러는 (A) 분기에서도 반드시 다시 재위임해야 한다.** 안 + 그러면 자기 아래 인덱스가 고아로 남음(아무도 안 지움). `StoreBind`/ + `NoneHandler`는 항상 재위임하므로 지금 위반 사례는 없지만, "조건부로만 + 재위임하는" 핸들러를 새로 만들면 재위임을 건너뛰는 그 자리에서 + `Dispatch.retractFrom(inst, k, index + 1)`을 직접 불러 아래를 정리해야 함. +- **`HandlerChanged` 같은 마커 값은 두지 않음** — "핸들러가 바뀜"은 **그 + 자리 retractor가 `nil`로 불린다는 사실 자체**로 이미 표현됨. 별도 마커를 + 만들면 그것도 결국 "인자로 넘어오는 정체불명의 값"이 되어 옛 모델의 + 결함을 되풀이함. +- **"이전 값"을 Dispatch가 저장하지 않는 이유**(사용자 지적: "이전 값인 + oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?") — 맞음. 클로저는 + 자기 `process` 호출의 `v`를 upvalue로 캡처하고 있고 새 값을 인자로 받으므로 + old/new를 이미 둘 다 갖고 있음. `chains`에 추가로 저장해야 하는 건 **비교용 + `handler` 하나뿐**. +- **인덱스의 의미 — 재귀 깊이, 서로 다른 키는 항상 1부터**: 같은 키에서 + 값이 한 겹 더 반응형으로 감싸져 재귀하면(`StoreBind`가 `realv`를 들고 + 다시 `Dispatch.process`를 부르는 경우) `index+1`을 넘김. **다른 + 키로 위임할 때는 그 키의 재귀 깊이와 무관하게 항상 `1`부터 시작** — + `chains[inst][key2]`는 `chains[inst][key1]`과 완전히 별개의 배열이라 + 연속성이 필요 없음(예: `Attribute` 그룹이 `(inst,배열위치)`에서 + `(inst,그룹전용 AttributeKey)`로 위임할 때). **시작 인덱스는 0이 아니라 + 1** — Luau `ipairs`/`#`(배열 part 순회)는 1부터 연속된 정수 키를 + 전제하므로(quad 자신이 "props 순회 순서" 절에서 이 관례에 의존), 0을 + 쓰면 그 항목이 `ipairs` 순회에서 조용히 빠지고 `quad-debug`가 나중에 + `chains`를 그대로 순회해서 보여주려는 계획과도 부딪힘. +- **`handler.process(inst,k,v,index)`를 `Dispatch.process`를 거치지 않고 + 직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 + 것.** 이유: 핸들러 비교·`chains` 저장 bookkeeping이 `Dispatch.process` + 내부에만 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 + 활성화됐는데도 체인에 안 올라가 — 나중에 `retractFrom`이 이 핸들러의 + 존재를 몰라 정리가 영영 안 되거나(리소스 누수), 반대로 같은 인덱스를 + 다른 핸들러가 또 차지해 정합성이 깨짐. +- **개별 핸들러의 retractor는 자기 위임 대상을 수동으로 안 쫓아가도 됨** — + `retractFrom`이 꼬리(가장 깊은 인덱스)부터 목표 인덱스까지 한 번의 + 루프로 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 핸들러는 **자기 + 자신의 자원만** 정리하면 자동으로 전파됨. 자기 자신을 포함해서 지우고 + 싶으면 자기 인덱스를 그대로 넘기고, 자기 아래만 지우고 싶으면 `index+1`을 + 넘김 — **"미만"과 "이하"를 별도 함수로 안 쪼개고 호출자가 넘기는 인덱스 + 하나로 통일**(옛 `retractUnder`/`retractSelfAndUnder` 두 함수가 이걸로 + 하나가 됨, `archive/checkpoint-handler-pattern-reversed.md` 참고). +- **소유권 충돌 감지는 이제 Dispatch의 일이 아님 — 필요한 도메인이 직접 + 한다.** 옛 모델의 `Dispatch.process`는 "이 인덱스가 이미 점유돼 있으면 + 즉시 error"를 냈고 `Attribute` 이름 소유권이 그 부수 효과에 얹혀 + 있었으나, 하강 diff에선 **점유는 정상 상태**(재프로세스가 늘 그 자리를 + 다시 씀)라 그 체크 자체가 성립하지 않음. 실제로 두 소유자가 한 자원을 + 다투는 유일한 사례였던 Attribute 이름은 **자기 도메인 안에서 이름별 + claim으로 해결**함(`base/attribute-plan.md` "이름 소유권" 절, `question.md` + 0-Z 결정) — Dispatch에 claimant 개념을 일반화하는 안은 명시적으로 기각. +- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 + B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜 + 무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가 + 나므로 애초에 일어날 수 없는 구조 — 값에 별도 플래그를 심어 의도적으로 + 순환을 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, + 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 + 같은 결로 UB 취급. 핸들러가 **같은 인덱스로** 자기 자신을 재진입시키는 + 버그도 같은 경로로 수렴함(자기 자신과 핸들러가 같으니 (A) 분기를 무한히 + 반복 → 스택오버플로). +- **`State>`는 정상 지원 대상** (2026-08-13 다섯 번째 세션 + 재정정, 열네 번째 세션에 힌트까지 보강). 원래(같은 날 두 번째 세션) + `store.key = a`(State), `a:Get() = b`(State)일 때 같은 `StoreBind` + 싱글톤이 같은 `(inst,k)`에 identity로 두 번 매치돼 옛 `retractUnder`의 + cutoff 계산이 안쪽 자신을 잘못 retract하는 실제 버그(체인 파손, 구독이 + 등록 직후 스스로 끊김)로 재현돼 "같은 핸들러 객체가 이미 있으면 즉시 + error" 가드로 막았었음(`archive/checkpoint-handler-pattern-reversed.md`가 + 인용하는 옛 코드 참고). 근본 원인은 "핸들러당 그 키에서 최대 한 번"을 + **객체 identity로** 강제하려 한 것 — 인덱스 기반에선 `a`를 처리하는 + StoreBind가 인덱스 N, `a:Get()`(=`b`)을 처리하는 (같은 싱글톤인) + StoreBind가 N+1을 써서 애초에 슬롯이 안 겹침. 임의 깊이의 + `State>>`도 인덱스가 늘어날 뿐 정상 동작하고, 위 + "깊은 체인에서도 힌트가 안 사라짐" 항목대로 **깜빡임 방지 최적화까지 + 정상 작동**함 — 유일하게 남는 UB는 위 "순환" 항목. +- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 + 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 + 미래의 재바인드는 `Dispatch.process(inst, k, newV, 1)` **한 줄**로 "이 + 키의 체인을 새 값에 맞춰 갈아 끼우기"가 됨(옛 모델에선 `retractFrom` + + `process` 두 줄이었음 — 하강 diff가 그 선행 철거를 흡수). 완전 해제만 + 원하면 `Dispatch.retractFrom(inst, k, 1)`. `research/debug-tooling-plan.md`의 + "무엇이 무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — + `handler`가 슬롯에 같이 저장되므로 "이 자리를 지금 누가 담당하는가"를 + 이름으로 바로 덤프할 수 있어 옛 모델보다 오히려 유리해짐. + +### Handler 작성 체크리스트 — 실제로 반복된 실수들 (2026-08-13 여섯 번째 세션 신설, 열네 번째 세션 하강 diff 기준으로 갱신) + +**왜 이 절이 있는가**: 인덱스 기반 재설계 직후 작성된 의사코드 +(`Dispatch` 자신, `Ref`, `Tag`, `Slot`, `Attribute`)에서 **같은 세션 +안에 버그 4건**이 나왔고, 그중 셋이 서로 다른 문서에 있으면서도 +**같은 종류의 착각**에서 나왔음. 새 Handler를 짜거나 기존 걸 고칠 때 +이 목록을 먼저 훑을 것 — 전부 "그럴듯해 보이는데 틀린" 것들이라 +리뷰로 잡기 어렵다. + +**1. 클로저는 early-return해도 체인에서 *소비*된다.** +`Dispatch.retractFrom`은 저장된 retractor를 호출하고 **항상** +`list[i] = nil`로 지움 — 그 클로저가 "새 값이 옛 값과 같으니 할 일 없음"으로 +바로 돌아왔더라도 마찬가지. `Dispatch.process`의 (A) 분기도 클로저를 부른 +직후 그 자리를 새 클로저로 교체함. 그러므로: +- **매 `process` 호출은 "이 자리를 무르는 책임"을 온전히 새로 짊어진 + 클로저를 반환해야 한다.** "이번엔 내가 실제로 한 일이 없으니 no-op을 + 돌려주자"는 거의 항상 버그 — 다음 사이클에 진짜 교체가 올 때 정리할 + 주체가 사라짐. (`SlotHandler`에서 실제로 이 함정에 빠졌었음.) +- 반대로 "아무 일도 안 했으니 무를 것도 없다"가 **진짜로** 맞으려면, + 그 자리가 무를 자원을 애초에 아무도 안 갖고 있어야 함(일반 + PropertyHandler처럼). + +**2. 재-dispatch 전에 미리 철거하지 않는다.** +**[전면 교체, 열네 번째 세션]** 옛 모델에서 `StoreBind`가 +`retractFrom(inst,k,index+1,realv)`를 선행 호출하던 것은 **폐기됨** — +지금은 그냥 `Dispatch.process(inst,k,realv,index+1)`만 부르고, 무엇을 +철거할지는 `Dispatch.process`가 핸들러를 비교해 결정함(위 "Dispatch 체인" +절 (A)/(B) 분기). **다른 키로 위임하면서 그 키를 미리 `retractFrom`으로 +비우는 것은 여전히, 그리고 더 명확하게 버그** — 그 자리를 누가 점유했든 +말없이 지워버려 **다른 소유자의 바인딩을 조용히 파괴**함. 다른 키의 정리는 +**그 키를 등록했던 클로저가** 자기 철거 시점에 한다. + +**3. 클로저의 인자는 `nil`이거나, 같은 핸들러가 처리할 새 값이다 — 그 +둘뿐.** +**[전면 교체, 열네 번째 세션]** 옛 모델의 3대 함정("타입 보장 안 됨 / +깊은 인덱스엔 안 옴 / `nil`이라 가정 금지") 중 앞의 둘은 하강 diff로 +구조적으로 사라졌음: +- 값이 넘어오는 건 **오직 같은 핸들러로 재프로세스될 때**이므로 그 값은 + 정의상 `isHandlable`을 만족함 → `isTag(...)` 같은 **방어 가드는 이제 + 불필요**(넣어도 무해하지만 죽은 코드). +- 깊이와 무관하게 **각 레벨이 자기 인자를 받음** → 깜빡임 방지 최적화가 + 깊은 체인에서도 유효. +- 다만 **`nil`이라고 가정하는 것은 여전히 금지**(단순 철거일 때만 `nil`). + `assert(v == nil)`류를 쓰면 안 됨 — 이미 한 번 전면 정정된 이력이 있음 + (`archive/retract-always-fires-reversed.md`). + +**4. "이전 값"을 알고 싶으면 클로저 캡처, "여러 위치/사이클을 가로지르는 +누적 상태"만 `Relate`.** 이 경계를 헷갈리면 양방향으로 틀림: +- 불필요한 `Relate`: `process`가 만든 걸 그 클로저가 정리하는 단발성 + handoff는 upvalue 캡처로 끝(옛 `kSlotMap`/`kTagMap`이 이걸로 삭제됨). +- 부족한 `Relate`: `Tag`의 `tagNameMap`(여러 위치가 한 이름을 공유), + `Attribute`의 이름 claim(`nameClaims`), `Ref`의 spurious 재바인딩 + dedup처럼 **자기 클로저 수명 밖의 정보**는 캡처로 대체 불가. +- 그리고 `Relate`에 쓴 걸 클로저에서 지울 땐 **"내가 실제로 물러날 + 때만"** 지울 것 — 조건 밖에서 무조건 지우면 dedup이 무력화됨 + (`RefLeafHandler`가 정확히 이 버그였음). + +**5. `Dispatch`를 통해서만 진입한다.** +`handler.process(...)`를 직접 부르면 핸들러 비교와 `chains` 기록이 통째로 +빠져 나중에 정리가 안 되거나 정합성이 깨짐(위 "Dispatch 체인" 절). +마찬가지로 클로저 안에서는 `Dispatch.process` 금지, **같은 키**에 대한 +`Dispatch.retractFrom`도 금지(진행 중인 루프가 `#list`를 이미 캡처). + +**6. 인덱스는 "같은 키 안의 재귀 깊이"다.** +같은 키로 재귀하면 `index + 1`, **다른 키로 위임하면 그 키에서 다시 +`1`부터**, `Dispatch.drive`의 최초 진입도 `1`. 배열 파트의 위치(`k`)와 +이 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를 +`index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음. + +**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil`을 +반환하면 그 자리 슬롯이 완성되지 못해 `#list`가 정의되지 않게 되고 +(`retractFrom` 순회 시작점이 어긋남) 체인 추적 자체가 깨짐 — +`Dispatch.process`가 (A)/(B) 양쪽에서 즉시 error를 냄. **[정정, 2026-08-13 +7차 감사]** 예전엔 이 항목이 "`attempt to call a nil value`로 크래시"라고 +적혀 있었으나 그때의 `retractFrom`이 `if retractor then`으로 조용히 넘기고 +있어 크래시조차 안 나는 게 실제였음. + +**8. 중간(래핑) 노드는 `inst`에 손대지 않는다, 그리고 항상 다시 +재위임한다.** (A) 분기는 아래를 안 건드린 채 중간 노드만 갈아치우므로, +중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐. +조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서 +`Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것. +### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) + +**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, +2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적 +자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상 +Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가 +자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은 +Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을 +만들어서 막다른 길. + +**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각 +구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지 +기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가 +`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라, +이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이 +필요 없음(이미 있는 store-bind 재실행 패턴 재사용). + +**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: + +```lua +Dispatch.setLength(inst, i, len: number | State) +Dispatch.setOffsetSource(inst, i, offset: Source | None) +``` + +**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 — +`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을 +재사용해 **Slot 자신을 owner 키로 써서 같은 두 함수를 한 번 더 +부르면, 최상위(Dispatch.drive의 리터럴 배열)와 중첩(Slot이 자기 +자신의 요소들에 대해)이 완전히 같은 메커니즘으로 재귀됨** — 새 함수를 +만들 필요 없음. 상세 재귀 흐름(Slot-in-Slot)은 `base/slot-plan.md`의 +"Slot-in-Slot 중첩" 절 참고, 이 문서는 그 절이 재사용하는 `recompute` +자체만 다룸(아래). + +- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의 + 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 + `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, + 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 + store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` + 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ + Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 + 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 + 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 + 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 + `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` + 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. +- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 + **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 + 하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우 + 이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래 + 참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이 + 등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정, + 2026-08-11 세션] 예전엔 이 Source를 "Handler가 자기 원소(들)의 + `LayoutOrder` 바인딩에 그대로 쓴다"고 서술했었는데 — 폐기.** Slot이 + 마운트한 원소에 `LayoutOrder`를 자동으로 덮어쓰면 (a) 사용자가 그 + 원소 자신의 프로퍼티로 `LayoutOrder`를 이미 지정해도 조용히 씹히는 + 매직이 되고, (b) `LayoutOrder`는 애초에 Roblox 전용 프로퍼티라 그 + 지식이 `Dispatch/Slot.luau`(엔진 무관) 층위로 새는 레이어링 위반이기도 + 함. 이제 `Offset`은 `Slot.Offset`으로 공개 노출만 되고, 각 원소의 + `LayoutOrder`(또는 웹의 CSS `order`)를 실제로 계산해 세팅하는 건 + `updateFn`(또는 수동 Slot 사용자)의 몫 — `updateFn`은 `index`를 raw + number로만 받고(`Slot.Length`/`item`과 같은 원칙, `:List`가 반응형을 + 강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를 + 만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼 + 써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의 + `Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 + 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 + 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 + 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 + 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 + "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 + 길이 합계와 실제 순서 계산이 어긋남). + +**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` +→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** +별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 +반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 +부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 +서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 +캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의 +`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 +틀려지는 문제는 아니지만(자기 length가 줄어도 자기 offset은 그대로라 +갱신될 일 자체가 없음) **invalid한 Source가 순회 대상에 남아있는 것 +자체가 위험**하므로 순서를 계약으로 고정. 상세·부수 방어 조치는 +`base/slot-plan.md`의 "구현상 바뀌어야 하는 것" 절 참고. + +**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB +(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — +이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 +이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. + +**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 +최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 +lazy 생성. + +**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 +원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) +`nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 +구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef +pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(`ref-plan.md`의 "PreRef" 절의 +"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 +되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef +pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 +하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 +`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 +자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 +여부 구별과 접근 비용" 쪽. + +**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: + +**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 +있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한 +뒤 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 +개수"가 아니라 **자기 자신을 포함한** 누적합이 되고 있었음 — 예를 +들어 `Frame{Slot1}` 하나뿐이어도(앞에 아무것도 없는데) `Slot1.Offset`이 +`Slot1.Length`가 되어버려 `index+offset` 공식이 어긋남. 순서를 +뒤집어(offset 먼저 Set, 그 다음에 자기 기여도를 sum에 누적) 수정 — +지금까지 실제 Luau로 돌려본 적이 없어 아무도 못 잡았던, Length/Offset +메커니즘 자체의 버그(오늘 논의한 중첩 기능과는 별개). + +**[검토했다가 기각, 2026-08-11 세션] 재진입 방지 가드 — 불필요함이 +재추적으로 확인됨.** 처음엔 recompute 도중 재귀 호출이 들어오는 경우를 +대비해 `_recomputing`/`_dirty` 플래그로 방어하는 안을 검토했으나, 실제 +호출 경로를 다시 추적한 결과 **각 Slot이 `Relate(자기 자신)`으로 독립된 +`bk`를 갖기 때문에, 중첩된 Slot의 Length 변경이 상위로 전파되는 경로는 +항상 서로 다른 `bk`를 거쳐 지나감** — 부모의 `recompute(parent, parentBk)`가 +자식의 `bk`를 건드리지 않고, 자식의 `recompute(child, childBk)`도 부모의 +`bk`를 안 건드림. 즉 **nesting이 있다는 사실만으로는 같은 `(ownerKey,bk)`가 +재진입되는 경로 자체가 없음** — "중첩 Slot이 있으면 항상 dirty가 켜진다"는 +초기 우려는 틀렸고, 가드 자체가 불필요한 걸로 확인됨. 진짜 재진입은 +`updateFn` 같은 부작용이 recompute 도중 **같은** Slot에 다시 `Add`/`Remove`를 +거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된 +"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로 +간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음. +**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입 +가드 없음.** + +**이 케이스를 명시적으로 UB로 명명(2026-08-11 세션, 사용자 제안)** — +`Source`가 `State`를 "단방향"으로만 만족한다는 이미 확정된 원칙 +(`base/store-semantics.md` "Source가 State를 만족함" 절 — 파생값이 +자기 upstream Source로 거꾸로 쓰기를 하지 않는다는 것)과 **같은 카테고리의 +위반**이라는 게 근거: `recompute`가 만드는 `offset`/`Length`는 전부 +`lengthList`(그 Slot의 upstream 입력)에서 파생된 다운스트림 값인데, +계산 도중 촉발된 부작용이 **자기 자신의 `lengthList` 입력을 다시 +mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `Set`을 +가하는 것"이 UB인 것과 동일한 이유로, "recompute 도중 발생한 부작용이 +같은 Slot의 length에 다시 쓰기를 가하는 것"도 UB로 문서화 — 새 원칙이 +아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한 +것뿐, 그래서 별도 방어 로직도 필요 없음. + +```lua +local function recompute(ownerKey, bk) + local sum = 0 + for i = 1, bk.N do + local offset = bk.sourceList[i] + -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 + -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. + -- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상 + -- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는 + -- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도 + -- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로. + if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set + offset:Set(sum) + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) + end + if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then + ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스 + end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절) +end +``` + +**`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11 +세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는 +`offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게 +0에서 시작함 — `updateFn`의 `index`(로컬 위치, 1-based Lua 관례)와 +`index + offset` 공식으로 섞이는 게 의도된 것이지 인덱싱 불일치가 +아님. `LayoutOrder` 자체도 0/음수가 허용되는 값이라 최종 결과에도 +문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치 +vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. + +전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 +길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, +`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 +일어나게 막음. + +**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: + +```lua +function Dispatch.setLength(inst, i, len) + local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 + + local oldObserver = bk.observers[i] + if oldObserver then + unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨 + bk.observers[i] = nil + end + + bk.lengthList[i] = len + + if isState(len) then + local observer = len:Observer(function() + recompute(inst, bk) + end) + bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + end + + recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) +end +``` + +`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 +본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 +같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 +끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, +`inst` 생명주기에 자동 귀속)를 충족. + +**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 +실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: +Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder +갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 +트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` +자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 +`Get` 가드와 같은 원칙을 호출부에서도 적용). + +**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 +한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 +도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. + +**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ +`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: +DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, +`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — +quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, +`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 +물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 +동일, backend Handler의 "무엇을 하는가"만 다름. + +**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**: +`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된 +개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`가 +읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을 +실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`는 +자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔 +`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자 +몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가 +채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source`. + +**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11 +세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가 +`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를 +`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을 +`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로, +`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md` +참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를 +구성해야 함 — 아무것도 안 하면 그냥 `LayoutOrder`가 안 바뀔 뿐. + +`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 +해소됨 — 상세는 그 문서 참고. + +**동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 `state`류 +store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Offset` +카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind +프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를 +호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는 +부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent = +parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래 +추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 +만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB. +`Slot`이든 `state`이든 둘 다 이미 이 두 API를 정확히 호출하는 +유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절 +참고) — 새 경로를 만들 필요 없이 "동적 자식은 반드시 이 둘 중 하나를 +거쳐야 한다"는 규칙만 문서화하면 됨. + +## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 + +사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 +재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음." + +**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 +다른 핸들러와 동일한 `isHandlable`/`priority`/`process`(반환값 포함) 계약을 +따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 +value)로 `Dispatch.process(inst,k,realv,index+1)`를 재귀 호출"하는 식으로 +구현됨. 이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해 +동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 +직결. **[2026-08-13 세션, 두 차례 정정]** 이 "동일한 재귀적 디스패치로 +처리 가능"은 처음엔 값이 또 State/Source면(`State>`) 같은 +핸들러가 같은 `(inst,k)`에 identity로 두 번 push돼 체인이 파손되는 실제 +버그로 낙관적으로 틀린 서술임이 드러났었으나(같은 날 두 번째 세션), 같은 +날 다섯 번째 세션에 `chains`를 핸들러 identity가 아니라 재귀 깊이 +인덱스로 추적하도록 재설계되며 **다시 맞는 서술로 돌아옴** — `realv`가 +또 State면 `index+1`이라는 별개 슬롯을 쓰므로 identity 충돌 자체가 없어짐 +(위 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고). + +**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 +확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 +서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 +`base/bind-system-plan.md`의 "`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 +구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: + +```lua +-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state, index) +function StoreBind.process(inst, k, state, index) + local observer = state:Observer(function() + local realv = state:Get() + -- 선행 철거 없음 — 아래로 그냥 내려보내면 Dispatch.process가 + -- 핸들러를 비교해 (같으면) 그 자리 클로저에 realv를 넘기고, + -- (다르면) 그 자리부터 아래를 철거하고 새로 설치함. + Dispatch.process(inst, k, realv, index + 1) + end) + bindLifetime(inst, observer) + return function() + -- 자기 자신의 자원(Observer 구독)만 정리 — observer는 위 클로저가 + -- upvalue로 이미 캡처하고 있어 별도 Relate 저장/조회가 필요 없음 + -- (2026-08-13 다섯 번째 세션, 계약이 클로저 반환으로 바뀌며 단순화됨). + unbindLifetime(inst, observer) + end +end +``` + +**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 +아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가 +아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/ +`:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 +최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 — +"leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면** +`:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든) +`bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그 +자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중 +바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, +`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). + +- **반환하는 클로저가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — + 위임 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractFrom`이 자기 + 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 + 클로저는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 + `event-plan.md`의 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 + 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). + **[2026-08-13 다섯 번째 세션] 별도 `Relate`가 더 이상 필요 없음** — + `observer`는 `process` 안의 로컬 변수를 반환 클로저가 upvalue로 그대로 + 캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가 + 나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위 + "핸들러 계약"/"핸들러 내부 상태 저장" 절 참고). +- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 + 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 + `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 + 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로 + 세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효. +- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 + 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 + 적용"을 별도로 안 짜도 되는 이유(`base/bind-system-plan.md`의 Observer 절의 + 원래 근거 그대로). + +Slot이 store 바인드로 넘어오는 경우도 이 래핑 방식과 자연스럽게 맞음 — +`State` 교체는 `Dispatch.process`가 (A)/(B) 분기로 판정하고, 그 자리 +클로저가 이전 Slot을 언마운트한다(파괴가 아님 — `base/slot-plan.md`). + diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 4b0956f..292bdee 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -242,7 +242,7 @@ end 직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회 비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상. -이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` +이건 `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` 직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지" (`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지" (`bindLifetime` + `canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는 @@ -325,6 +325,6 @@ Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서 `handler.retract(...)`를 찾으면 안 됨 — `local retractor = handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch`가 `chains`에 -보관했다가 부름(`base/bind-system-plan.md` "핸들러 계약"/"Dispatch 체인" +보관했다가 부름(`base/dispatch-core-plan.md` "핸들러 계약"/"Dispatch 체인" 절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은 전부 그 클로저가 호출되는 시점을 가리킴. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index dc75470..39f7d71 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -50,7 +50,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행 로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → -`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 +`base/dispatch-core-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 바로 이 base 제공 로직. 부수적으로 확인된 것: @@ -104,9 +104,9 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/ `priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13 다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종 - 계약이었으나, `process`가 자기 retract 클로저 `(hintValue) -> ()`를 + 계약이었으나, `process`가 자기 retract 클로저 `(nextValue: any?) -> ()`를 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end` - 반환 생략 불가까지 확정. `base/bind-system-plan.md` "핸들러 계약" 절. + 반환 생략 불가까지 확정. `base/dispatch-core-plan.md` "핸들러 계약" 절. - ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ **[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름. @@ -129,7 +129,14 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 처리" 절의 일반 "매치 실패 시 즉시 error" 규칙 하나로 자연히 커버됨. - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 - 주입한다는 패턴이 확정됨 — 상세는 `base/bind-system-plan.md`의 "base + 주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에 + 엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/ + `setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기 + 알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막 + 한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가 + 소유하는 핸들러와 주입되는 엔진 op" 절). 미주입 백엔드에서는 base + 스텁이 명확한 에러를 내고, 백엔드가 통째로 다르게 처리하고 싶으면 + `HANDLER_PRIORITY_FALLBACK`보다 높은 우선순위로 자기 핸들러를 등록하면 됨 — 상세는 `base/bind-system-plan.md`의 "base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — @@ -137,4 +144,4 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — - `base/bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절. + `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절. diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index fc4bf61..649877e 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -37,9 +37,14 @@ "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 규모가 다른 문제라 기각. - **패키지 경계: 전부 quad-roblox** — `Handlers/OnChange.luau`에 `OnChange(name)` - 키 팩토리와 Handler를 같이 둠(단일 키 `AttributeKey.luau`와 같은 배치, - base 쪽 값 타입 파일 없음 — 그룹 `Attribute.luau`는 값 타입 자체가 - quad-base 소속이라 다름, `base/attribute-plan.md` 참고). + 키 팩토리와 Handler를 같이 둠. **[정정, 2026-08-13 열네 번째 세션]** + 예전엔 "단일 키 `AttributeKey.luau`와 같은 배치"라고 적었으나 그 + `AttributeKey`는 같은 세션에 **quad-base로 옮겨갔음**(부기가 엔진 지식을 + 요구하지 않아서, `base/attribute-plan.md` "패키지 배치" 절) — `OnChange`가 + quad-roblox에 남는 이유는 그것과 달리 **`GetPropertyChangedSignal` + 자체가 로직**이라 "한 줄 op 주입"으로 줄어들지 않기 때문 + (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 + op" 절의 분할 기준). `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 다름. @@ -73,7 +78,7 @@ | | 소스 | 값 타입 | 패키지 경계 | |---|---|---|---| | 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | -| `AttributeKey(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/AttributeKey.luau`) | +| `AttributeKey(name)` | 주입된 `setAttribute` op | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | **quad-base**(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드 | | `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | `OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 66e58a0..24e9d40 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -12,8 +12,8 @@ **중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 -Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ -tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: +Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디스패치 +모델" 참고, `base/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: - v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. @@ -210,16 +210,13 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: ### `Ref`의 retract — `State` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) -> **⚠️ [2026-08-13 감사 보강] 이 절의 "이전 `process`가 반환한 클로저가 -> `hintValue`로 먼저 불려 언바인딩하고, 그 다음 `process`가 바인딩"이라는 -> 서술은 곧 교체될 예정 — 아직 반영 안 됨. `research/dispatch-redispatch-diff-plan.md`가 -> 폐기 대상으로 지목한 바로 그 **선행 `retractFrom` 호출** 패턴이다 — 이 문서가 -> `bind-system-plan.md`에서 분리(2026-08-13 아홉 번째 세션, 순수 이동)될 -> 때 원문이 그대로 옮겨오면서 `bind-system-plan.md`/`tag-plan.md`/ -> `slot-plan.md`/`attribute-plan.md`가 이미 달고 있는 것과 같은 0-Z -> 배너가 같이 안 옮겨졌음. `question.md` **0-Z**(Attribute 이름 소유권) -> 해소 시 "하강 diff" 모델대로 이 절도 같이 재작성할 것 — **여기 적힌 -> 대로 구현하면 옛 모델로 짜게 됨.** +> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.** +> "이전 클로저가 먼저 불려 언바인딩, 그 다음 `process`가 바인딩"이라는 +> **두 단계 자체는 그대로**이고, 그걸 일으키는 주체만 바뀌었음(래핑 +> 핸들러의 선행 `retractFrom` → `Dispatch.process`의 핸들러 선비교). +> 클로저가 받는 값의 타입도 이제 계약으로 보장됨(항상 `Ref`이거나 +> `nil`). 상세는 `base/dispatch-core-plan.md` "Dispatch 체인" 절, 옛 +> 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`. **배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State`가 실제로 @@ -229,19 +226,20 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: 조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. **메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담 -(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저 -반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔 -때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 -모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전 -`process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA`를 -언바인딩하고, 그 다음 `process(inst,k,refB,index)`가 `refB`를 바인딩하는 -두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할 +(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째/열네 번째 세션에 +서술 갱신).** 이전 `process`가 반환한 클로저는 store 값이 바뀔 +때마다(핸들러 타입이 그대로여도) 무조건 불림 — 같은 핸들러면 +`Dispatch.process`가 그 자리 클로저에 새 값을 넘기고 곧바로 `process`를 +다시 부르기 때문(`base/dispatch-core-plan.md` "Dispatch 체인" 절 (A) +분기). 그래서 `refA→refB` 전환은 이전 클로저가 `nextValue=refB`로 먼저 +불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB,index)`가 +`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할 필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔 여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지 skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데, -그건 인자로 안 들어오고(클로저의 `hintValue`는 다음 값이지 이전 값이 -아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러 -내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례): +그건 인자로 안 들어오고(클로저가 받는 건 다음 값이지 이전 값이 +아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음 +(`base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례): ```lua local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — @@ -255,14 +253,14 @@ function RefLeafHandler.process(inst, k, v, index) v:Set(inst) end relate:SetStrong(inst, k, v) - return function(hintValue) - -- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는 + return function(nextValue) + -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는 -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) - if hintValue ~= v then + if nextValue ~= v then v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 - -- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이 + -- 함 — 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이 -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. @@ -273,7 +271,7 @@ end ``` - **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff - 로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된 + 로직이 없음. `nextValue == v`(같은 Ref 객체가 스스로 재발행된 spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함. - **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 @@ -309,7 +307,8 @@ end 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) **children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 -타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 +타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`의 +"확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 어디에 놓느냐가 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index a9ec1d9..7c645c3 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -94,7 +94,7 @@ relate:GetWeak(inst: any, key: any): any? ## 언제 `Relate`를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설) Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌면서 -(`base/bind-system-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가 +(`base/dispatch-core-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가 크게 줄었음** — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠. **쓰지 말 것 — 클로저 캡처로 충분한 경우**: 이 `process` 호출이 만든 @@ -111,7 +111,7 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌 - **여러 위치가 하나의 자원을 공유**할 때의 참조 카운트 — `Tag`의 `tagNameMap`(이름 → 그 이름을 걸고 있는 위치 집합). - **여러 `process` 호출을 가로지르는 dedup 기록** — `Ref`의 "이 자리에 - 마지막으로 바인딩한 Ref"(클로저의 `hintValue`는 *다음* 값이지 *이전* + 마지막으로 바인딩한 Ref"(클로저가 받는 인자는 *다음* 값이지 *이전* 값이 아니라서 캡처로 대체 불가). - **소유권/멤버십 전역 판정** — `Slot`의 `elementOwner`. - **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute` @@ -162,7 +162,7 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌 ## 대체하는 것 -- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` +- `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. - `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index a4e4e7f..92749e9 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1,14 +1,12 @@ # Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨) -> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 -> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 -> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 -> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 -> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 -> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** -> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 -> 구현하면 옛 모델로 짜게 됨** — 반드시 -> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. +> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.** +> 이전 ⚠️ 배너가 예고하던 교체가 끝났음 — 래핑 핸들러의 `retractFrom` +> 선행 호출이 폐기되고 `Dispatch.process`가 핸들러를 먼저 비교하며, +> `SlotHandler`가 반환하는 클로저가 받는 값의 **타입이 계약으로 보장**됨 +> (같은 핸들러로 재프로세스될 때만 값이 넘어오므로 항상 `Slot`이거나 +> `nil`). 상세는 `base/dispatch-core-plan.md` "Dispatch 체인" 절, 옛 +> 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`. **상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, **[2026-08-13 여섯 번째 세션 역전] retract=언마운트** — 옛 "retract=폐기"는 뒤집혔음, @@ -225,16 +223,17 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract` > 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 > 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. -**[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에 -클로저 반환 계약으로 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이 -process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는 -`Dispatch.retractFrom`이 store 재발행마다(핸들러가 그대로여도) **항상** -이전 `process`가 반환한 클로저를 먼저 부름(`base/bind-system-plan.md` -"확정된 디스패치 모델"/일반 retract 계약 절 참고). 그래서 `State`이 -`slotA→slotB`로 바뀔 때도 **`slotA`를 처리했던 클로저가 `hintValue=slotB`로 -먼저 불려 `slotA`를 폐기하고, 그 다음 `process(inst,k,slotB,index)`가 -`slotB`를 마운트**하는 두 단계로 자연히 갈림 — 클로저가 "이전 것 정리", -`process`가 "새 것 마운트" 전담: +**[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째/열네 번째 +세션에 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이 process가 diff +담당"이라는 전제 자체가 틀렸음** — store 재발행마다(핸들러가 그대로여도) +**항상** 이전 `process`가 반환한 클로저가 먼저 불림. **[갱신, 열네 번째 +세션] 부르는 주체는 `Dispatch.process` 자신**(같은 핸들러면 그 자리 +클로저에 새 값을 넘기고 곧바로 `process`를 다시 부름 — +`base/dispatch-core-plan.md` "Dispatch 체인" 절 (A) 분기). 그래서 +`State`이 `slotA→slotB`로 바뀔 때 **`slotA`를 처리했던 클로저가 +`nextValue=slotB`로 먼저 불려 `slotA`를 언마운트하고, 그 다음 +`process(inst,k,slotB,index)`가 `slotB`를 마운트**하는 두 단계로 자연히 +갈림 — 클로저가 "이전 것 정리", `process`가 "새 것 마운트" 전담: **[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로 간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접 @@ -339,11 +338,13 @@ function SlotHandler.process(inst, k, slotValue, index) -- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로 -- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든 -- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로 - -- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: retractFrom은 - -- 클로저를 early-return시키든 말든 체인에서 항상 *소비*하므로, spurious 사이클에서 - -- no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐. - return function(hintValue) - if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op + -- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: 체인은 + -- 클로저를 early-return시키든 말든 항상 *소비*하므로(retractFrom도, 재프로세스도), + -- spurious 사이클에서 no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를 + -- 정리할 주체가 사라짐. + return function(nextValue) + -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 Slot — 타입 보장됨 + if slotValue == nextValue then return end -- 같은 Slot 재발행 → no-op -- [정정, 2026-08-13 감사 후속] 파괴가 아니라 **언마운트** — -- 아래 "`State` 교체는 파괴가 아니라 언마운트" 절이 확정한 -- 결정이 적용돼야 하는 자리가 바로 여기(최상위 dispatch 경로). @@ -379,8 +380,8 @@ end `kSlotMap`에 안 적힌 자리는 `retract`가 자연히 no-op이라 우연히 막혀 있었는데, `kSlotMap` 제거가 이 방어를 같이 걷어낸 회귀였음. 이제 `claimOwnerAt`이 `k=2`에서 곧바로 error를 내므로 그 상태 자체가 안 - 만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: `retractFrom`은 - 클로저가 early-return하든 말든 체인에서 항상 소비하므로, spurious + 만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: 체인은 + 클로저가 early-return하든 말든 항상 소비하므로, spurious 사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을 먼저 써봤다가 되돌림). @@ -405,7 +406,7 @@ error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으 **위치를 키에 넣어도 안전한 이유(사용자 확인)** — 여기서 쓰는 `k`는 "바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 `Length`가 변해도 바뀌지 않음. 실제 물리 배치의 변동은 전부 `offset`이 흡수하도록 설계돼 -있음(`base/bind-system-plan.md` "Length/Offset" 절의 `recompute`가 그 +있음(`base/dispatch-core-plan.md` "Length/Offset" 절의 `recompute`가 그 증거 — `lengthList`/`sourceList`는 위치별 배열이고 순서 계산만 누적합으로 함). top-level의 `k`는 특히 props 배열 리터럴의 위치라 저작 시점에 고정. **nested는 `Move`/`Swap`/`Splice`/`Remove`가 @@ -656,7 +657,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 간주"라는 기존 원칙이 그대로 적용됨. `recompute` 자체의 재진입(같은 Slot의 length를 자기 계산 도중 다시 건드리는 것)도 같은 톤으로 UB — - `base/bind-system-plan.md`의 "Length/Offset" 절, `Source⊇State` + `base/dispatch-core-plan.md`의 "Length/Offset" 절, `Source⊇State` 단방향 원칙과 같은 카테고리로 명명됨(2026-08-11 세션). - **`Slot(initial?: {T})` 생성자 — [정정, 2026-08-11 세션] "인자 없는 빈 생성자로 확정"을 뒤집고 초기 배열을 받는 옵션 생성자를 다시 엶.** @@ -810,7 +811,7 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it Slot이 대신 안 해주는가" 절의 예시 참고. - **`offset: Source`** — 이 `Slot` 자신의 `Slot.Offset`을 그대로 전달(모든 key가 같은 값을 공유) — 형제로 섞인 다른 Slot/정적 자식이 - 기여한 개수의 누적합(`base/bind-system-plan.md`의 "Length/Offset" 절 + 기여한 개수의 누적합(`base/dispatch-core-plan.md`의 "Length/Offset" 절 참고). `index`와 마찬가지로 실제 프로퍼티에 어떻게 반영할지는 전적으로 `updateFn` 몫 — `Slot`/Handler가 자동으로 해주지 않음 (2026-08-11 세션 확정, 아래 참고). @@ -1294,7 +1295,7 @@ GC에 위임" 원칙 그대로. 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. - **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은 - `base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스. + `base/dispatch-core-plan.md`의 "Length/Offset" 절이 최신 소스. ## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션) @@ -1382,7 +1383,7 @@ nil/None 금지)는 그대로. ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 -`base/bind-system-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 +`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출**이 됩니다. @@ -1844,7 +1845,7 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 - `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는 `sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 - (`base/bind-system-plan.md` "Length/Offset" 절). + (`base/dispatch-core-plan.md` "Length/Offset" 절). - 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index daa32a3..ff8257f 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -102,7 +102,7 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 **세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 — `process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정, -`base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 +`base/dispatch-core-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 "quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 `Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index b297c50..e9349d1 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -1,14 +1,13 @@ -# Tag — array-part 값 객체, `CollectionService` 얇은 래퍼 +# Tag — array-part 값 객체, 참조 카운트는 base / 엔진 호출은 주입 op -> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 -> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 -> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 -> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 -> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 -> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** -> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 -> 구현하면 옛 모델로 짜게 됨** — 반드시 -> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. +> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 + 패키지 +> 재배치 완료.** 이전 ⚠️ 배너가 예고하던 교체가 끝났음: (1) 클로저가 받는 +> 값의 **타입이 계약으로 보장**되어 `isTag(hintValue)` 방어 가드가 +> 불필요해졌고 깜빡임 방지가 **깊은 체인에서도 유지**됨 +> (`base/dispatch-core-plan.md` "Dispatch 체인" 절), (2) **참조 카운트 +> 알고리즘 전체가 quad-base 소속**이 되고 `addTag`/`removeTag` op만 +> 백엔드가 주입(아래 "패키지 배치" 절). 옛 모델 원문은 +> `archive/dispatch-hintvalue-model-reversed.md`. **상태**: base — 값 모양/이름은 확정. 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 모델은 `archive/tag-hash-key-model-reversed.md`에 @@ -16,9 +15,9 @@ `process`/`retract` 메커니즘을 참조 카운트 기반으로 전면 정정(옛 버전은 `archive/retract-always-fires-reversed.md`). 2026-08-12 열다섯 번째 세션에 `Added`/`Removed`를 단일 이름에서 `string | {string}`으로 -정정(아래 값 모양 절). **단, 위 배너대로 `hintValue`/`retractFrom` 재-dispatch -메커니즘은 `question.md` 0-Z 해소 대기 중 — "열린 질문 없음"은 값 -모양/이름에 한정.** +정정(아래 값 모양 절). **[2026-08-13 열네 번째 세션] 재디스패치 모델 +(0-A)과 패키지 배치까지 반영 완료 — 이 문서에 남은 열린 질문은 이름 +자체(용어 정리 대기열)뿐.** ## 왜 재설계됐나 @@ -83,7 +82,7 @@ plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌 `store.activeTag:Compute(function(name) return (if name:Get() == "btn1" then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" -문제의 해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 +문제의 해법이지(`dispatch-core-plan.md` "`None` 센티널" 절), 이건 함수 리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 @@ -106,13 +105,13 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라 `assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은 두 가지를 놓쳤음:** -1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 - 불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터 - 말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 - `Dispatch.retractFrom`(2026-08-13 다섯 번째 세션 전까지의 이름은 - `retractUnder`)을 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 - (상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/ - retract-always-fires-reversed.md`). +1. **반환하는 클로저는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 + 불림** — "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 + (`archive/retract-always-fires-reversed.md`). **[갱신, 2026-08-13 + 열네 번째 세션]** 부르는 주체는 이제 `StoreBind`의 선행 `retractFrom`이 + 아니라 `Dispatch.process` 자신임 — 같은 핸들러면 그 자리 클로저에 새 + 값을 넘기고 곧바로 `process`를 다시 부름(`dispatch-core-plan.md` + "Dispatch 체인" 절 (A) 분기). 호출 빈도는 그대로. 2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수 있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"`와 같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른 @@ -145,7 +144,7 @@ identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에 불필요해짐** — `retract`가 필요로 했던 "이 위치에 걸려 있던 Tag가 뭐였는가"는 이제 그 `process` 호출이 반환하는 클로저가 `v`를 upvalue로 직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위 -`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는 +`base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는 클로저로 충분). **`tagNameMap`(이름별 현재 걸고 있는 위치 집합)은 여전히 필요** — 이건 서로 다른 여러 위치를 가로지르는, `process`/클로저 하나의 호출 수명을 넘어서는 누적 상태라 `Relate`가 맞는 경우: @@ -164,86 +163,109 @@ function TagHandler.process(inst, k, v, index) tagNameMap:SetStrong(inst, name, holders) end if next(holders) == nil then - inst:AddTag(name) + addTag(inst, { name }) -- 주입된 엔진 op(아래 "패키지 배치" 절) end holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관) end - return function(hintValue) - if v == hintValue then return end -- Tag는 immutable이라 객체가 안 바뀌면 - -- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화 - local hintIsTag = isTag(hintValue) -- hintValue는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음 + return function(nextValue) + -- nextValue는 nil(단순 철거)이거나 같은 핸들러가 곧 처리할 새 Tag — + -- 타입이 계약으로 보장되므로 isTag 가드가 필요 없음(2026-08-13 열네 번째 세션). + if v == nextValue then return end -- Tag는 immutable이라 객체가 안 바뀌면 + -- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화 + local removed = {} for name in v:Names() do local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음 holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관) - if next(holders) == nil and not (hintIsTag and hintValue:Contains(name)) then - inst:RemoveTag(name) -- 곧 새 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지) + if next(holders) == nil and not (nextValue ~= nil and nextValue:Contains(name)) then + table.insert(removed, name) -- 곧 새 process가 재확정할 이름이면 skip(깜빡임 방지) end end + if #removed > 0 then + removeTag(inst, removed) -- 한 번에 — 웹 className 일괄 갱신을 위한 배치 계약 + end end end ``` -- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 반환하는 클로저** +- **`addTag`는 온전히 `process`, `removeTag`는 온전히 반환하는 클로저** — 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이 캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과 - 목록이 비었을 때 **실제 `RemoveTag` 호출만** "새로 들어올 `hintValue`가 - 그 이름을 여전히 Contains하는가"로 힌트를 줘서 skip — 소유 목록 자체는 + 목록이 비었을 때 **실제 `removeTag` 호출만** "새로 들어올 값이 + 그 이름을 여전히 Contains하는가"로 판단해 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을 넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음. `process`는 `v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이 - 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new diff가 전혀 + 비어있던 경우에만 실제 `addTag`) — 자기 나름의 old-vs-new diff가 전혀 필요 없음(그 일을 클로저가 매번 정확히 해줌). - **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `A`를 처리했던 `process`가 - 반환한 클로저가 `hintValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B`에 - 없는 것만 실제로 `RemoveTag`, 남은 건 힌트로 skip — 그 다음 + 반환한 클로저가 `nextValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B`에 + 없는 것만 실제로 `removeTag`, 남은 건 skip — 그 다음 `process(inst,k,B,index)`가 `B`의 이름 전부를 등록(이미 걸려있던 - 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록). - 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만 + 이름은 소유 목록이 안 비어 있어 `addTag` 자체가 안 불림, 소유 목록엔 + `B`의 위치가 그대로 유지). + 결과적으로 실제 `removeTag`/`addTag` 호출은 진짜 변경된 이름에만 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. - **[범위 한정, 2026-08-13 감사] 이 깜빡임 방지는 "이 Tag를 바로 위에서 - 직속으로 위임한 한 단계"에서만 성립함** — `Dispatch.retractFrom(inst, - k, index, v)`는 힌트 `v`를 정확히 `index` 자리에만 넘기고 그보다 깊은 - 인덱스에는 `nil`을 넘기기 때문(`bind-system-plan.md` "Dispatch 체인" - 절의 `retractFrom` 의사코드). 그래서 `State>`에서 **바깥** - store가 재발행하면 TagHandler(더 깊은 인덱스)는 `hintValue=nil`을 - 받아 이름 전부를 실제로 `RemoveTag`했다가 새 체인이 다시 `AddTag`함. - 구조상 불가피함(바깥 단계는 안쪽이 결국 어떤 Tag를 내놓을지 모름) - 이고, 흔한 경로(`State` 한 겹)는 영향 없음 — 실사용에서 문제가 - 되면 그때 힌트를 깊은 인덱스까지 전파하는 안을 재검토. -- **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`가 - 아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가 - 항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨 + **[범위 확대, 2026-08-13 열네 번째 세션] 이 깜빡임 방지는 이제 깊은 + 체인에서도 성립함** — 옛 모델에선 힌트가 `retractFrom`이 지목한 한 + 자리에만 전달돼서 `State>`의 바깥이 재발행하면 TagHandler가 + `nil`을 받아 전량 `RemoveTag` 후 재`AddTag`했으나, 하강 diff에선 **각 + 레벨이 자기 재프로세스에서 자기 값을 받으므로** 인덱스가 얼마나 깊든 + TagHandler는 진짜 `Tag` 객체를 받음(`dispatch-core-plan.md` "Dispatch + 체인" 절의 "깊은 체인에서도 힌트가 안 사라짐"). +- **`Tag(A)→nil`**: `A`의 클로저가 `nextValue=nil`로만 불림(값이 `Tag`가 + 아니게 돼 핸들러가 바뀌므로 `Dispatch.retractFrom` 경로) — `Contains` + 검사가 항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `removeTag`됨 (다른 위치가 그 이름을 계속 쓰고 있지 않다면). - **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`): 두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/자기 클로저를 타지만, `tagNameMap["a"]`는 **양쪽 위치(`k`)를 모두 담는 하나의 공유 집합** — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제 - `RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히 + `removeTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히 나옴. **위치 기준이므로 두 위치가 물리적으로 같은 `Tag` 객체를 재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정" 절에서 객체 identity 기준을 버린 이유.** - **클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — `Dispatch.retractFrom`이 체인 전체를 알아서 훑어주므로 TagHandler는 자기 자원(위 `tagNameMap` 하나)만 정리하면 됨. 상세 메커니즘은 - `bind-system-plan.md` "Dispatch 체인" 절. + `dispatch-core-plan.md` "Dispatch 체인" 절. -## 패키지 배치 — base는 값+API, roblox는 process 글루 +## 패키지 배치 — 값도 알고리즘도 quad-base, 주입되는 건 `addTag`/`removeTag` (2026-08-13 열네 번째 세션 재배치) -**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/ -`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히 -같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출 -(`TagHandler.process` 및 그 반환 클로저)만 quad-roblox 소속 — 이미 -확정된 "base는 인터페이스/값, backend는 process 글루" 패턴(`LifetimeHandle`, -`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한 -것뿐, 새 아키텍처 개념 아님. +**Tag의 값 타입/clone 체이닝 API(`Tag(...)`/`:Added`/`:Removed`/ +`:Contains`/`:Apply`/`Merged`/`:Names`)가 quad-base인 건 처음부터 그대로** +(`Modifier`와 같은 층위 — 엔진 무관, 순수 데이터+연산). **[재배치, +2026-08-13 열네 번째 세션] 여기에 더해 `TagHandler`(위 참조 카운트 +알고리즘 전체)도 quad-base로 옮김** — 예전엔 `CollectionService` 실제 +호출이 있다는 이유로 핸들러 통째로 quad-roblox였으나, 엔진에 종속된 건 +`AddTag`/`RemoveTag` 두 줄뿐이고 `tagNameMap` 참조 카운트는 순수 부기임. +웹에도 대응물이 있으므로(`className` 합집합) 그 배치대로면 **같은 참조 +카운트 알고리즘을 백엔드마다 재구현**하게 됨. + +```lua +addTag(inst: any, names: {string}): () -- 백엔드가 주입 +removeTag(inst: any, names: {string}): () +``` + +- **`{string}`을 받는 이유**: 호출자는 항상 quad 자신이고 넘기는 것도 + "이번 사이클에 실제로 추가/제거된 이름 집합"이라 테이블이 자연 단위임. + vararg면 `table.unpack(t)`가 **인자 목록 tail 위치일 때만** 완전히 + 펼쳐진다는 Lua 문법 제약에 걸리는데, 이건 이미 `Tag:Added`가 + vararg → `string | {string}`으로 되돌아갔던 것과 **같은 이유**(위 "값 + 모양" 절). 배치 호출 자체는 테이블로도 되므로 웹 `className` 일괄 + 갱신 요구도 그대로 충족됨. +- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK`** — 특정 백엔드가 태그 + 처리를 통째로 다르게 하고 싶으면 평범한 우선순위로 자기 핸들러를 + 등록하면 자동으로 이김. 주입 op이 없는 백엔드에서는 base 스텁이 + 명확한 에러를 냄. 일반 원칙과 근거는 `base/dispatch-core-plan.md`의 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절 — `Attribute`도 정확히 + 같은 구조(`base/attribute-plan.md`). +- 이건 새 아키텍처 개념이 아니라 이미 확정된 "base는 인터페이스/값, + backend는 구현"(`LifetimeHandle`의 `bindLifetime`/`canExecute`, + `Dispatch.addHandler` 자체가 그 패턴)을 핸들러 층까지 밀어붙인 것. ## 열린 질문 -값 모양/이름(`Tag`/`Added`/`Removed`/`Merged`, 용어 정리 대상, -`.claude/question.md`) 자체는 없음. **단, 이 문서 최상단 배너가 이미 -경고하듯 `hintValue`/`retractFrom` 선행 호출 메커니즘은 `question.md` -**0-Z**(Attribute 이름 소유권) 해소 대기 중인 "하강 diff" 재디스패치 -모델로 교체 예정** — `research/dispatch-redispatch-diff-plan.md` 6절이 -이 파일을 반영 대상으로 명시 지목(`isTag(hintValue)` 가드 제거 등). -이 절이 예전엔 "없음"으로만 적혀 있었던 건 그 배너가 붙기 전 작성된 -서술이 안 갱신된 stale — 재발 방지용으로 여기 명시. +값 모양/메커니즘/패키지 배치엔 **[2026-08-13 열네 번째 세션 기준] 없음** — +재디스패치 모델(`question.md` 0-A)과 패키지 재배치까지 전부 반영 완료. +남은 건 이름 자체(`Tag`/`Added`/`Removed`/`Merged`)가 용어 정리 +대기열(`.claude/question.md` 1번)에 있다는 것뿐. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 44dd2b0..3ea3981 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -118,7 +118,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저 "핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인 Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 -(`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. +(`base/dispatch-core-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. ## 패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 531713d..8b552a7 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -67,10 +67,10 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | 파일 | 검증 대상 | 근거 문서 | |---|---|---| -| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | +| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `dispatch-core-plan.md` "props 순회 순서", ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | -| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | -| `04-dispatch-chain-retractFrom.luau` | **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `bind-system-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | +| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | +| `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | @@ -85,7 +85,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | -| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **캐비엇**: B는 `question.md` 0-Z(하강 diff 모델에선 그룹↔그룹을 점유 체크만으론 못 잡음)가 정해지면 다시 손봐야 함 | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | +| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `bind-system-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | ## 공통 유틸리티 @@ -179,7 +179,7 @@ Luau로 재현해본 적이 없었던 갭 — `Slot`의 `kSlotMap`/`slotOwner` G `retractUnder`의 꼬리부터-cutoff 로직을 직접 되짚다가 "같은 키에서 핸들러가 재사용되면 문제 아닌가"를 제기 — 손으로 트레이싱해 `State>`(store가 emit하는 값 자체가 또 State/Source)가 실제로 체인을 파손시킴을 확인 -(`bind-system-plan.md` "확정된 디스패치 모델" 절 신규 항목). 기존 `04`가 +(`dispatch-core-plan.md` "확정된 디스패치 모델" 절 신규 항목). 기존 `04`가 정확히 이 시나리오(store-in-store)를 이미 스트레스 테스트로 다루고 있었지만, `retract`가 print만 하는 no-op 스텁이라 자기-retract 버그의 실제 증상(구독이 조용히 끊기는 것)을 절대 드러낼 수 없었다는 사각지대도 같이 발견 — diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 4a41979..26db0b7 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,7 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-13 열세 번째 세션(`08` 해소 → `done/`, `review-required/` 비워짐). +> 마지막 갱신: 2026-08-13 **열네 번째 세션**(하강 diff 재디스패치 확정으로 +> `04`/`19`가 옛 모델을 검증하고 있어 `rewrite-required/`로 이동). > 첫 실측은 여섯 번째 세션 — 상세 결과는 `.claude/audit/luau-test-first-run-2026-08-13.md`. > 실행법: `luau <파일>` (런타임) / `luau-analyze <파일>` (타입 전용). @@ -11,9 +12,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크 코드가 깨짐(설계 문제 **아님**) | 3 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 5 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 1(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -37,10 +38,19 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 깨짐, 설계 문제 아님 (3건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건) + +**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 +바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 +되면서, 이 둘이 검증하던 전제(선행 `retractFrom` + 4-인자 힌트 + 인덱스 +점유 체크)가 더 이상 설계가 아님. 통과 상태로 `done/`에 두면 **옛 모델을 +"검증됨"으로 오독하게 되므로** 옮김. 새 정본은 +`base/dispatch-core-plan.md`/`base/attribute-plan.md`. | 파일 | 상태 | 무엇을 고쳐야 하나 | |---|---|---| +| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | +| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 | @@ -52,23 +62,23 @@ | `10-roblox-studio-checks.server.luau` | **Studio 전용**(`luau` CLI로 못 돌림). A 섹션 앞부분만 사용자 자작 스크립트로 실측 — `audit/gcconn-trick-verification.md`. **A-1/A-2(`canBound` 게이트)/B/C는 여전히 미확인** | | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (16건) +## ✅ `done/` — 통과 or 판정 끝 (14건) -**런타임 12개 전원 통과**(crash 0 / FAIL 0): +**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 +`04`/`19`는 검증 대상 설계가 바뀌어 위 `rewrite-required/`로 이동했고, +아래 표엔 남은 10개만 있음**(실측 당시 통과였다는 사실 자체는 유효): | 파일 | 확인된 것 | |---|---| | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | -| `04-dispatch-chain-retractFrom` | 인덱스 기반 체인 + **음성 대조군이 감사 버그를 재현**(아래 별도 절) | | `05-store-state-diamond-propagation` | 다이아몬드에서 재계산 정확히 1회, invalidate 2번째는 즉시 중단 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | -| `19-ownership-refcount-relate-patterns` | Tag 참조 카운트 / Attribute 점유 체크 / Slot `claimOwner` vs `claimOwnerAt` — **음성 대조군 포함** 전원 통과. ⚠️ **B 섹션은 `question.md` 0-Z가 정해지면 다시 손봐야 함**(하강 diff 모델에선 그룹↔그룹을 점유 체크만으론 못 잡음) | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: @@ -82,7 +92,8 @@ ### 특별히 중요한 통과 3건 -**`04` — 직전 감사가 찾은 버그가 음성 대조군으로 재현됨** +**`04` — 직전 감사가 찾은 버그가 음성 대조군으로 재현됨**(파일은 지금 +`rewrite-required/`에 있음 — 아래 관측 자체는 새 모델에서도 유효) | 관측 지점 | 정상(수정본) | 대조군(버그) | |---|---|---| diff --git a/.claude/luau-test/done/04-dispatch-chain-retractFrom.luau b/.claude/luau-test/rewrite-required/04-dispatch-chain-retractFrom.luau similarity index 100% rename from .claude/luau-test/done/04-dispatch-chain-retractFrom.luau rename to .claude/luau-test/rewrite-required/04-dispatch-chain-retractFrom.luau diff --git a/.claude/luau-test/done/19-ownership-refcount-relate-patterns.luau b/.claude/luau-test/rewrite-required/19-ownership-refcount-relate-patterns.luau similarity index 100% rename from .claude/luau-test/done/19-ownership-refcount-relate-patterns.luau rename to .claude/luau-test/rewrite-required/19-ownership-refcount-relate-patterns.luau diff --git a/.claude/question.md b/.claude/question.md index 3481505..b6ea97a 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,68 +12,25 @@ --- -## ⭐ 최우선 — M0 착수를 막고 있음 (1건) +## ⭐ 최우선 — **없음** (2026-08-13 열네 번째 세션 기준) -이 항목은 사용자가 **직접 스케치하며 판단하겠다고 명시 이관**한 것이라 -에이전트가 기본값으로 밀고 갈 수 없음. 루트 `HUMAN_TODO.md` 4번에도 있음. - -> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`(`:Compute(fn)`의 -> lazy 핸들 계약)는 해소됨** — 실측 결론은 "계약은 그대로 두고, 파생 -> State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩한다. 그 외는 -> Luau의 현 한계라 지금 우리가 할 수 있는 바 없다". 지금 유효한 규약은 -> **`base/typing-limits.md`**, 실측 근거 전문은 -> `audit/type-recursion-issue/`, 해소 전 원문은 -> `archive/question-resolved.md`의 0-Y 절. - -### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관) - -**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의 -재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면 -⚠️ 배너를 단 **7개 문서**(`bind-system-plan.md`/`tag-plan.md`/ -`slot-plan.md`/`attribute-plan.md`/`ref-plan.md`/`architecture.md`/ -`ROADMAP.md` — `ref-plan.md`는 9차 세션 분할 때 배너가 같이 안 옮겨간 -걸 10차 세션 감사에서 발견해 추가)를 한 번에 옮기면 됨. - -**문제**: 새 모델(핸들러 선비교)에서는 그룹 A가 잡아둔 -`AttributeKey("foo")` 인덱스 1에 그룹 B가 들어와도 **양쪽 다 `StoreBind`에 -매치되므로 "같은 핸들러"로 판정돼 조용히 갈아탐.** 그리고 나중에 A의 -클로저가 자기 이름들을 `retractFrom`할 때 B의 바인딩을 대신 철거함 -(교차 오염). 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서 -고쳤던 바로 그 증상. 즉 **Dispatch의 점유 체크가 대신 잡아주던 걸 이제 -Attribute가 직접 해야 함.** - -**사용자 방향(2026-08-13, 심층 분석은 다음 세션)**: -> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요. -> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때 -> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며 -> 심층 분석해보겠습니다." - -- **"이전 결정을 다시 가져온다"** = 2026-08-13 네 번째 세션의 이름별 - claimant `Relate`(당시 이름 `owners`). **당시 기각 사유는 새 모델에서 - 구조적으로 소멸함** — 그때 버그는 "소유권 반납이 `process`의 `v==nil` - 분기에만 있어서, 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타 - 옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 불리므로 거기서 - 반납**하면 그 구멍이 안 생김. 사용자의 "본인 retract 처리를 수행할 때 - 무언가 하면 될듯"이 정확히 이 지점. -- **"막 깊게 Key → Group 필요한 것 같지는 않다"** — 키에서 그룹으로 - 거슬러 올라가는 양방향 레지스트리까지는 필요 없고, 이름 → 현재 - claimant 단방향이면 충분할 것이라는 방향. -- 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler` - 체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`, - 분석은 `research/dispatch-redispatch-diff-plan.md` 5절. - -**대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에 -국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 + -교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장, -(c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대. +> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 +> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 +> `0-A`(재디스패치 하강 diff)는 **열네 번째 세션에 확정·`base/` 반영 +> 완료**. 해소 전 원문과 결론은 `archive/question-resolved.md`, +> 뒤집힌 옛 재디스패치 모델은 `archive/dispatch-hintvalue-model-reversed.md`. +> +> **M0 착수 전 읽을 것**: `base/typing-limits.md`(0-Y가 남긴 구현 규약), +> `base/dispatch-core-plan.md`(0-A/0-Z가 반영된 디스패치 코어 — 열네 번째 +> 세션에 `bind-system-plan.md`에서 분리 신설). ## 결정 대기 — M0는 안 막음 ### 0-W. 같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가 (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견) -**0-Z(Attribute)를 보다가 "Ref에도 같은 문제가 있냐"는 사용자 질문에서 -나온 것 — 있고, 막는 장치가 전혀 없음.** 단 메커니즘은 0-Z와 **반대 -방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별 +**0-Z(Attribute, 열네 번째 세션에 해소됨)를 보다가 "Ref에도 같은 문제가 +있냐"는 사용자 질문에서 나온 것 — 있고, 막는 장치가 전혀 없음.** 단 +메커니즘은 0-Z와 **반대 방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별 메모이즈된 키라 수렴), Ref는 *한 객체 → 두 자리*(발산). **손 트레이싱**(`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입, @@ -82,7 +39,7 @@ Attribute가 직접 해야 함.** 1. `process(inst1,"Ref",r)` → `relate[inst1]["Ref"]`가 nil → `r:Set(inst1)` 2. `process(inst2,"Ref",r)` → `relate[inst2]["Ref"]`도 nil(**다른 키**) → `r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음** -3. inst1 자리가 retract → `hintValue(nil) ~= v(r)` → **`r:Set(nil)`** — +3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)` → **`r:Set(nil)`** — inst2가 정당하게 들고 있던 값을 지움(교차 오염) `relate`가 `(inst,k)`별로만 있어 "이 Ref가 이미 다른 자리에 있다"를 @@ -95,7 +52,7 @@ Attribute가 직접 해야 함.** | `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 | | `PreRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 | | `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 | -| `Attribute` | 이름 | 없음 | **0-Z** | +| `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘(0-Z 해소, 14차 세션) | | **`Ref`** | 자기 자신 | **없음** | **이 항목** | 특히 걸리는 두 가지: @@ -107,48 +64,18 @@ Attribute가 직접 해야 함.** 검증하는데 **`Ref`만 커버가 없음**. **0-Z와의 관계 — 독립**: 이건 하강 diff 모델이 만든 회귀가 **아니라 -원래부터 있던 갭**(점유 체크는 같은 `(inst,k,index)`만 봤지, 서로 다른 -자리를 가로지르는 건 원래 안 봤음). 0-Z를 어떻게 정하든 별도 결정이고, -0-Z와 달리 **M0를 막지 않음** — 다만 사용자가 소유권 설계를 스케치할 때 -같이 보는 게 자연스러움. +원래부터 있던 갭**(옛 점유 체크도 같은 `(inst,k,index)`만 봤지, 서로 다른 +자리를 가로지르는 건 원래 안 봤음). **[2026-08-13 열네 번째 세션] 0-Z가 +해소되면서 이 표에서 `Ref`만 유일하게 비어 있게 됐음** — Attribute는 +"이름 claim"이라는 국소 레지스트리로 갔으니, `Ref`도 같은 모양(`Ref → +현재 자리` 단방향 `Relate` + 즉시 error)이 자연스러운 선택지. 여전히 +**M0를 막지는 않음**. **선택지**: (a) `Slot`/`PreRef`와 같이 즉시 error(일관성 높음, `Relate` 하나로 Ref→현재 자리 추적), (b) UB로 두고 문서화만(현상 유지 — 단 증상이 "조용한 값 소실"이라 다른 UB보다 나쁨), (c) 마지막 쓰기 승리를 정식 동작으로 인정(비권장, `Ref`의 "확정된 값 박스" 의미와 충돌). -### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**) - -**검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.** -힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/ -`Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의 -`isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐** -(정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은 -`research/dispatch-redispatch-diff-plan.md`. - -**후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`을 -선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저 -비교**해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출, -다르면 그 자리부터 아래를 전량 철거). 이걸로 (a) 힌트의 타입이 -구조적으로 보장되고(같은 핸들러일 때만 값이 넘어가므로), (b) 깊은 체인의 -힌트 유실도 사라지며(각 레벨이 자기 재프로세스에서 자기 힌트를 받음), -(c) `oldValue`를 따로 넘기자던 보완안은 불필요해짐(사용자 지적: -"클로저라 이미 본인이 알지 않아요?" — 맞음, `chains`에 추가로 저장할 건 -비교용 `handler` 하나뿐), (d) `HandlerChanged` 마커도 불필요(핸들러가 -바뀌었다는 건 retractor가 `nil` 힌트로 불린다는 사실로 이미 표현됨). - -**남은 열린 항목은 Attribute 이름 소유권 하나뿐 — 위 0-Z로 분리해 -최우선 배치**(사용자가 다음 세션에 직접 스케치하며 심층 분석하기로). -그 하나 외에는 이 항목에 결정할 게 없음. - -**실행 규모**: `base/`의 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ -`attribute-plan.md`/`ref-plan.md` 의사코드 재작성 + `architecture.md` -소스트리 서술과 `ROADMAP.md` M2/M4/M6/M10 체크리스트 — 0-Z 하나만 정해지면 -한 번에 옮기면 됨(어디를 어떻게 고칠지는 -`research/dispatch-redispatch-diff-plan.md` 6절에 파일별로 적어둠 — 뒤 -셋은 2026-08-13 7차/10차 감사에서 그 목록에 빠져 있던 걸 발견해 추가). -**그때까지 `base/`의 현행 `hintValue` 서술이 유효** — 아직 안 옮겼다는 걸 -잊고 base만 읽으면 옛 모델로 구현하게 되니 주의. ### 0-B. `dispose(any)` — 시그니처/범위 (2026-08-13 여섯 번째 세션 신설, 사용자 제안) `State` 교체를 파괴가 아니라 **언마운트**로 확정하면서(`state`와 @@ -201,6 +128,13 @@ Attribute가 직접 해야 함.** 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 같이 검토할 것. +- **클로저 인자 이름 `hintValue`(3순위, 사소함, 2026-08-13 열네 번째 + 세션 신설)**: 하강 diff 재디스패치에서 이 인자는 더 이상 "힌트"가 + 아니라 **`nil`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로 + 보장됨(`base/dispatch-core-plan.md`) — 이름이 옛 모델의 잔재라 + `nextValue`류가 더 정확함. 코퍼스에 이미 널리 쓰인 이름이라 이번엔 + 안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`를 + 쓰기 시작했음). - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 10종 branded 타입 전부로 일반화) — `brand-plan.md`의 `Brand` @@ -251,12 +185,19 @@ Attribute가 직접 해야 함.** 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. - **중첩 State 평탄화 `State>` → `State`(2026-08-13 여섯 번째 - 세션 신설, 백로그)** — 인덱스 기반 `Dispatch` 재설계로 `State>`는 - UB에서 정상 지원 대상이 됐지만, 깊이가 늘수록 `retractFrom`의 힌트가 - 더 깊은 인덱스엔 `nil`로 전달돼 `Tag`/`Ref`/`Slot` 등의 힌트 기반 - 최적화(깜빡임 방지)가 무력화되는 실제 기능 손실이 있음 — 값 층에서 - 평탄화하는 `state:Flatten()`류 콤비네이터 아이디어는 나왔으나 착수 안 - 함. 상세는 `research/operator-sugar-plan.md` 마지막 절. + 세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의 + 주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는 + 실제 기능 손실이었는데, **하강 diff 재디스패치로 각 레벨이 자기 값을 + 받게 되면서 그 손실 자체가 없어졌음**(`base/dispatch-core-plan.md`). + 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라 + 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는 + 그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절. +- **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** — + 두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서 + 조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리). + 합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이 + 같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어 + 사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절. - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 73bb3a8..fba0553 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -96,7 +96,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / ~~retract=폐기 확정 히스토리(portal 검토 후 기각)~~ **[2026-08-13 정정] 위와 같은 이유로 역전 — 이 항목은 "왜 한때 destroy+no-portal로 결정했었는가"라는 히스토리 소재로만 유효, 현재 결론 아님** / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선) - 열린 질문(문서화 보류): ~~여러 Slot이 형제로 섞일 때 순서 보장~~ **[해소됨, 2026-08-09 여섯 번째 세션]** Length/Offset 누적합으로 확정, 심화 목록에 - 추가 필요(`base/bind-system-plan.md` "Length/Offset" 절). + 추가 필요(`base/dispatch-core-plan.md` "Length/Offset" 절). **[2026-08-09 추가]** `Slot:List`의 `prev`/`userdata` 재사용 최적화를 getting-started에서 "항상 파괴 후 재생성" 단순 버전만 가르치고 나중에 최적화 단계에서 별도로 알려줄지, 아니면 Slot이 학습 순서상 core loop @@ -250,7 +250,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: - **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/ `setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정, - `bind-system-plan.md` "Length/Offset" 절 참고. + `dispatch-core-plan.md` "Length/Offset" 절 참고. - **[해소됨, 2026-08-13 정정]** Tween 오버라이드/옵션 값 모양 — 2026-08-12 첫 번째 세션에 `Info: TweenInfo?`+편의 필드 폴백, override 정책은 `Tween.Cancel`(기본)/`Tween.Finish` 2값으로 확정, diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index c4a8796..9614303 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -306,15 +306,17 @@ Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 "Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님** (사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다"). -**왜 원치 않는가 — 단순 취향이 아니라 실제 기능 손실이 있음**: -`Dispatch.retractFrom(inst,k,index,v)`는 힌트 `v`를 정확히 `index` -자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘김. 그래서 `State` -(한 겹, StoreBind@1 → TagHandler@2)에서는 재발행 시 TagHandler가 -`hintValue=newTag`를 **확실히** 받아 깜빡임 방지가 동작하지만, -`State>`(StoreBind@1 → StoreBind@2 → TagHandler@3)에서 -**바깥** store가 재발행하면 TagHandler는 `nil`을 받아 `RemoveTag`→ -`AddTag` 왕복이 실제로 일어남. 깊이가 늘수록 힌트 기반 최적화 -(`Tag`의 `Contains`, `Ref`/`Slot`의 identity 비교)가 전부 무력화됨. +**[근거 축소, 2026-08-13 열네 번째 세션] 원래 이 항목의 주 근거였던 +"깊은 체인에선 힌트가 사라져 깜빡임 방지가 꺼진다"는 손실은 없어졌음.** +당시 서술: 옛 `Dispatch.retractFrom(inst,k,index,v)`가 힌트 `v`를 정확히 +`index` 자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘겼기 때문에 +`State>`에서 바깥이 재발행하면 TagHandler가 `nil`을 받아 +`RemoveTag`→`AddTag` 왕복이 일어났음. **하강 diff 재디스패치**에선 각 +레벨이 자기 재프로세스에서 자기 값을 받으므로 깊이와 무관하게 진짜 +`Tag` 객체가 전달됨(`base/dispatch-core-plan.md` "Dispatch 체인" 절). +남은 근거는 (a) 편의성/의도 표현, (b) `state>`류에서 Slot +offset이 밀리고 당겨지는 케이스(이건 이미 "그냥 확인된 것"으로 수용) +정도라 **우선순위가 더 내려감**. **아이디어(착수 안 함)**: 중첩을 Dispatch 층에서 감내하는 대신, 값 층에서 **평탄화하는 콤비네이터**를 제공 — `State>`를 받아 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 77299ee..55bfd4a 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -48,7 +48,7 @@ PropertyHandler가 직접 판별. 상세는 `base/tween-plan.md`(전면 재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는 원래 발견 당시 기록. -**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 — +**위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 67-79행 — "Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 잡아내는, 우선순위가 매우 높은 핸들러**"; `architecture.md` 소스트리엔 이 역할을 하는 quad-roblox 파일이 `Handlers/Tween.luau` 하나뿐(별도 범용 @@ -79,10 +79,11 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 ### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션] **해소**: `Dispatch`가 `(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을 -직접 소유하고, `Dispatch.retractFrom(inst,k,index,v)`가 꼬리부터 `index` +직접 소유하고, `Dispatch.retractFrom(inst,k,index)`가 꼬리부터 `index` 까지 정리해주는 걸로 확정(2026-08-08 확정 당시 이름은 `retractUnder`이고 체인이 핸들러 배열이었음 — 2026-08-13 다섯 번째 세션에 인덱스 기반으로 -재설계되며 개명, 결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가 +재설계되며 개명, 같은 날 열네 번째 세션에 힌트 인자가 빠져 3-인자가 됨, +결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가 "마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가 여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 @@ -90,7 +91,7 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 원래 발견 당시 기록. -**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 90-91행 — +**위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 90-91행 — "store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 한 번 호출해주면 됨." @@ -115,10 +116,10 @@ bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 매치 실패는 조용한 무시 없이 즉시 `error`(브랜드+`typeof` 출력, provider 초기화 확인 안내)로 확정. 핸들러 등록 시점 동률 감지 print 경고와 `Dispatch.listHandlers()`류 전체 목록 조회 함수도 M2 기본 기능으로 -확정 — 상세는 `base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" +확정 — 상세는 `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절. 아래는 원래 발견 당시 기록. -**위치**: `base/bind-system-plan.md` "핸들러 계약" 절 — "디스패치는 등록된 +**위치**: `base/dispatch-core-plan.md` "핸들러 계약" 절 — "디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리." **문제**: (a) 두 핸들러가 같은 `priority` 값을 가질 때 어느 쪽이 우선인지 diff --git a/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md b/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md new file mode 100644 index 0000000..77a4961 --- /dev/null +++ b/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md @@ -0,0 +1,198 @@ +# 2026-08-13 열네 번째 세션 — 0-Z(`Attribute:GetKey`) 확정, 하강 diff 재디스패치 전면 반영, Tag/Attribute를 quad-base로 재배치 + +**한 줄 요약**: 사용자가 `Attribute:GetKey(name)` 아이디어를 제시하며 0-Z를 +다시 열었고, 트레이싱으로 검증한 뒤 **그룹 전용 키 + 이름 claim**으로 +확정 → 그 김에 **0-A(하강 diff 재디스패치)까지 base 전면 반영 + 디스패치 +코어 분리(`dispatch-core-plan.md` 신설) + Tag/Attribute 패키지 재배치**를 +한 패스로 처리하고 커밋. **M0 착수를 막는 결정이 이제 하나도 없음.** + +--- + +## 1. 시작 — 사용자 문제 제기 + +> "Attribute 에 대해서 말이지, 우린 이전의 결정을 다시 돌아봐야할 필요가 +> 있는듯. `Attribute:GetKey(name)` 구현으로써 '조용한 문제 없음' 을 +> 해결할 수 있을것으로 보이는데. 한번 확인해볼래?" + +`question.md` 0-Z(Attribute 이름 소유권)는 여섯 번째 세션에 사용자가 +"다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로 명시 이관해둔 +최우선 항목이었고, 그때의 잠정 방향은 **(a) 이름별 claimant `Relate`를 +Attribute 안에 두기**였음. + +## 2. 검증 — 문제를 두 갈래로 분해 + +| | 증상 | 하강 diff 모델에서 | +|---|---|---| +| **C1 그룹 A ↔ 그룹 B** | 같은 이름 | 둘 다 `StoreBind` → "같은 핸들러"로 조용히 인계 + A 클로저가 B 걸 철거(교차 오염) | +| **C2 그룹 ↔ 직접 쓰기** | 같은 이름 | 핸들러가 다름 → `retractFrom` 후 조용히 교체 | +| **C3 같은 그룹 객체 두 자리** | 같은 이름 | 키가 같아 수렴 → C1과 동일 | + +**핵심 발견 — 권고안 (a)는 C2를 못 잡음.** 직접 쓰기는 그룹 코드를 아예 +안 지나가서 그룹 쪽 레지스트리에 등록될 자리가 없고, 두 경로가 실제로 +만나는 유일한 지점인 `AttributeKeyHandler`에서는 공개 캐시 키를 쓰는 한 +`k`가 **같은 객체**라 소유자를 구분할 방법이 원천적으로 없음. 옛 모델에선 +Dispatch의 점유 체크가 이걸 대신 잡아줬는데, 하강 diff에선 점유가 정상 +상태라 그 체크 자체가 성립하지 않음. + +**그래서 `GetKey`가 필요한 이유가 분명해짐**: 전용 키를 쓰면 +- 체인이 소유자별로 갈려 **교차 오염이 구조적으로 불가능**해지고, +- 소유권이 **키 identity**로 표현되므로 말단 핸들러가 "이 이름을 지금 + 누가 잡고 있나"를 단방향 맵 하나로 판정할 수 있음(그룹인지 직접 + 쓰기인지 알 필요조차 없음). + +즉 정확히는 **`GetKey`(교차 오염 제거) + 이름 claim(감지)** 두 조각이 +한 세트. `GetKey`만으로는 두 소유자가 각자 `setAttribute`를 쏘는 +flip-flop이 조용히 남음. + +**순서 검증**(하강 diff에서 "해제 → 재클레임"이 항상 보장되는가): +같은 핸들러 재프로세스는 `slot.retractor(v)` → `h.process(...)`, +핸들러 교체는 `retractFrom`(꼬리부터) → `process` — 어느 경로든 옛 claim +반납이 먼저. `5 → None → 5`처럼 체인 깊이가 오가는 경우도 인덱스가 +바뀌는 자리에서 `retractFrom`이 먼저 돌아 동일. `State`가 이미 +차단돼 있고 배열 위치는 정적이라, 위치를 가로지르는 "새 소유자 먼저 +claim" 경로도 없음. + +**남은 구멍으로 보고한 것**: (1) `GetKey`를 공개 API로 내면 사용자가 그 +키를 다른 자리에 다시 놓아 수렴시킬 수 있고 그건 claim(키 identity 기준) +으로도 못 잡음 → **사용자가 "비공개로 안 내도 될듯"으로 확정**, (2) +base/roblox 패키지 경계, (3) `Attribute.Merged`의 이름 중복이 dispatch +이전 단계라 claim이 못 잡음(→ 새 열린 질문으로 등록). + +## 3. 사용자의 두 번째 제기 — 패키지 배치가 이상하다 + +> "Attribute 는 왜 quad-base 인지 모르겠습니다. 사실, Attribute 랑 Tag +> 모두 다른 곳에서도 사용할 수 있거나 없거나 해서, 그냥 quad-base 에 있고, +> set 메커니즘(최종 inst:Set... 와 Tag 제거 추가) 부분만 quad-roblox 에 +> 작성하는게 좋을수도 있어보여요. 왜냐하면 웹에도 className, data- +> attribute 가 있습니다. (...) 안 그럼 다시 재구현 될 부분이 너무 많은것 +> 같은 느낌." + +동의. 당시 배치는 **알고리즘 전체가 quad-roblox, 값 타입만 base**였는데 +실제로 엔진에 종속된 건 마지막 한 줄뿐: + +| 층 | 내용 | 엔진 종속? | +|---|---|---| +| 값 타입/API | `Tag(...)`, `Attribute(...)`, `AttributeKey(name)`+weak 캐시 | ✗ | +| 알고리즘 | Tag 참조 카운트, 그룹 위임, **이름 claim**, `None` 처리 | ✗ | +| 실행 | `AddTag`/`RemoveTag` / `SetAttribute` | **✓ 3줄** | + +`architecture.md`가 이미 *"pluggable 디스패치 엔진 자체도 인터페이스로 +base가 소유 — 엔진마다 큰 구현을 중복하지 않기 위함"*이라고 못박아뒀는데 +Tag/Attribute만 그 원칙 밖에 있었음. 선례도 있음 — `LifetimeHandle`이 +base엔 인터페이스, quad-roblox엔 gcconn 구현으로 갈려 있고 주입은 +`RobloxFactory(BaseModule)` 뮤테이션. **`addTag`/`removeTag`/`setAttribute`는 +그 목록에 3개를 더하는 것뿐, 새 메커니즘이 아님.** + +세부 합의: +- **`addTag(inst, names: {string})`** — vararg가 아니라 테이블. 근거는 + 열다섯 번째 세션에 `Tag:Added`가 vararg → `string | {string}`으로 + 되돌아갔던 것과 같음(`table.unpack`이 tail 위치에서만 완전히 펼쳐짐 + + 대량 이름에서 unpack 한계). 웹 `className` 배치 갱신 요구도 테이블로 + 충족됨. **사용자 동의**. +- **타입 패밀리는 갈림** — 제네릭 `AttributeKey<>`와 스칼라 3종은 + base, `Color3Attribute`류는 백엔드. 사용자: "그건 D/DI 쪽에서 각자 + 구현임. 타입 쪽은 그쪽에서 처리하면 되는것 같고". +- **미주입 백엔드 실패 모드** → 사용자가 더 나은 안을 제시: + +> "그렇다면 failback... 이름의 아주 낮은 우선순위의 요소, +> PRIORITY_FAILBACK 정도를 잡아두는게 좋겠네요. 그건 base 에서 +> 주입해버려도 되고, 위에서 처리되면 상관 없게 잘 처리되니까요." + +→ **`HANDLER_PRIORITY_FALLBACK`** 신설(기존 `HANDLER_PRIORITY_*` 패밀리에 +추가, 철자는 영어 표준형 FALLBACK으로 정규화). base 제공 핸들러는 이 +밴드에 등록하므로 백엔드가 평범한 우선순위로 자기 핸들러를 등록하면 +언제나 이김 — 비활성화나 등록 순서 조정이 필요 없음. op 자체가 없는 +백엔드에서는 base 스텁이 명확한 에러를 냄. + +## 4. 반영 — 한 패스로 + +사용자 지시: *"모두 제 생각과 같으니까, 그렇게 처리해도 좋아요. 다만 +이전처럼 많은 루프를 돌며 문서를 안 고치게, 유의해가며 정리해줘요. +(그걸 위해서 상위 모델로 올리기도 했고.) 처리 후 핸드오버 할거예요. +clear 하게 준비해두고, 커밋하세요."* + +### 4-1. `bind-system-plan.md` 2단계 분할 + 재작성 + +9차 세션이 *"디스패치 코어는 0-Z 확정 시 어차피 전면 재작성 대상이라, +재작성하는 그 패스에서 파일을 가르는 게 총 변경량·실수 위험이 모두 +작음"*이라며 의도적으로 미뤄둔 계획을 그대로 실행 — `문제`/`핸들러 계약`/ +`확정된 디스패치 모델`/`None 센티널`/`Dispatch는 프리미티브가 아니다`/ +`Dispatch 체인`/`Handler 작성 체크리스트`/`Length·Offset`/`store 바인드는 +래핑` 블록(1074줄)을 **`base/dispatch-core-plan.md`**로 옮기고 새 모델로 +재작성. `bind-system-plan.md`는 2263 → 1219줄(반응형 코어 + 인체공학). + +인바운드 참조는 `doc-check.py` 출력을 그대로 입력으로 삼아 기계적으로 +고침(파일별 라인 지정 치환) — 15개 파일 30여 곳. + +### 4-2. 하강 diff 모델의 실제 내용 + +```lua +-- chains[inst][k][index] = { handler = h, retractor = fn } +function Dispatch.process(inst, k, v, index) + local list = <확보 + chains 등록> -- 순서 규칙 그대로(h.process 前) + local slot, h = list[index], Dispatch.getHandler(inst, k, v) + if slot ~= nil and slot.handler == h then -- (A) 같은 핸들러 + slot.retractor(v) -- v는 isHandlable(v) 보장됨 + slot.retractor = h.process(inst, k, v, index) + else -- (B) 다른 핸들러/빈 자리 + Dispatch.retractFrom(inst, k, index) + list[index] = { handler = h, retractor = NOOP } + list[index] = { handler = h, retractor = h.process(inst, k, v, index) } + end +end +``` + +**이 세션에서 새로 도출한 귀결 — `retractFrom`이 3-인자가 됨.** 값을 +넘기는 경로가 (A) 분기 하나로 통일되면서 외부가 힌트를 만들어 넣을 자리 +자체가 사라짐 → 옛 결함(래퍼/센티널이 힌트로 새는 것)이 **구조적으로 +재발 불가**. 설계안 원문(6절)엔 4-인자 `retractFrom(inst,k,index,nil)`이 +그대로 남아 있었는데, 모델의 필연적 귀결이라 판단해 3-인자로 정리하고 +핸드오버에 명시. + +같이 폐기/갱신된 것: +- `isX(hintValue)` 방어 가드 **일반 규칙 폐지**(타입이 계약으로 보장됨) +- "`hintValue`는 직속 1단계에만, 깊은 인덱스는 `nil`" 캐비엇 **삭제** → + 각 레벨이 자기 값을 받으므로 `State>`에서도 깜빡임 방지 유효 +- Dispatch의 **점유 체크(소유권 감지) 폐지** → 필요한 도메인(Attribute)이 + 직접 +- Handler 작성 체크리스트 2·3번 전면 교체, 8번(중간 노드는 `inst`에 + 손대지 않는다 + 항상 재위임) 신설 + +### 4-3. 반영 대상 문서 (배너 7개 + 인덱스 레이어) + +`base/`: `dispatch-core-plan.md`(신설) / `bind-system-plan.md` / +`attribute-plan.md` / `tag-plan.md` / `slot-plan.md` / `ref-plan.md` / +`architecture.md`(소스 트리 포함) / `module-lifecycle-plan.md`(주입 op +목록) / `onchange-plan.md`(AttributeKey 위치 정정) / `relate-plan.md` +문구. +루트: `ROADMAP.md`(M0/M2/M4/M6/M10 배너·체크리스트) / `CLAUDE.md` / +`HUMAN_TODO.md`. +인덱스/질문: `.claude/README.md`(행 5개 갱신 + 신설 2행) / +`question.md`(최우선 칸 비움) / `archive/question-resolved.md`(0-Z/0-A +해소 마킹). +아카이브: `research/dispatch-redispatch-diff-plan.md` → +`archive/dispatch-hintvalue-model-reversed.md`(옛 모델 골자 + 폐기된 +규칙 목록을 머리에 추가). + +### 4-4. 스파이크 상태 갱신 + +`04`(체인/`retractFrom`)와 `19`(소유권/참조카운트)가 **옛 모델을 검증 +중**이라 `done/` → `rewrite-required/`로 이동. "코드가 깨진" 게 아니라 +"설계가 바뀐" 경우라 STATUS.md에 그 구분을 명시하고, 각각 무엇을 +살리고 무엇을 바꿔야 하는지 적음(`04`의 `chains:SetStrong` 순서 음성 +대조군은 새 모델에서도 유효 → 살릴 것). + +## 5. 새로 연 것 / 남은 것 + +- **새 열린 질문 1개(사소)**: `Attribute.Merged`의 이름 중복 — + `:NameMap()` 평탄화가 dispatch 이전이라 이름 claim이 못 잡음. + error로 갈지 "뒤가 이긴다"를 의도된 override로 볼지 사용자 확인 + 대기(`question.md` 3번). +- **용어 대기열 1개**: 클로저 인자 이름 `hintValue` — 이제 "힌트"가 + 아니므로 `nextValue`류가 정확함. 코퍼스 전반에 퍼진 이름이라 이번엔 + 안 바꾸고 대기열에만 올림(새로 쓴 의사코드는 `nextValue` 사용). +- **0-W**(같은 `Ref` 객체 이중 배치)는 그대로 열림 — 0-Z가 닫히면서 + 형제 프리미티브 표에서 `Ref`만 유일하게 비게 됐고, Attribute가 간 + "국소 레지스트리 + 즉시 error" 모양이 자연스러운 선택지라는 점을 + 질문 문서에 덧붙임. +- `doc-check.py` **ERROR 0** 유지 확인. diff --git a/CLAUDE.md b/CLAUDE.md index eb349d8..c2e488a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,58 +158,39 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 ## 지금 할 일 (우선순위순) -0. **⭐ 최우선 — `.claude/question.md` 0-Z 결정.** (`question.md`엔 - 0-B `dispose(any)` 시그니처/범위도 열려 있지만, M6 구현 세부만 막을 뿐 - M0 착수 자체는 안 막아서 이 최우선 항목과 급 다르게 취급 — 별도 항목 - 아님.) +0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-13 열네 번째 세션 기준).** + `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy + 핸들 계약)는 13차 세션에, **`0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치 + 하강 diff)는 14차 세션에 확정·`base/` 반영 완료**. 남은 `0-W`(같은 `Ref` + 이중 배치)/`0-B`(`dispose` 시그니처)는 M0가 아니라 각각 M4/M6 구현 세부를 + 막을 뿐임. - > **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨.** - > `:Compute(fn)`의 lazy 핸들 계약은 **그대로 유지**로 확정. 44개 - > 스파이크 재실측 결과 진짜 원인이 콜백 계약이 아니라 **Luau 자체의 - > 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 - > 에러 없이 조용히 사라짐)였고, 당시 "raw 값이면 완전 클린"이라던 - > 1차 판정도 **뒤집혔음**(raw 값 계약도 똑같이 불안전). 사용자 정리: - > "quad가 타입을 비틀 일이 아니라 상위 Luau의 현 한계이고, RFC/이슈 - > 수혜를 받을 때 해결될 일이라 당장 우리가 할 수 있는 바 없다." - > **구현 시 지켜야 할 규약이 생겼으니 M0 착수 전 반드시 읽을 것: - > `base/typing-limits.md`**(핵심은 "파생 State를 만드는 자리마다 - > 결과 타입을 명시 주석으로 바인딩" + 7번 설계 체크리스트). 실측 - > 근거는 `audit/type-recursion-issue/`, 해소 전 원문은 - > `archive/question-resolved.md`의 0-Y 절. + **M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라 + 여전히 유효**: + - **`base/typing-limits.md`**(0-Y의 산물) — 핵심은 "파생 State를 만드는 + 자리마다 결과 타입을 명시 주석으로 바인딩" + 7번 설계 체크리스트. + 재귀 제네릭이 자기를 다른 타입 인자로 반환하면 Luau가 타입 안전성을 + **에러 없이 조용히** 잃는 상위 한계라 quad 쪽에서 우회하지 않기로 + 확정(RFC `relax-recursive-type-restriction` 수혜 대기, 추적 + `luau-lang/luau#2380`). 실측 근거는 `audit/type-recursion-issue/`. + - **`base/dispatch-core-plan.md`**(0-A/0-Z의 산물, 14차 세션에 + `bind-system-plan.md`에서 분리 신설) — 재디스패치가 "철거 후 재구축"이 + 아니라 **하강 diff**임, `retractFrom`은 3-인자, 클로저 인자는 + `nil`이거나 같은 핸들러가 처리할 값(타입 보장), `HANDLER_PRIORITY_FALLBACK`, + "base가 소유하는 핸들러와 주입되는 엔진 op"(`addTag`/`removeTag`/ + `setAttribute`). **Handler 작성 체크리스트 8개**를 새 핸들러 짜기 전에 + 훑을 것 — 지난 세션들에서 실제로 반복된 실수 목록임. - **0-Z: Attribute 이름 소유권 결정.** - 2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로 - 다시 정리되면서(`research/dispatch-redispatch-diff-plan.md`), **그 - 모델에서 유일하게 안 풀린 것이 Attribute 그룹의 이름 소유권 충돌 - 감지**임. 사용자가 "이전 결정(이름별 claimant `Relate`)을 다시 가져오는 - 게 맞아 보이나, 다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로 - 명시 이관 — 그전까지 아래 항목들보다 우선. - **핸드오버 시 반드시 알아야 할 것**: - - **`base/`의 현행 `hintValue` 서술은 아직 옛 모델(철거 선행)이다.** - 새 모델은 `research/dispatch-redispatch-diff-plan.md`에만 있음 — - base만 읽고 구현하면 옛 모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의 - 파일별 반영 목록대로 **한 번에** 옮길 것 — 대상은 ⚠️ 배너를 달고 - 있는 **7개**(`bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ - `attribute-plan.md` + **`architecture.md`/`ROADMAP.md`** — 뒤 둘은 - 2026-08-13 7차 감사에서 6절 목록에 빠져 있던 걸 발견해 추가 + - **`ref-plan.md`** — 9차 세션 분할 때 "`Ref`의 retract" 절이 배너 - 없이 옮겨간 걸 이번 감사에서 발견해 추가), - 반영 후 각 배너도 같이 제거. - - 반대로 **`base/slot-plan.md`의 "언마운트/`dispose`/해제 순서"는 이미 - 확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감). - - 이 세션에서 같은 날 두 차례 급하게 쓴 의사코드가 각각 버그를 냈다는 - 사실 자체가 교훈 — 0-Z 반영도 서두르지 말고 손 트레이싱을 거칠 것 - (`bind-system-plan.md` "Handler 작성 체크리스트" 절이 그 산물). + 해소 전 원문은 `archive/question-resolved.md`(0-Y/0-Z/0-A 절), 뒤집힌 옛 + 재디스패치 모델 전문은 `archive/dispatch-hintvalue-model-reversed.md`. 1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 로드맵 인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은 2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부 - 해소되어 **11개 전원 완료** — 이 항목(우선순위1) 기준 남은 유일한 - 게이트는 아래였음(**[2026-08-13 4차 감사 정정] 위 0번 항목의 0-Y/0-Z가 - 이후 같은 날 발견돼 실제로는 게이트가 하나 더 있었음 — "유일한"은 그 - 발견 전 서술. **[13차 세션 재정정] 그중 0-Y는 해소됐고 지금 남은 - 게이트는 0-Z 하나**, 다만 0-Y가 남긴 구현 규약 - `base/typing-limits.md`는 M0 착수 전에 읽어야 함**): + 해소되어 **11개 전원 완료**. **[14차 세션 기준] 0-Y/0-Z/0-A까지 전부 + 해소돼 설계 게이트는 남아있지 않음** — 착수 전 읽을 것은 위 0번의 두 + 문서(`typing-limits.md`/`dispatch-core-plan.md`)뿐이고, 스파이크 상태는 + 아래 그대로: - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크 결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].** **상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 / @@ -231,9 +212,11 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0 실제 코드 작성에 재사용. - **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에 - `08`이 `done/`으로 가며 `review-required/`가 비었음. **개수의 소스는 - 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 건 0-Z 하나이고, - 0-Y가 남긴 규약(`base/typing-limits.md`)은 착수 전 필독. + `08`이 `done/`으로 가며 `review-required/`가 비었고, **14차 세션에 + `04`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`로 갔음**. + **개수의 소스는 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 + 설계 결정은 없고, 0-Y/0-A가 남긴 규약(`base/typing-limits.md`/ + `base/dispatch-core-plan.md`)은 착수 전 필독. - 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된 `rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯 번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님. @@ -965,8 +948,9 @@ handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을 무력화, `SlotHandler`의 claim 실패 시 이중 파괴, `Ref` dedup 무력화). 이어 사용자 결정으로 **`State` 교체를 파괴→언마운트로 전환**(포탈이 그 귀결이 됨), **`dispose(value)`**(트리가 요구하면 파괴 거부·error) 신설, -**재디스패치를 "하강 diff"로 재설계**(`research/dispatch-redispatch-diff-plan.md`, -**base 미반영 — `question.md` 0-Z 하나 남음**). `ROADMAP.md`/base 계약 개수 +**재디스패치를 "하강 diff"로 재설계**(당시 `research/`의 설계안 — +**base 미반영, `question.md` 0-Z 하나 남음**이었고 14차 세션에 확정·반영 후 +`archive/dispatch-hintvalue-model-reversed.md`로 이전). `ROADMAP.md`/base 계약 개수 모순/`luau-test` stale도 정리. 마지막으로 `luau` 바이너리가 생겨 **첫 실측** — 런타임 12개 전원 통과, `04`가 위 버그를 음성 대조군으로 재현, `07` 보강으로 GC-native 전제 확정, `18`이 `Relate` 순환 경고 실증. 타입에선 `:Compute(fn)` @@ -1025,7 +1009,7 @@ Slot 언마운트 전환 미반영 6곳. 뒤집힌 "폐기, 옮기지 않음 + p **1단계 분할**(2989→2263줄, `ref-plan.md`/`event-plan.md`/`brand-plan.md`로 순수 이동) — 남은 디스패치·반응형 코어는 0-Z 반영 때 **어차피 전면 재작성** 대상이라 그 패스에서 같이 가르는 게 총 변경량·위험이 작다고 판단해 의도적 -연기(`dispatch-redispatch-diff-plan.md` 6절에 지시). (3) `question.md`를 +연기(당시 재디스패치 설계안 6절에 지시 — 14차 세션에 실제로 그렇게 처리됨). (3) `question.md`를 **사용자가 답할 것만**으로 축소(525→279줄, 해소분은 `archive/question-resolved.md`). (4) 재발 방지는 규율 문서가 아니라 **검사기**로 — `.claude/tools/doc-check.py` 신설(깨진 파일/절 참조, 색인 @@ -1054,7 +1038,7 @@ CLAUDE.md "작업 방식"에 중대 변경 핸드오버 체크리스트 6단계 정독. 열 번째 세션이 이미 고친 것과 같은 종류의 stale이 두 곳 더 남아있던 게 핵심 발견 — `question.md` 0-Z/0-A와 `HUMAN_TODO.md` 4번이 여전히 "6개 문서"(ref-plan.md 누락)로 서술 중이던 것을 "7개"로 정정(같은 정정이 -`dispatch-redispatch-diff-plan.md`/`CLAUDE.md`엔 이미 반영돼 있었으나 이 +재디스패치 설계안/`CLAUDE.md`엔 이미 반영돼 있었으나 이 두 파일엔 안 퍼져 있었음). 별도로 `ROADMAP.md` 백로그의 `objectListClass.__newIndex` 재현 테스트 항목이 세 번째 세션에 이미 불필요로 해소됐는데 그 반영이 이 파일에만 안 퍼져 있던 것도 정정. base/ @@ -1093,3 +1077,26 @@ research/reference/luau-test/archive 전체는 정합성 문제 없음 확인 배너뿐 아니라 본문 표·문단·결론까지 전수 수정(체크리스트 2번 준수). 교훈 — **`luau-analyze` 진단 0건은 타입 해소를 뜻하지 않음**, 타입 스파이크는 `--annotate` + 음성 대조군 필수. + +**2026-08-13 열네 번째 세션 — 0-Z(`Attribute:GetKey`) 확정, 하강 diff 재디스패치 +전면 반영, Tag/Attribute를 quad-base로 재배치** +(`session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md`) +사용자가 `Attribute:GetKey(name)`으로 0-Z를 다시 열었고, 트레이싱 결과 +**권고안 (a)(그룹 안 claimant `Relate`)가 그룹↔직접 쓰기 충돌을 못 잡는다**는 +게 드러나(두 경로가 만나는 말단 핸들러에서 공개 키는 같은 객체라 소유자 +구분 불가) **그룹 전용 키(비공개 `GetKey`) + `AttributeKeyHandler`의 이름 +claim**으로 확정. 이걸로 마지막 게이트가 열려 **0-A(하강 diff 재디스패치)까지 +한 패스로 base 전면 반영** — 9차 세션이 미뤄뒀던 `bind-system-plan.md` +2단계 분할을 같이 수행해 디스패치 코어를 **`base/dispatch-core-plan.md`로 +분리·재작성**(선행 `retractFrom` 폐기, `chains` 슬롯에 `handler` 동거, +`retractFrom`이 **3-인자**로 축소, `isX(hintValue)` 가드 규칙 폐지, 깊은 +체인 힌트 유실 캐비엇 삭제, 점유 체크 폐지). 사용자 제기로 **Tag/Attribute의 +부기 알고리즘을 통째로 quad-base로 재배치**하고 백엔드는 +`addTag`/`removeTag(inst,{string})`/`setAttribute(inst,name,v)` 3개 op만 +주입(웹 `className`/`data-*` 대응 — 안 그러면 같은 참조카운트/소유권 +알고리즘이 백엔드마다 복제됨), 그 실패 모드를 위해 **`HANDLER_PRIORITY_FALLBACK`** +신설(사용자 제안 — base 핸들러는 최하위 밴드, 백엔드가 덮어쓰면 언제나 +이김). 옛 모델은 `archive/dispatch-hintvalue-model-reversed.md`로 이전, +스파이크 `04`/`19`는 옛 모델을 검증 중이라 `rewrite-required/`로 이동. +**M0 착수를 막는 결정이 이제 없음** — 새로 연 것은 사소한 둘뿐 +(`Attribute.Merged` 이름 중복, `hintValue` 이름 재검토). diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 0d67619..1fd077e 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -3,8 +3,8 @@ 에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서 멈춰둔 것만 여기 모음. 설계 질문은 대체로 `.claude/question.md`에 따로 있고 디폴트를 잡아둔 채 진행 중이라 급하지 않음 — **단 2026-08-13부터는 예외가 생겨 아래 4번에 -올렸음**(0-Z, M0 착수를 실제로 막고 있고 사용자가 직접 판단하겠다고 한 항목. -같이 올렸던 0-Y는 같은 날 열세 번째 세션에 해소됨). +올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은 +사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소). ## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용) @@ -56,34 +56,27 @@ git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초 내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나 사람이 바로 이어받을 수 있게 할 것. -## 4. ⭐ **[2026-08-13 신설, 막고 있음] `question.md` 0-Z 결정** +## 4. ~~`question.md` 0-Z 결정~~ **[해소됨, 2026-08-13 열네 번째 세션]** -**이건 위 3번과 성격이 다름 — 실제로 M0 구현 착수를 막고 있고, 사용자가 -"직접 스케치하며 판단하겠다"고 명시 이관한 항목**이라 에이전트가 기본값으로 -밀고 갈 수 없음. +**더 이상 사람이 막고 있는 결정이 아님.** 사용자가 같은 세션에 직접 +`Attribute:GetKey(name)` 방향을 제시했고, 트레이싱으로 검증한 뒤 +**그룹 전용 키(비공개 `GetKey`) + `AttributeKeyHandler`의 이름 claim**으로 +확정 → `base/attribute-plan.md` "이름 소유권" 절에 반영. 같이 묶여 있던 +재디스패치 모델(0-A)도 같은 패스에서 `base/dispatch-core-plan.md`(신설)로 +전면 반영됐고, ⚠️ 배너를 달고 있던 7개 문서 전부 갱신 완료. -- **0-Z — Attribute 이름 소유권을 무엇으로 판정할 것인가.** 재디스패치 - 모델을 "하강 diff"로 재설계하면서 유일하게 안 풀린 항목. 사용자 코멘트: - "이전 결정(이름별 claimant `Relate`)을 다시 가져오는 게 맞아 보이나, 나중에 - 제가 물리적으로 스케치해보며 심층 분석해보겠습니다." +**같은 세션에 사용자가 추가로 결정한 것** — `Tag`/`Attribute`의 알고리즘을 +통째로 quad-base로 옮기고 백엔드는 `addTag`/`removeTag`/`setAttribute` 세 +op만 주입(웹의 `className`/`data-*` 대응 때문). 상세는 +`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절. -> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨 — 사람이 -> 결정할 게 더 없음.** 44개 스파이크 재실측으로 원인이 콜백 계약이 아니라 -> **Luau 자체의 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 -> 안전성이 조용히 사라짐)임이 확정됐고, 사용자가 "quad가 타입을 비틀 일이 -> 아니라 상위 Luau 한계이니 당장 할 수 있는 바 없다"로 정리. 계약은 그대로 -> 유지, 대응은 "파생 State를 만드는 자리마다 결과 타입 명시 주석 바인딩" -> 관례 하나. 규약은 `.claude/base/typing-limits.md`, 근거는 -> `.claude/audit/type-recursion-issue/`. -> -> 다만 **거기서 파생된 작은 확인거리 하나가 아래 6번으로 넘어감**(에디터의 -> Luau 솔버 설정) — M0 착수 때 확인하면 되고 지금 막고 있진 않음. - -**막고 있는 범위**: 0-Z가 정해져야 `bind-system-plan.md`/`tag-plan.md`/ -`slot-plan.md`/`attribute-plan.md`/`ref-plan.md`/`architecture.md`/ -`ROADMAP.md` 7개를 새 모델로 한 번에 옮길 수 있음 — 그 전에 M2/M4/M6/M10을 -구현하면 곧 갈아엎어야 하는 코드를 짜게 됨. 상세/선택지는 -`.claude/question.md` 최상단. +> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`도 해소됨** — 44개 +> 스파이크 재실측으로 원인이 콜백 계약이 아니라 **Luau 자체의 한계**임이 +> 확정됐고, 대응은 "파생 State를 만드는 자리마다 결과 타입 명시 주석 +> 바인딩" 관례 하나. 규약은 `.claude/base/typing-limits.md`, 근거는 +> `.claude/audit/type-recursion-issue/`. 거기서 파생된 작은 확인거리 +> 하나(에디터의 Luau 솔버 설정)만 아래 6번에 남아 있음 — M0 착수 때 +> 확인하면 되고 지금 막고 있진 않음. ## 5. Studio 전용 스파이크 `10` 마저 돌리기 (사람만 가능) @@ -120,8 +113,9 @@ M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 -마음에 안 드는 것만 답해주면 됨 — **위 4번(0-Z)을 제외하면** 막고 있는 -항목은 없음(0-B `dispose` 시그니처는 M6 구현 세부만 막고 M0 착수는 안 막음). +마음에 안 드는 것만 답해주면 됨 — **[2026-08-13 열네 번째 세션 기준] +M0 착수를 막는 항목은 하나도 없음**(0-W `Ref` 이중 배치는 M4, 0-B +`dispose` 시그니처는 M6 구현 세부만 막음). --- Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio) diff --git a/ROADMAP.md b/ROADMAP.md index b9affb3..6a884ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,16 +8,16 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 자체는 시작 안 함.** 다음 세션은 바로 M0부터. -> **⚠️ M0 착수 자체가 한 결정으로 막혀 있음 — `.claude/question.md` -> **0-Z**(Attribute 이름 소유권). "M0 착수 전에 정할 것"으로 명시돼 -> 있음 — `HUMAN_TODO.md` 4번, `CLAUDE.md` "지금 할 일" 0번 항목 참고. -> 아래 M0 체크리스트 자체는 이 결정과 무관하게 유효하지만, 순서상 이게 -> 먼저.** +> **✅ [2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음.** +> `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강 +> diff, 둘 다 14차 세션) 확정·반영 완료 — `.claude/question.md`의 최우선 +> 칸이 비었습니다. > -> **[2026-08-13 열세 번째 세션] 같이 막고 있던 `0-Y`(`:Compute(fn)` lazy -> 핸들 계약)는 해소됨** — 계약은 그대로 유지로 확정, 남은 건 Luau의 현 -> 한계라 quad가 지금 할 수 있는 게 없음. **다만 구현 시 지켜야 할 규약이 -> 하나 생겼으니 M0 착수 전에 반드시 읽을 것: `.claude/base/typing-limits.md`** +> **다만 M0 착수 전에 반드시 읽을 구현 규약 두 개**: +> `.claude/base/typing-limits.md`(0-Y의 산물 — 파생 State마다 결과 타입을 +> 명시 주석으로 바인딩)와 `.claude/base/dispatch-core-plan.md`(0-A/0-Z의 +> 산물 — 하강 diff 재디스패치, 3-인자 `retractFrom`, Handler 작성 +> 체크리스트 8개, `HANDLER_PRIORITY_FALLBACK`, 주입되는 엔진 op). > (특히 "파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"과 > 7번 체크리스트). @@ -76,23 +76,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M2 — 디스패치 엔진 -> **⚠️ 착수 전 필독 — 아래 체크리스트는 *현행* 모델(래핑 핸들러가 재-dispatch -> 전에 `Dispatch.retractFrom`을 선행 호출)을 기준으로 쓰여 있고, 그 모델은 -> 교체가 예정돼 있습니다.** 새 모델("하강 diff": 선행 `retractFrom`을 폐기하고 -> `Dispatch.process`가 **핸들러를 먼저 비교** — 같으면 그 자리 클로저에 새 -> 값을 넘기고 자기 `process` 재호출, 다르면 그 자리부터 전량 철거)은 -> `.claude/research/dispatch-redispatch-diff-plan.md`에 있고, `.claude/question.md` -> **0-Z**(Attribute 이름 소유권) 하나만 정해지면 base와 이 문서에 한 번에 -> 반영됩니다. **0-Z가 미해결인 채로 M2/M4/M10을 구현하면 곧 갈아엎어야 하는 -> 코드를 짜게 됩니다** — 먼저 0-Z를 해소할 것. (base 4개 문서에도 같은 취지의 -> ⚠️ 배너가 달려 있는데, ROADMAP에만 없어서 2026-08-13 감사에서 지적됨.) +> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래 +> 체크리스트는 새 모델("하강 diff") 기준으로 갱신됐습니다.** 래핑 핸들러의 +> 선행 `retractFrom`은 폐기됐고, `Dispatch.process`가 슬롯의 `handler`를 +> 먼저 비교해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` +> 재호출, 다르면 그 자리부터 전량 철거) 처리합니다. 정본은 +> `.claude/base/dispatch-core-plan.md`(같은 세션에 `bind-system-plan.md`에서 +> 분리 신설), 뒤집힌 옛 모델은 +> `.claude/archive/dispatch-hintvalue-model-reversed.md`. - [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)` - (오케스트레이터: 그 인덱스 점유 여부 체크 → getHandler → 매치된 - 핸들러의 `.process`를 불러 **그 반환값(retractor 클로저)을 - `chains`의 그 인덱스에 저장**) / `Dispatch.retractFrom(inst,k,index,v)` + (오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교** → + 같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`로 + 자리 교체, 다르면 `retractFrom` 후 새로 설치. 반환값이 `nil`이면 + 즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)` (아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 @@ -160,17 +159,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 - (2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" + (2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset" 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 - 호출, `base/bind-system-plan.md` "핸들러 계약" 절, 2026-08-08 세션 + 호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션 / **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process` 반환값으로 합쳐지며 대상만 바뀜**) - [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정, - `base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" 절) — - `HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW` 등 목적별 우선순위 상수, + `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절) — + `HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW`/**`_FALLBACK`**(base 제공 + 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 자기 핸들러를 + 등록하면 언제나 이김, 2026-08-13 열네 번째 세션 신설) 등 목적별 상수, 매치 실패(`isHandlable`을 만족하는 핸들러 없음)는 `Brand`+`typeof(v)` 출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider 미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md` @@ -180,25 +181,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) -- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]=retractor}}}` — - **핸들러 배열이 아니라 재귀 깊이 인덱스→retractor 클로저 맵**) + - `Dispatch.retractFrom(inst,k,index,v)` — 재귀 재-dispatch(StoreBind/ - NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 세 번째 - 세션 신설, **2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계** — - `base/bind-system-plan.md` "Dispatch 체인" 절, - `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). - `Dispatch.process(inst,k,v,index)`가 반환 클로저를 그 인덱스에 - 저장하는 것도 이 항목에 포함. **구현 시 반드시 지킬 것**: +- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]={handler, retractor}}}}` + — **재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)**) + + **3-인자** `Dispatch.retractFrom(inst,k,index)` — 재귀 재-dispatch + (StoreBind/NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 + 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → **같은 날 열네 번째 세션 + 하강 diff로 전면 교체**, `base/dispatch-core-plan.md` "Dispatch 체인" + 절, `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). + **구현 시 반드시 지킬 것**: + - **재디스패치는 하강 diff** — 래핑 핸들러는 선행 `retractFrom`을 + 부르지 않고 그냥 `Dispatch.process(inst,k,realv,index+1)`. 비교는 + `Dispatch.process` 안에서: 슬롯의 `handler`가 같으면 그 자리 + 클로저에 새 값을 넘긴 뒤 같은 핸들러의 `process`로 자리 교체, + 다르면 `retractFrom(inst,k,index)` 후 새로 설치 - `chains:SetStrong(inst,k,list)`는 `handler.process` 호출 **전에** — 뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써 하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그) - - `handler.process` 호출 전에 그 인덱스에 no-op 점유 마커를 박아 - `list`를 구멍 없는 시퀀스로 유지(hole 있는 테이블의 `#`는 Lua가 - 보장 안 함) + 같은 index 재진입도 가드에 걸리게 - - 점유 체크는 `getHandler`/`handler.process`보다 **먼저**(핸들러 - 부작용 낭비 없음) + - 새 자리를 여는 (B) 분기에선 `handler.process` 호출 전에 no-op + 점유 마커를 박아 `list`를 구멍 없는 시퀀스로 유지(hole 있는 + 테이블의 `#`는 Lua가 보장 안 함) + - `process`가 `nil`을 반환하면 (A)/(B) 양쪽에서 즉시 error - 다른 키로 위임할 땐 항상 `index=1`, 같은 키 재귀는 `index+1`; `Dispatch.drive`의 진입도 항상 `1` + - **소유권 충돌 감지는 Dispatch의 일이 아님**(옛 점유 error 폐지) — + 필요한 도메인이 직접(Attribute 이름 claim, M10) - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -255,14 +261,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M4 — 첫 end-to-end 반응형 업데이트 -> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 재디스패치 모델이 교체 예정, -> `question.md` 0-Z 먼저 해소할 것. +> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료** — 아래 +> 항목은 `base/dispatch-core-plan.md`의 하강 diff 기준으로 읽을 것. -- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch - 전 `Dispatch.retractFrom(inst,k,index+1,realv)` 호출 필수, 그 다음 - `Dispatch.process(inst,k,realv,index+1)`. `base/bind-system-plan.md` - "Dispatch 체인" 절) +- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — **선행 + `retractFrom` 없이** `Dispatch.process(inst,k,realv,index+1)` 한 줄 + (2026-08-13 열네 번째 세션 하강 diff), 반환 클로저는 자기 Observer + 구독만 해제. `base/dispatch-core-plan.md` "Dispatch 체인" 절) - [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + "이전 값이 다른 타입이면 이전 `process`가 반환했던 retractor 클로저가 정확히 불린다" 확인 + **`State>`(값이 또 State/Source)가 @@ -281,10 +287,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M6 — Slot -> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 아래 "`SlotHandler.process`는 claim -> 실패 시에도 파괴적 클로저를 반환해야 함(`retractFrom`은... 항상 소비)" -> 항목은 현행(교체 예정) 재-dispatch 모델을 전제로 쓰여 있음. -> `question.md` 0-Z 먼저 해소할 것. +> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료** — 아래 +> "`SlotHandler.process`는 claim 실패 시에도 파괴적 클로저를 반환해야 함" +> 항목은 새 모델에서도 그대로 유효함(체인은 클로저를 early-return +> 여부와 무관하게 항상 소비 — `base/dispatch-core-plan.md` "Handler 작성 +> 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 +> 계약으로 보장된다는 점만 새로 추가됨. - [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]** - **`State` 교체 = 파괴가 아니라 언마운트**(`state`와 동일). @@ -402,7 +410,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **`Slot.Offset: Source`도 `Slot.Length`처럼 공개 필드로 노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는 바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션, - `base/bind-system-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절) + `base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절) - [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) @@ -504,9 +512,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널 - 자체는 확정 완료지만, **⚠️ M2 배너와 같은 주의**: `NoneHandler`가 - 쓰는 재-dispatch 배관(선행 `retractFrom` 호출)은 재디스패치 모델 - 교체 대상이라 `question.md` 0-Z 먼저 해소할 것 + 자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`가 + 쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** — + 그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄 + (`base/dispatch-core-plan.md`) - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` @@ -556,9 +565,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M10 — Event / OnChange / Attribute / Tag -> **⚠️ M2의 ⚠️ 배너와 같은 주의** — 재디스패치 모델이 교체 예정이고, -> 특히 **Attribute 이름 소유권(`question.md` 0-Z)이 이 마일스톤의 직접 -> 대상**임. 0-Z 먼저 해소할 것. +> **✅ [2026-08-13 열네 번째 세션] 0-Z(Attribute 이름 소유권)/0-A(하강 +> diff) 확정 완료** — 이 마일스톤의 Tag/Attribute 항목은 **quad-base로 +> 재배치**됐고(엔진 op `addTag`/`removeTag`/`setAttribute`만 주입), +> 이름 소유권은 그룹 전용 키 + `AttributeKeyHandler`의 이름 claim이 +> 판정함. 정본은 `base/attribute-plan.md`/`base/tag-plan.md`. - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) @@ -567,33 +578,47 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시)) -- [ ] `Handlers/AttributeKey.luau`(단일 키 `AttributeKey<>(name)`/ - `BooleanAttribute`류 DI 키 팩토리+Handler — 메커니즘/`None`/`retract` - 불필요 확정, 이름별 weak 캐시로 동등성 보장, 타입 파라미터화 이름만 - 착수 전 확인, `base/attribute-plan.md`) +- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau` — + 주입되는 엔진 op 3개**: `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" 절) +- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<>(name)` + + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 + `String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리 + (`Color3Attribute`류)만 quad-roblox의 `D`/`DI` 층에서 각자 추가. + 타입 파라미터화 이름만 착수 전 확인, `base/attribute-plan.md`) +- [ ] `quad-base/Dispatch/AttributeKey.luau`(`AttributeKeyHandler` — + `setAttribute(inst,name,v)`를 `v`가 뭐든 무조건 호출 + **이름 + claim**(`nameClaims` Relate, 다른 키 객체가 같은 이름에 들어오면 + 즉시 error, 반환 클로저는 자기 claim만 반납하고 엔진 부작용 없음). + `HANDLER_PRIORITY_FALLBACK`으로 등록. `question.md` 0-Z 결정 — + `base/attribute-plan.md` "이름 소유권" 절) - [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1, - store2, ...)`/`Merged`, `Tag`와 동형 array-part 값 객체, + store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형 array-part 값 객체, `base/attribute-plan.md`) -- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 `process`가 이름마다 - 공개 `AttributeKey(name)`로 `Dispatch.process(inst,key,source,1)`만 - 부르고, 반환 클로저가 **자기가 등록한 이름 전부**에 - `Dispatch.retractFrom(inst,key,1,nil)`. 실제 `SetAttribute`/store-bind - 구독은 전부 단일 키 경로 재사용(중복 구현 없음). - **`process` 안에서 `retractFrom`을 먼저 부르면 안 됨** — 인덱스 1이 - 무조건 비워져 소유권 충돌 점유 체크가 무력화됨(2026-08-13 감사), - `base/attribute-plan.md` "메커니즘" 절) +- [ ] `quad-base/Dispatch/Attribute.luau`(`AttributeGroupHandler` — 이름마다 + **그룹 전용 키**(비공개 `GetKey`, 그룹 값 객체별·이름별 메모이즈)로 + `Dispatch.process(inst,key,source,1)`만 부르고, 반환 클로저가 자기가 + 등록한 키 전부에 `Dispatch.retractFrom(inst,key,1)`. + **`process` 안에서 `retractFrom`을 먼저 부르면 안 됨**(철거는 전적으로 + 클로저 몫). 실제 `setAttribute`/store-bind 구독/이름 claim은 전부 단일 + 키 경로 재사용 — `base/attribute-plan.md` "메커니즘" 절) - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ - `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` - — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 - 모델은 `archive/tag-hash-key-model-reversed.md`) -- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` 글루만, - `isHandlable`은 `isTag(v)`. **`AddTag`는 온전히 `process`, `RemoveTag`는 - 온전히 반환 클로저** — 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준 - 참조 카운트)이 비었을 때만 실제 `RemoveTag`, 그마저도 `hintValue`가 - 그 이름을 `Contains`하면 skip해 깜빡임 방지(전체 삭제 후 재생성 - 금지). `process` 쪽 별도 diff 없음, `kTagMap`도 불필요(클로저가 `v`를 - 직접 캡처) — 2026-08-12 열한 번째 / 2026-08-13 네·다섯 번째 세션, - `base/tag-plan.md` "메커니즘" 절) + `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`, + `base/tag-plan.md` — 2026-08-08 세 번째 세션 array-part 값 객체로 + 재설계, 구 해시 파트 모델은 `archive/tag-hash-key-model-reversed.md`) +- [ ] `quad-base/Dispatch/Tag.luau`(`TagHandler` — `isHandlable`은 `isTag(v)`. + **`addTag`는 온전히 `process`, `removeTag`는 온전히 반환 클로저** — + 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준 참조 카운트)이 비었을 + 때만 실제 `removeTag`, 그마저도 클로저가 받은 새 값이 그 이름을 + `Contains`하면 skip해 깜빡임 방지. 제거할 이름은 모아서 **한 번에** + `removeTag(inst, names)`. `process` 쪽 별도 diff 없음, `kTagMap`도 + 불필요(클로저가 `v`를 직접 캡처). `HANDLER_PRIORITY_FALLBACK`으로 + 등록 — 2026-08-12 열한 번째 / 2026-08-13 네·다섯·열네 번째 세션, + `base/tag-plan.md`) ## M11 — Tween