quad/ROADMAP.md
qwreey e0ef7ce34b
docs(dispatch): PreRef 소진 센티널을 ProcessedPreRef로 교체, Length/Offset 등록 갭 해소
PreRef pre-pass 소진 슬롯이 None으로 뭉뚱그려져 있어 setLength/
setOffsetSource 등록 책임자가 불분명했던 갭(같은 세션 조사에서 발견)을,
전용 센티널 ProcessedPreRef + ProcessedPreRefHandler로 교체해 "이 위치를
처음 매치한 Handler가 등록 책임을 진다"는 기존 계약에 특수 취급 없이
편입시킴. 파생 서술(동적 경로 가드, 취소 개념 없음 근거) 정정 포함.

PostRef 백로그 스케치도 같은 원리로 갱신 — 별도 후행 재순회 없이 PreRef
pre-pass 한 번의 스윕에서 isPostRef도 같이 소진하고 postRefList에
적재해두는 안으로 PreRef/PostRef 소진 메커니즘을 완전히 대칭화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 02:45:50 +09:00

51 KiB

ROADMAP.md

quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 안 옮겨적음 — .claude/base/가 소스, 여긴 순서와 진행 상황만. 마일스톤 시작할 때 체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서.

2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 자체는 시작 안 함. 다음 세션은 바로 M0부터.

[2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음. 0-Y(13차 세션), 0-Z(Attribute 이름 소유권)/0-A(재디스패치 하강 diff, 둘 다 14차 세션) 확정·반영 완료 — .claude/question.md의 최우선 칸이 비었습니다.

다만 M0 착수 전에 반드시 읽을 구현 규약 두 개: .claude/base/typing-limits.md(0-Y의 산물 — 파생 State마다 결과 타입을 명시 주석으로 바인딩)와 .claude/base/dispatch-core-plan.md(0-A/0-Z의 산물 — 하강 diff 재디스패치, 3-인자 retractFrom, Handler 작성 체크리스트 8개, HANDLER_PRIORITY_FALLBACK, 주입되는 엔진 op). (특히 "파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"과 7번 체크리스트).

M0 — 스켈레톤 + 기술검증 (스파이크, "진짜" 마일스톤 아님)

최종 소스 트리를 그대로 만들기 전에, 지금까지 추론만으로 확정하고 실제 Luau 코드로 부딪혀본 적 없는 세 가지를 던지는 코드로 검증하는 단계 — .claude/base/ 감사에서 나온 결론(2026-08-04). 여기서 뭔가 어긋나면 architecture.md/bind-system-plan.md 등을 이 시점에 고치는 게 정상 — 실패가 아니라 이 단계의 목적.

  • Store/State push-invalidate → pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 포함 — 이미 invalid면 전파 중단되는지)
  • Source가 State를 구조적으로 만족하는 제네릭 타입(:Compute<U>(self: Source<T>, ...) -> State<U>류, self 타이핑 + State 참조 혼합)이 Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, base/store-semantics.md "Source가 State를 만족함" 절 — State<T>Source를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 피할 수 있어 보이나 실제 검증 전엔 확정 아님)
  • process(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable 우선순위 스캔 포함)
  • props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, PreRef pre-pass + 일반 Ref의 위치 기반 순서까지 최소 스파이크로 검증 (2026-08-07 세 번째 세션, base/ref-plan.md "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절) — **PreRef pre-pass의 소진은 nil이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가 Luau REPL로 반례 제시 — 키가 듬성듬성해지면 순회가 index 순서를 전혀 안 지킴), 이 경로는 nil-hole 위험이 아예 없도록 설계됐으므로 "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 nil이 아니라 None인가" 참고). [정정, 2026-08-14 두 번째 세션] 소진 값은 이제 None이 아니라 전용 센티널 ProcessedPreRef — 정상 두 패스가 그 자리를 ProcessedPreRefHandler로 매치해 Dispatch.setLength(0)/setOffsetSource(None)을 등록하도록 재설계됨 (base/ref-plan.md "PreRef" 절, base/dispatch-core-plan.md "Length/Offset" 절) — 아래 PreRef pre-pass/동적 경로 가드 체크리스트 항목도 이 값으로 스파이크할 것.
  • props.Modifier/props.Ref named-parameter로 받는 컴포넌트 하나 작성, export type Params = {...}로 타입 체크되는지 확인 (component-composition-plan.md 최종 결론 1번) — props.Modifier or None/props.Ref or None 관용구(2026-08-07 열 번째 세션 확정, component-composition-plan.md "필수 관용구" 절)로 nil-hole을 막는 케이스를 반드시 포함할 것 — caller가 Modifier/Ref를 안 넘겨도 or None이 항상 non-nil을 보장하므로 {nil, ref, child}류 리터럴 구멍 자체가 안 생김(research/pre-implementation-audit.md 1-5). M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로 타입 체크/런타임 양쪽에서 문제없이 동작하는지
  • 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 .claude/base/ 갱신

통과 기준: 세 개 다 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행. 안 되면 여기서 관련 base/ 문서부터 고치고 재시도.

M1 — 실제 스캐폴딩

  • quad-base/, quad-roblox/ 폴더 + 각 wally.toml
  • 루트 default.project.json, .luaurc(architecture.md "구현 착수: 소스 트리 구조 확정" 절 그대로)
  • quad-base용 최소 mock 테스트 하네스(Vide test/mock.luau 선례, 순수 luau CLI, architecture.md "테스트 전략" 절 참고)
  • 이 시점부터 .claude/qa-request//.claude/archive/ 폴더 실사용 시작

M2 — 디스패치 엔진

[2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래 체크리스트는 새 모델("하강 diff") 기준으로 갱신됐습니다. 래핑 핸들러의 선행 retractFrom은 폐기됐고, Dispatch.process가 슬롯의 handler를 먼저 비교해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 process 재호출, 다르면 그 자리부터 전량 철거) 처리합니다. 정본은 .claude/base/dispatch-core-plan.md(같은 세션에 bind-system-plan.md에서 분리 신설), 뒤집힌 옛 모델은 .claude/archive/dispatch-hintvalue-model-reversed.md.

  • Dispatch/init.luauDispatch.getHandler(inst,k,v): Handler?(순수 스캔, isHandlable+priority) / Dispatch.process(inst,k,v,index) (오케스트레이터: getHandler → 그 인덱스의 기존 핸들러와 비교 → 같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 .process로 자리 교체, 다르면 retractFrom 후 새로 설치. 반환값이 nil이면 즉시 error) / 3-인자 Dispatch.retractFrom(inst,k,index) (아래 항목) / Dispatch.addHandler(handler)(레지스트리 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / Dispatch.drive(inst, flattened)(배열→해시 두 패스 순회하며 각 (k,v)Dispatch.process(inst,k,v,1) 호출 — dispatch-core-plan.mdNone 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). [2026-08-13 다섯 번째 세션 전면 재설계] "이전 담당자와 다르면 그 retract"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로 재귀/래핑 핸들러(StoreBind/NoneHandler)가 재-dispatch 전에 스스로 retractFrom을 부르는 쪽에 있고, Dispatch.process는 diff를 하지 않음
  • Handler.luau(핸들러 계약 타입: isHandlable(inst,k,v)/priority/ process(inst,k,v,index) -> (hintValue)->() 3종isHandlableinst를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 retract 필드는 process 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
  • Brand.luau(공유 weak-key 레지스트리, Brand.set(x,tag)/ Brand.get(x)isState뿐 아니라 isObserver/isEffect/isTag/ isAttributeKey/isAttribute/isTween/isBlocker/isSource/ isStore/isSlot/isRef/isPreRef/isModifier(2026-08-07 열 번째 세션 추가 — 원래 태그 목록에서 빠져있었음. [정정, 2026-08-09 열한 번째 세션] isRef/isPreRefisState처럼 상위-하위 관계로 재정정됨 — isPreRef가 가장 구체적인 항등, isRef는 그 위에 얹혀 isPreReftrue로 통과시킴(PreRef가 Ref 런타임을 재사용하는 것과 정합). (v=Ref) children leaf 매치 핸들러는 이제 isRef(v) and not isPreRef(v)로 명시적으로 좁혀야 함. isModifier는 여전히 단순 항등, 상위 개념 없음. [정정, 2026-08-11 아홉 번째 세션] isAttribute 하나였던 게 isAttributeKey(단일 키 DI 키 predicate, 해시파트 k를 판별)와 isAttribute(그룹 값 predicate, array-part v를 판별, isTag와 같은 결)로 분리됨 — 그룹 Attribute(...) 프리미티브 신설로 같은 이름이 서로 다른 두 대상(키 vs 값)을 가리키게 돼서 갈라짐, base/attribute-plan.md 참고) 전부의 기반. isNone만 예외로 레지스트리 없이 x == None 항등 비교 — brand-plan.mdBrand 절, 2026-08-07 여덟 번째 세션 신설)
  • Relate.luau(전체가 quad-base, 순수 Lua — base/relate-plan.md) — Relate() 비싱글톤 생성자, :SetWeak/:GetWeak/:SetStrong/:GetStrong. inst(첫 인자)는 항상 weak, StrongMap/WeakMap 서브테이블은 lazy 생성(첫 Set 호출 시에만), WeakMap은 공유 메타테이블({__mode="v"}) 재사용 — 구 base.perInstanceState(inst)/PerInstanceState.luau를 대체(2026-08-08 세션 신설).
  • LifetimeHandle.luau 인터페이스만(bindLifetime(inst,value)/ unbindLifetime(inst,value)/canExecute(inst,value) 탑레벨 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 Connected 확인)/M6(Slot의 canExecute)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(pre-implementation-audit.md 우선순위1-9, question.md 2번 — 2026-08-07 네 번째 세션에 반영). canExecute(inst, value) -> boolean으로 재확정(2026-08-08 세션, (handle) 단일 인자 서술을 대체) — Observer/Effect는 자기 Subscribed 상태를 먼저 확인, 그 다음 inst의 공유 gcconn(Relate로 저장)의 .Connected를 봄. unbindLifetime(inst,value) 추가 (2026-08-09 여섯 번째 세션)inst 전체 죽기 전에 특정 값 하나만 조기 해제(Dispatch.setLength가 State 재등록 시 이전 Observer를 정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화. bindLifetime/unbindLifetime/canExecute 셋 다 네임스페이스 없이 탑레벨 함수로 export(Dispatch.xxx류 시스템 네임싱과 구분, isState/isObserver와 같은 1급 프리미티브 취급) — base/ lifecycle-pattern.md의 "bindLifetime/canExecute/unbindLifetime — 확정" 절 참고. Observer/Effect 값에는 bindLifetime/ unbindLifetime도 M3의 canBound 게이트를 확인/세팅 — children 배열 leaf 부착이 실제로는 bindLifetime 호출이라서(M3 체크박스 참고, 구현 순서상 M2가 M3의 canBound를 참조하게 됨에 유의)
  • Dispatch.setLength(inst,i,len:number|State<number>)/ Dispatch.setOffsetSource(inst,i,offset:Source<number>|None) — array part 형제 순서 보장(Length/Offset 누적합→LayoutOrder 리액티브 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 UB, Handler 구현체 작성자만의 계약) — recompute는 leaf-lifetime 경로(bindLifetime/unbindLifetime)로 등록, :Subscribe() 아님 (2026-08-09 여섯 번째 세션, base/dispatch-core-plan.md "Length/Offset" 절 — base/slot-plan.md "여러 Slot이 섞일 때 순서 보장" 해소)
  • 핸들러 계약 검증: process가 retractor 클로저를 반환하지 않는 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 function() end를 반환 — Dispatch.retractFrom이 nil 체크 없이 호출, base/dispatch-core-plan.md "핸들러 계약" 절, 2026-08-08 세션 / 2026-08-13 다섯 번째 세션에 별도 retract 필드가 process 반환값으로 합쳐지며 대상만 바뀜)
  • 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정, base/dispatch-core-plan.md "우선순위 동률/매치 실패 처리" 절) — HANDLER_PRIORITY_HIGH/_NORMAL/_LOW/_FALLBACK(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 자기 핸들러를 등록하면 언제나 이김, 2026-08-13 열네 번째 세션 신설) 등 목적별 상수, 매치 실패(isHandlable을 만족하는 핸들러 없음)는 Brand+typeof(v) 출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider 미주입 상태도 이 경로로 자동 커버, pre-implementation-audit.md 1-3/1-4), 핸들러 등록/정렬 시점 동률 감지 print 경고 + Dispatch.listHandlers() 디버그 유틸
  • Dispatch/Leaf.luau(i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind.luau와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, base/ dispatch-core-plan.md "Dispatch는 프리미티브가 아니다" 절)
  • chains(Relate 기반, {[inst(weak)]={[k]={[index]={handler, retractor}}}}재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)) + 3-인자 Dispatch.retractFrom(inst,k,index) — 재귀 재-dispatch (StoreBind/NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff로 전면 교체, base/dispatch-core-plan.md "Dispatch 체인" 절, pre-implementation-audit.md 1-2번 "이전 핸들러 추적" 항목 해소). 구현 시 반드시 지킬 것: - 재디스패치는 하강 diff — 래핑 핸들러는 선행 retractFrom을 부르지 않고 그냥 Dispatch.process(inst,k,realv,index+1). 비교는 Dispatch.process 안에서: 슬롯의 handler가 같으면 그 자리 클로저에 새 값을 넘긴 뒤 같은 핸들러의 process로 자리 교체, 다르면 retractFrom(inst,k,index) 후 새로 설치 - chains:SetStrong(inst,k,list)handler.process 호출 전에 — 뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써 하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그) - 새 자리를 여는 (B) 분기에선 handler.process 호출 전에 no-op 점유 마커를 박아 list를 구멍 없는 시퀀스로 유지(hole 있는 테이블의 #는 Lua가 보장 안 함) - processnil을 반환하면 (A)/(B) 양쪽에서 즉시 error - 다른 키로 위임할 땐 항상 index=1, 같은 키 재귀는 index+1; Dispatch.drive의 진입도 항상 1 - 소유권 충돌 감지는 Dispatch의 일이 아님(옛 점유 error 폐지) — 필요한 도메인이 직접(Attribute 이름 claim, M10)
  • mock 대상 테스트

M3 — Store/State/Source

  • Source.luau/State.luau/Store.luau
  • store.key dot-access 타입 추론 확인 — Luau type function (WrapStore/ProcessStoreType)으로 Store<T>T의 각 필드를 Source로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 번째 세션, base/typing-limits.md "store.key 레코드 필드 타이핑" 절) — 실제 문법이 통과하는지는 luau-test16-type-store-key-typefunction.luau로 실측 필요
  • :Compute(fn, ...) — trailing args로 추가 의존성 직접 받는 sugar (2026-08-11 세션, base/bind-system-plan.md ":Compute(fn, ...)" 절) — :With(...):Compute(fn) 체인과 달리 노드 1개(Compute 노드 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M3 스파이크에서 확인. Effect/Observer는 대칭 sugar 없이 :With 명시 유지(의도적 비대칭, 같은 절 참고)
  • trailing deps를 fn에 lazy positional 인자로도 노출(fn(self, previous?, dep1, ..., depN) — 순서는 Luau 값 레벨 ...가 파라미터 리스트 맨 끝이어야 하는 것과 같은 이유로 previous?가 deps 팩 에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째 세션에 순서 정정, base/bind-system-plan.md "trailing deps를 fn에 lazy positional 인자로도 노출" 절) — 방향/순서는 확정, luau-test15-type-compute-trailing-deps-typepack.luau로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정)
  • Blocker.luau(base/blocker-plan.md 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
  • state:Apply(factory)(base/bind-system-plan.md "state:Apply(factory)" 절, 2026-08-07 일곱 번째 세션) — factory(self)를 체이닝 문법으로 부르는 순수 설탕, factory: (State<T>) -> U): U로 열린 타입. Source도 기존 :With/:Compute 델리게이션에 얹혀 자동 포함
  • state:Observer(fn) — children 배열 leaf 참가자, 등록 즉시 1회 실행 확정(base/bind-system-plan.md의 Observer 절), isObserver 판별자, canExecute 게이팅, :Subscribe()/:Unsubscribe()
  • Effect(fn, state?)(base/effect-plan.md) — state 생략 시 설치 1회+leaf 사망 시 확정 정리, state 지정 시 내부적으로 state:Observer(...)를 조합해 재실행+cleanup 체이닝(React useEffect 동형). Observer 구현 이후에 착수(의존 관계). EffectHandle:Subscribe()/:Unsubscribe()도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — :Unsubscribe()는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션)
  • Observer/Effect 이중 바인딩 금지 — canBound(handle) predicate로 :Subscribe()(전역)와 bindLifetime(inst-scoped, leaf 부착도 내부적으로 이걸 호출)이 동시에 걸리면 즉시 error(base/ bind-system-plan.md "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 2026-08-09 세션에 canBound로 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 독립 경로는 둘뿐). canBound의 내부 플래그는 canExecute가 보는 .Subscribed와 같은 필드 — bindLifetime/unbindLifetime도 (Observer/Effect 값에 한해) 이 필드를 세팅/해제
  • mock 대상 테스트

M4 — 첫 end-to-end 반응형 업데이트

[2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래 항목은 base/dispatch-core-plan.md의 하강 diff 기준으로 읽을 것.

  • Dispatch/StoreBind.luau(재귀 재실행 로직, 엔진 무관 — 선행 retractFrom 없이 Dispatch.process(inst,k,realv,index+1) 한 줄 (2026-08-13 열네 번째 세션 하강 diff), 반환 클로저는 자기 Observer 구독만 해제. base/dispatch-core-plan.md "Dispatch 체인" 절)
  • mock 대상으로 "store 값 바꾸면 process가 다시 호출된다" + "이전 값이 다른 타입이면 이전 process가 반환했던 retractor 클로저가 정확히 불린다" 확인 + State<State<T>>(값이 또 State/Source)가 인덱스 N/N+1로 안 겹치고 정상 동작하는지(2026-08-13 다섯 번째 세션에 UB→정상 지원으로 재정정) + 최초 마운트 직후 첫 재발행에서 인덱스 2의 retractor가 실제로 불리는지(위 M2의 SetStrong 순서 버그가 정확히 여기서 증상으로 나타남)

M5 — quad-roblox 최소 프로바이더

  • RobloxFactory.luau(BaseModule 뮤테이션, 재호출 가드)
  • DI/init.luau(제네릭 생성자 + ~25개 정적 필드)
  • Handlers/Property.luau, Handlers/InstanceChild.luau
  • 실제 Roblox에서 첫 Frame{...} 렌더 확인 — Studio 작업이라 HUMAN_TODO.md 1번(계정 분리) 먼저 되어야 진행 가능, SAFETY.md 준수

M6 — Slot

[2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래 "SlotHandler.process는 claim 실패 시에도 파괴적 클로저를 반환해야 함" 항목은 새 모델에서도 그대로 유효함(체인은 클로저를 early-return 여부와 무관하게 항상 소비 — base/dispatch-core-plan.md "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 Slot이거나 nil임이 계약으로 보장된다는 점만 새로 추가됨.

  • [2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독] - State<Slot> 교체 = 파괴가 아니라 언마운트(state<Frame>와 동일). 비파괴 경로 unmountSlotTreedestroySlotTree와 별도로 구현 — 차이는 딱 둘: 실제 Destroy()를 안 하고, 자식 releaseOwner도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). 쓰는 자리 둘: SlotHandler.process가 반환하는 클로저, :Listreconcile. 여전히 파괴인 것: 명시적 Remove/Clear/dispose. - 해제 시 owner 등록 되돌리는 순서 고정setOffsetSource(inst,k,None) 먼저, setLength(inst,k,0) 나중. 반대로 하면 setLength 안의 recompute가 죽는 중인 서브트리의 offset Source에 헛된 :Set()을 날림. recomputesourceList[i]nil 이어도 None처럼 skip(방어), 해제 시 slot.Offset = nil. - 소유권 판정을 둘로 분리 — nested(rawAdd)는 엄격 claimOwner (같은 owner 재클레임도 error, Slot{a,a} 차단, 반환값 없음), top-level은 claimOwnerAt(element, inst, k)(정확히 같은 (inst,k)의 spurious 재발행만 false, Frame{slot,slot}은 error). releaseOwner는 불일치 시 즉시 error. - rawRemovereleaseOwner를 부를 것(옛 의사코드에서 누락돼 있었음), destroySlotTree가 자식 소유권 반납 + _mounted/_mountedInst 복원 (GC에 맡기면 재사용이 GC 타이밍 의존으로 비결정적 실패). - SlotHandler.process는 claim 실패 시에도 파괴적 클로저를 반환해야 함 — no-op을 반환하면 다음 진짜 교체 때 정리 주체가 사라짐(retractFrom은 클로저가 early-return해도 체인에서 항상 소비하므로). - 전부 base/slot-plan.md에 반영돼 있고, luau-test/19 C 섹션이 소유권 분기를 음성 대조군까지 포함해 실측 검증함.

  • dispose(value) — 대상이 아직 어느 트리에 의해 살아있길 요구되면 파괴를 거부하고 즉시 error(떼어내주지 않음 — 떼는 건 Set=언마운트의 몫). 엔진은 Destroy/Clear에 에러를 안 내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게 지우는 유일한 경로. 마운트 위치는 elementOwner가 이미 알고 있어 새 부기 불필요. 시그니처/대상 범위는 .claude/question.md 0-B에서 미확정

  • "여러 Slot이 형제로 섞일 때 순서 보장" 해소(2026-08-09 여섯 번째 세션) — Dispatch.setLength/setOffsetSource 메커니즘, base/ dispatch-core-plan.md "Length/Offset" 절. Slot.Length: State<number>도 이때 확정(CRUD/:List 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 :List/CRUD의 raw*가 호출.

  • Slot의 Add/Remove/Extract/ExtractAll/Clear/Move/Swap/ Get/IndexOf/Splice CRUD 의미론 확정 (2026-08-09 세 번째 세션, 2026-08-09 열한 번째 세션에 식별 기준 재정정, Splice는 2026-08-12 열다섯 번째 세션 신설 — [2026-08-13 5차 감사에서 추가] Splice가 이 체크리스트에 누락돼 있었음, luau-test/20으로 산술 실측 통과됨) — 에러 조건까지 전부 확정 (base/slot-plan.md "CRUD API 확정"). "재마운트 시 즉시 throw"도 isMounted 이중 추적 분리로 개별 element/Slot 컨테이너 기준이 명확히 갈림(같은 문서 "isMounted 이중 추적 분리" 절). [정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 인덱스 기준으로 전환Remove(index)/Extract(index, newElement?) (O(n) 또는 O(1))/Move(oldIndex, newIndex)(O(n))/Swap(indexA, indexB)(O(1)) 전부 인덱스, Add(element, index?)만 element를 직접 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 Add 리턴값을 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안 맞았음 — 레퍼런스만 있으면 IndexOf(element): number?로 인덱스를 구하면 됨. ExtractAll(): {T}(Clear의 비파괴 버전), Get(index): T? 신설(get/set 드롭했던 걸 재추가). Extract(index, newElement?)newElement 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 mutate 메소드 전부 "가드 확인 + raw* 위임" 얇은 wrapper(Get/ IndexOf는 순수 읽기라 가드 대상 아님). base/roblox 경계에 mount/unmount 외 reposition 훅 추가됨. Slot<T>() 제네릭화, 요소 타입 제약 확정nil/None 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 T만), 핸들러 계층 값(Ref/PreRef/Observer/ Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(Modifier 필드와 같은 판별 메커니즘 재사용) — DI.InstSlot = Slot<<Instance>>(DI 네임스페이스 이름 자체는 question.md 1번 용어정리 대기 중, 여기선 잠정 표기)가 quad-roblox의 사실상 유일한 Slot 타입.

  • Slot:List(data, updateFn, keyFn?) — 키 기반 동적 컬렉션 재조정, keyFn(item, index) -> key 생략 시 원본 data 배열 위치(raw index)를 그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). updateFn<UD=any>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)매 reconcile 사이클마다 호출 (filter/toggle 지원 — 첫 반환값 nil 시 실제 파괴, Visible 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), prev 그대로 반환하면 저비용 재사용 경로. 파라미터 순서는 반환값 순서(prev류 먼저, userdata류 나중)와 맞춤(2026-08-11 세션 정정, 원래 userdataprev보다 앞이었음). updateFnindexkeyFn의 raw index(원본 data 배열 위치)와 다른 값 — "이번 사이클에 살아남으면 차지할 압축된 마운트 위치"(candidateIndex, filter로 압축됨), key와도 무관(순서/레이아웃 전용, 식별 목적 아님) — 문서화 시 셋(원본 raw index/key/updateFnindex)을 혼동하지 않게 주의. offsetSlot.Offset을 그대로 전달(형제 Slot/정적 자식 누적합, base/dispatch-core-plan.md의 "Length/Offset" 절) — index/offset 둘 다 raw 값으로만 전달, Slot/Handler가 LayoutOrder 등을 자동으로 세팅해주지 않음 (2026-08-11 세션 확정 — 자동 바인딩은 컴포넌트가 이미 지정한 값을 매직으로 덮어쓰는 문제가 있어 기각, 실제 반영은 전적으로 updateFn 몫). :ListSource를 대신 안 만듦 — item/index를 반응형으로 감쌀지는 updateFnuserdata에 직접 관리, "버림(nil 반환)/ 다시 그림(prev==nil, 항상 새 Source로 처음부터 올바른 값 생성)/ source만 갱신(prev 재사용, 값 다를 때만 :Set)" 세 갈래를 updateFn이 명시적으로 나눠야 낭비 없음 — 재사용 중인 Source에 미리 :Set()해뒀다가 결국 새로 그리게 되면 그 :Set()은 아무도 안 구독한 상태라 무의미한 연산이 됨, updateFn만 이 갈래를 정확히 알아 낭비를 피할 수 있음(반환값 두 개는 서로 독립, resultnil이어도 userdata는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는 mounted가 아니라 직전 사이클 keyIndex 전체를 순회해야 함 (userdata만 살아있는 채로 key가 완전히 사라지는 케이스 커버). userdata = userdata or {} lazy-init 패턴이 Luau 제네릭에서 잘 좁혀지는지 실측 필요. userdata는 GC-native 값만 허용, :Subscribe()한 Observer류 명시적 cleanup 필요한 값은 UBitem을 nilable로 바꿔 최종 제거 시 정리 훅을 한 번 더 부르는 안은 기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서 절반만 동작, retract가 Destroy 시 안 불리는 것과 같은 이유). (2026-08-09 세 번째 세션 확정, offset/raw index/세 갈래 구조는 2026-08-11 세션 추가 확정, base/slot-plan.md "Slot:List(...)" 절) 구현. data:Observer(fn) 구독은 :List() 호출 시점이 아니라 Slot 마운트 시점까지 lazy — Dispatch.setLength와 같은 패턴으로 bindLifetime(inst,observer)(마운트 이후 :List()가 불리면 self._mounted 확인 후 즉시 활성화) (2026-08-09 일곱 번째 세션, base/slot-plan.md "Slot:List(...)"의 "구독 시점" 절) Slot.Offset: Source<number>Slot.Length처럼 공개 필드로 노출 — Slot 마운트 시점에 Dispatch.setOffsetSource가 등록하는 바로 그 Source를 self.Offset으로도 저장(2026-08-11 세션, base/dispatch-core-plan.md의 "Slot.Length와 Slot.Offset은 별개" 절)

  • base Dispatch/Slot.luau(추상 재조정, mount/unmount/reposition 3훅) + quad-roblox Handlers/Slot.luau(실제 Parent 조작 + reposition — SetSiblingIndex 또는 LayoutOrder 기반이면 no-op, 구현 선택)

  • Slot:Single(state, updateFn?) 확정:List를 0/1개짜리 배열로 감싸는 순수 sugar, index 없이 offset/prev/userdata만 전달, 고정 key로 prev 재사용 보장(2026-08-11 세션, base/ slot-plan.md "Slot:Single" 절). [2026-08-11 일곱 번째 세션] updateFn이 선택 인자로 완화됨(기본값 identity) — 아래 반응형 raw 요소 항목 참고.

  • Slot-in-Slot 중첩 확정 — 요소 타입 제약에서 Slot 배제 해제 (T = Instance | Slot<Instance>, 자기 참조 제네릭은 실측 필요). Dispatch.setLength/setOffsetSource를 물리 inst 대신 Slot 자신을 owner 키로 재사용하는 재귀 attachSlot으로 최상위/중첩 마운트 통합(새 프리미티브 없음). Slot.Length가 raw 개수에서 "요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 Clear()가 아니라 flat destroySlotTree(파괴 walk + unbindLifetime walk, outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리 서브트리만 죽는 경우 명시적 unbindLifetime 필요(GC-native 정리의 예외 케이스). DOM 백엔드가 nested Slot을 실제 <div> 중첩으로 매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) — 숫자 기반 메커니즘이 web에도 그대로 필요하나, insertBefore/ removeChild가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, base/slot-plan.md "Slot-in-Slot 중첩" 절).

  • Slot(initial?: {T}) 생성자로 확장 — "인자 없는 빈 생성자로 확정"을 뒤집음, :Add 반복 호출 sugar일 뿐(새 마운트 로직 없음). initial ~= nil이면(빈 테이블도) 즉시 _crudUsed = true — 상태상 Add→Remove와 동일하므로. _crudUsed_listed 상호 배타 가드 신설 — 기존엔 :List 설치 후 수동 CRUD만 막았지 반대(수동 CRUD 후 :List 설치)는 안 막아서 :List의 reconcile이 기존 요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, base/ slot-plan.md "CRUD API 확정" 절).

  • recompute off-by-one 버그 수정(2026-08-11 세션, base/ dispatch-core-plan.md "Length/Offset" 절) — sum 누적과 offset:Set 순서가 뒤바뀌어 Offset이 자기 자신을 포함해버리던 버그(예: 유일한 자식인데도 Offset이 0이 아니게 됨) 수정. 재진입 방지 가드는 검토 후 기각 — 각 Slot이 Relate(자기 자신)으로 독립된 bk를 가져서 nesting만으로는 같은 bk가 재진입되는 경로 자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute 도중 같은 Slot의 length에 다시 쓰기)은 Source⊇State의 "단방향" 원칙과 같은 카테고리의 위반으로 명시적 UB 명명(방어 로직 없음, 기존 "일반적 재진입 방어 안 함" 원칙과 정합). offset/sum은 0-based 개수, index는 1-based Lua 관례라는 것도 명시.

  • 반응형 raw 요소 — State<T>/Source<T>도 Slot 요소로 허용 (2026-08-11 일곱 번째 세션, 같은 세션에 정정) — Slot:Add가 받는 실제 타입은 T | State<T> | Source<T>(임의 깊이 조합 가능). [정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를 Compute로 파생" 안은 기각(nilable 지원하려면 배열 파트 None을 다시 끌어들여야 하고, Length 계산에 예외가 생기고, Move/Swap이 인덱스-구독 동기화 부담을 짐 — :List가 element 아닌 key 기준인 이유와 정면 충돌) — 새 메커니즘 없이 순수 :Single sugar로 확정: isState(element)면 그 자리에 내부적으로 Slot(): Single(element)(updateFn 생략 시 identity 기본값)를 대신 삽입. _elementsNone이 절대 안 들어감(비어있는 nested Slot이 자연히 Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구. :SingleupdateFn도 이 sugar가 성립하도록 선택 인자로 완화 (Slot:Single(state, updateFn?), 기본값 identity). :Single/:List와는 대체 관계가 아니라 같은 메커니즘 위의 다른 updateFn일 뿐 — raw State<T> 요소(identity)는 coarse swap, updateFn 직접 지정 시 prev/userdata patch-reuse + offset 접근(:Single이 애초에 생긴 이유). 부수 발견(사용자): :Listreconcile이 nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 index를 그 결과의 .Length만큼 건너뛰도록 pos 커밋 공식도 같이 수정 (pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가 겹침. base/slot-plan.md "반응형 raw 요소" 절.

M7 — Modifier

  • Modifier()(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 명시 — Source(default)/Ref(default)/Store({defaults})와 같은 Type(args) 팩토리 관습, modifier-plan.md 3번)
  • flatten-before-dispatch(isModifier(v)로 배열 항목 중 Modifier만 판별해 필드 merge, 나머지는 안 건드리고 통과 — 2026-08-07 열 번째 세션 명시, modifier-plan.md 1번), immutable table.clone 체이닝 — table.clone이 메타테이블을 복사 아닌 참조로 공유해 제네릭 __index 기반 체이닝이 안 끊긴다는 메커니즘은 확인됨(2026-08-12 열일곱 번째 세션, modifier-plan.md "table.clone의 정확한 동작" 절) — 실제 Luau 실행 확인은 luau-test17-modifier-index-tableclone-chaining.luau
  • Modifier.Overridden(mod1, mod2, ...)(이름 확정, 구 MergeOverride, 2026-08-08 세션) — 필드별 raw 덮어쓰기, 특별한 State/함수 분기 불필요(modifier-plan.md 9번)
  • Overridden가 서브타입 관계인 서로 다른 Modifier 타입(예: FrameModifier/ GuiObjectModifier)을 섞을 때의 타입 시그니처 — [해소됨, 2026-08-13 첫 실측 라운드] luau-test/09로 실측 완료, 우려대로 깨짐 확인됨 → Overridden(...: any): any로 느슨하게 열어두는 게 실제 구현 방향(modifier-plan.md 9-2번)
  • State<Modifier> 조합에 isModifier 기반 명시적 error 적용 (modifier-plan.md 7번, 2026-08-09 세션 확정) — 타입 차단은 되면 좋은 보너스로 선택 검증(필수 아님)
  • :Apply(factory) 팩토리 함수 체이닝(modifier-plan.md 8번, 예약 키 Apply가 제네릭 __index 필드 setter와 안 겹치는지 확인)
  • :Peek<<T>>(key): T|State<T>|nil 필드 읽기 접근자 + isState(x)/isSource(x): boolean(Brand 공유 레지스트리 기반 — modifier-plan.md 9번, brand-plan.mdBrand 절, M2의 Brand.luau에 이미 구현돼 있어야 함)
  • 인라인 키/setter로 modifier 필드를 명시적으로 지우는 None 센티널 (이름 확정, modifier-plan.md 2-1번, Peek 반환 타입에 None 추가) + 이를 nil로 재디스패치하는 base 내장 NoneHandler (dispatch-core-plan.mdNone 센티널 절, M2 dispatch 엔진의 "이전 매치 핸들러 추적" 항목과 함께 구현 — StoreBind 핸들러와 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — None 센티널 자체는 확정 완료. [2026-08-13 열네 번째 세션 갱신] NoneHandler가 쓰는 재-dispatch 배관에서 선행 retractFrom 호출은 폐기됨 — 그냥 Dispatch.process(inst,k,nil,index+1) 한 줄 (base/dispatch-core-plan.md)
  • 프로퍼티류 필드 타입에 T' = T | Tween<T> 치환 반영(타입 생성 스크립트가 Position: UDim2 자리를 UDim2 | Tween<UDim2>로 만들면 끝, Modifier 런타임/__index 자체엔 변경 없음 — modifier-plan.md 10번, 2026-08-10 세션, base/tween-plan.md)

M8 — Ref

  • Ref.luau(.Value 읽기 전용 필드 + :Set(value)/:Callback(fn)/ :Wait(thread?), 전부 self 반환) + PreRef.luau(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — base/ref-plan.md "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절 + "API 모양" 절)
  • (v=Ref) 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 Ref(default) 인스턴스를 인식해 바인드(별도 CreatedRef 래퍼 없음 — 이름 자체가 폐기됨, 아래 참고)
  • PreRef pre-pass — 새 Dispatch.* 함수 없이 Dispatch.drive(inst, flattened) 자신이 두 패스(배열→해시) 루프 전에 배열 파트를 훑어 PreRef 항목만 fire(Dispatch.process/getHandler 우회하는 raw 루프, flatten 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 충돌하므로 기각). 복수 PreRef는 배열 index 순서 그대로(별도 규칙 없음). fire된 슬롯은 그 자리에서 소진([정정, 2026-08-14 두 번째 세션] None이 아니라 전용 센티널 ProcessedPreRef 처리 — 아래 ProcessedPreRefHandler 항목이 그 자리를 정상 두 패스로 마저 처리) — base/ref-plan.md "PreRef" 절
  • PreRef 동적 경로 가드 Handler — {isHandlable = v is PreRef, process = error(...)} 형태로 정상 우선순위 레지스트리에 등록, NoneHandler와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 우회한 버그라는 뜻 — 같은 절 참고
  • [2026-08-14 두 번째 세션 신설] ProcessedPreRefHandler{isHandlable = v == ProcessedPreRef, process = setLength(0)+ setOffsetSource(None)+no-op retract} 형태로 정상 우선순위 레지스트리에 등록, NoneHandler와 같은 "한 값 종류 전담" 패턴. PreRef pre-pass가 소진시킨 자리가 Length/Offset에 "0 기여"를 등록할 책임을 지는 자리 — base/ref-plan.md "PreRef" 절, base/ dispatch-core-plan.md "Length/Offset" 절
  • Ref 콜백/대기자 실행 루프(type(v)=="thread"coroutine.resume(v, self)+nil로 소진(2026-08-09 열한 번째 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 None이 아닌 nil, table.insert 대신 빈 슬롯 선형 탐색 등록), 함수면 v(value) 호출+유지 — 같은 배열 하나로 통합). :Wait(thread?)threadnil이면 coroutine.running() 캡처+yield, 있으면 등록만 하고 즉시 self 반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
  • LifetimeHandle quad-roblox 실제 구현 — bindLifetime/canExecute 본체(GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장 — 인터페이스 자체는 M2로 이동됨, Relate 자체는 quad-base라 quad-roblox 쪽 재구현 없음)

M9 — 컴포넌트 합성 레이어

  • 플레인 함수 컴포넌트 관례 문서화/예제
  • props.Modifier/props.Ref 전달 관례를 정식 컴포넌트로 검증(M0 스파이크를 정식화)

M10 — Event / OnChange / Attribute / Tag

[2026-08-13 열네 번째 세션] 0-Z(Attribute 이름 소유권)/0-A(하강 diff) 확정 완료 — 이 마일스톤의 Tag/Attribute 항목은 quad-base로 재배치됐고(엔진 op addTag/removeTag/setAttribute만 주입), 이름 소유권은 그룹 전용 키 + AttributeKeyHandler의 이름 claim이 판정함. 정본은 base/attribute-plan.md/base/tag-plan.md.

  • Handlers/Event.luau(ReflectionService 기반 자동 판별)
  • Handlers/OnChange.luau(OnChange(name) DI 키 팩토리+Handler, GetPropertyChangedSignal 바인딩 — 제네릭 없이 콜백 타입은 인라인 명시, 이름별 weak 캐시로 OnChange(a) == OnChange(a) 동등성 보장 (AttributeKey와 동일 기법), base/onchange-plan.md, 2026-08-10 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
  • [2026-08-13 열네 번째 세션 재배치] quad-roblox/EngineOps.luau — 주입되는 엔진 op 3개: addTag(inst,{string})/removeTag(inst,{string}) (CollectionService), setAttribute(inst,name,v)(v==nil이면 삭제). RobloxFactoryBaseModule에 주입(bindLifetime/canExecute와 같은 패턴) — 아래 base 핸들러들이 이걸 호출함 (base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
  • quad-base/AttributeKey.luau(단일 키 AttributeKey<<T>>(name) + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 String/Number/BooleanAttribute — 엔진 고유 타입 패밀리 (Color3Attribute류)만 quad-roblox의 D/DI 층에서 각자 추가. 타입 파라미터화 이름만 착수 전 확인, base/attribute-plan.md)
  • quad-base/Dispatch/AttributeKey.luau(AttributeKeyHandlersetAttribute(inst,name,v)v가 뭐든 무조건 호출 + 이름 claim(nameClaims Relate, 다른 키 객체가 같은 이름에 들어오면 즉시 error, 반환 클로저는 자기 claim만 반납하고 엔진 부작용 없음). HANDLER_PRIORITY_FALLBACK으로 등록. question.md 0-Z 결정 — base/attribute-plan.md "이름 소유권" 절)
  • Attribute.luau(quad-base — 그룹 값 타입+API: Attribute(store1, store2, ...)/Merged/:NameMap, Tag와 동형 array-part 값 객체, base/attribute-plan.md)
  • quad-base/Dispatch/Attribute.luau(AttributeGroupHandler — 이름마다 그룹 전용 키(비공개 GetKey, 그룹 값 객체별·이름별 메모이즈)로 Dispatch.process(inst,key,source,1)만 부르고, 반환 클로저가 자기가 등록한 키 전부에 Dispatch.retractFrom(inst,key,1). process 안에서 retractFrom을 먼저 부르면 안 됨(철거는 전적으로 클로저 몫). 실제 setAttribute/store-bind 구독/이름 claim은 전부 단일 키 경로 재사용 — base/attribute-plan.md "메커니즘" 절)
  • Tag.luau(quad-base — 값 타입+immutable clone 체이닝: Tag(...)/ :Added/:Removed/:Contains/:Apply/Merged/:Names, base/tag-plan.md — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 모델은 archive/tag-hash-key-model-reversed.md)
  • quad-base/Dispatch/Tag.luau(TagHandlerisHandlableisTag(v). addTag는 온전히 process, removeTag는 온전히 반환 클로저 — 이름별 홀더 집합(tagNameMap, 위치 k 기준 참조 카운트)이 비었을 때만 실제 removeTag, 그마저도 클로저가 받은 새 값이 그 이름을 Contains하면 skip해 깜빡임 방지. 제거할 이름은 모아서 한 번에 removeTag(inst, names). process 쪽 별도 diff 없음, kTagMap도 불필요(클로저가 v를 직접 캡처). HANDLER_PRIORITY_FALLBACK으로 등록 — 2026-08-12 열한 번째 / 2026-08-13 네·다섯·열네 번째 세션, base/tag-plan.md)

M11 — Tween

[2026-08-10 세션, 구조 재설계] 독립 Dispatch 핸들러 모델에서 값-레벨 Tween<T> 래퍼 모델로 전환 — 상세는 base/tween-plan.md(전면 재작성), 구 모델은 archive/tween-special-bind-key-reversed.md.

  • quad-base/Tween.luau(값 타입만 — Tween(opts) 팩토리, isTween/ TweenTag Brand, Value: T plain만 받고 State 재귀 없음)
  • Handlers/Property.luauisTween(realv) 분기 추가(기존 Handlers/Tween.luau 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 (RobloxTween | true | nilnil=첫 세팅, true=세팅됨/트윈 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
  • override 정책 확정 완료(2026-08-12 세션, base/tween-plan.md "확정: Tween{...} 최종 모양" 절) — 검토했던 4가지가 Tween.Cancel (기본)/Tween.Finish 2값으로 압축됨(로블록스 TweenBase API 현실상 나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두 "정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정. 구현 시 순서 주의: 이전 트윈 정리 → 그 다음 새 값 세팅
  • 트윈 옵션 값 모양 확정 완료(2026-08-12 세션, base/tween-plan.md) — Info: TweenInfo? 우선 + 편의 필드(Time/Style/...) 폴백, 기본값은 로블록스 TweenInfo.new() 자체 기본값과 일치. 옵션 필드는 전부 plain만(State 불가)
  • quad-roblox/Animate.luau시그니처도 이미 확정 완료(2026-08-12 두 번째/세 번째 세션, base/tween-plan.md): Tween opts(Value 제외)를 T|State<T>로 받아 각 필드를 resolve한 뒤 Tween{...}을 반환하는 function(self)...end:Apply(Animate{...})로 체이닝(:Compute가 아님, research/operator-sugar-plan.md "왜 :Apply인가"). CanAnimate 필드 포함(falseTween으로 안 감싸고 plain 값 그대로). M11은 구현만 하면 됨
  • initValue(진입 애니메이션) — 에이전트 범위 제외로 확정 (2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님

특정 마일스톤에 안 묶이고 병행 가능

  • 용어 정리 스윕 — State/DI/Slot 등(PerInstanceStateRelate로 대체·해소됨) — .claude/question.md 1번, 최종 이름 확정되는 대로 아무 시점에나
  • 각 마일스톤 완료 시 .claude/qa-request//.claude/archive/에 기록, 필요하면 CLAUDE.md "세션 히스토리"도 갱신(전체 원문은 .claude/session/에, CLAUDE.md엔 2~4줄 요약+링크만 — 2026-08-11 재구조화 세션 참고)

백로그 (스코프 밖 — 필요성이 실제로 드러나면 그때 설계)

  • research/existing-instance-bind-plan.md — Modifier 정적 flatten과 긴장 관계 있음, 재검토 시 그 문서부터 다시 볼 것
  • 범용 렌더 디버깅 도구로서의 quad-mock(Tween mock 등 동적 동작 포함, M1의 quad-base 테스트용 mock과는 별개)
  • quad-debug/quad-debug-roblox-plugin — 실물 Instance→코드 위치 역추적 Studio 플러그인(research/debug-tooling-plan.md). 위 quad-mock과 목적이 다름(오프라인 검증 vs 실시간 라이브 관찰) — 단 trace 이벤트 스키마를 공유할 여지는 있음, 그 문서 참고. M2/M3/M5 구현 시 훅 확장 지점만 고려해두면 이 항목 자체는 지금 착수 불필요.
  • v1 마이그레이션 가이드(objectListClass.__newIndex 오타 기능 재현 테스트는 2026-08-13 세 번째 세션에 불필요로 해소됨 — archive/question-resolved.md 참고, v2엔 대응 개념 자체가 없음)
  • Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음