design(dispatch): 사각지대 손 트레이싱 라운드 + Dispatch 인덱스 기반 전면 재설계

네 번째 세션: State<State<T>> 발견 방식을 서브에이전트로 코퍼스 전체에
반복해 Tag 참조 카운트(객체 identity 키잉)/Attribute 그룹 자기충돌 버그
발견·수정. Attribute 소유권 충돌 방지를 위해 처음엔 Dispatch.processAs/
retractSelfAndUnder 체크포인트 핸들러 패턴을 신설.

다섯 번째 세션: 체크포인트 패턴이 임시방편이라는 지적에서 출발해, chains의
핸들러 객체 identity 기반 추적 자체가 State<State<T>>를 UB로 만든 근본
원인이었음을 재확인 — 재귀 깊이 인덱스로 재설계(같은 키 재귀는 index+1,
다른 키 위임은 항상 1부터), Handler 계약을 process/retract 2-메소드에서
process가 자기 retract 클로저를 반환하는 1-메소드로 축소. 이걸로
State<State<T>>가 UB에서 정상 지원 대상으로 바뀌고, 전날 만든 체크포인트
패턴 전체가 불필요해져 archive로 이전. 여러 핸들러(StoreBind/Ref/Tag/Slot/
Attribute)의 private Relate 상태 저장소도 대거 정리됨.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132hkscPQWa34iyHrHdoRiF
This commit is contained in:
qwreey 2026-08-13 14:24:48 +09:00
parent 35c66d4cd0
commit c33ae041b0
Signed by: qwreey
GPG key ID: D28DB79297A214BD
12 changed files with 933 additions and 529 deletions

View file

@ -31,17 +31,17 @@
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `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` | | `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<State<T>>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정 | | `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<State<T>>`(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<State<T>>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적 |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | | `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<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`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<UD>(item, index: number, offset: Source<number>, 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<Instance>`), `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<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `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 생존) | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`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<UD>(item, index: number, offset: Source<number>, 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<Instance>`), `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<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `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`이 이미 `SlotHandler.retract`에 있는 것과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process``retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/bind-system-plan.md` "Dispatch 체인" 절 참고 |
| `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 우선순위를 쓰는 이유) | | `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` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `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` 포인터로 압축 | | `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)과 함께 개발. 메커니즘+이름 확정 | | `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와의 관계 해소 완료 | | `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` 아님) | | `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 유지 | | `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<T> "Name"]`(구 `Attribute<T>`) — `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 가드" 버전은 이걸로 폐기 | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `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` |
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 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`가 실제 사례이자 수정 사례 | | `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`가 실제 사례이자 수정 사례 |
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
@ -87,6 +87,7 @@
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) | | `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) |
| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 |
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
## 참고 ## 참고

View file

@ -0,0 +1,95 @@
# [역전됨] `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴
**상태**: 역전됨 — 2026-08-13 네 번째 세션에 신설, 같은 날 다섯 번째
세션에 `chains`를 핸들러 객체 identity가 아니라 **인덱스**로 추적하는
더 근본적인 재설계로 대체되며 통째로 불필요해짐. 원문(신설 당시
`base/bind-system-plan.md`에 있던 그대로) + 역전 이유를 여기 보존.
## 신설 배경 (네 번째 세션)
`base/attribute-plan.md`의 그룹(`Attribute(...)`)이 여러 이름을 기존
단일 키 `AttributeKey` 경로에 재귀 위임하면서, 예전엔 `rawNew(name)`
그룹 전용 키 객체를 만들고 `owners`라는 별도 `Relate` 레지스트리로
"누가 이 이름을 관리 중인가"를 수동 추적했음 — 이 방식이 "그룹이 이름을
놓았다 나중에 같은 그룹이 다시 그 이름을 포함하면 자기 자신과 충돌"하는
실제 버그로 이어져(소유권 반납이 `process``v==nil` 분기에만 있어서,
그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 키가 영원히 남음),
전면 재설계됨.
## 원문 (신설 당시 pseudocode 그대로)
```lua
function Dispatch.processAs(inst, k, v, handler)
-- getHandler 스캔을 건너뛰고 handler를 직접 지정 — isHandlable이
-- 없는(스캔 자체에 안 걸리는) 체크포인트 핸들러 전용 진입점.
-- push 직전 중복 검사는 Dispatch.process와 완전히 동일(같은 재진입 가드).
local list = chains:GetStrong(inst, k) or {}
for _, existing in list do
if existing == handler then
error("Dispatch: handler already active for this (inst,k) — re-entrant processAs")
end
end
table.insert(list, handler)
chains:SetStrong(inst, k, list)
handler.process(inst, k, v)
end
function Dispatch.retractSelfAndUnder(inst, k, target, v)
-- retractUnder와 동일하되 target "이하"(자기 자신 포함)까지 지움 —
-- cutoff를 target의 인덱스가 아니라 그 한 칸 앞으로 잡는 것만 다름.
local list = chains:GetStrong(inst, k)
if not list then return end
local cutoff = 0
if target then
for i, h in list do if h == target then cutoff = i - 1 break end end
end
for i = #list, cutoff + 1, -1 do
list[i].retract(inst, k, if i == cutoff + 1 then v else nil)
list[i] = nil
end
end
```
**쓰는 쪽 패턴**: 위임하는 핸들러(`AttributeGroupHandler`)가 자기 전용
체크포인트 싱글톤(`AttributeGroupKeyHandler`, `isHandlable` 없음, `process`
가공 없이 `Dispatch.process(inst,k,v)`로 그대로 전달해 실제 매치는 정상
스캔에 맡김, `retract`는 no-op — 자기 자원이 없는 순수 마커)을
`Dispatch.processAs(inst,k,v,checkpoint)`로 체인 맨 위에 꽂고, 나중에 그
이름을 완전히 정리할 때 `Dispatch.retractSelfAndUnder(inst,k,checkpoint,v)`
한 번으로 체크포인트 자신을 포함해 그 아래 전부를 정리.
당시 장점으로 꼽았던 것: 소유권 충돌 감지가 기존 "같은 (inst,k)에 같은
핸들러 재사용 시 error" 가드(`State<State<T>>` 가드와 동일 메커니즘)에
공짜로 얹힘 — 별도 `owners` 레지스트리 불필요.
## 역전 이유 (다섯 번째 세션, 같은 날)
사용자가 근본 문제를 다시 짚음: `chains`가 핸들러 **객체 identity**로
포지션을 추적하는 것 자체가 `State<State<T>>`가 UB여야 했던 원인이었고
(같은 싱글톤이 재귀로 스스로와 매치되면 identity로 구분 불가), 체크포인트
패턴은 그 문제를 Attribute 한정으로 우회한 것일 뿐 근본 해법이 아니었음.
대신 `chains`를 명시적 **재귀 깊이 인덱스**로 추적하도록 바꾸면(같은 키
재귀는 `index+1`, 다른 키로 위임은 `index=1`부터) 두 가지가 동시에
풀림:
1. `State<State<T>>`가 UB에서 **정상 지원 대상**으로 바뀜 — 각 재귀
단계가 서로 다른 인덱스를 쓰므로 객체 identity 충돌 자체가 발생하지
않음(순환 참조만 여전히 UB로 남음, 기존 "순환은 UB" 원칙과 같은 급).
2. Attribute 그룹은 체크포인트 마커 없이 `Dispatch.process(inst,
AttributeKey(name), source, 1)`을 직접 부르면 됨 — "인덱스 1이 이미
점유돼 있는가"라는 occupancy 체크가 소유권 충돌 감지를 그대로
대신함, 별도 마커 객체·`processAs`·`retractSelfAndUnder` 전부 불필요.
`retractSelfAndUnder``retractUnder`와 딱 하나(cutoff를 target
포함이냐 미만이냐)만 다른 거의 중복 함수였다는 것도 별도로 지적됨 —
새 모델의 `Dispatch.retractFrom(inst,k,index,v)` 하나가 "자기 포함
철거"(`index` 그대로 넘김)와 "자기 아래만 철거"(`index+1` 넘김) 둘 다
호출자의 인덱스 선택만으로 표현하므로 두 함수로 쪼갤 이유 자체가
없어짐.
**교훈**: 기존 메커니즘 위에 새 진입점을 얹어 특정 사례(Attribute)를
푸는 것보다, 그 문제를 만든 더 근본적인 표현(객체 identity 기반 추적)을
먼저 의심하는 게 나을 때가 있음 — 오늘 하루 안에서 신설과 역전이 바로
이어진 사례. 최신 설계는 `base/bind-system-plan.md`의 "Dispatch 체인"
절 참고.

View file

@ -146,8 +146,8 @@ quad/
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) │ ├── 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`) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Dispatch/ │ ├── Dispatch/
│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션) │ │ ├── init.luau # process 엔진(반환값=retract 클로저), isHandlable 우선순위 스캔, `chains`(inst,k별 인덱스 배열)+`retractFrom`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계)
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
@ -166,8 +166,8 @@ quad/
│ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Event.luau # ReflectionService 기반 자동 판별
│ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── AttributeKey.luau # 단일 키(`AttributeKey<<T>>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절) │ ├── AttributeKey.luau # 단일 키(`AttributeKey<<T>>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절)
│ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process/retract — 이름 집합 diff만 자체 로직, 실제 `SetAttribute`/구독은 메모이즈된 `AttributeKey(name)``Dispatch.process`/`retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`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/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) │ ├── Tag.luau # CollectionService 글루만(process, 반환 클로저가 정리 담당) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`) ├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`)

View file

@ -3,7 +3,15 @@
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는 **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서 전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/ `retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-11 아홉 번째 세션 추가]** "그룹 `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 아홉 번째 세션 추가]**
Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)` Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
`Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은 `Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
@ -105,8 +113,8 @@ end
타입 파라미터화 이름과 무관하게 런타임 동작은 확정: 타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부, - `process(inst, k, v, index)` — `inst:SetAttribute(name, v)`가 사실상
**`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반 전부, **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None` 프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`
가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)` 가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`
"그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None → "그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None →
@ -114,28 +122,28 @@ end
도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name, 도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name,
nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을
수동으로 찾아 지우는" 로직조차 필요 없음. 수동으로 찾아 지우는" 로직조차 필요 없음.
- **`retract`는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번 - **반환하는 클로저는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번
불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라 불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라
"항상"** — 일반 프로퍼티 핸들러(`retract`가 완전 무조건 no-op, "항상"** — 일반 프로퍼티 핸들러(반환 클로저가 완전 무조건 no-op,
`bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와 `bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와
완전히 같은 성격으로 재정정. **`AttributeKeyHandler.retract`는 완전히 같은 성격으로 재정정. **`AttributeKeyHandler`가 반환하
`SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 유일한 클로저는 `SetAttribute`를 절대 호출하지 않음** — attribute를 지우는
경로는 `process(inst,k,nil)`(`None`이든, State가 스스로 `nil` 유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가 스스로
바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가 `nil` 바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가
`SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1) `SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1)
`retract` 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md` 이 클로저 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`
"retract는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는 "이 클로저는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는
성격의 코드가 됨, (2) 그룹이 survivor 이름에 `retractUnder(...,source)` 성격의 코드가 됨, (2) 그룹이 survivor 이름에 재위임할 때 그 시점에
부를 때 그 시점에 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 `a→nil→b` 깜빡임
`a→nil→b` 깜빡임 위험(사용자 지적) — `retract`가 완전 no-op이면 이 위험(사용자 지적) — 클로저가 완전 no-op이면 이 경로 자체가 물리적으로
경로 자체가 물리적으로 없어짐. 없어짐.
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>` - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
값도 받음). 값도 받음).
### 이름 소유권 — 그룹/직접 쓰기 충돌 방지, `rawNew`와 per-name 전용 키 (2026-08-12 열 번째 세션) ### 이름 소유권 — 그룹/직접 쓰기 충돌 방지 (2026-08-12 열 번째 세션, 2026-08-13 다섯 번째 세션 전면 재정정)
**문제**: `AttributeKey(name)`이 이름별 weak 캐시로 항상 같은 객체를 **문제**: `AttributeKey(name)`이 이름별 weak 캐시로 항상 같은 객체를
리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용( "메커니즘" 리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용(아래 "메커니즘"
절)하다 보니, **서로 다른 원래 위치(해시파트 직접 쓰기 `[AttributeKey 절)하다 보니, **서로 다른 원래 위치(해시파트 직접 쓰기 `[AttributeKey
"name"]=value` vs 배열파트 `Attribute(store)`, 또는 서로 다른 두 "name"]=value` vs 배열파트 `Attribute(store)`, 또는 서로 다른 두
`Attribute(...)` 그룹)가 같은 이름을 동시에 관리하려 하면 정확히 같은 `Attribute(...)` 그룹)가 같은 이름을 동시에 관리하려 하면 정확히 같은
@ -144,55 +152,48 @@ end
(같은 해시 키는 한 Modifier 안에 하나뿐), 그룹의 이름 집합은 런타임에 (같은 해시 키는 한 Modifier 안에 하나뿐), 그룹의 이름 집합은 런타임에
동적이라 이 해소망 밖에 있음. 동적이라 이 해소망 밖에 있음.
**해법 — 그룹은 공개 `AttributeKey(name)` 캐시를 안 쓰고, 이름당 자기 **[역사, 2026-08-13 세션 안에서 두 번 뒤집힘]** 첫 버전(`rawNew`로 그룹
전용 키 객체를 만들어 씀.** `AttributeKey`의 내부 구현을 캐시 조회 전용 키를 만들고 `owners` Relate로 이름별 소유권을 수동 추적)은 "그룹이
(`rawNew`가 없으면 만들어서 캐시)와 순수 객체 생성(`rawNew(name)`, 이름을 놓았다 나중에 같은 그룹이 그 이름을 다시 포함하면 자기 자신과
브랜드 태그/`Name` 필드는 있지만 캐시를 거치지 않는 raw 생성자)로 분리 — 충돌"하는 실제 버그가 있었음(소유권 반납이 `process``v==nil` 분기에만
공개 `AttributeKey(name)`은 지금처럼 캐시를 거치고, **그룹 Handler(roblox 있어서, 그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 소유권
글루)만 `rawNew`를 직접 써서 이름마다 자기만의 키 객체를 만듦.** 기록이 안 지워짐). 두 번째 버전(`AttributeGroupKeyHandler`라는 스캔
불가 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push, 소유권 충돌을
Dispatch의 재진입 가드에 얹어 감지)은 이 버그를 고치긴 했으나, 같은 날
다섯 번째 세션에 `Dispatch` 자체가 `chains`를 핸들러 identity가 아니라
**인덱스**로 추적하도록 재설계되며(`base/bind-system-plan.md` "Dispatch
체인" 절) 체크포인트가 하던 일 자체가 통째로 불필요해짐 — 원문·역전
이유는 `archive/checkpoint-handler-pattern-reversed.md`.
**최종(세 번째 버전) — 체크포인트도 `owners`도 없이, 항상 인덱스 1부터
직접 위임.** 그룹이 이름마다 공개 `AttributeKey(name)`으로 그냥
`Dispatch.process(inst, key, source, 1)`를 부르면 끝 — **"인덱스 1이 이미
점유돼 있는가"라는 `Dispatch.process` 자신의 점유 체크가 소유권 충돌
감지를 그대로 대신함**: 다른 그룹이나 직접 쓰기가 이미 그 이름을
점유했다면, 이 호출은(체크포인트를 거칠 필요도 없이) 곧바로 "이미
점유됨" error를 냄. `AttributeKeyHandler`도 다시 완전 무상태로 되돌아감:
```lua ```lua
-- AttributeKeyHandler(quad-roblox) 전용, (inst,name)별 현재 이 이름을 쓰는 키 객체 -- AttributeKeyHandler(quad-roblox) — 완전 무상태, 소유권 추적 없음
local owners = Relate() -- {[inst(weak)] = {[name]: keyObject}} function AttributeKeyHandler.process(inst, k, v, index)
inst:SetAttribute(k.Name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
function AttributeKeyHandler.process(inst, k, v) return function() end -- 지울 게 없음 — SetAttribute는 오직 process(inst,k,nil)로만
local name = k.Name
local map = owners:GetStrong(inst) or {}
local current = map[name]
if current ~= nil and current ~= k then
error(("attribute \"%s\"는 이미 다른 AttributeKey가 관리 중"):format(name))
end
inst:SetAttribute(name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
map[name] = if v == nil then nil else k -- nil로 귀결되면 소유권도 같이 반납
owners:SetStrong(inst, map)
end
function AttributeKeyHandler.retract(inst, k, v)
-- 완전 no-op. 일반 프로퍼티와 동일 — "unset" 개념 자체가 없음.
-- retract 안에서 process를 부르는 건 Dispatch.retractUnder의 체인
-- 추적을 꼬는 UB(base/bind-system-plan.md 일반 규칙)라 여기서도
-- SetAttribute를 직접이든 간접이든 절대 안 부름 — 지우는 건 오직
-- process(inst,k,nil).
end end
``` ```
- **직접 리터럴 쓰기**(`[AttributeKey<<T>> "name"] = value`)는 공개 - **직접 리터럴 쓰기**(`[AttributeKey<<T>> "name"] = value`)는 공개
`AttributeKey(name)`을 그대로 씀 — 한 Modifier 안에 같은 해시 키가 `AttributeKey(name)`을 그대로 씀, 정상 스캔으로 바로
중복될 수 없어 이 경로의 claimant는 항상 유일, 별도 캐싱 불필요. `AttributeKeyHandler`에 도달(인덱스 1) — 한 Modifier 안에 같은 해시
- **그룹**은 자기가 이미 갖고 있던 "(inst, 자기 배열 위치)별 마지막으로 키가 중복될 수 없어 이 경로 자체의 claimant는 항상 유일. 그룹이
쓴 attribute 상태" 릴레이션(위 "메커니즘" 절)의 저장 형태를 **이름 이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의
문자열 집합 → `{[name]: 그 이름 전용 키 객체}` 맵으로 확장**만 하면 됨 — `Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error —
새 릴레이션 불필요, 이미 있던 걸 재사용. 이름을 처음 보면 `rawNew(name)` 그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘.
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에 - **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.retractFrom`+
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로 `Dispatch.process` 페어로 위임 — 전용 키 객체도, 소유권 레지스트리도
재사용**해서 위임. 이 diff/재위임 로직의 정확한 코드는 아래 "그룹 필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1).
`Attribute(...)`" 절의 `AttributeGroupHandler.process`/`.retract` 참고 - **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리
`AttributeKeyHandler` 자신은 diff를 전혀 모름(위 "이름이 살아있는 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽
동안 항상 같은 키 재사용" 전제만 지켜지면 그만). `Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
base쪽 `Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설) ## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설)
@ -228,67 +229,57 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
Store 기각과 안 부딪히나" 참고. Store 기각과 안 부딪히나" 참고.
### 메커니즘 — per-name 전용 키로 기존 단일 키 경로에 재귀 위임 ### 메커니즘 — 항상 인덱스 1부터 기존 단일 키 경로에 직접 위임
**[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler, **[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler,
Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독" Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독"
이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유 이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유
자체가 없어짐 — 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을 자체가 없어짐 — 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을
새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로 새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로
재귀 위임** — `None`/`retract`/store-bind 전부 이미 확정된 단일 키 재귀 위임** — `None`/store-bind 전부 이미 확정된 단일 키 메커니즘을
메커니즘을 100% 재사용, 중복 구현 없음. **[정정, 2026-08-12 열 번째 100% 재사용, 중복 구현 없음. **[전면 재정정, 2026-08-13 다섯 번째 세션]
세션] 위임에 쓰는 키가 공개 `AttributeKey(name)`이 아니라 `rawNew(name)` `rawNew(name)` 그룹 전용 키 → `AttributeGroupKeyHandler` 체크포인트로
매번 그룹 전용으로 만드는 키로 바뀜** — 이유·정확한 소유권 판정 방식은 두 번 거쳐온 위임 메커니즘이, `Dispatch`의 인덱스 기반 재설계로 다시
위 "이름 소유권" 절 참고, 이 절은 그 위에서 diff 로직이 어떻게 도는지만 한번 단순화됨 — 이제 그냥 공개 `AttributeKey(name)`으로 인덱스 1에
설명: 직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/
checkpoint-handler-pattern-reversed.md`):
**그룹의 `process`/`retract` — [전면 재정정, 2026-08-12 세션 후속]** **그룹의 `process`** — `(inst, index)`(array-part 위치, `Tag`
아래는 `(inst, index)`(array-part 위치, `Tag``relate:GetStrong(inst,k)` `relate:GetStrong(inst,k)` 동일 키잉 — `k`는 배열 인덱스)에서 호출되고,
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이름 → 반환하는 클로저가 "지금 관리 중인 이름 집합"을 직접 캡처 — **별도 `Relate`
그 이름 전용 키 객체 맵"**(이름 존재 여부뿐 아니라 그때 쓴 키 객체 불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의
자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이 이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가
성립)을 씀: 없어짐):
```lua ```lua
local groupState = Relate() -- {[inst(weak)] = {[index]: {[name]: keyObject}}}
function AttributeGroupHandler.process(inst, index, v) function AttributeGroupHandler.process(inst, index, v)
if v == nil then return end local names = {}
local map = groupState:GetStrong(inst, index) or {} for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장
for name, source in pairs(v:NameMap()) do local key = AttributeKey(name) -- 공개 캐시 그대로 — 그룹 전용 키 불필요
local key = map[name] or rawNew(name) -- 남아있던 이름은 캐싱된 같은 객체 재사용 Dispatch.retractFrom(inst, key, 1, source) -- 그 이름 자리에 이미 뭔가 있으면 통째로 철거(신규는 no-op)
map[name] = key Dispatch.process(inst, key, source, 1) -- 항상 인덱스 1부터 새로 위임 — StoreBind/AttributeKeyHandler는 정상 스캔으로 쌓임
Dispatch.retractUnder(inst, key, nil, source) -- chain-append-leak 방지, 매번(신규는 빈 체인이라 no-op) names[name] = true
Dispatch.process(inst, key, source)
end end
groupState:SetStrong(inst, index, map) return function(hintValue)
end -- hintValue: 다음에 이 자리를 대체할 값(다른 Attribute, nil 등) — Tag/Ref와 같은 힌트 패턴
local newNames = if isAttribute(hintValue) then hintValue:NameMap() else {}
function AttributeGroupHandler.retract(inst, index, v) for name in pairs(names) do
local map = groupState:GetStrong(inst, index) if newNames[name] == nil then -- 더 이상 이 그룹이 안 쓰는 이름만
if not map then return end Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙)
local newNames = if isAttribute(v) then v:NameMap() else {} end
for name, key in pairs(map) do
if newNames[name] == nil then -- 새 v에 이제 없는 이름만
Dispatch.retractUnder(inst, key, nil, nil) -- 구독만 끊음 — SetAttribute는 안 일어남(아래 원칙)
map[name] = nil
end end
end end
end end
``` ```
- **`process`는 매번 살아있는 이름 전부를 `retractUnder`+`process` - **`process`는 매번 살아있는 이름 전부를 `retractFrom`+`process`
페어로 재위임** — 신규/생존 구분 없이 균일 처리. `Dispatch.process` 페어로 재위임** — 신규/생존 구분 없이 균일 처리(신규 이름은 아직 체인이
매번 체인 꼬리에 새 항목을 쌓기만 하지 스스로 옛 항목을 안 지우므로 없어 `retractFrom`이 그냥 no-op이라 이 페어링을 통일해도
(팝은 `retractUnder`의 일), `retractUnder` 없이 `Dispatch.process` 비용 없음). **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State
반복 호출하면 같은 키 자리에 옛 `AttributeKeyHandler` 항목이 계속 계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지",
쌓이는 누수가 생김 — 신규 이름은 아직 체인이 없어 `retractUnder` `store-semantics.md` "하드 경계" 절)과 어긋나고, `source`
그냥 no-op이라 이 페어링을 신규/생존 가리지 않고 통일해도 비용 없음. `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
**값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상 해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
"하드 경계" 절)과 어긋나고, `source``State`/`Source`면
`Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가
따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract``SetAttribute` - **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract``SetAttribute`
절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.** 절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.**
그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가 그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가
@ -314,23 +305,19 @@ end
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가 `Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가
문제). 그래서 `retract`는 사라진 이름에 한해 `Dispatch.retractUnder(inst, 문제). 그래서 반환하는 클로저는 사라진 이름에 한해
key, nil, nil)`만 부름 — **`Dispatch.process`는 절대 안 부르므로** `Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`만 부름 —
(retract 안에서 process 호출은 `retractUnder`의 체인 추적을 꼬는 UB, **`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을
`bind-system-plan.md` 일반 규칙) `AttributeKeyHandler.retract`(완전 트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙)
no-op)만 타고 끝나 `SetAttribute`는 여기서도 절대 안 일어남 — 위 그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`
"명시적 None으로만 지운다" 원칙과 안 부딪힘. 여기서도 절대 안 일어남 — 위 "명시적 None으로만 지운다" 원칙과 안
부딪힘.
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는 - **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린 안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로 단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로). `SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로).
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** — **`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎. 키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 영속되는
건 이미 확정돼 있던 설계.
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제 **그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키 `SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키

View file

@ -22,7 +22,12 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것) ## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것)
핸들러는 다음 4개를 제공하는 등록 가능한 객체: **[전면 재정정, 2026-08-13 다섯 번째 세션] `process`/`retract` 2-메소드
계약에서 `process`가 자기 retract 클로저를 반환하는 1-메소드 계약으로
전환.** 계기와 근거는 아래 "Dispatch 체인" 절 참고 — 이 절은 바뀐 최종
계약만 서술.
핸들러는 다음 3개를 제공하는 등록 가능한 객체:
- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value - `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value
조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게**
@ -37,24 +42,32 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른
백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러
계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 —
사용자 판단으로 확정. 사용자 판단으로 확정. **[2026-08-13 세션] 생략 불가, 항상 정의할
것** — 같은 날 네 번째 세션에서 한때 "생략하면 스캔 불가시 체크포인트
핸들러"로 확장했으나, 다섯 번째 세션(아래 "Dispatch 체인" 절)의 인덱스
기반 재설계로 그 용도(`AttributeGroupKeyHandler`류 마커) 자체가
없어져 이 확장도 같은 세션 안에서 신설·철회가 끝나 archive 이전 없이
이 한 줄로만 기록.
- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 - `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의
action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. action() 우선순위)보다 일반화된 **열린 숫자 공간**으로.
- `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델" - `process(inst, key, value, index): (hintValue) -> ()` — 실제 처리
절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할. 수행(아래 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고) 하고,
- `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절, **자기 자신이 방금 벌인 일을 무르는 1-인자 클로저를 반환**.
`base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는 v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할 + 예전의 `retract`
없음(예: 일반 프로퍼티 핸들러는 보통 no-op). 필드가 여기로 합쳐짐(**[전면 재정정, 2026-08-13 다섯 번째 세션]**,
**`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 계기·근거는 아래 "Dispatch 체인" 절). **반환값 생략 불가 — 정리할
세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** —
store 재발행마다(핸들러 *타입*이 안 바뀌어도) 이전 핸들러의 `retract` `Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가
nil 체크 없이 무조건 호출함(`[전면 정정, 2026-08-12 열한 번째 세션]`, 나중에 정확히 이 클로저 하나만 호출해서 정리하므로, 반환을
아래 "retract-always-fires" 정정 절 참고 — 담당 타입이 바뀔 때만 생략하면(`nil`을 돌려주면) 나중에 `attempt to call a nil value`
불린다는 건 예전의 틀린 가정이었음). 필드를 생략한 핸들러는 이 반복 크래시(예전 "retract 필드 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴).
호출에서 바로 `attempt to call a nil value`로 크래시 — "의미 있게 **핸들러가 직접 자기 자신의 하위 위임(재귀 `Dispatch.process`로 만든
구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도 것들)까지 클로저 안에서 다시 정리할 필요는 없음** — `Dispatch.
안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 retractFrom`의 순회 구조 자체가 항상 깊은 인덱스부터 먼저 정리하고
없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). 나서 얕은 인덱스로 올라오므로, 이 클로저가 불릴 시점엔 자기보다
아래(자기가 만들어낸 하위 위임)는 이미 전부 정리된 뒤임(아래
"Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접
자원**(Observer 구독 등)만 정리하면 됨.
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라
@ -98,10 +111,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의, `quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
라이브러리 자체에 내장된 개발자 편의 기능. 라이브러리 자체에 내장된 개발자 편의 기능.
## 확정된 디스패치 모델: `process(inst, k, v)` / `retract(inst, k, v)` ## 확정된 디스패치 모델: `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는 "인스턴스를 생성하고 - 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고
그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을
@ -115,12 +134,15 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 —
base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만
지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량.
- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, - `process(inst, k, v, index)` — 우선순위 순으로 등록된 핸들러를 스캔,
`isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당. `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당하고
**이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔 자기 retract 클로저를 반환. **이 "스캔+실행" 오케스트레이터는
부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 `Dispatch.process`로, 순수 스캔 부분은 `Dispatch.getHandler`로 이름이
절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이
그냥 `process`로 계속 씀. 절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. **`index`
뭔지·왜 필요한지는 아래 "Dispatch 체인" 절 참고** — 요약하면 같은
`(inst,k)` 안에서 "지금 몇 번째로 겹쳐 위임됐는지"를 나타내는 정수로,
핸들러 객체 identity 대신 이 숫자로 체인 위치를 추적함.
- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 - 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고
`v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** —
`v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서:
@ -129,113 +151,111 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로
그 시점엔 그냥 `Connected`를 보고 무시(no-op). 그 시점엔 그냥 `Connected`를 보고 무시(no-op).
2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산.
3. **재귀 호출 전에 먼저 `Dispatch.retractUnder(inst, k, self, realv)`를 3. **재귀 호출 전에 먼저 `Dispatch.retractFrom(inst, k, index + 1, realv)`를
불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고
`Dispatch.process(inst, k, realv)`를 재귀 호출**(정확한 메커니즘은 `Dispatch.process(inst, k, realv, index + 1)`를 재귀 호출**(정확한
아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 메커니즘은 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션에 처음
확정, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재정정 — 오케스트레이터
이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째
세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는
래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이
아니라면 자연히 `StoreBind``isHandlable`을 통과 못 하고 우선순위상 아니라면 자연히 `StoreBind``isHandlable`을 통과 못 하고 우선순위상
다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음.
`realv`가 또 State/Source(`State<State<T>>`)여도 이제 **자연스럽게
처리됨** — 안쪽 재귀는 `index+1`이라는 별개 슬롯을 쓰므로 바깥
StoreBind의 슬롯(`index`)과 절대 안 겹침(아래 "Dispatch 체인" 절의
`State<State<T>>` 재정정 참고, 예전엔 이게 UB였음).
**[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind
핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가
소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며(`research/ 소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며(`research/
tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이
자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이
패턴의 남은 대표 예시. 패턴의 남은 대표 예시.
- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 - **`process`가 반환하는 retractor(`(hintValue) -> ()`)** (이전 초안의
`base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "cleanup"/별도 `retract` 필드, 이름 변경 근거는 `base/lifecycle-pattern.md`
"같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — 참고, 별도 필드에서 반환값으로 합쳐진 경위는 위 "핸들러 계약" 절 —
인스턴스/바인드 전체가 Destroy될 때는 `retract`가 호출되지 않음(`base/ 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서
lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). 이전 처리를 갈아치우는" 시나리오에만 존재** — 인스턴스/바인드 전체가
Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의
"quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만
- **[전면 정정, 2026-08-12 열한 번째 세션] `retract`는 "핸들러 타입이 반환하면 됨.
바뀔 때만" 불리는 게 아니라, **store 바인드가 재발행될 때마다(값이 - **[정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에 계약
뭐로 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터 자체가 클로저로 바뀌며 서술 갱신] 이 클로저는 "핸들러 타입이 바뀔
때만" 불리는 게 아니라, store 바인드가 재발행될 때마다(값이 뭐로
바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터
말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건** 말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건**
`Dispatch.retractUnder(inst,k,self,realv)`를 부르고, `retractUnder` `Dispatch.retractFrom(inst,k,index+1,realv)`를 불러 자기 밑에 쌓인
`keep` 바로 다음 항목에게 그 `realv`를(그 다음 항목들에겐 `nil`을) 클로저들을 꼬리부터 전부 부른 뒤에야 `Dispatch.process`가 다시
넘기며 체인을 통째로 걷어낸 뒤에야 `Dispatch.process`가 다시 매치를 매치를 시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract
시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract 없이 process가 없이 process가 diff"라는 이전 서술은 틀렸음** — 그 오류의 상세
diff"라는 이전 서술은 틀렸음** — `Tag`의 옛 경위(`Tag`의 옛 `assert(v==nil)`을 액면 그대로 믿고 거꾸로 일반
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")` 규칙을 추론한 것)는 `archive/retract-always-fires-reversed.md` 참고,
액면 그대로 믿고 거꾸로 일반 규칙을 추론한 게 오류의 출처(2026-08-07 지금은 결론만 인용.
여덟 번째 세션 정정 당시엔 안 걸렸던 부분, `bind-system-plan.md` 자기
"확정된 디스패치 모델" 절과 실제로 모순돼 있었음). 이 오류는
`base/tag-plan.md`(원 출처)와, 이번 대화에서 그걸 그대로 이어받은
`Ref`/`Slot`/`Attribute` 세 곳 전부에 퍼져 있었음 — 전부 정정
완료(각 문서의 해당 절 참고). **[아카이브, `archive/
retract-always-fires-reversed.md`]**.
- **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이 - **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이
없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가 없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가
없음) `retract`가 사실상 no-op일 뿐, "타입이 안 바뀌면 retract가 아예 없음) 반환하는 클로저가 사실상 no-op일 뿐, "타입이 안 바뀌면 아예
안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러 안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러
위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를 위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를
공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는, 공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는,
`retract`가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`v`)과 이 클로저가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`hintValue`)과
사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 `v` 사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 힌트
힌트 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 — 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 —
`Tag``Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. **`v` `Tag``Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예.
반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 자체일 수 **`hintValue`를 반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값
있음) — 새 핸들러를 짤 때 `retract` 안에서 `v`의 타입을 방어적으로 그 자체일 수 있음) — 새 핸들러를 짤 때 이 클로저 안에서 `hintValue`
확인할 것(2026-08-12 열한 번째 세션, 실제로 `Tag(...)`/`Ref`/`Slot` 타입을 방어적으로 확인할 것.
설계 전부에서 이 확인이 빠져 있었음이 드러남).
- **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개 - **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개
"`retract`가 이전 기여를 걷어내고(실제 해제는 `v` 힌트로 skip "반환한 클로저가 이전 기여를 걷어내고(실제 해제는 힌트로 skip
가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 — 가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 —
`process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 `retract` `process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 클로저
이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute` 이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`
그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도 그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md` 패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot` "이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고. `slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
- **[일반 규칙, 2026-08-12 세션 후속] `retract``v`는 타입을 보장 안 - **[일반 규칙] 클로저의 `hintValue`는 타입을 보장 안 함 — 내용(메소드/
함 — 내용(메소드/필드)을 보려면 반드시 `isX(v)` 가드부터.** `retract` 필드)을 보려면 반드시 `isX(hintValue)` 가드부터.** identity/nil만
`old ~= v`/`v == nil`처럼 identity/nil만 비교하면 가드가 필요 없지만 비교하면 가드가 필요 없지만(`Ref`/`Slot`/`AttributeKey`가 이 경우),
(`Ref`/`Slot`/`AttributeKey`가 이 경우), `v`의 내용을 실제로 들여다봐야 내용을 실제로 들여다봐야 하면(`TagHandler`의 `newv:Contains(name)`처럼)
하면(`TagHandler.retract`의 `newv:Contains(name)`처럼) 그 전에 반드시 그 전에 반드시 `isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면
`isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 그 자리 핸들러 그 자리 핸들러 *타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를
*타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 호출해 크래시함. 호출해 크래시함.
(`AttributeGroupHandler.retract`가 이 가드를 처음엔 빠뜨렸던 실수, - **[일반 규칙] 이 클로저 안에서 `Dispatch.process`를 부르는 것은
`base/attribute-plan.md` 참고.) UB — `Dispatch.retractFrom`이 체인을 걷는 도중의 트래킹이 꼬임.**
- **[일반 규칙, 2026-08-12 세션 후속] `retract` 안에서 `Dispatch.process` 이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새
부르는 것은 UB — `Dispatch.retractUnder`가 체인을 걷는 도중의 트래킹이 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
꼬임.** `retract`는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹 순서로만 일어나야 함. `Dispatch.retractFrom`을 (다른 키에 대해)
로직이 `retract` 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는 이 클로저 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
순서로만 일어나야 함. `Dispatch.retractUnder`를 (다른 키에 대해) - **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할
`retract` 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`. 필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`
순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로
올라오므로 자동으로 해결됨(재귀/래핑 핸들러가 다단으로 겹쳐도
각 클로저는 자기 자신의 자원만 책임지면 전체 cascade가 저절로 됨 —
2026-08-08 세 번째 세션에 확정된 "다단 체인 자동 전파" 성질이 인덱스
모델에서도 그대로 유지, 오히려 더 단순해짐).
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가 - Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상 소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은 PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`,
`archive/tween-special-bind-key-reversed.md`). `archive/tween-special-bind-key-reversed.md`).
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 - **핸들러 내부 상태 저장 — 클로저로 충분한 것과 `Relate`가 필요한 것을
됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 구분할 것.** "이 `process` 호출이 만든 걸 나중에 정리하는" 단발성
재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 handoff는 이제 클로저의 업밸류 캡처만으로 충분(예: Observer 객체를
2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약). 로컬 변수로 만들고 그대로 반환 클로저가 캡처) — 예전처럼 `Relate`
- **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 저장했다가 나중에 다시 조회할 필요가 없어짐(**[2026-08-13 다섯 번째
Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 세션, 이 문단 재작성]**). `Relate`가 여전히 필요한 경우는 **여러 번의
weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 독립적인 `process`/클로저 호출을 가로질러 누적되는 상태**뿐 —
멈추거나 끝냄). **[정정, 2026-08-08 세션] `base.perInstanceState(inst)`라는 `Tag``tagNameMap`(여러 위치가 같은 이름을 공유), `Attribute`
이름/모양은 폐기 — `base/relate-plan.md``Relate` 프리미티브로 구체화됨.** 이름 소유권처럼 "이 `(inst,k)` 하나의 클로저 수명을 넘어서는" 정보만
각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 하나 두고 `local relate = Relate()`(모듈 톱레벨, `relate:SetStrong(inst,k,v)`/
`relate:SetStrong(inst, k, tween)`/`relate:GetStrong(inst, k)`로 저장/조회 — `:GetStrong(inst,k)`)로 저장. `base/lifecycle-pattern.md`
"모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는 `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가
그대로, `Relate`가 그 공유 유틸의 정식 인터페이스. `base/lifecycle-pattern.md` 다르니 별도 `Relate()` 인스턴스) — 이건 "언제까지 실행돼도 되는지"를
`bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 다르니 묻는 것이라 애초에 클로저 수명과 무관한, 계속 남는 질문이라 그대로
별도 `Relate()` 인스턴스). **왜 GC-안전한가(2026-08-07 여섯 번째 세션, `Relate` 기반.
명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽
릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로
사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 —
별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween
인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]**
Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부
로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil`
3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/
tween-plan.md` "3-상태 저장" 절 참고.
- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot
핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를
갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨.
@ -278,10 +298,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의
`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** `StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.**
``` ```lua
NoneHandler.priority = <매우 높음> NoneHandler.priority = <매우 높음>
NoneHandler.isHandlable(inst, k, v) = (v == None) NoneHandler.isHandlable(inst, k, v) = (v == None)
NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 function NoneHandler.process(inst, k, v, index)
Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스
return function() end -- 자기 자신은 아무 상태도 없어 no-op
end
``` ```
- **매치 predicate는 `isHandlable`**`canExecute`가 아님. 둘은 완전히 - **매치 predicate는 `isHandlable`**`canExecute`가 아님. 둘은 완전히
@ -312,14 +335,16 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
명시적으로 분리: 명시적으로 분리:
- `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+
`priority`), 부작용 없음. `priority`), 부작용 없음.
- `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → - `Dispatch.process(inst,k,v,index)` — 오케스트레이터: 그 인덱스가 이미
매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process` 점유돼 있으면 즉시 error(핸들러 호출 전에 걸러짐) → 아니면
호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` `getHandler` 호출 → 매치된 핸들러의 `.process`를 불러 그 반환값
자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/ (retractor 클로저)을 `(inst,k)` 체인의 그 인덱스에 저장. **"이전
`NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst, 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` 자신의 일이
k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한 아님** — 재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch
메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자 전에 스스로 `Dispatch.retractFrom(inst, k, index + 1, newV)`를 먼저
슬롯 하나로 diff하는 안은 래핑 핸들러에서 깨져서 기각됨). 불러 자기 밑을 정리하는 책임을 짐(정확한 메커니즘·기각된 대안은
아래 "Dispatch 체인" 절 참고 — 전역 소유자 슬롯 하나로 diff하는
안은 래핑 핸들러에서 깨져서 기각됨).
- `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에
등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만
있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/
@ -352,18 +377,19 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 —
PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야
함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
- **`retract`는 여기서 할 일이 없음** — **[정정, 2026-08-12 열한 번째 - **반환하는 retractor는 여기서 할 일이 없음**`NoneHandler``v==None`
세션]** `retract`는 store 재발행마다 항상 불리지만(위 "확정된 디스패치 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`
모델"/일반 retract 계약 절 정정분 참고), `NoneHandler``v==None`
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil)`
부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등
전혀 안 씀) `retract`가 불려도 정리할 게 없음 — 일반 프로퍼티 핸들러가 전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티
`retract`를 no-op으로 두는 것과 같은 이유. 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에
- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가" 쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위
bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로 "핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음.
매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch - **[해소됨, 2026-08-08 세 번째 세션, 2026-08-13 다섯 번째 세션에
체인" 절의 `chains`/`Dispatch.retractUnder`로 구체화됨 — `NoneHandler` 인덱스 기반으로 재정정]** "이 키를 지금 누가 담당 중인가" bookkeeping —
재귀 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 `pre-implementation-audit.md` 우선순위1 "이전에 실제로 매치됐던 핸들러
추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch 체인" 절의
`chains`/`Dispatch.retractFrom`로 구체화됨 — `NoneHandler`의 재귀
재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매
사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요).
### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) ### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션)
@ -383,8 +409,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐. 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐.
- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 - **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두
가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약**
(`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf, (`isHandlable`/`priority`/`process`(반환값 포함) 시그니처만 있는 순수
Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 leaf, Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을
**구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존
방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau`
(`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`
@ -419,151 +445,142 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스
테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.
### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) ### Dispatch 체인 — 인덱스 기반 재추적, `Dispatch.retractFrom` (2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 전면 재설계)
**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 **[전면 재설계, 2026-08-13 다섯 번째 세션]** 이 절은 원래 `chains`
`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 핸들러 **객체 identity**로 추적했으나(옛 버전은 `archive/
`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 checkpoint-handler-pattern-reversed.md`에 그 위에 얹혔던 체크포인트
깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, 패턴과 함께 보존), 그 표현 자체가 `State<State<T>>`를 UB로 만든
A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 두고 서로 근본 원인이었음이 드러나 **재귀 깊이를 나타내는 정수 인덱스**로
덮어씀**. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가 추적하도록 다시 설계됨. 계기: `AttributeGroupHandler`의 소유권 충돌
스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그 버그를 고치려고 체크포인트 핸들러 패턴을 얹었다가, 사용자가 "그 문제도
슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게 결국 identity 기반 추적이 원인 아니냐"고 되짚으면서 인덱스 기반으로
retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음 가면 체크포인트 없이도 같은 문제가 풀리고 `State<State<T>>` UB 자체도
검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시 없앨 수 있다는 게 같은 세션 안에서 확인됨.
대화에서 직접 반례로 확인).
**해법 — Dispatch가 `(inst,k)`별 핸들러 체인(순서 있는 배열)을 소유**: **문제(원래 동기, 여전히 유효)**: `NoneHandler`/`StoreBind`처럼 자기
`process` 안에서 `Dispatch.process(inst,k,realv)`를 다시 부르는 래핑
핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯
하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의
Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을
두고 서로 덮어씀**. 처음 검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은
이 이유로 기각됨.
**해법 — Dispatch가 `(inst,k)`별로 인덱스 배열을 소유, 각 슬롯엔 그
`process` 호출이 반환한 retractor 클로저만 저장**:
```lua ```lua
-- Dispatch/init.luau -- Dispatch/init.luau
local chains = Relate() -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}} local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = retractor}(strong)}}
function Dispatch.process(inst, k, v) function Dispatch.process(inst, k, v, index)
local h = Dispatch.getHandler(inst, k, v) local list = chains:GetStrong(inst, k) or {}
if h then if list[index] ~= nil then
local list = chains:GetStrong(inst, k) or {} error("Dispatch: (inst,k)의 이 인덱스는 이미 점유돼 있음 — 먼저 retract할 것")
for _, existing in list do
if existing == h then
error("Dispatch: handler already active for this (inst,k) — re-entrant dispatch (e.g. State<State<T>>) is not supported")
end
end
table.insert(list, h) -- 항상 꼬리에 추가
chains:SetStrong(inst, k, list)
h.process(inst, k, v)
end end
local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error
list[index] = h.process(inst, k, v, index) -- 반환값 = retractor 클로저, 생략 불가
chains:SetStrong(inst, k, list)
end end
function Dispatch.retractUnder(inst, k, keep, v) function Dispatch.retractFrom(inst, k, index, v)
-- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리.
local list = chains:GetStrong(inst, k) local list = chains:GetStrong(inst, k)
if not list then return end if not list then return end
local cutoff = 0 for i = #list, index, -1 do
if keep then local retractor = list[i]
for i, h in list do if h == keep then cutoff = i break end end if retractor then
end retractor(if i == index then v else nil)
for i = #list, cutoff + 1, -1 do end
list[i].retract(inst, k, if i == cutoff + 1 then v else nil)
list[i] = nil list[i] = nil
end end
end end
``` ```
**[2026-08-12 세션에서 정정, 같은 세션 후속으로 예외 조항까지 폐기]** - **점유 체크는 핸들러를 부르기 전에, Dispatch가 직접 함** — 매치된
`list[i].retract(...)`의 세 번째 인자가 원래 `i == cutoff + 1 and v or 핸들러의 `.process`가 실제 부작용(`SetAttribute`, Observer 구독 등)을
nil`(and/or 삼항 관용구)이었으나, `v``false`일 때(정당한 boolean 일으키기 *전에* 걸러야, "일단 실행해보고 나중에 되돌리는" 낭비/깜빡임이
프로퍼티 값) `and`의 결과가 falsy가 되어 `i == cutoff + 1`이 참이어도 없음. 충돌이 나면 핸들러 코드는 한 줄도 안 돔. **에러 메시지가
`or nil`로 새는 조용한 버그였음 — Luau의 `if-then-else` 표현식(2021년 도메인 특화(예: "attribute 이름 foo 충돌")로 상세하지 않은 건 의도된
도입)으로 교체. **일반 규칙(강화): `cond and x or y` 삼항 관용구는 트레이드오프** — 에러가 나는 순간 이미 처리는 패닉 상태이고 그 이후의
전면 금지, `if cond then x else y`만 쓸 것** — 처음엔 "가운데 값이 정합성은 애초에 관리 대상이 아니므로(2026-08-04 "일반적 무한루프 방어
테이블/항상-truthy일 때만 예외적으로 and/or 허용"이었으나, 안전 여부와 안 함"과 같은 결), 상세한 설명을 위해 먼저 실행해보는 비용을 들일
무관하게 `if-then-else`가 항상 우월하다는 게 재확인돼 예외 자체를 이유가 없음. 필요하면 호출부(`AttributeGroupHandler` 등)가 이 에러를
없앰: `and`/`or`는 진짜 short-circuit이라 각 단계마다 truthiness를 잡아 도메인 언어로 다시 던지는 건 자유(강제 안 함).
테스트하는 명령이 들어가는데(최대 2회 분기), `if-then-else``cond` - **인덱스의 의미 — 재귀 깊이, 서로 다른 키는 항상 1부터**: 같은 키에서
하나만 테스트하고 단일 분기로 끝남 — 안전한 경우에도 `if-then-else` 값이 한 겹 더 반응형으로 감싸져 재귀하면(`StoreBind`가 `realv`를 들고
쪽이 바이트코드상 더 적은 분기. `base/architecture.md`의 "코드 스타일 다시 `Dispatch.process`를 부르는 경우) `index+1`을 넘김. **다른
— Luau 문법 관례" 절도 같이 갱신. 키로 위임할 때는 그 키의 재귀 깊이와 무관하게 항상 `1`부터 시작** —
`chains[inst][key2]``chains[inst][key1]`과 완전히 별개의 배열이라
- **`handler.process(inst,k,v)``Dispatch.process`를 거치지 않고 직접 연속성이 필요 없음(예: `Attribute` 그룹이 `(inst,index)`에서
호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** `(inst,AttributeKey(name))`으로 위임할 때). **시작 인덱스는 0이 아니라
이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만 1** — Luau `ipairs`/`#`(배열 part 순회)는 1부터 연속된 정수 키를
있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도 전제하므로(quad 자신이 "props 순회 순서" 절에서 이 관례에 의존), 0을
체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이 쓰면 그 항목이 `ipairs` 순회에서 조용히 빠지고 `quad-debug`가 나중에
핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로 `chains`를 그대로 순회해서 보여주려는 계획과도 부딪힘.
체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/ - **`handler.process(inst,k,v,index)``Dispatch.process`를 거치지 않고
래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)` 직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할
불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨. 것.** 이유: 점유 체크·`chains` 저장 bookkeeping이 `Dispatch.process`
- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, 내부에만 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로
k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)` 활성화됐는데도 체인에 안 올라가 — 나중에 `retractFrom`이 이 핸들러의
부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) 존재를 몰라 정리가 영영 안 되거나(리소스 누수), 반대로 같은 인덱스를
바로 다음 항목만 실제 `newV`를 받고, 그보다 더 안쪽(다단 체인이 있을 다른 핸들러가 또 점유 시도해 정합성이 깨짐.
경우)은 `nil`을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지" - **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractFrom(inst,
정보가 없고 "완전히 사라진다"는 것만 사실이라서. k, index + 1, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV,
- **개별 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 index + 1)`를 부름** — "내 바로 아래부터 전부 정리하고 새로 위임".
쫓아가도 됨** — `retractUnder`가 꼬리부터 `keep` 앞까지 한 번의 자기 자신(`index`)은 그대로 살아있으므로 자기 자신의 retractor는 이
루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 시점에 안 불림 — 자기가 완전히 사라질 때(더 바깥의 `retractFrom`
핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨(질문 자기 인덱스까지 포함해서 부를 때)만 불림.
제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로 - **개별 핸들러의 retractor는 더 이상 자기 위임 대상을 수동으로 안
해소 — `retractUnder`의 루프 자체가 체인 전체를 훑으므로 각 핸들러가 쫓아가도 됨** — `retractFrom`이 꼬리(가장 깊은 인덱스)부터 목표
수동으로 cascade할 필요가 원천적으로 없음). 인덱스까지 한 번의 루프로 순서대로 정리해주므로, A→B→C처럼 몇 단계든
- **구멍 걱정 없음** — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택 각 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨. 자기
모양이라(`retractUnder`가 항상 꼬리부터 연속으로 지움), "촘촘하지 자신을 포함해서 지우고 싶으면(`Attribute` 그룹처럼 위임한 것 전체를
않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 `None` 통째로 걷어내고 싶은 경우) 호출자가 자기 자신의 인덱스를 그대로
소진 이슈)가 애초에 발생할 구조가 아님. 넘기면 되고, 자기 아래만 지우고 싶으면(`StoreBind`가 자기 구독은
- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화 유지한 채 하위만 갈아치우는 경우) `index+1`을 넘기면 됨 — **"미만"과
중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 "이하"를 별도 함수로 안 쪼개고 호출자가 넘기는 인덱스 하나로 통일**
있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 (옛 `retractUnder`/`retractSelfAndUnder` 두 함수가 이걸로 하나가 됨,
핸들러마다 다름 — **[정정, 2026-08-12 열한 번째 세션]** Tag는 오히려 `archive/checkpoint-handler-pattern-reversed.md` 참고).
반대로 `v`를 반드시 봐야 하는 대표 사례다: `retract`는 store - **`process`가 반환하는 retractor는 여전히 `hintValue` 1-인자** — 드롭하자는
재발행마다(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler.retract` 제안이 대화 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야
`v`가 여전히 그 이름을 `Contains`하는지 힌트로 확인해 실제 엔진 하는 핸들러가 있어서, `base/tag-plan.md` 참고). `Tag`는 오히려
`RemoveTag` 호출만 skip한다 — "더 이상 매치 안 될 때만 불리므로 무조건 이 힌트를 반드시 봐야 하는 대표 사례다: 이 클로저는 store 재발행마다
전체 삭제가 맞다"는 원 서술은 이 정정 전 잘못된 가정이었음, 상세는 (핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler`가 반환한
`base/tag-plan.md` "메커니즘" 절 참고. `v`는 "계약상 항상 주어지지만 클로저는 `hintValue`가 여전히 그 이름을 `Contains`하는지 확인해 실제
안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. 엔진 `RemoveTag` 호출만 skip한다 — 상세는 `base/tag-plan.md` "메커니즘"
**[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기 절 참고. `hintValue`는 "계약상 항상 주어지지만 안 쓰는 핸들러가
`Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더 있어도 됨" 정도로 이해할 것.
이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`base/
tween-plan.md`).
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 - **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜
나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가
키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을 나므로 애초에 일어날 수 없는 구조 — 값에 별도 플래그를 심어 의도적으로
만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, 순환을 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖,
2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과
같은 결로 UB 취급. 같은 결로 UB 취급.
- **[2026-08-13 세션 발견·수정] "각 핸들러는 최대 한 번씩만 그 키에서 - **`State<State<T>>`는 더 이상 UB가 아님 — 정상 지원 대상으로 재정정
호출됨" 전제는 순환뿐 아니라 `State<State<T>>`(store가 emit하는 값 (2026-08-13 다섯 번째 세션).** 원래(같은 날 두 번째 세션) `store.key
자체가 또 State/Source인 경우)에서도 깨짐 — 단 순환과 달리 = a`(State), `a:Get() = b`(State)일 때 같은 `StoreBind` 싱글톤이 같은
스택오버플로로 걸러지지 않고 **조용히 체인을 파손시키는 실제 버그로 `(inst,k)`에 identity로 두 번 매치돼 `retractUnder`의 cutoff 계산이
재현됨**. `store.key = a`(State), `a:Get() = b`(State)일 때: (1) 안쪽 자신을 잘못 retract하는 실제 버그(체인 파손, 구독이 등록 직후
`Dispatch.process(inst,k,a)`가 StoreBind를 매치해 `list={SB}` 생성, 스스로 끊김)로 재현돼 "같은 (inst,k)에 같은 핸들러 객체가 이미 있으면
`Observer` 즉시 1회 실행이 `Dispatch.process(inst,k,b)`로 재귀; (2) `b` 즉시 error" 가드로 막았었음(당시 고정, `archive/
State라 StoreBind가 **같은 핸들러 객체로 또 매치**돼 `list={SB,SB}` checkpoint-handler-pattern-reversed.md`가 인용하는 옛 코드 참고). 이
되고, 안쪽 Observer가 `relate:SetStrong(inst,k,·)`로 바깥 Observer의 버그의 근본 원인은 "핸들러당 최대 한 번씩만 그 키에서 호출됨"을
참조를 덮어써 바깥 것이 `bindLifetime`엔 살아있는데 `relate`에서 못 객체 identity로 강제하려 한 것 — 인덱스 기반으로 바꾸면 `a`를 처리하는
찾는 유령 구독으로 새고; (3) 안쪽 Observer의 즉시 실행이 부르는 StoreBind는 인덱스 N, `a:Get()`(=`b`)을 처리하는(같은 싱글톤이지만)
`retractUnder(inst,k,SB,·)`는 위 457행 루프가 **첫 번째**로 매치되는 StoreBind는 인덱스 N+1을 써서 **애초에 슬롯이 안 겹침** — 같은 핸들러
`SB`(바깥 것의 인덱스)를 `cutoff`로 잡아버려, 그 다음 인덱스(안쪽 객체가 같은 키에서 여러 번 매치되는 것 자체가 문제가 아니게 됨.
자기 자신)를 대신 retract — **안쪽 State의 구독이 등록 직후 자기 임의 깊이의 `State<State<State<...>>>`도 그냥 인덱스가 계속 늘어날
자신에 의해 끊겨 이후 `b:Set(...)`이 조용히 무시됨.** Attribute의 뿐 정상 동작 — 유일하게 남는 UB는 위 "순환" 항목(값이 결국 순환 참조를
`rawNew(name)` 위임은 별개의 `(inst, key)` 체인으로 옮겨가는 것뿐이라 이뤄 무한히 깊어지는 경우)뿐.
이 문제를 원천적으로 피하지 못함(위임된 값 자체가 `State<State<T>>`이면
`AttributeKey` 체인 안에서 동일하게 재현). **고정**: 위 `Dispatch.process`
pseudocode에 "같은 `(inst,k)`에 이미 같은 핸들러 객체가 push돼 있으면
즉시 error" 가드를 추가 — 순환과 같은 전제(핸들러당 최대 1회)를 지키는
가장 저비용 지점이 여기(push 직전)라서. `State<State<T>>`가 정당한 값
모양인지 자체는 별도 논의(아래 "Store가 Store를 저장 가능한가" 참고) —
이 가드는 그 논의와 무관하게 체인 파손을 조용히 넘기지 않게만 함.
- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 - **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에
중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰
미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV); 미래의 재바인드는 `Dispatch.retractFrom(inst, k, 1, newV);
Dispatch.process(inst, k, newV)` 두 줄로 "이 키의 체인을 통째로 갈아 Dispatch.process(inst, k, newV, 1)` 두 줄로 "이 키의 체인을 통째로 갈아
끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 `Relate`에 위임 끼우기"가 자연스럽게 됨. `research/debug-tooling-plan.md`의 "무엇이
대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후 무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 —
기각). `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가" quad-debug 착수 시점에 새로 설계할 필요 없음.
그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에
새로 설계할 필요 없음.
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) ### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
@ -833,16 +850,19 @@ parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을
재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음." 재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음."
**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 **확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도
다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 다른 핸들러와 동일한 `isHandlable`/`priority`/`process`(반환값 포함) 계약을
따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새
value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. value)로 `Dispatch.process(inst,k,realv,index+1)`를 재귀 호출"하는 식으로
이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해 동일한 구현됨. 이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해
재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와
직결. **[2026-08-13 세션 정정]** 단 "심지어 다른 store"는 낙관적으로 직결. **[2026-08-13 세션, 두 차례 정정]** 이 "동일한 재귀적 디스패치로
틀린 서술이었음 — 실제로 값이 또 State/Source면(`State<State<T>>`) 같은 처리 가능"은 처음엔 값이 또 State/Source면(`State<State<T>>`) 같은
핸들러가 같은 `(inst,k)`에 두 번 push되어 체인이 파손됨(위 "확정된 핸들러가 같은 `(inst,k)`에 identity로 두 번 push돼 체인이 파손되는 실제
디스패치 모델" 절의 2026-08-13 발견 항목 참고), 이제 `Dispatch.process` 버그로 낙관적으로 틀린 서술임이 드러났었으나(같은 날 두 번째 세션), 같은
중복 핸들러 가드가 이 경우 error로 막음. 날 다섯 번째 세션에 `chains`를 핸들러 identity가 아니라 재귀 깊이
인덱스로 추적하도록 재설계되며 **다시 맞는 서술로 돌아옴**`realv`
또 State면 `index+1`이라는 별개 슬롯을 쓰므로 identity 충돌 자체가 없어짐
(위 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고).
**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 **"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로
확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만
@ -851,13 +871,21 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구
구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: 구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음:
```lua ```lua
-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) -- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state, index)
local observer = state:Observer(function() function StoreBind.process(inst, k, state, index)
Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 local observer = state:Observer(function()
Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) local realv = state:Get()
end) Dispatch.retractFrom(inst, k, index + 1, realv) -- 내 바로 아래부터 정리
bindLifetime(inst, observer) Dispatch.process(inst, k, realv, index + 1) -- 새로 위임(체인에 push)
relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함 end)
bindLifetime(inst, observer)
return function()
-- 자기 자신의 자원(Observer 구독)만 정리 — observer는 위 클로저가
-- upvalue로 이미 캡처하고 있어 별도 Relate 저장/조회가 필요 없음
-- (2026-08-13 다섯 번째 세션, 계약이 클로저 반환으로 바뀌며 단순화됨).
unbindLifetime(inst, observer)
end
end
``` ```
**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 **[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가
@ -872,13 +900,17 @@ relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르
바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, 바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라,
`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). `:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임).
- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임 - **반환하는 클로저가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 —
대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractFrom`이 자기
밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이
핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면 클로저는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위
끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고
비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 계약만 구현).
계약만 구현). **[2026-08-13 다섯 번째 세션] 별도 `Relate`가 더 이상 필요 없음** —
`observer``process` 안의 로컬 변수를 반환 클로저가 upvalue로 그대로
캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가
나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위
"핸들러 계약"/"핸들러 내부 상태 저장" 절 참고).
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가
이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md` 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`
`canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서
@ -887,8 +919,6 @@ relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르
- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 - Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은
코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회
적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로).
- `relate``base/relate-plan.md``Relate` 인스턴스 — 이 핸들러 모듈
톱레벨에 `local relate = Relate()`로 하나 두고 재사용.
Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가
필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과
@ -909,13 +939,17 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의
보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. 보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음.
**[2026-08-13 세션, 스코프 명확화]** 이 절은 "Store *필드*가 Store/State를 **[2026-08-13 세션, 스코프 명확화, 같은 날 다섯 번째 세션에 결론 갱신]**
담는가"(예: `store.a = otherStore`) 얘기이고, "State가 *emit하는 값*이 이 절은 "Store *필드*가 Store/State를 담는가"(예: `store.a = otherStore`)
State/Source인가"(`State<State<T>>`, 예: `store.key`에 대입된 값 자체가 얘기이고, "State가 *emit하는 값*이 State/Source인가"(`State<State<T>>`,
State)는 다른 축 — 후자는 위 "확정된 디스패치 모델" 절에서 실제 체인 예: `store.key`에 대입된 값 자체가 State)는 다른 축. 이 절의 "별도로
파손 버그로 확인됨. 이 절의 "별도로 신경 쓰지 않음"은 전자에만 해당하고, 신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State<State<T>>`)는
후자는 이제 `Dispatch.process`가 명시적으로 error하도록 막혀 있어 "신경 한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error
안 씀 = 조용히 UB"가 아니라 "신경 안 씀 = 즉시 실패"로 취급됨. 하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반
재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상**(위
"Dispatch 체인" 절의 `State<State<T>>` 재정정 참고) — "신경 안 씀"의
의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로
다시 한번 바뀜.
## Ref — 도입 확정, 단 용도는 재정의됨 ## Ref — 도입 확정, 단 용도는 재정의됨
@ -1126,45 +1160,52 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는 `refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. 조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
**메커니즘 — `retract`가 매번 불린다는 전제 위에서 언바인딩 전담 **메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담
(2026-08-12 열한 번째 세션 정정).** `Dispatch.retractUnder`는 store 값이 (2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저
바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 `retract(inst,k, 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치
refB)`가 먼저 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB)` 모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 `process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA`
따로 계산할 필요가 없어짐(그 일을 `retract`가 매번 정확히 대신 해줌): 언바인딩하고, 그 다음 `process(inst,k,refB,index)``refB`를 바인딩하는
두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할
필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔
여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지
skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데,
그건 인자로 안 들어오고(클로저의 `hintValue`는 다음 값이지 이전 값이
아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러
내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례):
```lua ```lua
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
-- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가)
RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v)
function RefLeafHandler.process(inst, k, v) function RefLeafHandler.process(inst, k, v, index)
local old = relate:GetStrong(inst, k) local old = relate:GetStrong(inst, k)
if old == v then return end -- 이미 같은 Ref가 이 자리를 차지 중(retract가 if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
-- 방금 손 안 댄 경우) — 콜백 재통지 없이 no-op v:Set(inst)
v:Set(inst) end
relate:SetStrong(inst, k, v) relate:SetStrong(inst, k, v)
end return function(hintValue)
-- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는
function RefLeafHandler.retract(inst, k, v) -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
local old = relate:GetStrong(inst, k) if hintValue ~= v then
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
-- 어느 쪽이든 old와 다르면 old는 확실히 이 자리를 잃음 -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
old:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — end
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
relate:SetStrong(inst, k, nil)
end end
-- old == v(같은 Ref 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
end end
``` ```
- **`retract`가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff - **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
로직이 없음. `old == v`(같은 Ref 객체가 스스로 재발행된 spurious한 로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된
경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함. spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안
불리게 함.
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 - **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
`retract`가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) 이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
아예 안 불리`relate:GetStrong(inst,k)``nil`이라 `process`가 바로 아예 `relate:GetStrong(inst,k)``nil`이라 `process`가 바로
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 `v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
구현으로 자연히 커버됨, 케이스 분기 불필요. 구현으로 자연히 커버됨, 케이스 분기 불필요.
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 - **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
@ -1364,7 +1405,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
(2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로 (2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로
교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상 교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상
우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 — 우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 —
`Dispatch.retractUnder`가 그 체인을 대상으로 동작함. `PreRef`는 애초에 `Dispatch.retractFrom`이 그 체인을 대상으로 동작함. `PreRef`는 애초에
그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로 그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로
소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) — 소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) —
그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로 그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로

View file

@ -24,7 +24,7 @@ Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. **팩토리
### 1. 런타임 pluggable 핸들러 아님 — 정적 merge ### 1. 런타임 pluggable 핸들러 아님 — 정적 merge
Modifier는 `isHandlable`/`priority`/`process`/`retract` 핸들러 레지스트리에 Modifier는 `isHandlable`/`priority`/`process` 핸들러 레지스트리에
안 들어감. 그냥 평범한 테이블(데이터)을 보유하는 값이고, 디스패치 들어가기 안 들어감. 그냥 평범한 테이블(데이터)을 보유하는 값이고, 디스패치 들어가기
전에 한 번 평탄화(flatten)돼서 최종 props 테이블에 합쳐짐. 이유: 런타임 전에 한 번 평탄화(flatten)돼서 최종 props 테이블에 합쳐짐. 이유: 런타임
pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 다투는 CSS pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 다투는 CSS

View file

@ -211,15 +211,16 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 > 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. > 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
**[전면 정정, 2026-08-12 열한 번째 세션] 위 "핸들러 타입이 안 바뀌면 **[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에
retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는 클로저 반환 계약으로 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이
`Dispatch.retractUnder`가 store 재발행마다(핸들러가 그대로여도) **항상** process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
먼저 불림(`base/bind-system-plan.md` "확정된 디스패치 모델"/일반 retract `Dispatch.retractFrom`이 store 재발행마다(핸들러가 그대로여도) **항상**
계약 절, 2026-08-12 열한 번째 세션 전면 정정 참고). 그래서 `State<Slot>` 이전 `process`가 반환한 클로저를 먼저 부름(`base/bind-system-plan.md`
`slotA→slotB`로 바뀔 때도 **`retract(inst,k,slotB)`가 먼저 불려 `slotA` "확정된 디스패치 모델"/일반 retract 계약 절 참고). 그래서 `State<Slot>`
폐기하고, 그 다음 `process(inst,k,slotB)``slotB`를 마운트**하는 두 `slotA→slotB`로 바뀔 때도 **`slotA`를 처리했던 클로저가 `hintValue=slotB`
단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것 먼저 불려 `slotA`를 폐기하고, 그 다음 `process(inst,k,slotB,index)`
마운트" 전담: `slotB`를 마운트**하는 두 단계로 자연히 갈림 — 클로저가 "이전 것 정리",
`process`가 "새 것 마운트" 전담:
**[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로 **[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로
간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접 간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접
@ -260,7 +261,6 @@ tables" 항목).** 즉 이 회피는 "혹시 몰라서"가 아니라 **Luau에
nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합: nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합:
```lua ```lua
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용 local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용
-- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak -- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak
local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후 local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
@ -280,28 +280,43 @@ local function claimOwner(element, ownerKey)
end end
local function releaseOwner(element, ownerKey) local function releaseOwner(element, ownerKey)
if elementOwner:GetWeak(element, OWNER) == ownerKey then -- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claimOwner가 항상
elementOwner:SetWeak(element, OWNER, nil) -- 먼저 성공해야만 이 element를 이 ownerKey가 들고 있을 수 있으므로, 여기서
-- 불일치가 관측되는 것 자체가 호출측(rawRemove/rawExtract/SlotHandler.process가 반환한 클로저)의
-- 소유권 bookkeeping이 어딘가 깨졌다는 뜻 — Dispatch의 "매치 실패는 조용한
-- 무시 없이 즉시 error" 원칙과 같은 결로 즉시 error.
local current = elementOwner:GetWeak(element, OWNER)
if current ~= ownerKey then
error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음")
end end
elementOwner:SetWeak(element, OWNER, nil)
end end
function SlotHandler.process(inst, k, slotValue) function SlotHandler.process(inst, k, slotValue, index)
if not claimOwner(slotValue, inst) then if claimOwner(slotValue, inst) then
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op -- [정정, 2026-08-13 세션] bindLifetime을 attachSlot 밖, 여기(top-level Handler)로
-- 이동 — 반환하는 클로저 쪽 unbindLifetime과 같은 층위(Handler)로 대칭. 중첩
-- Slot은 여전히 anchor 불필요(자신을 담는 outer의 `_elements`(plain strong
-- array)로 이미 transitively 살아있음 — elementOwner는 전부 weak라 별도 anchor
-- 아님) — 그 구분은 이제 "attachSlot을 top-level에서 부르는 이 자리에서만
-- bindLifetime한다"는 호출부 자체의 위치로 표현됨, attachSlot 내부 분기가 아님.
bindLifetime(inst, slotValue)
attachSlot(slotValue, inst, inst, k)
end end
attachSlot(slotValue, inst, inst, k) -- top-level이라 내부에서 bindLifetime(inst, slotValue) 호출(위 "재귀 메커니즘" 절) -- claimOwner가 false여도(이미 같은 Slot이 이 자리를 차지 중인 spurious 재발행)
kSlotMap:SetWeak(inst, k, slotValue) -- 아래 클로저는 항상 똑같이 올바르게 동작함 — attachSlot을 두 번 안 부르는
end -- 것만 위에서 갈렸을 뿐, "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 어느
-- 쪽 process 호출이 반환하든 slotValue/inst가 같아서 동일함(2026-08-13 다섯
function SlotHandler.retract(inst, k, v) -- 번째 세션 — 이래서 별도 `kSlotMap`이 더 이상 필요 없음: 나중에 이 인덱스가
local old = kSlotMap:GetWeak(inst, k) -- 통째로 철거될 때 실제로 불리는 건 항상 *가장 최근* process 호출이 반환한
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음 -- 클로저인데, 그게 어느 호출에서 왔든 캡처된 slotValue/inst는 늘 참값이라
destroySlotTree(old) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용) -- "누가 진짜로 attach했는지"를 별도로 기억해둘 필요 자체가 없음).
unbindLifetime(inst, old) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝 return function(hintValue)
releaseOwner(old, inst) if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op
kSlotMap:SetWeak(inst, k, nil) destroySlotTree(slotValue) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
unbindLifetime(inst, slotValue) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝
releaseOwner(slotValue, inst)
end end
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
end end
``` ```
@ -324,8 +339,8 @@ Dispatch 마운트(`SlotHandler.process`)만 `slotOwner`를 봤고, `Add`가
**해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를 **해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를
Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의 Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`/`.retract`)과 소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`와 그 반환
nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은 클로저)과 nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은
`Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시 `Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시
봄: 봄:
@ -1219,21 +1234,15 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
```lua ```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position) local function attachSlot(slot, physicalTarget, ownerKey, position)
-- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용
-- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제
-- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적
-- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract
-- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
-- 안 하고 Handler.retract에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
-- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
slot._mounted = true slot._mounted = true
slot._mountedInst = physicalTarget slot._mountedInst = physicalTarget
if ownerKey == physicalTarget then
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] bindLifetime은 최상위(물리
-- inst에 직접 연동하는 말단)에서만 — 중첩 Slot은 자신을 담는 outer의
-- `_elements`(plain strong array)로 이미 transitively 살아있어서
-- (elementOwner는 전부 weak라 별도 anchor 아님, 위 "요소 소유권 —
-- `elementOwner`" 절 참고) 여기서 또 anchor할 이유가 없음 — State의 노드 연결처럼
-- 중간 노드는 구조로만 연결되고, 말단만 실제 엔진 생명주기에 건다는
-- 원칙과 같은 결. 매 레벨 anchor하면 매 레벨 파괴 시 짝을 맞춰
-- unbindLifetime해야 하는 부담만 늘어남(destroySlotTree 참고).
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] 최상위
-- Slot의 GC 앵커 — 이게 빠져있으면 아무도
-- 강하게 안 붙잡아 조기 GC될 수 있음
end
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로 Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
local offsetSource = Source(0) local offsetSource = Source(0)
@ -1303,8 +1312,8 @@ local function destroySlotTree(slot)
end end
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은 -- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도 -- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
-- 최상위 파괴 지점(SlotHandler.retract, 아래)에서만 한 번. destroySlotTree는 -- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
-- 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당. -- destroySlotTree는 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
end end
-- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이 -- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이

View file

@ -92,8 +92,9 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립
결정하는 기준으로 쓸 수 있음. 결정하는 기준으로 쓸 수 있음.
**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 **세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째
세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process`/`retract` 세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 —
4종 계약, `base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 `process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정,
`base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는
"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 "quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가
없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼
`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 `Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은

View file

@ -109,83 +109,107 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단 clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단
값**(Tween과 같은 결) — 그래서 `State<Tag>`가 진짜로 다른 내용을 내놓을 값**(Tween과 같은 결) — 그래서 `State<Tag>`가 진짜로 다른 내용을 내놓을
때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로 때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로
"어떤 `Tag` 객체들이 지금 이 이름을 걸고 있는가"를 집합으로 추적하면 **"지금 이 이름을 걸고 있는 위치(`k`)가 몇 개인가"를 집합으로 추적**하면
`retract`(이전 객체가 이 이름을 놓음)/`process`(새 객체가 이 이름을 걺)가 `retract`(이전 위치가 이 이름을 놓음)/`process`(그 위치가 새 이름을 걺)가
겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨: 겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨:
**[정정, 2026-08-13 네 번째 세션] holders는 `Tag` 객체가 아니라 위치(`k`)로
키잉함.** 최초안은 `holders[Tag객체] = true`(객체 identity 기준)이었으나,
`Tag`는 immutable이라 **재사용이 자연스러운 관례**(예: `local SELECTED =
Tag("selected")`를 모듈 상수로 만들어 여러 위치에서 재사용)인데, 객체
identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에 걸었을 때
`tagNameMap`엔 **단일 엔트리**만 생겨 두 위치가 구분이 안 됨 — `k1`
`retract`돼도 그 하나뿐인 엔트리가 지워져 `holders`가 비고, `k2`가 여전히
그 이름을 쓰고 있는데도 `RemoveTag`가 불려버리는 실제 참조 카운트 버그로
손 트레이싱에서 재현됨(하단 "여러 위치가 같은 이름을 겹쳐 가지는 경우"
절이 암묵적으로 "서로 다른 객체"만 가정하고 있었던 게 원인). "여러 위치가
같은 이름을 겹칠 수 있다"는 이 절의 원래 취지 자체가 **위치 기준
집합**이어야 성립하므로, 홀더를 `k`로 바꾸는 게 원 의도와도 더 맞음:
**[정정, 2026-08-13 다섯 번째 세션] `process`가 자기 retract 클로저를
반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히
불필요해짐** — `retract`가 필요로 했던 "이 위치에 걸려 있던 Tag가
뭐였는가"는 이제 그 `process` 호출이 반환하는 클로저가 `v`를 upvalue로
직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위
`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는
클로저로 충분). **`tagNameMap`(이름별 현재 걸고 있는 위치 집합)은 여전히
필요** — 이건 서로 다른 여러 위치를 가로지르는, `process`/클로저 하나의
호출 수명을 넘어서는 누적 상태라 `Relate`가 맞는 경우:
```lua ```lua
local kTagMap = Relate() -- {[inst(weak)] = {[k]: Tag}} — 위치별 마지막으로 반영한 Tag local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — 이름별 현재 걸고 있는 위치들
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[Tag]: true}}} — 이름별 현재 걸고 있는 Tag들
TagHandler.priority = <일반> TagHandler.priority = <일반>
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
function TagHandler.retract(inst, k, newv) function TagHandler.process(inst, k, v, index)
local oldv = kTagMap:GetStrong(inst, k)
if not oldv then return end
local newvIsTag = isTag(newv) -- newv는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
for name in oldv:Names() do
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
holders[oldv] = nil
if next(holders) == nil and not (newvIsTag and newv:Contains(name)) then
inst:RemoveTag(name) -- 곧 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지)
end
end
end
function TagHandler.process(inst, k, v)
for name in v:Names() do for name in v:Names() do
local holders = tagNameMap:GetStrong(inst, name) local holders = tagNameMap:GetStrong(inst, name)
if not holders then if not holders then
holders = {} -- strong map — Tag가 살아있는 동안 소유 목록도 살아있어야 함 holders = {} -- strong map — 이 이름을 거는 위치가 하나라도 있는 동안 소유 목록도 살아있어야 함
tagNameMap:SetStrong(inst, name, holders) tagNameMap:SetStrong(inst, name, holders)
end end
if next(holders) == nil then if next(holders) == nil then
inst:AddTag(name) inst:AddTag(name)
end end
holders[v] = true holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관)
end
return function(hintValue)
if v == hintValue then return end -- Tag는 immutable이라 객체가 안 바뀌면
-- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화
local hintIsTag = isTag(hintValue) -- hintValue는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
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(깜빡임 방지)
end
end
end end
kTagMap:SetStrong(inst, k, v)
end end
``` ```
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 `retract`** — 서로 - **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 반환하는 클로저**
겹치는 diff 계산이 없음. `retract`가 이전 `Tag`(`oldv`)가 걸었던 이름 — 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이
전부를 소유 목록에서 빼되(항상 실행), 그 결과 목록이 비었을 때 **실제 캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과
`RemoveTag` 호출만** "새로 들어올 `newv`가 그 이름을 여전히 Contains하는가"로 목록이 비었을 때 **실제 `RemoveTag` 호출만** "새로 들어올 `hintValue`
힌트를 줘서 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히 그 이름을 여전히 Contains하는가"로 힌트를 줘서 skip — 소유 목록 자체는
`oldv`를 빼고 `v`를 넣는 두 단계), 이름이 살아남는 경우에도 stale 항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을
레퍼런스가 안 남음. `process``v`가 새로 거는 이름 전부를 무조건 넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음.
등록(소유 목록이 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new `process``v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이
diff가 전혀 필요 없음(그 일을 `retract`가 매번 정확히 해줌). 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new diff가 전혀
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `retract(inst,k,B)`가 먼저 필요 없음(그 일을 클로저가 매번 정확히 해줌).
불려 `A`가 걸었던 이름 중 `B`에 없는 것만 실제로 `RemoveTag`, 남은 건 - **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `A`를 처리했던 `process`
힌트로 skip — 그 다음 `process(inst,k,B)``B`의 이름 전부를 등록(이미 반환한 클로저가 `hintValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B`
걸려있던 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 없는 것만 실제로 `RemoveTag`, 남은 건 힌트로 skip — 그 다음
등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만 `process(inst,k,B,index)``B`의 이름 전부를 등록(이미 걸려있던
이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록).
결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
- **`Tag(A)→nil`**: `retract(inst,k,nil)`만 불림(값이 `Tag`가 아니게 돼 - **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`
`process`는 매치 자체가 안 됨) — `newvIsTag=false`라 힌트가 항상 아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가
거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`(다른 항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`
위치가 그 이름을 계속 쓰고 있지 않다면). (다른 위치가 그 이름을 계속 쓰고 있지 않다면).
- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`): - **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`):
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/`retract`를 타지만, 두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/자기 클로저를
`tagNameMap["a"]`는 **양쪽 위치`Tag` 객체를 모두 담는 하나의 공유 타지만, `tagNameMap["a"]`는 **양쪽 위치(`k`)를 모두 담는 하나의 공유
집합** — 한쪽이 "a"를 잃어도 다른 쪽 객체가 집합에 남아있으면 실제 집합** — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제
`RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히 `RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히
나옴. 나옴. **위치 기준이므로 두 위치가 물리적으로 같은 `Tag` 객체를
- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — 재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정"
`Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 절에서 객체 identity 기준을 버린 이유.**
자기 자원(위 두 릴레이션)만 정리하면 됨. 상세 메커니즘은 - **클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨**
`Dispatch.retractFrom`이 체인 전체를 알아서 훑어주므로 TagHandler는
자기 자원(위 `tagNameMap` 하나)만 정리하면 됨. 상세 메커니즘은
`bind-system-plan.md` "Dispatch 체인" 절. `bind-system-plan.md` "Dispatch 체인" 절.
## 패키지 배치 — base는 값+API, roblox는 process/retract 글루 ## 패키지 배치 — base는 값+API, roblox는 process 글루
**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/ **Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/
`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히 `:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히
같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출 같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출
(`TagHandler.process`/`retract`)만 quad-roblox 소속 — 이미 확정된 "base는 (`TagHandler.process` 및 그 반환 클로저)만 quad-roblox 소속 — 이미
인터페이스/값, backend는 process·retract 글루" 패턴(`LifetimeHandle`, 확정된 "base는 인터페이스/값, backend는 process 글루" 패턴(`LifetimeHandle`,
`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한 `Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한
것뿐, 새 아키텍처 개념 아님. 것뿐, 새 아키텍처 개념 아님.

View file

@ -0,0 +1,94 @@
# 2026-08-13 네 번째 세션 — 사각지대 손 트레이싱 라운드, `Dispatch.processAs`/`retractSelfAndUnder` 체크포인트 핸들러 신설
## 배경
직전 세션(02)에서 `State<State<T>>`가 UB인데 "가능하다"로 문서에 낙관적으로
적혀 있던 문제를 발견·수정한 것을 계기로, 사용자가 같은 방식(합성
시나리오를 pseudocode에 손으로 대입)으로 다른 사각지대가 더 있는지
찾아달라고 요청. 서브에이전트 4개(Tag/Attribute/Slot/Ref+교차훑기)를
병렬로 띄워 `.claude/base/` 전체를 훑음.
## 1부 — 서브에이전트 발견 (실제 버그 3건)
1. **Tag 참조 카운트 붕괴**: `tagNameMap`의 holders가 `Tag` 객체 identity로
키잉돼 있어서, 같은 Tag 객체(immutable이라 재사용이 흔한 관례,
`local SELECTED = Tag("selected")`류)를 두 위치에 걸면 한 위치만
retract돼도 다른 위치가 쓰는 태그가 지워지는 버그.
2. **Attribute 그룹 자기충돌**: 그룹이 이름을 놓았다(retract, 로컬 캐시
초기화) 나중에 같은 그룹이 같은 이름을 다시 포함하면(`rawNew`로 새
키 생성), 전역 `owners` 레지스트리엔 옛 키가 안 지워진 채 남아있어
"이미 다른 AttributeKey가 관리 중" 에러 — 그룹이 자기 자신과 충돌.
3. **Slot `rawAdd`의 이중 State 언랩 실패**: `Slot:Add(State<State<T>>)`
이중 래핑에서 `reconcile`이 부르는 `rawAdd``isState` 재검사를 안
해서 State 객체가 그대로 `_elements`에 박힘 — **[정정, 2부에서 사용자
확인] 실제로는 버그가 아니라 이미 확정된 `State<State<T>>` UB 범위의
당연한 사례**, 최초 보고가 과다 보고였음.
문제 없음으로 확인된 것: `State<State<Ref>>`/`State<State<Tag>>`(StoreBind
재진입 가드가 먼저 걸림), `PreRef` 재사용 가드, Slot의 owner-키 Relate ↔
`chains` 네임스페이스 분리, `elementOwner`의 다단 Slot-in-Slot GC 안전성.
## 2부 — 사용자의 직접 기술 리뷰, 세 번째 설계로 수렴
사용자가 서브에이전트 보고에 대해 직접 pseudocode 레벨로 반박/재설계를
제시(이 세션의 핵심):
- **Tag**: 위치(`k`) 기준 재키잉에 동의. 추가로 "Tag는 immutable이니
`oldv==newv`면 retract 스킵 가능" 최적화 제안 — 채택.
- **Attribute**: `AttributeGroupHandler.process``retractUnder``self`
안 넣는 이유를 질문하다, `retractUnder``process` 안에 있는 것 자체가
구조적으로 틀렸다고 재지적(`process`가 다시 안 불리는 상황이면
`retractUnder`도 안 일어남) — 결론적으로 `owners`/`rawNew` 레지스트리를
통째로 버리고, `isHandlable`이 없는(스캔에 안 걸리는) 순수 체크포인트
핸들러 `AttributeGroupKeyHandler`를 새로 만들어 `Dispatch.processAs`
명시 push하고, 나중에 `Dispatch.retractSelfAndUnder`(신설 — 기존
`retractUnder``keep` **미만**만 지우는 것과 달리 `target` **이하**까지
지움)로 체크포인트+그 아래 전부를 한 번에 철거하는 설계 제안. 손
트레이싱으로 검증: 이 설계는 소유권 충돌 감지를 별도 레지스트리 없이
기존 "같은 (inst,k)에 같은 핸들러 재사용 시 error" 가드(`State<State<T>>`
가드와 동일 메커니즘)로 공짜로 얻음 — 채택, `AttributeKeyHandler`
완전 무상태로 단순화됨.
- **Slot**: `releaseOwner`의 소유권 불일치를 조용히 무시하던 걸 즉시
error로 바꿔야 한다는 지적(그 상황 자체가 이미 버그라는 판단) — 채택.
`bindLifetime``attachSlot`의 조건 분기에 묻혀있는 게 `unbindLifetime`
Handler 층위(`SlotHandler.retract`)에 있는 것과 비대칭이라는 지적 —
`bindLifetime``SlotHandler.process`로 이동, `attachSlot`은 순수
구조적 mount 로직으로 단순화. 채택.
- **Brand**: `isXX` 판별 함수들이 `nil`을 안전하게 처리하는지 서브에이전트로
확인 요청 — 결과 **안전함**(weak registry lookup 방식이라 `nil`
조회도 항상 안전하게 `false`로 귀결, 에러 나는 경로 없음). 문서 수정
불필요, M0 스파이크 목록에 `isXX(nil)` 명시적 실측 케이스 추가는 선택
사항으로 남김(안 함).
## 반영 완료
- `base/bind-system-plan.md`: 핸들러 계약에 `isHandlable` 선택적 필드로
확장(생략 시 스캔 불가), "Dispatch 체인" 절에 `Dispatch.processAs`/
`Dispatch.retractSelfAndUnder` 신설 + 체크포인트 패턴 설명, `handler.process`
직접 호출 UB 경고에 `processAs`도 정식 진입점으로 포함.
- `base/tag-plan.md`: holders를 Tag 객체 identity에서 위치(`k`) 기준으로
재키잉, `TagHandler.retract``oldv==newv` 조기 반환 추가.
- `base/attribute-plan.md`: "이름 소유권"/"메커니즘" 두 절 전면 재작성 —
`rawNew`/`owners` 제거, `AttributeGroupKeyHandler` 체크포인트+
`processAs`/`retractSelfAndUnder` 페어로 교체, `AttributeKeyHandler`
무상태화, `groupState`를 "이름→키 객체 맵"에서 "이름 집합"으로 단순화.
- `base/slot-plan.md`: `releaseOwner` 불일치 시 error로 강화, `bindLifetime`
호출을 `attachSlot`에서 `SlotHandler.process`로 이동.
- `.claude/README.md`: 4개 파일 행에 이 세션 요약 append.
## 서브에이전트 조사(문서 수정 없음, 결과만)
- Relate `GetStrong`/`SetStrong` 키 누락 + const-키 비효율 패턴 코퍼스
전수조사 — `attribute-plan.md``owners:GetStrong(inst)` 1건(이미 이
세션에서 재설계로 해소됨) 외 추가 발견 없음, 나머지 전부 정상 사용.
- Brand/`isXX` nil 처리 전수조사 — 안전함 확인(위 2부 참고).
## 남는 것
- `Slot:ExtractAll()`/`:Splice(...)`의 반환값을 `Slot(...)` 생성자에 바로
넣을 수 있다는 조합 예시를 문서에 추가하면 좋겠다는 사용자 코멘트 —
버그 아님, 착수 안 함(원하면 다음 세션에).
- `luau-test/` 스파이크(20개, 이 세션에서 다룬 새 메커니즘은 아직 스파이크
파일 없음 — `Dispatch.processAs`/`retractSelfAndUnder`/`AttributeGroupKeyHandler`
재설계를 검증할 스파이크 추가 여지가 있음, 필요시 다음 세션) — 여전히
사용자가 `luau`로 안 돌려봄, M0 착수 전 최우선 게이트 그대로.

View file

@ -0,0 +1,95 @@
# 2026-08-13 다섯 번째 세션 — `Dispatch` 인덱스 기반 전면 재설계, `State<State<T>>` UB 해제
## 배경
직전 세션(04)에서 Attribute 그룹의 이름 소유권 충돌 버그를 고치려고
`Dispatch.processAs`/`Dispatch.retractSelfAndUnder`("체크포인트 핸들러")
패턴을 신설했음. 사용자가 이 설계에 대해 솔직한 평가를 요청 — Claude가
`retractSelfAndUnder`가 사실 `retractUnder(keep=nil)`과 동치라 불필요할
수 있다는 것과, "공짜 충돌 감지"가 실은 도메인 특화가 아닌 저수준 에러
메시지라는 비용을 감춘다는 두 가지를 솔직히 지적.
## 1부 — 체크포인트 패턴 자체에 대한 사용자의 근본적 문제 제기
사용자가 "최상단에서 뭔가 지우는 일은 하기 싫다 — 그걸 제공한다는 것부터
'어떤 핸들러가 최상단에 프로세싱을 넣어야 했다'는 가정이 생기고, 가정이
많아지는 건 안 좋다"고 지적. 대신 "명시적으로 자신이 만들어낸 처리"를
기입하는 편이 버그를 내기 어렵고 엔지니어링 비용도 거의 공짜라며, 각
`process`/`processAs` 호출이 자신이 push했던 체인 인덱스를 제공하고, 각
`process` 함수 자신이 retract 클로저를 반환하는 커링 방식을 제안. 재귀는
`index+1`, 새 키로 위임하면 `1`부터 — `retractUnder`/`retractSelfAndUnder`도
인덱스를 받게 하면 `State<State<T>>` 같은 복합 처리도 깨끗이 지원되면서
전체 구조가 훨씬 단순해질 거라는 통찰.
Claude가 이 통찰을 검증: 기존 `chains`가 핸들러 **객체 identity**로
포지션을 추적하던 것 자체가 `State<State<T>>`를 UB로 만들었던 근본
원인(같은 싱글톤이 재귀로 자신과 매치되면 identity로 구분 불가)이었고,
인덱스 기반으로 바꾸면 각 재귀 단계가 서로 다른 슬롯을 쓰므로 그 모호성
자체가 사라짐 — 순환 참조만 여전히 UB로 남음(기존 "순환은 UB" 원칙과
같은 급).
## 2부 — 세부 메커니즘 확정
사용자와 왕복하며 세 가지를 확정:
1. **`Dispatch.process`가 핸들러 호출 *전에* 점유 체크, 핸들러 부르고
나서가 아님** — 부작용(SetAttribute, Observer 구독 등)이 실제로
일어나기 전에 걸러야 낭비/깜빡임이 없음. 에러 메시지가 도메인 특화로
상세하지 않은 것에 대해 사용자가 "이미 에러가 나면 패닉 상태고 그
이후 정합성은 관리 대상이 아니다"라고 스스로 정리 — 상세 설명을 위해
먼저 실행해보는 비용을 들일 이유가 없다는 데 동의.
2. **시작 인덱스는 0이 아니라 1** — Luau `ipairs`/`#`은 1부터 연속된
정수 키를 전제하므로(quad가 "props 순회 순서" 절에서 이미 이 관례에
의존), 0을 쓰면 `ipairs` 순회에서 그 항목이 조용히 빠지고
`quad-debug``chains`를 그대로 순회해 보여주려는 계획과도 부딪힘.
사용자 확인.
3. **`retractUnder`/`retractSelfAndUnder`를 `Dispatch.retractFrom(inst,k,
index,v)` 하나로 통합** — "자기 포함이냐 미만이냐"는 순전히 호출자가
어느 인덱스를 넘기느냐의 문제가 됨(`myIndex` vs `myIndex+1`). 이걸로
전날 세션에서 지적됐던 "retractSelfAndUnder가 사실 불필요할 수 있다"는
비판이 자연스럽게 해소됨.
4. 사용자가 추가로 "이러면 store 바인드가 이전 것을 retract할 필요도
없다"고 지적 — `retractFrom`의 순회 구조(항상 깊은 인덱스부터 정리)가
각 핸들러의 하위 위임 cascade를 자동으로 대신해준다는 걸 재확인. 이
원칙을 손으로 검증하는 과정에서 SlotHandler의 "claimOwner가 false를
반환하는(spurious 재발행) 분기"가 순진하게 no-op 클로저를 반환하면
실제 자원의 cleanup 책임이 유실되는 사각지대를 발견 — 모든 process
호출이 항상 동일한(slotValue/inst를 캡처하는) 대칭적 클로저를
반환하도록 고쳐 해결(Ref는 이 문제가 없음 — 클로저의 동작이 `v`
identity에만 의존해 어느 호출의 클로저든 동치이기 때문, Slot은
`attachSlot`이라는 진짜 1회성 부작용이 있어서 달랐던 것).
## 반영 완료
- `base/bind-system-plan.md`: 핸들러 계약(3필드로 축소, `process`
retractor 반환), "확정된 디스패치 모델"/"None 센티널"/"Dispatch 체인"
섹션 전면 재작성, `Dispatch.processAs`/`retractSelfAndUnder` 섹션
삭제(→ archive), Store 바인드/Ref retract 예시 pseudocode 전부 새
계약으로 갱신 — 여러 곳에서 private `Relate`가 클로저 캡처로 대체되며
불필요해짐(StoreBind의 observer 저장, Ref의 언바인딩용 relate 일부).
- `archive/checkpoint-handler-pattern-reversed.md` 신설 — 전날 만든
체크포인트 패턴 원문+역전 이유 보존.
- `base/attribute-plan.md`: `AttributeGroupKeyHandler`/`processAs`/
`retractSelfAndUnder` 전부 제거, 그룹이 공개 `AttributeKey(name)`으로
항상 인덱스 1에 직접 위임하도록 재작성. `groupState` Relate도 제거
(반환 클로저가 이름 집합을 직접 캡처).
- `base/tag-plan.md`: `TagHandler.process`가 retractor 반환, `kTagMap`
제거(클로저가 `v` 직접 캡처), `tagNameMap`만 유지(여러 위치를
가로지르는 진짜 누적 상태).
- `base/slot-plan.md`: `SlotHandler.process`가 retractor 반환, `kSlotMap`
제거 — 위 2부 4번 항목의 대칭성 수정 포함.
- `architecture.md`/`store-semantics.md`/`modifier-plan.md`: 4필드
Handler 계약 언급을 3필드로 정정.
- 코퍼스 전체 grep sweep으로 `retractUnder`/`processAs`/
`retractSelfAndUnder`/`.retract(inst` 잔존 참조 확인 — 의도된 역사
서술 외 전부 반영 완료.
## 남는 것
- 오늘 신설한 인덱스 기반 모델(`Dispatch.process`의 점유 체크,
`retractFrom`)을 검증할 `luau-test/` 스파이크가 아직 없음 — 기존
`04`(다단 체인 스트레스 테스트)가 가장 가까운 후보라 다음 세션에
그 파일을 새 모델에 맞춰 재작성하는 게 자연스러움.
- `.claude/luau-test/`는 여전히 사용자가 `luau`로 안 돌려봄 — M0 착수
전 최우선 게이트 그대로 유지, 이번 세션의 재설계도 손 트레이싱으로만
검증됨.

View file

@ -125,7 +125,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선 나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선
확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고, 확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고,
없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌 없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌
게 없음 — `.claude/question.md` 2번이 최신 상태. 게 없음 — `.claude/question.md` 2번이 최신 상태. **[2026-08-13 다섯
번째 세션 추가 주의]** `Dispatch`가 인덱스 기반으로 전면 재설계됨
(`base/bind-system-plan.md` "Dispatch 체인" 절, `chains`/`retractUnder`
대신 `retractFrom`+`process`가 반환하는 클로저) — 기존 `luau-test/04`
(다단 체인 스트레스 테스트)는 옛 모델(핸들러 identity 기반 `chains`)
전제로 쓰여 있어서, 돌려보기 전에 새 모델에 맞춰 먼저 다시 써야 함
(`session/2026-08-13-05-dispatch-index-based-redesign.md` "남는 것"
참고) — 아직 안 함, 다음 세션 우선 항목.
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State` stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
@ -792,3 +799,53 @@ v2 논의 대상 아님으로 확정** (`session/2026-08-13-03-v1-newindex-typo-
(`GetObjects()`류) 개념 자체가 없어져 재현 여부와 무관하게 v2 마이그레이션 (`GetObjects()`류) 개념 자체가 없어져 재현 여부와 무관하게 v2 마이그레이션
가이드에서 다룰 대상이 아님(있었다 해도 v1 전용 기능). `question.md`/ 가이드에서 다룰 대상이 아님(있었다 해도 v1 전용 기능). `question.md`/
`reference/quad-v1-architecture.md` 둘 다 해소로 반영. `reference/quad-v1-architecture.md` 둘 다 해소로 반영.
**2026-08-13 네 번째 세션 — 사각지대 손 트레이싱 라운드, `Dispatch.processAs`/
`retractSelfAndUnder` 체크포인트 핸들러 신설**
(`session/2026-08-13-04-blind-spot-audit-checkpoint-handlers.md`)
직전 세션의 `State<State<T>>` 발견 방식(합성 시나리오를 pseudocode에 손
대입)을 서브에이전트 4개로 코퍼스 전체에 반복 — 실제 버그 3건 발견:
Tag 참조 카운트가 객체 identity 기준이라 같은 Tag 객체 재사용 시 깨짐,
Attribute 그룹이 이름을 놓았다 다시 포함하면 자기 자신과 소유권 충돌,
(Slot의 이중 State 언랩은 사용자 확인 결과 버그가 아니라 기존
`State<State<T>>` UB 범위였음, 과다 보고 정정). 사용자가 Attribute
설계를 직접 재검토하며 `owners`/`rawNew` 수동 레지스트리를 통째로
버리고, `isHandlable` 없는(스캔 불가) 순수 체크포인트 핸들러를
`Dispatch.processAs`로 명시 push + `Dispatch.retractSelfAndUnder`(target
자신 포함 철거, 신설)로 통째 정리하는 설계로 전환 — 소유권 충돌 감지가
기존 재진입 가드로 공짜로 해결됨. Slot의 `releaseOwner` 불일치 무시를
error로 강화, `bindLifetime` 위치를 Handler 층위로 이동해 `unbindLifetime`
대칭 맞춤. `Brand`/`isXX`의 nil 처리는 서브에이전트 확인 결과 안전.
`base/bind-system-plan.md`/`tag-plan.md`/`attribute-plan.md`/`slot-plan.md`
전부 반영 완료. **[정정, 같은 날 다섯 번째 세션]** 이 세션에서 신설한
`Dispatch.processAs`/`retractSelfAndUnder` 체크포인트 패턴은 바로 다음
세션에 더 근본적인 인덱스 기반 재설계로 대체되며 전부 걷어내짐 — 아래
다섯 번째 세션 항목 참고, 원문은 `archive/checkpoint-handler-pattern-reversed.md`.
**2026-08-13 다섯 번째 세션 — `Dispatch` 인덱스 기반 전면 재설계,
`State<State<T>>` UB 해제** (`session/2026-08-13-05-dispatch-index-based-redesign.md`)
사용자가 체크포인트 패턴에 "왜 최상단에서 뭔가 지우는 일을 만들었냐,
가정이 늘어나는 건 안 좋다"고 문제 제기하며 시작 — `chains`가 핸들러
**객체 identity**로 위치를 추적하는 것 자체가 `State<State<T>>`를 UB로
만든 근본 원인이라는 데까지 논의가 이어짐. 최종 설계: `chains`
**재귀 깊이 인덱스**로 추적(같은 키 재귀는 `index+1`, 다른 키 위임은
항상 `1`부터, 0이 아니라 1인 이유는 Luau `ipairs`/`#` 관례), `Handler`
계약이 `process`/`retract` 2-메소드에서 `process`가 자기 retract
클로저(`(hintValue)->()`)를 반환하는 1-메소드로 축소, `Dispatch.process`
핸들러를 부르기 전에 그 인덱스 점유 여부를 먼저 체크(핸들러 부작용 낭비
없음, 도메인 특화 에러 메시지가 없는 건 의도된 트레이드오프 — 에러=패닉
상태라 상세 설명 비용을 들일 이유가 없다는 데 사용자 동의),
`retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)`
하나로 통합(자기 포함/미만 여부는 호출자가 넘기는 인덱스 자체로 표현).
이 재설계로 `State<State<T>>`가 UB에서 정상 지원 대상으로 재정정되고,
전날 만든 체크포인트 패턴 전체(`AttributeGroupKeyHandler`/`processAs`/
`retractSelfAndUnder`)가 통째로 불필요해짐 — Attribute 그룹은 이제
공개 `AttributeKey(name)`으로 항상 인덱스 1에 직접 위임, 점유 체크가
소유권 충돌 감지를 대신함. 부수 효과로 여러 핸들러(StoreBind/Ref/Tag/
Slot/Attribute)의 private `Relate` 상태 저장소가 대거 줄어듦(process가
반환하는 클로저가 upvalue로 직접 캡처하므로 process→retract 사이 단발성
handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을 가로지르는
누적 상태에만 남음). `bind-system-plan.md`/`tag-plan.md`/
`attribute-plan.md`/`slot-plan.md`/`architecture.md`/`store-semantics.md`/
`modifier-plan.md` 전부 반영, `archive/checkpoint-handler-pattern-reversed.md`
신설.