quad/ROADMAP.md
qwreey 031495cc0b
qa: 9라운드 손 트레이싱 실행 + Q1~Q3 확정·반영 — 체크포인트 (Q4~Q10 다음 세션)
8라운드가 써둔 지시서(-round9-brief.md)대로 커밋 9dd8213의 델타를 재트레이싱해
발견 18건(H-124~H-141)을 냈고(qa-request/pre-implementation-handtrace-round9.md),
§4 문항 중 Q1~Q3를 사용자와 확정해 base/·ROADMAP에 반영했다. 결정의 소스는
-round9-followup.md(진행 표가 상태의 소스). 🔴 둘 다 luau 실측 재현.

Q1 H-124 — recompute가 lengthList[i]를 되감기 판정보다 먼저 읽어, offset:Set 안의
  사용자 코드가 커서 뒤 자리 수를 줄이면 sum += nil로 죽고 recomputeBlocker가
  영구 On. 되감기 판정을 앞으로(continue), 읽기·누적은 되감지 않을 때만.
Q2 H-125 — 재마운트 시 setOffsetSource가 slot.Offset을 바꾸는 순간 _baseObserver가
  unbind 상태라 두 필드가 0으로 안 내려가 옛 베이스의 offsetCache[1]을 씀.
  사용자 확정: Offset·_baseObserver를 Slot 생성자로(첫 마운트/재마운트 분기
  소멸), materializeSlotTree는 blocker:On → bindLifetime → setOffsetSource,
  파괴는 _destroyed 플래그 하나(핸들은 unbind만, mutate CRUD·:List·마운트 진입
  error, Owned=false는 안 섬, 이중 dispose no-op).
Q3 H-126/H-137/H-141 — element→index 맵이 Slot 층(slot._elemIndex)과 Dispatch
  층(bk.tokens/indexOfToken)에 두 벌 있었고, 후자의 token은 사용자가 정한 적
  없는 것(2026-08-25 /code-review가 발명해 사용자 인용문 옆에 앉아 있던 것).
  bk.indexOfElement 하나로 통일, setLength 5번째 인자 element, splice가 비운
  자리는 세 배열 전부 처리. H-137 소멸.

부수: H-140(ROADMAP의 폐기된 "해제 시 slot.Offset = nil" 잔존) 정정,
H-125 피해 범위를 "중첩 Slot의 Offset"으로 축소(유저 체인은 요소 인스턴스에
바인드돼 전파됨 — 실측), G각도로 "for d in seen do는 유효한 Luau가 아니다"가
거짓임을 확인, keyof<{}> 빈 Store 실측 클린.

conventions.md 신설: "/code-review(그리고 메인 세션)가 내놓는 새 필드·인자·
이름·메커니즘은 발견이지 결정이 아니다" — 이 세션에서 메인 세션도 같은 실수를
세 번 했다(subject 인자 / Observer 위치 필드 = 기각된 Effect userdata 재개방 /
조회 클로저).

검증: quad-doc-auditor 6라운드(확실 1→1→3→1→1→0, base/ 본문 결함은 1라운드
이후 0건 — 나머지는 인덱스 레이어·인용처), doc-check ERROR 0. /code-review high는
Q4~Q10 반영 뒤 한 번에(같은 파일을 또 건드려 diff가 섞이므로 지금 체크포인트).

Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn
Co-authored-by: qwreey <me@qwreey.moe>
2026-08-27 01:30:48 +09:00

130 KiB

ROADMAP.md

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

2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.

[2026-08-24 기준] M0/M1의 원래 체크박스는 전부 닫혔고 다음은 M2(반응형 코어) — 단 M0에는 [2026-08-22] "재검증 대기" 미체크 항목들이 새로 붙었습니다(설계가 바뀌어 무효화된 스파이크들, 아래 그 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. [2026-08-24] 착수를 막던 마일스톤 순서 문제는 해소됐습니다 — M2와 M3의 번호를 맞바꿔 반응형을 먼저 짓기로 확정했습니다(아래 M2 배너가 소스). [2026-08-25] 그 교체로 올라왔던 항목 둘도 닫혔습니다 — 중간 State GC는 _hold 불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 store:Of<<T>>(name) 하나로(옛 GetDynamic 흡수) + 예약 키 충돌은 예약 키 진단 타입 함수로. .claude/question.md 최우선 절은 지금 비어 있습니다. 결정 전량의 소스는 .claude/qa-request/pre-implementation-handtrace-round7-followup.md, 그리고 [2026-08-26] 8라운드 몫은 -round8-followup.md(7라운드 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다. [2026-08-27] 9라운드 몫은 -round9-followup.md — Q1Q3(recompute 되감기 순서 / Slot 생성자의 Offset·_baseObserver·_destroyed / bk.indexOfElement로 토큰 폐기)까지 반영됐고 Q4Q10은 대기 중입니다. 어느 체크박스가 바뀌었는지는 여기서 세지 않습니다 — 해당 체크박스에 각각 H-1xx 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 있습니다). M1까지의 산출물은 quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/.luaurc, mock 테스트 하네스, New()/RunInit/ AddPlugin 골격. 다만 M0의 검증 스파이크 여러 개가 설계 변경으로 재작성 대기 상태입니다 — 아래 "재검증 대기" 절 참고(개수·현황의 소스는 .claude/luau-test/STATUS.md, 여기서도 그 절에서도 세지 않음).

[2026-08-24] 이 문서에 이번에 반영된 것: M2(반응형)와 M3(디스패치)의 번호·순서 교체, 그에 따라 Brand/Relate/LifetimeHandle 인터페이스가 M2 앞머리로 이동하고 EpochMap/GateNode/Blocker가 M2로 복귀, Observer/Effect 동적 경로 가드 등록과 ObserverEffectLeafHandler가 M3로 이동.

[2026-08-22] 그 전 회차에 반영된 것: Dispatch.drive가 두 패스가 아니라 단일 일반화 for(F-4-1) / 물리 조작 주입 op 이름이 native*로 확정 / None이 M7에서 디스패치로 이동 / [x] 표기 의미 분리 (바로 아래).

⚠️ [x]의 의미 — 체크박스는 "짜야 할 코드"만 담습니다. "설계가 확정됐다"/"타입을 실측해봤다"는 사실은 체크박스가 아니라 각 마일스톤의 ### 확정된 것 절(항목이 여럿일 때) 또는 - **[확정된 것 — 코드 아님]** 불릿(하나뿐일 때)에 둡니다. 예전엔 둘이 같은 목록에 섞여 있어 [x]만 보고는 코드가 있는지 알 수 없었습니다 — M0/M1의 [x]는 실제 구현이고, M2/M6/M11에 섞여 있던 [x]는 설계 확정·실측이었습니다. 새 항목을 추가할 때도 이 구분을 지킬 것.

M1 착수 도중 wally→pesde 전환이 확정돼(base/project-setup-plan.md) M1 체크박스의 wally.toml 표기도 pesde.toml로 정정. 부수로 M2가 의존하는 quad-types/type-version-check 두 워크스페이스 멤버도 이 과정에서 먼저 신설됨(base/quad-types-plan.md).

[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을 실제로 짜보기(다이아몬드 의존성 케이스 포함 — [2026-08-14 정정] 확인할 것은 "이미 invalid면 전파 중단되는지"가 아니라 그 반대: emit은 자기 invalid 상태와 무관하게 전파되고, 중복 재계산은 :Get() 시점 캐시로만 막히는지([2026-08-21] 그 뒤 "항상"에서 "같은 Epoch의 같은 리비전이 두 번째로 도착했을 때만 접힘"으로 좁혀졌다 — 아래 참고). 특히 :Get()을 안 부르는 Observer가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터 침묵했음(archive/invalidate-dedup-propagation-reversed.md). 스파이크 05-store-state-diamond-propagation.luau[2026-08-19 재작성 완료] 그 모델("emit은 항상 전파 + :Get() 시점 캐시로만 dedup")로 재검증 통과 — [2026-08-21] 그 모델이 다시 바뀌어 rewrite-required/로 되돌아갔다(Epoch 리비전 비교 채택으로 다이아몬드 Observer가 이제 변경당 1회만 울어야 함, base/state-epoch-plan.md))
  • Source가 State를 구조적으로 만족하는 제네릭 타입(:Compute<U>(self: Source<T>, ...) -> State<U>류, self 타이핑 + State 참조 혼합)이 Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, base/source-state-plan.md "Source가 State를 만족함" 절 — State<T>Source를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 피할 수 있어 보이나 실제 검증 전엔 확정 아님. [통과] luau-test/done/08-type-source-satisfies-state.luau — 핵심 케이스 통과, 잔여 자기재귀 케이스는 Luau 한계로 별도 확정 (base/typing-limits.md), 설계 영향 없음)
  • process(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable 우선순위 스캔 포함 — luau-test/done/03-recursive-store-bind-dispatch.luau 통과)
  • props 순회의 "배열 파트 먼저, 해시 파트 나중" 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, PreRef pre-pass + 일반 Ref의 위치 기반 순서까지 최소 스파이크로 검증 ([2026-08-21 정정] 이 계약을 두 패스로 구현한다는 서술은 F-4-1로 폐기 — 구현은 단일 일반화 for, 계약은 그대로. 그래서 두 루프로 짜인 스파이크 01rewrite-required/로 갔다 — 아래 "재검증 대기" 참고) (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에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로 타입 체크/런타임 양쪽에서 문제없이 동작하는지luau-test/done/06-component-boundary-nil-hole-props.luau 통과
  • 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 .claude/base/ 갱신 — 실제로 여러 차례 발생, 그때마다 반영됨(각 스파이크 항목의 "정정"/"재작성" 표시가 그 기록)

통과 기준: 위 항목들이 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행 — [2026-08-19] 전부 통과, M1 진행 중(개수는 luau-test/STATUS.md가 소스, 여기서 세지 않음).

재검증 대기 — 위 [x]가 검증했던 스파이크 일부가 무효화됨

⚠️ [2026-08-22 신설] M0의 원래 체크박스는 전부 닫혔지만, 그 뒤 설계가 바뀌면서 당시 통과했던 스파이크 몇 개가 지금 계약을 검증하지 않는 상태가 됐다. rewrite-required/로 되돌아간 것들이고, 어느 마일스톤 체크박스에도 없어서 그냥 잊히기 쉬운 자리라 여기 모은다. 무엇이 지금 어느 폴더에 있는지의 소스는 항상 .claude/luau-test/STATUS.md — 여기서 세지 않는다.

  • 01(props 순회 순서) — 두 루프로 짜여 있어 지금 계약의 구현 (단일 일반화 for, F-4-1)과 안 맞음. 재작성하면서 "배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index 순서"를 그대로 확인할 것
  • 05(다이아몬드 전파)Epoch 리비전 비교 채택으로 다이아몬드 Observer가 이제 변경당 1회만 울어야 함(옛 "emit은 항상 전파" 모델을 검증 중). base/state-epoch-plan.md 기준으로 재작성
  • 04(Dispatch 체인 retractFrom) — 하강 diff 확정으로 무효화. M3 착수 시 같이 처리하는 게 자연스러움
  • 19(소유권/참조카운트 Relate 패턴)B 섹션만 낡음 (공개 AttributeKey(name) + 인덱스 1 점유 체크가 폐기되고 그룹 전용 키 + AttributeKeyHandler의 이름 claim으로 바뀜, 0-Z 확정). A/C 섹션은 손댈 것 없음 — Attribute 소관이므로 M10 착수 시 같이 처리([2026-08-22 정정] 여기 "M6"라고 적었으나 B 섹션이 검증하는 건 Slot이 아니라 Attribute 이름 소유권이다)
  • 22(Ref/PreRef/PostRef 브랜드)Brand가 인스턴스 브랜드로 전면 재작성되며 옛 Brand.set/Brand.get 구현에 의존하던 부분이 깨짐(검증 대상인 isRef/isPreRef 포함 관계 자체는 그대로). M8 착수 시 같이 처리
  • 15(:Compute trailing deps 타입팩) — 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정). M2 착수 시 같이 처리
  • 10(Roblox Studio 확인)bindLifetime/canExecute/ unbindLifetime 재정정으로 무효화. Studio 작업이라 HUMAN_TODO.md 1번(계정 분리)이 선행
  • 아직 파일이 없는 실측 항목중간 State GC (base/source-state-plan.md, 상류 strong / 하류 weak 불변식 — .claude/todos.md가 "M2 착수 전 필요"로 지정. [2026-08-24 승격] 순서 교체로 반응형이 바로 다음 마일스톤이 되면서 question.md 최우선 절로 올라갔다). [2026-08-24 정리] 여기 같이 적혀 있던 R-11table.insert 구멍 재사용은 6라운드 H-7전제 자체가 없어져 폐기됐다(Ref.Callbacks가 해시맵 셋이 되어 구멍 개념이 없음) — luau-test/STATUS.md가 소스

M1 — 실제 스캐폴딩

  • quad-base/, quad-roblox/ 폴더 + 각 pesde.toml([2026-08-19 정정] 이 체크박스는 원래 wally.toml이라 적혀 있었으나 같은 날 wally→pesde 전환이 확정돼 pesde.toml로 정정 — base/project-setup-plan.md 참고. quad-roblox/src는 아직 빈 폴더 — 실제 소스는 M5)
  • 루트 default.project.json, .luaurc(architecture.md "구현 착수: 소스 트리 구조 확정" 절 그대로)
  • [2026-08-24 소급 등재, 6라운드 H-47/H-48] quad-base/src/Debug/init.luauInitDebug(module)module.debug = false를 심는다. 이미 커밋돼 있었는데 이 목록에도 architecture.md 소스 트리에도 없었다(ROADMAP.md 전체에 "Debug"가 0건이었다). ROADMAP.md가 진행 상황의 소스인데 M1 체크박스만 보면 이 모듈의 존재도 완료 여부도 알 수 없던 상태. 같이 확인된 것: Quad.debug의 스코프는 "인스턴스별"로 이미 확정돼 있다(이 파일이 인스턴스 필드를 심고 quad-typesQuaddebug: boolean을 필드로 갖는다) — base/module-lifecycle-plan.md가 "미정"이라 적어둔 걸 H-48이 닫았다
  • quad-base용 최소 mock 테스트 하네스(Vide test/mock.luau 선례, 순수 luau CLI, architecture.md "테스트 전략" 절 참고) — quad-base/test/mock.luau + smoke.*.luau[2026-08-25 정정, 7라운드 H-78] "전부 PASS"엔 전제조건이 있었다. luau CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘 다) pesde의 워크스페이스 링크가 전부 심볼릭이라, 그대로는 smoke.init/smoke.plugincould not resolve child component "src"로 죽고 luau-analyze조용히 통과한다(모듈을 any로 떨어뜨림 — "거짓 클린"). ./scripts/test.sh로 돌릴 것 — 그게 scripts/relink.sh를 먼저 돌려 심볼릭을 실제 복사로 바꾼다. 그 뒤 셋 전부 PASS 확인
  • 최상위 New()/InitXxx(module) 팩토리 체이닝 골격 — 각 서브시스템 Init이 module을 파라미터로 받아 뮤테이션, Relate 기반 인스턴스별 멱등 가드(base/module-lifecycle-plan.md의 "New()의 내부 구성" 절 그대로, 2026-08-19 확정) — quad-base/src/init.luauNew()/ RunInit/AddPlugin으로 구현·smoke 테스트 검증 완료
  • 이 시점부터 .claude/qa-request//.claude/archive/ 폴더 실사용 시작([2026-08-19 확인] 두 폴더 모두 M1 이전인 설계 단계부터 이미 쓰이고 있었고 — QA 라운드/역전 결정 기록 — M1 착수 이후에도 계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상 항상 충족돼 있었음)

M2 — 반응형 코어 (Source/State/Store)

[2026-08-24 순서 교체] 옛 M3(반응형)와 옛 M2(디스패치)의 번호를 맞바꿨습니다. 의존이 양방향처럼 보였지만 실제로는 한 방향이었고 (디스패치 → 반응형이 본체 의존, 반대는 핸들러 등록 표면뿐), 옛 순서로는 디스패치의 Length/Offset 배관도 그 마일스톤의 mock 대상 테스트도 State 없이 짤 수 없었습니다. 근거와 기각된 선택지는 .claude/archive/question-resolved.md의 "마일스톤 경계" 절.

⚠️ 이 날짜 이전에 쓰인 session/·archive/·qa-request/ 문서의 M2/M3는 옛 의미(M2=디스패치, M3=반응형)입니다 — 히스토리 문서라 소급 수정하지 않았습니다. 라이브 문서(base//research//reference// luau-test//인덱스 레이어)는 전부 새 번호로 맞췄습니다.

부수로 Brand/Relate/LifetimeHandle 인터페이스가 디스패치에서 이 마일스톤 앞머리로 왔습니다 — 반응형이 이 셋을 먼저 요구하기 때문: SourceSourceBrand+EpochBrand에 등록되고(Brand), State 전파 루프가 매 발화마다 canExecute를 부르며(LifetimeHandle), 그 판정이 Relate에 복사해둔 gcconn 위에 얹힙니다. 반대로 2026-08-22에 여기서 디스패치로 옮겼던 EpochMap/GateNode/Blocker는 되돌아왔습니다 — 앞당길 이유 자체가 순서 교체로 사라졌고, "게이팅 먼저"는 그대로 지켜집니다.

공통 기반 — 반응형보다 먼저 (구 M2, 지금의 M3에서 이동)

셋 다 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다.

  • Brand.luau([2026-08-21 재작성] 인스턴스 브랜드 — Brand()가 브랜드마다 weak-key 집합 하나를 들고 :register(x)/:is(x), 다중 태깅 허용(SourceSourceBrand이면서 동시에 EpochBrand). 옛 공유 레지스트리 + Brand.get(x) -> tagarchive/brand-shared-registry-reversed.md. [2026-08-22] isEpoch도 여기 포함Epoch 인터페이스 확정으로 EpochBrand가 생겼고 base/state-epoch-plan.md/base/source-state-plan.md가 "런타임 분기는 isEpoch로"라고 확정했다 — 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) and not isPostRef(v)로 명시적으로 좁혀야 함([2026-08-14 아홉 번째 세션] PostRef 확정으로 제외 항 하나 추가, isPostRefisRef 아래 형제로 신설). isModifier는 여전히 단순 항등, 상위 개념 없음. [정정, 2026-08-11 아홉 번째 세션] isAttribute 하나였던 게 isAttributeKey(단일 키 특수 키 predicate, 해시파트 k를 판별)와 isAttribute(그룹 값 predicate, array-part v를 판별, isTag와 같은 결)로 분리됨 — 그룹 Attribute(...) 프리미티브 신설로 같은 이름이 서로 다른 두 대상(키 vs 값)을 가리키게 돼서 갈라짐, base/attribute-plan.md 참고) 전부의 기반. isNone은 레지스트리를 안 쓰고 x == None 항등 비교이지만 Brand.luauNone을 참조하지는 않는다 ([2026-08-18 정정] brand-plan.md가 *"Brand → None 의존을 만들지 않는다"*로 확정 — isNoneNone.luau(M3) 쪽에 산다. 이 마일스톤 분리로 처음 눈에 띈 잔재를 [2026-08-24] 정정한 것) — 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(value)/canBound(value)/canExecute(value) 탑레벨 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 Connected 확인)/M6(Slot의 canExecute)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(pre-implementation-audit.md 우선순위1-9 — 2026-08-07 네 번째 세션에 반영. [2026-08-22 정정] 여기 있던 question.md 번호 참조는 그 항목이 해소되며 이미 깨져 있었고, 지금 그 번호는 다른 항목이 쓰고 있어서 지웠다). [정정, 2026-08-14 다섯 번째 세션] unbindLifetime/canExecuteinst를 안 받는다 — 옛 2-인자 시그니처((inst, value))는 오염이었음. bindLifetime이 바인딩 시점에 inst의 gcconn 참조를 valueRelate로 복사해두므로 "지금 실행돼도 되는가"를 value 하나로 물을 수 있고, canExecute의 실제 호출부(State 전파 루프)엔 inst가 없어서 2-인자로는 호출 자체가 불가능했음. 판정은 (a) 복사된 gcconn의 .Connected 또는 (b) Observer/Effect의 .Subscribed 둘 중 하나 — .Subscribed는 전역 구독 경로 전용 필드라 bindLifetime/unbindLifetime이 읽지도 쓰지도 않음 ([2026-08-26 표기 정정, 8라운드 H-111] "전역 :Subscribe() 전용" 이라고 적혀 있었으나 :WeakSubscribe()도 이 필드를 세운다 — 강·약이 갈리는 건 레지스트리를 강하게 잡느냐뿐이다. 이 문장의 요지 (bindLifetime이 안 건드린다)는 그대로 유효). 역전 원문은 archive/canexecute-inst-arg-reversed.md. unbindLifetime(value) 추가(2026-08-09 여섯 번째 세션)inst 전체 죽기 전에 특정 값 하나만 조기 해제(Dispatch.setLength가 State 재등록 시 이전 Observer를 정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화. bindLifetime/unbindLifetime/canBound/canExecute 넷 다 네임스페이스 없이 탑레벨 함수로 export(Dispatch.xxx류 시스템 네임싱과 구분, isState/isObserver와 같은 1급 프리미티브 취급) — base/lifecycle-pattern.md의 "bindLifetime/canBound/ canExecute/unbindLifetime — 확정" 절 참고. 이중 바인딩 금지 게이트는 canBound(canExecute는 emit 전파 게이팅 전용 — [2026-08-14 열한 번째 세션] canBound가 별도 진입점으로 재도입되어 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스 참고), children 배열 leaf 부착이 실제로는 bindLifetime 호출이라 이 게이트를 그대로 탐

반응형 본체

  • EpochMap.luau ([2026-08-24] 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크 부기 객체(:Update(Epoch|EpochSet) -> boolean이 "뒤로 전파가 필요한가"를 답함, :Refresh/:Sync/:TrackFrom. EpochSet = {[Epoch]: true}배열이 아니라 집합). State.luau에 묻지 말고 별도 모듈로 낼 것 — GateNode(아래)와 State/Effect가 전부 같은 것을 쓴다. Epoch 인터페이스 자체({ Revision: number })와 리비전 갱신(bit32.bnot(-rev))도 여기서 확정 — base/state-epoch-plan.md
  • [2026-08-21 5라운드 — 채택 확정, 같은 날 Epoch로 일반화] State의 재계산/전파 판정은 Epoch 리비전 비교다(base/state-epoch-plan.md) — invalid 플래그가 아니다. 아래 Source.luau/State.luau가 이걸 전제로 짜여야 하므로 EpochMap.luau(위 항목)가 State 본체보다 먼저 온다(StatevalueEpochMap/emitEpochMap 둘을 컴포지션한다 — [2026-08-24] 감사에서 순서가 반대로 놓여 있던 걸 잡아 앞으로 옮김): Sourcetype Epoch = { Revision: number }를 구조적으로 만족하고(EpochBrand에도 등록), 부기는 재사용 가능한 EpochMap(:Update(Epoch|EpochSet) -> boolean, :Refresh, :Sync, :TrackFromEpochSet = {[Epoch]: true}, 배열 아님) 으로 떼어내며, State가 그걸 컴포지션한다 — valueEpochMap(값 유효성)/emitEpochMap(전파 dedup). emit은 값도 리비전도 안 싣고 출처(Epoch나 그 집합)만 싣고, 순회는 캐시 카운터가 같을 때만(옛 rawInvalid == false 자리 — [2026-08-25 H-85] 불린이 cacheTargetCount/cacheCurrCount 쌍으로 교체됐다) 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 다이아몬드 중복 통지가 접히므로 스파이크 05도 그에 맞춰 재작성해야 한다 (luau-test/STATUS.md).
  • Source.luau/State.luau/Store.luau
  • [2026-08-18 신설, 2026-08-25 확정] store:Of<<T>>(name): Source<T> — 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 store "key" 문자열 커링은 기각). 콜론 메소드로 확정했고, 예약 키 (Of/Names/__reservedCheck[2026-08-26 /code-review high] 팬텀 필드가 교집합 안에 살아 자기 이름도 예약해야 한다) 충돌은 CheckReservedKeys<keyof<T>> 타입 함수가 사용 지점에서 잡는다([2026-08-26 H-112] 옛 이름·배선은 CheckReserved<T>였는데 T를 통째로 넘기면 실사용 T에서 아예 안 돈다 — base/store-plan.md의 "타입 추론 문제" 절). <<T>>가 값 호출부에서 실제로 T를 묶는 것도 실측 확인됨. [2026-08-25] 옛 이름은 GetDynamic이었다 — Of가 흡수했다
  • [2026-08-25 신설] store:Names(): { string } — 선언된 키 집합 열거(그림자 테이블의 키). 그룹 Attribute(...)/attr:NameMap()이 요구한다(base/attribute-plan.md)
  • [2026-08-25 신설, 2026-08-26 배선 정정 H-112] CheckReservedKeys 타입 함수 — T가 아니라 keyof<T>(키 싱글톤 유니온)를 받아 예약 키를 검증만 하고, 팬텀 필드 __reservedCheck로 격리한다. error()가 아니라 print(...) + return types.never를 써야 한다. T를 통째로 넘기는 옛 배선은 실사용 T에서 아예 안 돈다 — 최종 Store의 T{hp: Source<number>, …}이고 그 Source*error-type*을 품어 유효한 Store 전부에 스퓨리어스 타입 함수 에러가 뜬다(실측). 근거·통과 배선은 base/store-plan.md. 타입 함수는 이 용도(진단)까지만 쓴다base/typing-limits.md §0
  • [2026-08-25 신설] 명시적 초기화 — 타입 인자에 Source<T>를 직접 쓰고 defaults에도 Source(v)를 직접 넣는다. 옛 lazy __index (없는 키를 그 자리에서 만들어 저장)는 폐기. 그래서 defaults가 곧 선언 키 집합이고 Names()가 성립한다
  • State 전파 루프 — 구독자는 weak, 발화마다 canExecute 게이팅 (2026-08-14 다섯 번째 세션 확정, base/lifecycle-pattern.md의 "실제 호출부 — State 전파(emit)가 canExecute로 게이팅한다" 절) — State는 구독자를 weak-키로만 담고, 살려두는 책임은 gchold(leaf) 또는 전역 Subscribed 테이블(전역)에 있음 (어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). [2026-08-25 정정, 7라운드 H-56] 확정 의사코드는 base/source-state-plan.md의 "전파 루프 — 확정 의사코드" 절이 소스다. 여기 한때 적혀 있던 두 가지가 틀렸었다 — (a) 집합의 원소는 "Observer의 emit 클로저"가 아니라 Observer 값이다(bindLifetime이 그 identity를 쓰므로 클로저를 담으면 canExecute가 항상 거짓), (b) canExecute는 Observer/Effect 구독자에만 적용된다 — 자식 State 노드는 이 게이트를 안 탄다. "각 구독자에 대해"로 짜면 :With/:Compute/:Gate가 만든 파생 노드가 전부 걸러져 그 아래 모든 Observer가 침묵한다. 자식 노드의 생존은 canExecute가 아니라 같은 문서의 _hold 불변식(하류 → 상류 강함)이 책임진다. lua if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다) sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처) end canExecuteinst를 인자로 받을 수 없는 이유는 그대로다(State는 자기가 어느 Instance에 걸렸는지 모름). state:Observer(fn)의 "등록 즉시 1회 실행"은 bindLifetime 이전에 동기적으로 일어나므로 이 게이팅과 무관. 순회 전 스냅샷 필수(H-23) — pairs 순회 중 새 키 추가는 미정의다
  • [확정된 것 — 코드 아님] store.key dot-access 타입 추론 확인 — [2026-08-25 재작성]WrapStore/ProcessStoreType 합성 접근은 폐기됐고(archive/store-value-field-redesign-withdrawn.md), 지금은 타입 함수를 안 쓰고 타입 인자에 Source<T>를 직접 써서 평범한 레코드로 짓는다(같은 날 index<>/keyof<> + 팬텀 필드 안을 넣었다가 §0 원칙에 따라 철회 — archive/store-value-field-redesign-withdrawn.md). luau-analyze로 양성 9건 + 음성 대조군 전부 확인 — base/store-plan.md의 "store.key 레코드 필드 타이핑" 절이 소스. 스파이크 16/21은 폐기된 접근을 검증한 것이라 재작성 대기 (luau-test/STATUS.md)
  • :Compute(fn, ...) — trailing args로 추가 의존성 직접 받는 sugar (2026-08-11 세션, base/source-state-plan.md ":Compute(fn, ...)" 절) — :With(...):Compute(fn) 체인과 달리 노드 1개(Compute 노드 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M2 스파이크에서 확인. [2026-08-24 정정, 6라운드 H-13] 여기 원래 *"Effect/Observer는 대칭 sugar 없이 :With 명시 유지"*라고 적혀 있었는데 EffectC-6에서 이미 역전됐다 — 각 dep에 구독을 따로 걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. 기각으로 남은 건 Observer 하나이고 근거도 새로 쓰였다("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다")
  • state:Apply(factory)(base/source-state-plan.md "state:Apply(factory)" 절, 2026-08-07 일곱 번째 세션) — factory(self)를 체이닝 문법으로 부르는 순수 설탕, factory: (State<T>) -> U): U로 열린 타입. Source도 기존 :With/:Compute 델리게이션에 얹혀 자동 포함
  • Observer.luauObserver 객체와 :Subscribe()/:WeakSubscribe() 전역 레지스트리의 소유 모듈. EpochMap.luau와 같은 이유로 State.luau에 묻지 않는다(Effect/GateNode/leaf 핸들러가 전부 이 레지스트리를 본다) — base/architecture.md 소스 트리, 7라운드 H-99
  • state:Observer(fn) — children 배열 leaf 참가자, 등록 즉시 1회 실행 확정(base/source-state-plan.md의 Observer 절), isObserver 판별자, canExecute 게이팅, :Subscribe()/:Unsubscribe() + :WeakSubscribe()/:WeakUnsubscribe()(Weak 쪽이 프리미티브, 7라운드 H-58/H-59). [2026-08-26 추가, H-109/H-110; /code-review high 7차가 누락을 잡음] fn의 시그니처는 세 자리 fn(targetState, self, emitFrom)이고, 생성 시 observer._state = state(리시버 강참조)를 세운다 — 전파 루프가 그 필드를 1번 인자로 읽는다(위 루프 스니펫). 이걸 빼고 Observer.luau를 짜면 sub._statenil이라 모든 콜백이 리시버 자리에 nil을 받고 무인자 유틸이 nil:Get()으로 죽는다 — H-109가 고치려던 그 크래시. observer._state는 Observer의 _hold 상당이기도 하다(H-110). 무인자 state:Observer()의 내부 콜백은 function(targetState) targetState:Get() end(H-61; [2026-08-26] 파라미터 이름이 self였는데 1번 자리는 Observer가 아니라 리시버다). 동적 경로 가드({priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is Observer, process = error(...)}, k 타입 안 가림, 2026-08-14 열한 번째 세션 — PreRef와 같은 패턴)도 같이 등록 ⚠️ [2026-08-24] 단 그 가드를 Dispatch.addHandler로 등록하는 것 자체는 M3다 — 레지스트리가 거기서 생긴다(M3의 그 항목).
  • Effect(fn, ...deps)⚠️ 선행: Blocker의 기본 메커니즘 (On/Off/IsOn/OffWithoutEmit). 생성자가 등록 구간 억제에 사적 Blocker 하나를 쓴다(base/effect-plan.md). 아래 Blocker.luau 체크박스가 이 항목보다 뒤에 있지만 그 기본 넷은 GateNode/:Policy와 무관하게 독립 완결이라(base/blocker-plan.md의 "메커니즘" 절) 그 부분만 먼저 만들면 된다 — :Policy/state:Block 배선은 GateNode 뒤에. (base/effect-plan.md, [2026-08-21 5라운드 C-6] 옛 시그니처는 Effect(fn, state?)) — deps 생략 시 설치 1회+leaf 사망 시 확정 정리, deps 지정 시 각각에 맞는 구독 (State/Source는 Observer, Ref:Callback)을 걸어 재실행+cleanup 체이닝(React useEffect 동형). EffectHandleEpochMap을 하나 들어 공통 상류로 인한 중복 발화를 접고, 설치 구간 억제 플래그가 그 Update보다 먼저 와야 함 (base/state-epoch-plan.md). Observer 구현 이후에 착수(의존 관계). EffectHandle:Subscribe()/:Unsubscribe()도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — :Unsubscribe()는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). 동적 경로 가드도 Observer와 같은 패턴으로 등록(base/effect-plan.md "동적 경로 가드" 절, 2026-08-14 열한 번째 세션) ⚠️ [2026-08-24] 단 그 가드를 Dispatch.addHandler로 등록하는 것 자체는 M3다 — 레지스트리가 거기서 생긴다(M3의 그 항목).
  • [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편] Effect 구현 시 같이 만들 것handle._deps({[Ref|State] = fn|Observer}, 강참조 — 옛 _observers/_refDeps/_refCallbacks 셋이 여기로 통합됐다) · handle._epochs(EpochMapRefEpoch라 dep 종류가 균일) · [2026-08-26 추가, 8라운드 H-107/Q2-후속] dep 등록 클로저는 종류별로 이다onRefFire(_, ref)(Ref 콜백은 fn(value, ref)라 출처가 2번째)와 onStateFire(_, _, from)(Observer는 fn(targetState, self, emitFrom)라 출처가 3번째). 하나로 합치려 들지 말 것 — 한때 effect-plan에 *"클로저는 하나로 통일한다"*고 적혀 있었으나 근거 없는 서술이라 삭제됐다(사용자 확정: 두 콜백은 이질적이고, Observer엔 자기 epoch가 없지만 Ref는 그 자체가 epoch다). dedup은 클로저 identity가 아니라 _deps/_epochs 맵이 한다 · handle._blocker(등록 구간의 즉시-1회 호출 억제 — 옛 _installing 플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) · handle._cleanup(직전 cleanup 보관, RerunDestroying 클로저가 같은 자리를 읽는다) · handle._installed(설치 여부 — fn의 cleanup 반환이 선택이라 _cleanup의 유무로는 판정할 수 없다) · handle._running/_pending(Rerun 재진입 지연) · handle._destroyConn · :_bindDestroying(inst)/:_unbindDestroying() (bindLifetime/unbindLifetimeisEffect일 때 부르는 훅, H-11) · :Rerun()(정의는 H-60이 채웠다 — 실행 중 재진입은 지연 재실행, error는 UB) · :_consumeCleanup()(읽고 → 지우고 → 실행). fn 시그니처는 fn(self: EffectHandle) -> ...(() -> ())이고 ([2026-08-25 H-95] 가변 반환 팩 — 옛 -> (() -> ())?는 콜백이 "선언보다 적게 반환"할 때 strict에서 막혀 정상 용례가 전부 안 통과했다) ...depsfn에 안 넘어간다(H-14). dep 등록은 생성자에서 한 번만:WeakSubscribe()/ :WeakCallback()으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 (H-58/H-59). 발화 게이트는 전부 canExecute(handle) 하나다 (H-7). 캐치업은 바인드 직후 조건부 최대 1회 (if not self._installed or self._epochs:Refresh() then self:Rerun() endH-64/H-65). 의사코드는 base/effect-plan.md가 소스
  • [2026-08-24 H-23] State 전파 루프는 구독자 집합을 배열로 스냅샷한 뒤 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
  • Observer/Effect 이중 바인딩 금지 — canBound(value) 게이트로 :Subscribe()(전역)와 bindLifetime(inst-scoped, leaf 부착도 내부적으로 이걸 호출)이 동시에 걸리면 즉시 error(base/source-state-plan.md "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 독립 경로는 둘뿐). [2026-08-14 다섯 번째 세션에 별도 predicate canBound(handle)을 폐기하고 canExecute 하나로 합쳤다가, 같은 날 열한 번째 세션에 다시 갈라짐] — "지금 묶어도 되는가"(bound 문맥)와 "지금 발화해도 되는가"(execute 문맥)는 호출부의 질문이 다르고 [2026-08-18 구현 전 QA 정정] 판정값도 같은 게 아니라 서로의 부정이라(canBound(v) == not canExecute(v), 게이트는 항상 if not canBound(v) then error(...)), Ref 이중 배치 방지(question.md 0-W)를 계기로 canBound가 별도 진입점으로 재도입됨 — 판정 로직(비공개 isBoundAlive 헬퍼)은 공유해 코드 중복은 없음. 이 절이 쓰는 게이트는 이제 canBound (emit 전파 게이팅 전용 canExecute가 아님). .Subscribed 필드가 leaf 경로와 무관하다는 것, leaf 생존 판정을 bindLifetimevalueRelate에 복사해둔 gcconn으로 하는 것은 안 바뀜 — base/ lifecycle-pattern.md의 "canBound vs canExecute" 절, 역전 경위는 archive/canexecute-inst-arg-reversed.md. 부수 효과로 바인딩이 죽은 뒤(Destroy/unbindLifetime)의 재사용은 게이트를 통과 (살아있는 바인딩만 막는 게 의도, 안 바뀜)
  • state:Gate(setup) + GateNode ([2026-08-24]EpochMap과 같이 되돌아옴) — emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드 (ComputeNode와 같은 층위, 탑레벨 Gate(...) 프리미티브는 안 만듦). 유보 배치는 withheld : { [epoch] : true }(집합), flush 때 테이블을 통째로 갈고, 내보내는 emit이 싣는 건 그렇게 떼어낸 EpochSet 스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다(하류가 게이트 identity를 한 번도 안 쓴다, base/state-epoch-plan.md §5). 빈 배치는 아무것도 안 함. emitEpochMap을 쓰므로 위 EpochMap.luau가 선행 — base/gate-plan.md. "게이팅 먼저" 결정의 산물 (사용자: "게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"). [2026-08-25 갱신, 7라운드 H-55/H-72/H-86] setup 계약이 바뀌었다setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ()). 인자 목록은 그대로지만 emit(false)흡수 집합을 버리고(정책이 그걸 할 통로가 없었다), 반환값이 **"실제로 내보내거나 버릴 게 있었는가"**를 준다(정책이 pending을 읽을 통로가 없어 Throttle의 창이 idle로 못 돌아왔다). 그리고 **수신 시점 판정은 emitEpochMap:Peek**을 쓴다 — :Update는 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다. flush 순서는 빈 배치 얼리리턴 → 스왑 → :Sync(batch) → 전파
  • Blocker.luau ([2026-08-24] 위 둘과 같이 되돌아옴) — 위 GateNode 위에 얹히는 정책(다시 노드를 만들지 말 것). [2026-08-25 추가, 7라운드 H-63] onunblock 핸들 보관 세 자리: (1) weak-키 해시맵 셋(__mode = "k") — 값-weak 배열이면 구멍에서 ipairs가 멈춰 뒤의 살아있는 게이트가 Off()를 못 받는다, (2) 강한 주인은 정책이 반환하는 onUpstreamEmit 클로저(upvalue로 잡는다) — Blocker가 강하게 들면 거기 걸렸던 모든 gated state와 상류 체인이 영원히 산다, (3) Off()/OffWithoutEmit()스냅샷 뒤 순회. 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive (base/blocker-plan.md). M3의 Dispatch.setLength/setOffsetSource의 배치 등록이 :On()/:IsOn()/:OffWithoutEmit() 세 메서드를 호출하므로(base/gate-plan.md 9번이 소스 — Blocker 인스턴스를 lazy 조회하는 getBlocker(ownerKey)는 Blocker 메서드가 아니라 Dispatch 쪽 헬퍼다) 최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구
  • [2026-08-24 H-25 파생, 2026-08-25 H-80으로 목록 확장] quad-typesQuad이 마일스톤이 얹는 탑레벨 값 전부 추가 — Source / Store / Effect / Blocker / Relate / is* 전량(isState/isSource/isStore/isRef/isObserver/ isEffect/isEpoch/isModifier …) / bindLifetime·unbindLifetime· canBound·canExecute 4종. ⚠️ State는 런타임 생성자가 없다 — 파생(:With/:Compute/:Gate)으로만 생기므로 타입만 재수출한다. 옛 목록(Source/State/Store 셋)은 M2가 실제로 얹는 것의 일부만 담고 있었고, 빠진 것 전부가 H-25가 만든 바로 그 벽에 부딪힌다. 규칙 요지: New(): Quad가 닫힌 레코드이고 RunInit은 반환값이 없어 타입을 못 넓히므로, 서브시스템을 붙이는 마일스톤마다 quad-typesQuad에 그 필드와 타입 재수출을 같이 추가한다. 안 하면 그 마일스톤 완료 후 quad.Store 접근이 런타임엔 되는데 luau-analyze에선 타입에러다(H-25 실측). 순서 교체로 이 마일스톤이 그 규칙의 첫 적용 지점이고, 규칙의 정본은 base/quad-types-plan.md의 "Quad 타입 — 확정된 표면" 절 (M3의 H-25 항목이 같은 규칙을 Dispatch 기준으로 서술한다)
  • trailing deps를 fn에 lazy positional 인자로도 노출(⚠️ [2026-08-24 H-14] 이 항목은 :Compute 한정이다Effectfn엔 deps가 안 넘어간다) — (fn(self, previous?, dep1, ..., depN) — 순서는 Luau 값 레벨 ...가 파라미터 리스트 맨 끝이어야 하는 것과 같은 이유로 previous?가 deps 팩 에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째 세션에 순서 정정, base/source-state-plan.md "trailing deps를 fn에 lazy positional 인자로도 노출" 절) — 방향/순서는 확정, luau-test15-type-compute-trailing-deps-typepack.luau로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정)
  • [2026-08-25 신설, H-84] :With(...) / state:Block(blocker) / Source:Emit():Compute/:Apply/:Observer는 각각 체크박스가 있는데 이 셋만 빠져 있었다
  • [2026-08-25 신설, H-81; 2026-08-26 자리 정정 H-122] isModifier 런타임 가드 — 적용 지점이 전부 이 마일스톤의 코드다: Source(...) 생성자 / Source:Set / State의 :Compute 결과 캐싱. 체크박스가 M7에만 있었다. [2026-08-26] 여기 한때 *"Store 생성 시 eager Source(default)"*가 적혀 있었으나 명시적 초기화 이후 defaults 경로엔 그 지점이 없다 ([2026-08-26 정밀화] 동적 키 창구 store:Of(name)은 여전히 만든다) — 가드를 Source 생성자에 두면 그 둘이 한 번에 커버된다 (base/modifier-plan.md 7번이 소스)
  • [2026-08-26 신설, H-122] Store 생성자의 defaults 런타임 검증 — 값 전량에 isSource 화이트리스트, 거짓이면 error(..., 2) (영어 메시지). 타입은 Source<T>를 요구하지만 --!nocheck/동적 코드가 raw 값을 넘기면 지금 스케치(table.clone)는 조용히 받고 첫 :Get()에서 엉뚱한 에러로 죽는다. 생성 시 1회라 hot path 아님
  • [2026-08-25 신설, H-97] mock 백엔드용 생명주기 4종 최소 구현 — bindLifetime/unbindLifetime/canBound/canExecute. 안 하면 아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다(루프가 매 발화마다 canExecute를 부르는데 그건 M8 구현이고 미주입 슬롯은 에러 스텁이다). 커밋된 quad-base/test/mock.luau에 signal/Connection이 이미 있으므로 그 Destroying을 그대로 쓰면 된다
  • mock 대상 테스트 — 전파 루프를 실제로 돌릴 것(위 항목이 선행). M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면 7라운드가 찾은 종류의 결함을 그대로 낳는다

M3 — 디스패치 엔진

[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.

[2026-08-24 순서 교체] 이 마일스톤은 옛 M2입니다 — 번호를 반응형 코어와 맞바꿔 그 뒤로 옮겼습니다(경위는 M2 배너가 소스, 여기서 반복하지 않음). 2026-08-22에 여기로 옮겼던 EpochMap/GateNode/Blocker와, 반응형이 먼저 요구하는 Brand/Relate/LifetimeHandle 인터페이스는 M2로 갔습니다. 여기 남은 것은 전부 디스패치 배관이고, 이제 이 마일스톤은 M2 위에 단방향으로 얹힙니다. 개념상 역방향이던 둘 (Observer/Effect 가드 등록, ObserverEffectLeafHandler — "핸들러를 등록한다"뿐인 것)은 맨 아래 두 항목으로 여기 옮겨져 있으므로, 빌드 순서상 역방향 간선은 없습니다.

  • 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)(일반화 for 한 번으로 순회하며 각 (k,v)Dispatch.process(inst,k,v,1) 호출 — dispatch-core-plan.mdNone 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). [2026-08-21 구현 전 QA 4라운드 F-4-1] 순회는 두 패스가 아니라 단일 일반화 for — "배열 파트 전체가 해시 파트보다 먼저"는 여전히 base가 보장하는 계약이지만, flattened가 항상 평범한 Luau 테이블이라 일반화 for 한 번이 그 순서를 그대로 주고 두 층위는 type(k) == "number" 분기로 가른다(base/dispatch-core-plan.mdF-4-1 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로 못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 01도 재작성 대기 (luau-test/STATUS.md)
  • [2026-08-24 신설, 6라운드 손 트레이싱 H-39] 말단 핸들러는 예외 없이 자기 배열 자리의 setOffsetSource(inst,k,None)setLength(inst,k,0)을 등록한다 — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이 빠져 있었으니 각 마일스톤에서 확인할 것: TagHandler(M10) / AttributeGroupHandler(M10) / RefLeafHandler(M8) / ObserverEffectLeafHandler(이 마일스톤, 맨 아래). 안 하면 bk.N이 다른 자리 등록으로 커질 때 그 구멍이 범위 안에 끼어 recompute명시적 error로 죽는다 (Frame { Tag("card"), TextLabel{} }처럼 말단이 앞에 오는 흔한 배치). 배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다. 소스는 base/dispatch-core-plan.md의 등록 책임 절
  • [2026-08-24 신설, 6라운드 손 트레이싱 H-25] quad-typesQuad 타입에 Dispatch 필드와 그 타입 재수출을 추가Quad가 5필드 닫힌 레코드이고 RunInit은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면 module:RunInit(InitDispatch) 뒤의 quad.Dispatch 접근이 런타임엔 되는데 luau-analyze에선 타입에러다(실측 재현). base/architecture.md의 확정된 결정 13번이 그 접근을 표준 사용법으로 못박아뒀다. 상세는 base/quad-types-plan.md의 "Quad 타입 — 확정된 표면" 절. ⚠️ 이건 M3 한 번으로 끝나는 일이 아니고, 첫 적용은 오히려 M2다 — 그 문서가 *"마일스톤마다 갱신한다 … 이후 서브시스템도 같은 규칙을 따른다"*로 확정했으므로, 서브시스템을 붙이는 모든 마일스톤이 같은 항목을 진다: M2(Source/State/Store, 규칙이 처음 적용되는 자리) · M6(Slot) · M7(Modifier) · M8(Ref) · M10(Tag/Attribute). ([2026-08-24] 규칙 자체는 Dispatch를 계기로 쓰였지만 마일스톤 순서 교체로 M2가 앞에 오게 됐다 — M2를 짜는 사람이 이 항목을 앞으로 참조하지 않아도 되게, 규칙 요지는 M2의 H-25 파생 항목에도 적어뒀다.) 빠뜨리면 그 마일스톤 완료 후 quad.Store/quad.Slot 접근이 런타임엔 되는데 luau-analyze에선 타입에러인, H-25가 실측한 그 문제가 마일스톤마다 반복된다.
  • Handler.luau(핸들러 계약 타입: isHandlable(inst,k,v)/priority/ process(inst,k,v,index) -> (hintValue)->() 3종isHandlableinst를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 retract 필드는 process 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
  • [2026-08-22 M7에서 이동] None.luau(센티널) + Dispatch/None.luau (NoneHandler + NilHandler)None은 modifier 전용 값이 아니라 디스패치 배관이라 여기 소속(architecture.md의 소스 트리에 있는 건 핸들러 쪽 Dispatch/None.luau뿐 — [2026-08-22] 탑레벨 센티널 None.luauBrand.luau는 그 트리에 아직 줄이 없다, M3 구현 시 트리도 같이 채울 것). NoneHandler는 배열/해시 구분 없이 Dispatch.process(inst,k,nil,index+1) 재귀만 하고(선행 retractFrom 없음 — 하강 diff), 실제 정리는 NilHandler(isHandlabletype(k) == "number" and v == nil일 때만 매치하는 말단)가 Dispatch.setLength(inst,k,0) + Dispatch.setOffsetSource(inst,k,None) 등록으로 맡는다. Dispatch.driveNone 스킵 분기는 없다(반응형 값이 내놓는 None은 어차피 process에 도착하므로 — 2026-08-18 재설계) — base/dispatch-core-plan.md의 "None 센티널"/"NilHandler" 절. Modifier 쪽 표면(인라인 키로 필드 지우기, Peek 반환 타입)은 M7
  • Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?,element?) ([2026-08-27, 9라운드 Q3] 5번째 element = 그 자리의 inst|slotgatedRecompute가 인덱스 대신 이걸 캡처해 bk.indexOfElement를 조회한다. 옛 bk.tokens/indexOfToken(사용자가 정한 적 없는 token = {} 신원)은 폐기. 상수 길이 자리(Nil/None 핸들러)는 생략)/ Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)/ Dispatch.getOffsetAt(ownerKey,i)[2026-08-21 구현 전 QA 5라운드 반영] setLength의 4번째 인자 anchor(생명주기 앵커, 항상 물리 Instance — 부기 키와 분리, 생략 시 ownerKey로 폴백이라 최상위 호출부는 3-인자 그대로), setOffsetSourceNone이면 얼리 리턴(그 None은 "발행 채널 없음"이지 "참여 안 함"이 아니다 — 참여 여부는 setLength가 답), 숫자가 필요한 쪽(예: 물리 삽입 위치)은 getOffsetAt으로 pull. [2026-08-24 6라운드 H-3/H-4/H-19] getOffsetAt의 접두합 캐시 계약을 반드시 같이 구현할 것 — 이게 빠지면 형제 Slot이 커져도 뒤 형제의 Offset영원히 고정된다(sum은 매번 새로 더하므로 위로는 맞고 옆으로만 틀려 알아채기 특히 어렵다): (a) getBookkeepingbk.offsetCache = {}, bk.offsetCacheValidUpTo = 0, bk.offsetSetUpTo = 0, bk.recomputeBlocker = Blocker(), bk.indexOfElement = {}([2026-08-27 Q3])으로 초기화(nil 시작이면 첫 setOffsetSourcenil 비교에서 죽는다), (b) 캐시를 앞으로 당기는 자리(개수는 base/dispatch-core-plan.md의 무효화 표가 소스) — setLength 본문과 그 자리 length가 State일 때 다는 Observer 콜백(두 필드 다 math.min(…, i)), spliceArraysUp/spliceArraysDown(두 필드 다 math.min(…, i - 1)[2026-08-26 정정, 8라운드 H-113] 한때 setLength와 "같은 식"이라 적혀 있었으나 i로 당기면 recompute의 커서가 정확히 i일 때 "변경 없음"과 구분이 안 돼 되감기가 안 걸린다), slot._baseObserver 콜백 (베이스가 바뀐 경우라 0), 그리고 [2026-08-26 신설, /code-review high] rawMove/rawSwap/rawExtract(자리 수는 그대로, 순서만 바뀜 — 두 필드 다 math.min(…, minPos - 1), H-29 규약 3번). 전부 recompute보다 먼저. (c) recompute 호출은 setLength의 단독 책임이다 — rawAdd/ rawReplace의 명시 호출은 삭제됐고, 자리가 없어지는 경로 (rawRemove/rawUnmount/rawDetach)만 예외로 직접 부른다. [2026-08-26 추가, 8라운드 H-119] 그 명시 호출도 재진입 게이트를 먼저 본다blocker:IsOn() or bk.recomputeBlocker:IsOn()이면 건너뛴다(건너뛴 몫은 splice가 당겨둔 offsetSetUpTo로 바깥 루프의 되감기가 복구). 안 그러면 Add는 안전한데 Remove만 중첩 recompute가 완주해 바깥의 Length를 낡은 합으로 덮는다. _baseObserver 콜백도 같은 게이트 + 두 필드 0. [2026-08-26 신설, /code-review high 4차] 부기 필드가 하나에서 둘로 갈라졌다bk.offsetCacheValidUpTo(offsetCache가 여기까지 정확, getOffsetAt이 올림)와 bk.offsetSetUpTo(offset Source에 여기까지 :Set 완료, recompute 올림). 옛 단일 invalidAfter는 두 뜻을 겸했고, 그래서 getOffsetAt의 부수효과가 되감기 신호를 조용히 지웠다. 무효화는 둘 다 내린다. base/dispatch-core-plan.md의 "두 필드" 절이 소스. recompute는 owner의 베이스(Slot이면 자기 .Offset, 최상위면 0)에서 시작하고 중첩 Slot은 자기 Offset을 관측해 자식 offset을 다시 민다. 이 셋이 하는 일: 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이 섞일 때 순서 보장" 해소). [2026-08-18 구현 전 QA 2라운드 후속] bk.N≥2인 자리가 처음 채워지는 동안 크래시하던 경로(RC-1)는 owner별 Blocker 게이팅으로 해결됨setLength/setOffsetSource가 배치 등록 중엔 recompute를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는 base/dispatch-core-plan.md의 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절. [정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 bk.N의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가 바뀌며 사라졌음 — 지금 이 두 함수 구현이 여전히 Blocker (:On()/:IsOn()/:OffWithoutEmit() 세 메서드 + 그 인스턴스를 lazy 조회하는 Dispatch 쪽 헬퍼 getBlocker(ownerKey))를 호출하는 이유는 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. [2026-08-24 정정] 그래서 필요한 GateNode/Blocker/EpochMap은 M2(반응형 코어)에 체크박스로 있다 — 2026-08-22엔 각주로만 예고돼 있던 걸 이 마일스톤으로 앞당겼었는데, 같은 달 24일 마일스톤 순서 교체로 M2가 먼저 지어지게 되면서 셋 다 그리로 되돌아갔다(앞당길 이유가 사라짐). 여기서는 M2가 이미 만들어둔 것을 호출하기만 한다. 결정 경위("게이팅 먼저", 그리고 앞당기는 대상이 Blocker 자체가 아니라 그 아래 공용 Gate 노드로 바뀐 것)는 qa-request/pre-implementation-qa-round5-followup.mdbase/gate-plan.md가 소스 — 여기서 반복하지 않는다.
  • 핸들러 계약 검증: 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() 디버그 유틸. [2026-08-18] 동률 경고는 무조건 찍지 않고 모듈 표면의 Quad.debug(boolean, 기본 false)가 참일 때만Quad.debug 자체가 이번에 신설된 새 공개 표면이다 (base/module-lifecycle-plan.md의 "모듈 표면의 디버그 플래그" 절)
  • Dispatch/Leaf.luau(i:number, v=Ref/Observer/PreRef/PostRef) 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)
  • [2026-08-24 M2에서 이동] Observer/Effect 동적 경로 가드 등록{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v가 Observer/Effect, process = error(...)} 둘을 Dispatch.addHandler로 등록(k 타입 안 가림, PreRef와 같은 패턴 — base/source-state-plan.md의 Observer 절, base/effect-plan.md의 "동적 경로 가드" 절). M2에서 Observer/Effect 본체를 짤 때는 이 등록만 빼고 간다 — 레지스트리(Dispatch.addHandler + Handler.luau 계약)가 이 마일스톤에서 생기기 때문. M2가 M3에 개념상 지던 의존은 이 항목과 바로 아래 항목뿐이었고, 둘 다 "핸들러를 등록한다"뿐이라 이쪽으로 미룰 수 있었다 — 그래서 빌드 순서엔 역방향 간선이 남지 않는다. 순서 교체(2026-08-24)의 근거
  • [2026-08-24 M2에서 이동, H-39] ObserverEffectLeafHandler.process가 자기 배열 자리의 setOffsetSource(inst,k,None)/setLength(inst,k,0)을 등록 — 빠져 있었다(위 말단 핸들러 항목)
  • 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가 실제로 불리는지(위 M3의 SetStrong 순서 버그가 정확히 여기서 증상으로 나타남)

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

[2026-08-21 5라운드] 주입 표면이 늘었다 — native* 물리 트리 조작 계층. nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap/ nativeDispose. base가 Parent를 모른다는 원칙을 실제로 지키기 위한 것이고, 미주입이면 에러가 아니라 조합 폴백이라 최소 구현 부담은 nativeInsert/nativeExtract/nativeDispose 셋이다(나머지는 이득 있을 때만 덮어씀 — Roblox는 nativeRemove를 "그 자리에서 바로 Destroy"로 융합하는 게 실익). 상세는 base/slot-plan.md의 "물리 조작은 주입 op다" 절.

⚠️ [2026-08-24 정정, 6라운드 손 트레이싱 H-34/H-44] 위 "최소 구현 부담은 셋" 서술을 좁힌다 — Roblox 백엔드는 nativeMove/nativeSwap도 반드시 덮어써야 한다. 조합 폴백(nativeMove = nativeExtract + nativeInsert)은 여기선 "느린 정답"이 아니라 관측 가능한 동작 차이를 만든다: Move/Swap을 공개 CRUD에 추가한 근거 자체가 *"Extract+Add는 실제 Parent 조작이 두 번(detach+reattach) 일어남"*을 피하려는 것이었는데, 폴백은 정확히 그 두 번을 되돌려 AncestryChanged 재발화·깜빡임·재바인딩 비용을 다시 만든다. offset을 무시하는 백엔드에서 순서 이동은 애초에 물리 조작이 아니므로 no-op으로 덮어쓰면 된다. 그리고 주입 op이 둘 더 늘었다(6라운드) — isInst(H-40): 요소 타입 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, 조합 폴백이 불가능해 미주입이면 에러다(quad-roblox 구현은 typeof(v) == "Instance" 한 줄). onDestroying(inst, fn)(H-11): Effect의 leaf 사망 cleanup을 발화시키는 훅으로, bindLifetimeisEffect일 때 부른다(quad-roblox 구현은 inst.Destroying:Connect(fn)). 이것도 조합으로 못 만들므로 미주입이면 에러다.

⚠️ 구현 관례: quad-roblox의 공개 타입은 지금부터 단일 파일 (src/init.luau 또는 types.luau)에 몰아둘 것 — 나중에 필요해지면 백로그 quad-roblox-types(가칭, quad-types와 같은 패턴)로 쉽게 분리할 수 있게 하기 위함. base/quad-types-plan.md의 "남은 것" 절이 소스.

  • RobloxFactory.luau(BaseModule 뮤테이션, 재호출 가드) — 진입점 QuadRoblox(Quad): QuadRobloxQuadTypes.CheckedQuad<T, Pattern>으로 주입받은 quad-base 버전을 확인(base/quad-types-plan.md 참고)
  • D/init.luau(제네릭 생성자 New + 생성기가 찍는 정적 별칭 필드 — [2026-08-18] 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, base/bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)
  • Handlers/Property.luau, Handlers/InstanceChild.luau
  • Instance 생성 시점의 gcconn/gchold 셋업(2026-08-14 다섯 번째 세션 확정, 옛 "bindLifetime 첫 호출에서 lazy 생성"에서 전환 — base/ lifecycle-pattern.md의 "(0) gcconn/gchold는 Instance 생성 시점에 만든다" 절) — quad가 만든 모든 Instance에 대해 핸들러/바인딩 유무와 무관하게 생성 직후 무조건 GetPropertyChangedSignal("ClassName") 연결(절대 발화 안 함)로 gcconn을 만들고 gchold[1]=gcconn, InstData:SetWeak(inst,"gchold"/"gcconn",...). 클로저가 gcholdinst를 둘 다 캡처해야 함 — Instance userdata 포인터 동일성을 고정하는 게 목적이고, 그래야 inst를 키로 쓰는 모든 Relate (elementOwner/nameClaims/Tag 참조카운트 등)가 성립함. 대가는 "quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않고 반드시 Destroy가 필요" — 바인딩이 하나라도 걸리면 어차피 같은 순환이 생기므로 실질적 신규 제약은 아님
  • 실제 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임이 계약으로 보장된다는 점만 새로 추가됨.

확정된 것 — 코드 아님, 구현 전 필독

아래는 설계가 확정됐다는 사실이지 짠 코드가 아니다 — 체크박스로 두면 [x]가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다 ([2026-08-22]).

  • "여러 Slot이 형제로 섞일 때 순서 보장" 해소(2026-08-09 여섯 번째 세션) — Dispatch.setLength/setOffsetSource 메커니즘, base/ dispatch-core-plan.md "Length/Offset" 절. Slot.Length: State<number>도 이때 확정(CRUD/:List 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 :List/CRUD의 raw*가 호출. recompute 트리거 모델의 크래시(RC-1)는 Blocker 게이팅으로 해결됨, 위 M3의 Dispatch.setLength/setOffsetSource 항목 참고.
  • Slot의 Add/Remove/Extract/ExtractAll/Clear/Move/Swap/ Get/IndexOf/Splice CRUD 의미론 확정 (2026-08-09 세 번째 세션, 2026-08-09 열한 번째 세션에 식별 기준 재정정, Splice는 2026-08-12 열다섯 번째 세션 신설 — [2026-08-13 5차 감사에서 추가] Splice가 이 체크리스트에 누락돼 있었음, luau-test/20으로 산술 실측 통과됨) — 에러 조건까지 전부 확정 (base/slot-plan.md "CRUD API 확정"). "재마운트 시 즉시 throw"도 isMounted 이중 추적 분리로 개별 element/Slot 컨테이너 기준이 명확히 갈림(같은 문서 "isMounted 이중 추적 분리" 절). [정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 인덱스 기준으로 전환Remove(index)/Extract(index, newElement?) (O(n) 또는 O(1))/Move(oldIndex, newIndex)(O(n))/Swap(indexA, indexB)(O(1)) 전부 인덱스, Add(element, index?)만 element를 직접 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 Add 리턴값을 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안 맞았음 — 레퍼런스만 있으면 IndexOf(element): number?로 인덱스를 구하면 됨. ExtractAll(): {T}(Clear의 비파괴 버전), Get(index): T? 신설(get/set 드롭했던 걸 재추가). Extract(index, newElement?)newElement 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 mutate 메소드 전부 "가드 확인 + raw* 위임" 얇은 wrapper(Get/ IndexOf는 순수 읽기라 가드 대상 아님). [2026-08-21 5라운드] Replace(index, newElement) 추가(그 자리 교체
    • 이전 것 파괴Extract의 파괴 짝, RemoveExtract와 같은 축)와 rawReplace/rawAdd 의사코드 확정, 그리고 래핑/언래핑 한 쌍 (State를 요소로 받으면 내부적으로 :Single 래퍼 Slot이 되는데, Get/IndexOf/Extract가 돌려주는 값과 :Listprev는 전부 언래핑된 원래 값이다). base/roblox 경계에 mount/unmount 외 reposition 훅 추가됨. Slot<T>() 제네릭화, 요소 타입 제약 확정[2026-08-24 6라운드 H-40로 전면 정정] 판정은 블랙리스트가 아니라 화이트리스트다: isSlotisState(래퍼 Slot으로 풀어 재귀) → 주입 술어 isInst, 셋 중 어디에도 안 걸리면 error. 관문은 wrapElement 하나(공개 CRUD와 :Listsettle이 둘 다 지난다). 아래 나열은 이제 그 화이트리스트의 따름정리다(에러 메시지는 계속 구분해 낸다) — nil/None 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 T만), 핸들러 계층 값(Ref/PreRef/Observer/ Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(Modifier 필드와 같은 판별 메커니즘 재사용) — D.InstSlot = Slot<<Instance>>([2026-08-18] D 네임스페이스 이름 확정 — 옛 question.md 1번 용어정리 항목은 해소되어 archive/question-resolved.md로 이전됨)가 quad-roblox의 사실상 유일한 Slot 타입.
  • Slot:Single(state, updateFn?, opts?) 확정 — ([2026-08-26 표기 정정, 8라운드 H-123] 3번째 opts(= Owned)는 H-22 확정 의사코드가 받아 :List로 전달한다 — 여기와 아래가 2-인자 표기로 남아 있었다.) :List를 0/1개짜리 배열로 감싸는 순수 sugar, index 없이 offset/prev/userdata만 전달, 고정 key로 prev 재사용 보장(2026-08-11 세션, base/ slot-plan.md "Slot:Single" 절). [2026-08-11 일곱 번째 세션] updateFn이 선택 인자로 완화됨(기본값 identity) — 아래 반응형 raw 요소 항목 참고.
  • Slot-in-Slot 중첩 확정 — 요소 타입 제약에서 Slot 배제 해제 (T = Instance | Slot<Instance>, 자기 참조 제네릭은 실측 필요). Dispatch.setLength/setOffsetSource를 물리 inst 대신 Slot 자신을 owner 키로 재사용하는 재귀 attachSlot으로 최상위/중첩 마운트 통합(새 프리미티브 없음). Slot.Length가 raw 개수에서 "요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 Clear()가 아니라 flat destroySlotTree(파괴 walk + unbindLifetime walk, outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리 서브트리만 죽는 경우 명시적 unbindLifetime 필요(GC-native 정리의 예외 케이스). DOM 백엔드가 nested Slot을 실제 <div> 중첩으로 매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) — 숫자 기반 메커니즘이 web에도 그대로 필요하나, insertBefore/ removeChild가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, base/slot-plan.md "Slot-in-Slot 중첩" 절). [2026-08-21 5라운드] 그 web 경로가 실제로 삽입 위치를 알 수 있도록 물리 조작이 native* 주입 op 계층으로 정리됐고(M5 배너가 목록, 시그니처는 base/slot-plan.md의 "물리 조작은 주입 op다" 절 — [2026-08-22 정정] 여기 확정 전 가칭 mountInst/unmountInst/ disposeInst가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과 재마운트가 Offset Source를 새로 만들던 결함도 같이 수정됐다. recompute 트리거 모델의 크래시(RC-1)는 Blocker 게이팅으로 해결됨 — 위 M3의 Dispatch.setLength/setOffsetSource 항목 참고(base/slot-plan.md "재귀 메커니즘" 절). [재설계, 2026-08-21] attachSlot은 비공개 재귀 둘로 분해됨materializeSlotTree(부기만, Blocker가 감싸는 건 이제 여기뿐) + mountSlotTree(물리 Parent 대입만, Blocker 불필요), 그리고 그 둘을 순서대로 부르는 두 줄짜리 공개 attachSlot(이름/시그니처/호출부 전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다 먼저"가 처음으로 동시에 만족되고, 배치 밖 재마운트의 부모 recompute가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 함수 경계로 강제되므로 RC-1/RC-3/RC-4 같은 "줄 순서를 잘못 잡아서" 나던 버그 클래스가 구조적으로 사라짐. 근거 기록은 reference/slot-attach-decomposition.md. 관측 가능한 변화 하나: Parent 대입 순서는 그대로지만 물리 마운트가 "부기 완료 후 일괄"이 되어, ChildAdded 핸들러가 볼 때 서브트리 전체의 Length/Offset이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
  • Slot(initial?: {T}) 생성자로 확장 — "인자 없는 빈 생성자로 확정"을 뒤집음, :Add 반복 호출 sugar일 뿐(새 마운트 로직 없음). initial ~= nil이면(빈 테이블도) 즉시 _crudUsed = true — 상태상 Add→Remove와 동일하므로. _crudUsed_listed 상호 배타 가드 신설 — 기존엔 :List 설치 후 수동 CRUD만 막았지 반대(수동 CRUD 후 :List 설치)는 안 막아서 :List의 reconcile이 기존 요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, base/ slot-plan.md "CRUD API 확정" 절).
  • recompute off-by-one 버그 수정(2026-08-11 세션, base/ dispatch-core-plan.md "Length/Offset" 절) — sum 누적과 offset:Set 순서가 뒤바뀌어 Offset이 자기 자신을 포함해버리던 버그(예: 유일한 자식인데도 Offset이 0이 아니게 됨) 수정. 재진입 방지 가드는 검토 후 기각 — 각 Slot이 Relate(자기 자신)으로 독립된 bk를 가져서 nesting만으로는 같은 bk가 재진입되는 경로 자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute 도중 같은 Slot의 length에 다시 쓰기)은 Source⊇State의 "단방향" 원칙과 같은 카테고리의 위반으로 명시적 UB 명명(방어 로직 없음, 기존 "일반적 재진입 방어 안 함" 원칙과 정합). offset/sum은 0-based 개수, index는 1-based Lua 관례라는 것도 명시.
  • 반응형 raw 요소 — State<T>/Source<T>도 Slot 요소로 허용 (2026-08-11 일곱 번째 세션, 같은 세션에 정정) — Slot:Add가 받는 실제 타입은 T | State<T> | Source<T>(임의 깊이 조합 가능). [정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를 Compute로 파생" 안은 기각(nilable 지원하려면 배열 파트 None을 다시 끌어들여야 하고, Length 계산에 예외가 생기고, Move/Swap이 인덱스-구독 동기화 부담을 짐 — :List가 element 아닌 key 기준인 이유와 정면 충돌) — 새 메커니즘 없이 순수 :Single sugar로 확정: isState(element)면 그 자리에 내부적으로 Slot(): Single(element)(updateFn 생략 시 identity 기본값)를 대신 삽입. _elementsNone이 절대 안 들어감(비어있는 nested Slot이 자연히 Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구. :SingleupdateFn도 이 sugar가 성립하도록 선택 인자로 완화 (Slot:Single(state, updateFn?, opts?), 기본값 identity). :Single/:List와는 대체 관계가 아니라 같은 메커니즘 위의 다른 updateFn일 뿐 — raw State<T> 요소(identity)는 coarse swap, updateFn 직접 지정 시 prev/userdata patch-reuse + offset 접근(:Single이 애초에 생긴 이유). 부수 발견(사용자): :Listreconcile이 nested-Slot 결과를 반환하는 아이템 다음 형제의 index가 그 결과의 물리 개수만큼 건너뛰어야 함 — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가 겹침. base/slot-plan.md "반응형 raw 요소" 절. ⚠️ [2026-08-24 6라운드 H-2] 그 의도를 구현하던 옛 공식 (pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1))은 폐기됐다 — 두 좌표계(리프 개수 / _elements 자리)를 한 변수에 겹쳐 써서 table.insert(t, 0, x)로 조용한 영구 고아를 만들었고, .Length를 읽는 시점이 항상 0(마운트 전이라 recompute가 아직 안 돎)이라 애초에 아무것도 반영하지 못했다. 확정된 해법은 배열 자리를 slotPos로 따로 세고 updateFnindexDispatch.getOffsetAt에서 뽑는 것 — 같은 문서의 reconcile 의사코드가 소스.

짜야 할 것

  • [2026-08-24 신설, 6라운드 손 트레이싱 H-46] top-level quad-base/Slot.luau — 값 타입 본체가 들어가는 파일. 생성자, 공개 CRUD 11종, :List/:Single, raw* 세트, wrapElement/unwrapElement, attachSlot 3형제(materializeSlotTree/mountSlotTree), elementOwner/claimOwner/releaseOwner, reindexFrom, collectLeaves, dispose, Detach/KeyGone. 아래 불릿들이 전부 이 파일의 내용이다. Dispatch/Slot.luau(맨 아래)는 핸들러/부기만 — 다른 값 타입(Modifier/Tag/Tween/Ref/Effect)이 전부 top-level 파일을 갖는 것과 같은 대칭이고, base/slot-plan.mdattachSlot 블록이 이미 머리에 이 파일명을 적어뒀다

  • [2026-08-24, 6라운드] 이 마일스톤에서 새로 생긴 필드·헬퍼 (구현 항목으로 드러나야 놓치지 않는다): bk.indexOfElement(물리 요소→_elements 인덱스 역방향 맵, indexOfRaw가 이걸 O(1)로 조회하는 기본 경로. [2026-08-27, 9라운드 Q3] 옛 이름 slot._elemIndex — 같은 뜻의 맵이 Slot 층과 Dispatch 층에 따로 살던 것을 Dispatch 부기 하나로 통일했다, base/dispatch-core-plan.md setLength 절) · reindexFrom(self, from)(_elements를 시프트하는 모든 자리가 부른다 — 실체화 여부와 무관하게 항상; 갱신 대상은 위 bk.indexOfElement) · [2026-08-27, 9라운드 Q2] 생성자에서 나는 Offset/_baseObserver (Length와 같은 자리 — 마운트 시점에 만들면 첫 마운트/재마운트가 갈려 재마운트 캐시가 낡았다, H-125. 둘 다 bind/unbind로만 관리하고 제거/생성하지 않는다) · slot._destroyed(파괴됨은 이 플래그 하나만 말한다 — 핸들을 nil로 지워 그 뜻을 겸하게 하지 않는다. 마운트·공개 CRUD·:List 진입에서 error level 2, 이중 dispose는 no-op, base/slot-plan.md의 "파괴된 Slot은 재사용 불가" 절) · slot._physicalTarget(실체화 시점부터의 생명주기 앵커, _mountedInst는 "마운트됨"만 뜻하게 좁혀졌다) · collectLeaves(slot)(중첩 Slot의 물리 리프 평탄화 — Move/Swap/Extract/Splicenative*에 넘길 elements 배열을 만드는 데 필요, 없어서 그 넷이 미작성이었다) · :ListprevKeys(옛 keyIndex의 강등판, 단순 키 집합). 전부 base/slot-plan.md가 소스

  • [2026-08-24 H-25 파생] quad-typesQuadSlot 필드 추가 (위 M3 항목의 "마일스톤마다" 규칙)

  • [2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독] - State<Slot> 교체 = 파괴가 아니라 언마운트(state<Frame>와 동일). 비파괴 경로 unmountSlotTreedestroySlotTree와 별도로 구현 — 차이는 딱 둘: 실제 Destroy()를 안 하고, 자식 releaseOwner도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). 쓰는 자리: SlotHandler.process가 반환하는 클로저, 그리고 :ListreconcileOwned = false 설치와 Detach 경로 ([재정정, 2026-08-21] 값 교체는 Owned = true면 파괴가 맞다 — updateFn이 만든 걸 자기 손으로 못 지우기 때문. state<Frame> 의미론은 Owned = false가 담당). 여전히 파괴인 것: 명시적 Remove/Clear/dispose, 그리고 [재정정, 2026-08-18 구현 전 QA] :List에서 updateFnnil/None을 반환하거나 키가 데이터에서 사라진 경로(2026-08-13의 "reconcile은 전부 비파괴" 일반화가 :List엔 안 맞았음 — base/slot-plan.md의 "nil 리턴은 파괴가 기본" 절이 소스). - 해제 시 owner 등록 되돌리는 순서 고정setOffsetSource(inst,k,None) 먼저, setLength(inst,k,0) 나중. 반대로 하면 setLength 안의 recompute가 죽는 중인 서브트리의 offset Source에 헛된 :Set()을 날림. recomputesourceList[i]nil 이어도 None처럼 skip(방어). (⚠️ [2026-08-27 정정, 9라운드 H-140] 여기 한때 *"해제 시 slot.Offset = nil"*이 붙어 있었는데 그건 4라운드 SL-75/D-60폐기한 문장이다 — nil로 갈아치우면 그 Source를 구독 중인 다운스트림이 끊겨 포탈이 깨진다. slot.Offset은 생성자에서 나서 Slot과 함께 죽는다(위 _baseObserver 항목과 같은 불변식).) - 소유권 판정을 둘로 분리 — 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: Slot | Instance) — 대상이 아직 어느 트리에 의해 살아있길 요구되면 파괴를 거부하고 즉시 error(떼어내주지 않음 — 떼는 건 Set=언마운트의 몫). 엔진은 Destroy/Clear에 에러를 안 내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게 지우는 유일한 경로. 마운트 위치는 elementOwner가 이미 알고 있어 새 부기 불필요. isSlot(value)면 그 경로, 아니면 백엔드가 주입하는 nativeDispose(element): ()(addTag/removeTag/setAttribute와 같은 "base 소유+op 주입" 패턴, quad-roblox는 inst:Destroy())로 위임 ([2026-08-22 정정] 여기 disposeInst라 적혀 있었으나 이름은 2026-08-21에 native* 계층으로 확정됨 — M5 배너 참고). Observer/Effect는 범위 밖(GC-native bindLifetime/ unbindLifetime만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째 세션에 question.md 0-B 해소, 정본은 base/slot-plan.md "dispose(value)" 절

  • 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 그대로 반환하면 저비용 재사용 경로. [2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정] Detach 반환 경로updateFnDetach를 반환하면 그 자리는 파괴하지 않고 Parent = nil로만 내려와 Slot에서 빠진다. 보존 주체는 userdata가 아니라 slot._detached 필드(Slot 필드여야 destroySlotTree walk가 닿고 소유권도 유지됨 — ud로는 최종 처분이 불가능). reconcile이 prev로 그대로 돌려주므로 ud에 담을 필요 없이 그대로 반환하면 재마운트되고, 이미 detach 상태에서 또 Detachnop. 언마운트는 소유권을 유지하는 rawDetach를 씀(rawUnmount 아님). Instance.new/Destroy 비용을 아끼는 filter용 경로. 공개 표면은 None과 같이 패키지 최상위 export(base/slot-plan.md의 "Detach된 요소는 slot._detached가 보유한다" 절). [2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은 KeyGone 센티널로 확정updateFn(KeyGone, 0, offset, prev, ud)로 한 번 더 물어 처분을 받고, owner가 죽으면 activateList가 건 Effect_detached를 전부 정리한다(같은 문서의 "KeyGone" 절). ⚠️ [2026-08-24 H-50] 단 그 마지막 절반은 아직 "될 예정"이지 "된다"가 아니었다Effect의 leaf 사망 cleanup을 실제로 발화시키는 배선이 코퍼스 어디에도 없었다(6라운드 H-11). 같은 날 확정으로 bindLifetime/unbindLifetimeisEffect(value)를 보고 Destroying을 걸고/끊는 것으로 닫혔고(base/effect-plan.md + base/lifecycle-pattern.md의 그 의사코드), 이 문장은 그 배선이 구현된 뒤에야 참이 된다 — M2 Effect 구현이 M6의 이 항목을 선행한다는 뜻이다. ([같은 날 재결정] 처음엔 *"EffectHandle이 자기 bindLifetime 직후에 건다"*였으나 /code-review high그 호출부가 실재하지 않는다는 걸 지적했다 — 핸들은 남이 자기를 bind하는 걸 관측할 수 없고, Effect가 바인드되는 경로가 둘이라 호출부 쪽에 두면 반드시 한쪽이 샌다.) Owned 옵션 신설:List/:Single의 설치 시점 플래그(기본 true), false면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가 state에 담아 넘긴 요소용, Slot:Add(state) sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(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가 아니라 직전 사이클의 키 집합 prevKeys 전체를 순회해야 함 ([2026-08-24 H-1] 옛 이름은 keyIndex였고 인덱스 맵이었으나, 역방향 맵(지금은 bk.indexOfElement — 2026-08-27 Q3로 Dispatch 부기에 통일, 그때 이름은 slot._elemIndex)이 생기며 단순 키 집합으로 강등됐다) (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, updateFn, keyFn?)" 절) 구현. data:Observer(fn) 구독은 :List() 호출 시점이 아니라 Slot 마운트 시점까지 lazy — Dispatch.setLength와 같은 패턴으로 bindLifetime(inst,observer)(마운트 이후 :List()가 불리면 self._mounted 확인 후 즉시 활성화) (2026-08-09 일곱 번째 세션, base/slot-plan.md "Slot:List(data, updateFn, keyFn?)"의 "구독 시점" 절) Slot.Offset: Source<number>Slot.Length처럼 공개 필드로 노출 — Length와 같은 자리, 즉 생성자에서 Source(0)으로 만들고 마운트 시점엔 Dispatch.setOffsetSource가 그 Source를 등록만 한다 (2026-08-11 세션, base/dispatch-core-plan.md의 "Slot.Length와 Slot.Offset은 별개" 절. [2026-08-27 정정, 9라운드 H-125/Q2] 여기 한때 *"마운트 시점에 setOffsetSource가 등록하는 바로 그 Source를 self.Offset으로도 저장"*이었다 — 그러면 첫 마운트와 재마운트가 갈려 재마운트 캐시가 낡는다)

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

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([2026-08-21 갱신] 인스턴스 브랜드 멤버십 기반 — isSource(x)SourceBrand:is(x), isState는 그 위에 StateBrand:is(x)를 OR로 얹은 상위 개념. 옛 "공유 레지스트리 + Brand.get(x) == SourceTag" 서술은 역전됨, archive/brand-shared-registry-reversed.md. modifier-plan.md 9번, brand-plan.md의 "isX wrapper" 절, M2의 Brand.luau에 이미 구현돼 있어야 함)

[2026-08-22 이동] None 센티널 + Dispatch/None.luau(NoneHandler/ NilHandler)는 M3로 옮겼습니다None은 modifier 전용 값이 아니라 quad-base 디스패치 배관이고(architecture.md의 소스 트리에서도 Dispatch/None.luau), M0의 props.Modifier or None 관용구 · M3의 Dispatch.drive · M6의 setOffsetSource(..., None)이 전부 이미 전제합니다. M7에서는 Modifier 쪽 표면만 다룹니다 — 인라인 키/setter로 필드를 지우는 용법과 Peek 반환 타입에 None을 추가하는 것(modifier-plan.md 2-1번).

  • 프로퍼티류 필드 타입에 T' = T | Tween<T> 치환 반영(타입 생성 스크립트가 Position: UDim2 자리를 UDim2 | Tween<UDim2>로 만들면 끝, Modifier 런타임/__index 자체엔 변경 없음 — modifier-plan.md 10번, 2026-08-10 세션, base/tween-plan.md)
  • [2026-08-24 신설, 6라운드 손 트레이싱 H-35] quad-base/Dispatch/Modifier.luauProcessedModifierHandler. flatten이 배열 자리를 ProcessedModifier 센티널로 소진하고, 이 전담 nop 핸들러가 정상 Dispatch.process 경로에서 캐치해 setOffsetSource(None)/setLength(0)만 등록한다(Pre/PostRefProcessed* 핸들러와 완전히 같은 모양). Modifier가 하나라도 든 Frame{...} 호출은 전부 이 핸들러를 거치는데 색인 두 곳에서 통째로 빠져 있어 구현자가 존재 자체를 놓칠 수 있던 자리다. 의사코드는 base/modifier-plan.md의 flatten 절이 소스
  • [2026-08-24 H-25 파생] quad-typesQuadModifier 관련 표면이 노출돼야 하면 같이 갱신(위 M3 항목의 "마일스톤마다" 규칙)

M8 — Ref

  • [2026-08-24 H-25 파생] quad-typesQuadRef 필드 추가 (위 M3 항목의 "마일스톤마다" 규칙)
  • Ref.luau(.Value 읽기 전용 필드 + :Set(value)/:Callback(fn)/ :Wait(thread?), 전부 self 반환). [2026-08-25 추가, 7라운드 H-58/H-64/H-70] RefEpoch를 만족하게 됐다 — 공개 필드 .Revision(:Set()Source와 같은 bit32.bnot(-rev)로 갱신) + EpochBrand:register(self), 그리고 약하게 등록하는 :WeakCallback(fn)(weak-키 별도 테이블, Weak 쪽이 프리미티브이고 :Callback이 그 위에 "GC 킵"을 얹은 것). M2가 이미 이 표면을 전제한다Effect 생성자가 Ref dep을 self._epochs:Sync(d)EpochMap에 태운다(base/effect-plan.md). base/ref-plan.md의 "RefEpoch를 만족한다" 절이 소스 + PreRef.luau/PostRef.luau(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — base/ref-plan.md "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절 + "API 모양" 절)
  • (v=Ref) 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 Ref(default) 인스턴스를 인식해 바인드(별도 CreatedRef 래퍼 없음 — 이름 자체가 폐기됨, 아래 참고)
  • 이중 배치 방지(question.md 0-W, 2026-08-14 열한 번째 세션 해소) — RefLeafHandler.process가 실제 바인딩 분기에서 bindLifetime(inst, v)를, 실제 언바인딩 분기에서 unbindLifetime(v)를 호출. 새 Relate 불필요 — bindLifetime이 이미 내장한 canBound 이중 바인딩 가드를 재사용하는 것뿐(같은 Ref가 이미 다른 자리에 살아있으면 그 자리에서 즉시 error) — base/ref-plan.md "이중 배치 방지" 절
  • PreRef/PostRef pre-pass — 새 Dispatch.* 함수 없이 Dispatch.drive(inst, flattened) 자신이 본체 루프 전에 배열 파트를 한 번 훑어([2026-08-22 정정] 여기 "두 패스(배열→해시) 루프 전에"라고 적혀 있었으나 본체는 단일 일반화 for다 — F-4-1), PreRef는 그 자리에서 fire하고 PostRef는 로컬 postRefList에 push만 함(Dispatch.process/getHandler 우회하는 raw 루프, flatten 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 충돌하므로 기각). 복수 PreRef/PostRef의 계열 안 상대 순서는 배열 index 순서 그대로 보장(별도 규칙 없음 — 배열 파트 index 순서 계약의 귀결. 2026-08-14 아홉 번째 세션에 잠깐 미보장으로 뒤집었다가 같은 세션에 철회 — archive/preref-order-unguaranteed-withdrawn.md, FastQuery(...) -> PreRef류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진([정정, 2026-08-14 두 번째 세션] None이 아니라 전용 센티널 ProcessedPreRef/ ProcessedPostRef 처리 — 아래 Processed*Handler 항목이 그 자리를 정상 본체 루프로 마저 처리) — base/ref-plan.md "PreRef" 절 / "PostRef" 절
  • [2026-08-14 아홉 번째 세션 신설] PostRef.luau + 본체 루프 뒤 postRefList 소비 루프 — PreRef.luau와 같은 방식(Ref 런타임 재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store 타입 차단, _fired 1회용 가드). Dispatch.drive가 해시 파트까지 끝낸 뒤 postRefList를 순회하며 각 PostRef를 fire — 배열 재순회가 아니라 실제 개수만큼의 짧은 루프. 보장 범위 주의: 자기 서브트리 완성은 보장하되 이 인스턴스가 부모에 붙는 것보다는 먼저임 — base/ref-plan.md "PostRef" 절
  • PostRef 동적 경로 가드 Handler — PreRef의 것과 완전한 거울상 ({priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is PostRef, process = error(...)}), 같은 절 참고
  • PreRef 동적 경로 가드 Handler — {priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is PreRef, process = error(...)} 형태로 정상 우선순위 레지스트리에 등록(k 타입 안 가림), NoneHandler와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 우회한 버그라는 뜻 — 같은 절 참고. [2026-08-14 열한 번째 세션] 우선순위가 하드 블록이 아니라 FALLBACK인 이유(나중에 named 자리 바인드가 확정되면 평범한 우선순위 Handler로 덮어쓸 수 있게 — Tag/Attribute와 같은 이유)와 Observer/EffectHandle에도 같은 패턴의 가드가 추가됨은 base/source-state-plan.md/base/effect-plan.md의 "동적 경로 가드" 절 참고
  • [2026-08-14 두 번째 세션 신설] ProcessedPreRefHandler + [아홉 번째 세션] ProcessedPostRefHandler(완전한 거울상, 코드 한 글자 차이) — {isHandlable = v == Processed*Ref, process = setLength(0)+setOffsetSource(None)+no-op retract} 형태로 정상 우선순위 레지스트리에 등록, NoneHandler와 같은 "한 값 종류 전담" 패턴. pre-pass가 소진시킨 자리가 Length/Offset에 "0 기여"를 등록할 책임을 지는 자리 — base/ref-plan.md "PreRef" 절 / "PostRef" 절, base/dispatch-core-plan.md "Length/Offset" 절
  • Ref 콜백/대기자 실행 루프 — [2026-08-24 6라운드 H-7로 재작성] .Callbacks{[callback|thread] = true} 해시맵 셋이다(배열 아님). :Set(value).Value를 먼저 쓰고, 순회 전 스냅샷을 뜬 뒤 (pairs 순회 중 새 키 추가가 Lua에서 미정의라 — H-23과 같은 처방) 키 타입으로 분기: type(k) == "thread"Callbacks[k] = nil로 소진 후 coroutine.resume(k, self)(값이 아니라 Ref 자신), 함수면 k(value, self) 호출 + 유지([2026-08-26, 8라운드 H-107] 두 번째 인자로 Ref 자신 = Epoch을 준다 — EffectUpdate(from)에 넘길 통로다). 순회 대상은 두 테이블.Callbacks(강)와 .WeakCallbacks(weak-키)를 각각 스냅샷한다(H-108). 그리고 .Value 대입 직후·순회 전self.Revision = bit32.bnot(-self.Revision) (순서 계약: 값 → 리비전 → 콜백). 중복 등록은 dedup이 계약이고 해제는 :Uncallback(fn)(양쪽 테이블을 다 본다). [2026-08-25 추가, 7라운드 H-58/H-59] 약하게 등록하는 짝 :WeakCallback(fn)이 신설됐고 그쪽이 프리미티브다:Callback(fn)은 거기에 "GC 안 되도록 킵" 하나를 더 얹은 것이다. 약한 등록은 weak-키 테이블(__mode = "k")로 .Callbacks별도이고(Lua의 weak 모드는 테이블 단위), 발화 순회는 둘 다 훑으며 :Uncallback은 양쪽을 본다. 의사코드는 base/ref-plan.md가 소스. ⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, [i] = nil 소진, #t border 실측)는 전부 폐기됐다 — 해시맵엔 구멍도 border도 없다. :Wait(thread?)는 그대로 — threadnil이면 coroutine.running() 캡처+yield, 있으면 등록만 하고 즉시 self 반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
  • [2026-08-24 신설, 6라운드 H-7 / 2026-08-25 범위 축소, 7라운드 H-58] Ref:Uncallback(fn) — 해제 경로(강·약 두 테이블을 다 본다). ⚠️ Effect는 더 이상 이걸 안 부른다 — 여기 한때 *"Effect가 자기 Ref dep 콜백을 뗄 때 쓰고(_refCallbacks에 보관해둔 바로 그 클로저를 값으로 뗀다), unbindLifetime/:Unsubscribe()가 부른다"*고 적혀 있었는데 그 계약은 base/effect-plan.md가 명시적으로 retract했다. 지금은 Effect생성자에서 :WeakCallback으로 한 번만 걸고 강한 주인은 _deps이며(_refCallbacks는 폐기된 필드), 바인드/언바인드는 Ref를 아예 안 건드린다. :Uncallback사용자가 직접 건 콜백을 떼는 공개 표면으로 남는다
  • LifetimeHandle quad-roblox 실제 구현 — bindLifetime/ unbindLifetime/canBound/canExecute 본체(인터페이스 자체는 M2로 이동됨, Relate 자체는 quad-base라 quad-roblox 쪽 재구현 없음). [2026-08-14 다섯 번째 세션 정정] gcconn/gchold를 여기서 lazy 생성하지 않는다 — 생성은 M5의 Instance 생성 경로가 이미 끝내둔 것이고, 이 함수들은 InstData에서 찾아 쓰기만 함. bindLifetimegchold[value]=true(강참조로 생존 보장)와 BindData:SetWeak(value, "gchold"/"gcconn", ...)(값이 자기 생존 판정 근거를 직접 들고 있게) 둘을 하고, unbindLifetime(value)은 그걸 되돌림. [2026-08-24 6라운드 H-11, 2026-08-25 7라운드 H-58로 축소] 그리고 isEffect(value)면 분기가 하나 더 붙는다value:_bindDestroying(inst) / :_unbindDestroying()를 부른다. 여기 원래 *"둘만 하고"*라고 적혀 있었는데 이 분기와 직접 모순이었다. ⚠️ [2026-08-25 정정] handle._observers 전부로 cascade하지 않는다 — 그 필드 자체가 폐기됐다(_deps 하나로 통합). 그리고 _bindDestroyingRef dep 콜백을 (재)등록하지 않는다 — dep 등록은 생성자에서 끝나고, 여기서 하는 건 Destroying 연결과 조건부 캐치업 한 줄(if not self._installed or self._epochs:Refresh() then self:Rerun() end)뿐이다. 이게 Effect의 leaf 사망 cleanup을 실제로 발화시키는 유일한 배선이고, M6의 _detached 정리가 여기 의존한다 (그 항목의 H-50 각주 참고). 의사코드는 base/lifecycle-pattern.mdbase/effect-plan.md가 소스. [2026-08-14 열한 번째 세션] canBound(value)/canExecute(value)는 비공개 헬퍼 isBoundAlive(value) 하나(복사된 gcconn의 .Connected 또는 .Subscribed를 봄)를 공유하는 얇은 진입점 둘로 분리bindLifetime/ Observer:Subscribe()의 이중 바인딩 가드는 canBound, State emit 전파 루프만 canExecute. 저장은 전부 SetWeak(SetStrong 아님 — gchold/gcconn은 아래 M5 클로저↔gchold[1] 상호 참조로 이미 안전하게 살아있고, "다른 곳에서 안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙). base/lifecycle-pattern.md의 "bindLifetime / canBound / canExecute / unbindLifetime" 절

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.

[2026-08-14 열두 번째 세션 정정] TagHandler/AttributeKeyHandler/ AttributeGroupHandler는 참조 카운트/이름 claim 알고리즘 구현일 뿐 — HANDLER_PRIORITY_FALLBACK에 실제로 등록되는 건 이를 감싸는 별도 파일 TagFallbackHandler/AttributeKeyFallbackHandler/ AttributeGroupFallbackHandler이고, [재역전, 2026-08-18 구현 전 QA] 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신(백엔드 미로드 상태에서도 안내 에러 경로가 돌아야 하기 때문 — base/dispatch-core-plan.md의 해당 절). 아래 체크리스트의 Handler 파일 항목은 전부 이 구분을 반영하도록 갱신됨 — 뒤집힌 옛 모델은 archive/tag-attribute-load-time-registration-reversed.md.

  • [2026-08-24 H-39] TagHandler/AttributeGroupHandler가 자기 배열 자리의 setOffsetSource(inst,k,None)/setLength(inst,k,0)을 등록 — 둘 다 0건이었다(위 M3의 그 항목). 같이 type(k) == "number" 가드도 추가(H-52RefLeafHandler가 2026-08-18에 받은 수정을 이 둘은 못 받았다)
  • [2026-08-24 H-41] AttributeGroupHandler.processgroupClaimKeys 위치 claim 배선 — 5라운드 AT-1에서 (inst, groupValue) → k로 확정해놓고 의사코드에 안 들어가 있었다. nameClaims보다 먼저 해야 절반만 기록되는 중간 상태가 안 생긴다
  • [2026-08-24 H-27] OnChangeHandler.processv == nil 얼리리턴 — 없으면 None으로 콜백을 끄는 게 실제로는 나중에 터질 Connection을 새로 심는 동작이 된다
  • [2026-08-24 H-25 파생] quad-typesQuadTag/Attribute 필드 추가(위 M3 항목의 "마일스톤마다" 규칙)
  • Handlers/Event.luau(ReflectionService 기반 자동 판별)
  • Handlers/OnChange.luau(OnChange(name) 특수 키 팩토리+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의 Tag/Attribute 몫addTag(inst,{string})/removeTag(inst,{string}) (CollectionService), setAttribute(inst,name,v)(v==nil이면 삭제). RobloxFactoryBaseModule에 주입(bindLifetime/canExecute와 같은 패턴) — 아래 base 핸들러들이 이걸 호출함 (base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절). [2026-08-22 정정] 여기 "주입되는 엔진 op 3개"라고 적혀 있었으나 이 파일이 담는 op은 그 셋이 전부가 아니다 — M5의 native* 계층이 같은 파일에 들어오고 백로그의 setTimeout/ clearTimeout도 예정돼 있다. 주입 op 전체 목록의 소스는 base/architecture.md의 소스 트리 안 EngineOps.luau — 여기서 세지 않는다.
  • quad-base/AttributeKey.luau(단일 키 AttributeKey<<T>>(name) + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 String/Number/BooleanAttribute — 엔진 고유 타입 패밀리 (Color3Attribute류)만 quad-roblox의 D(Declarative) 층에서 각자 추가. 타입 파라미터화 이름만 착수 전 확인, base/attribute-plan.md)
  • quad-base/Dispatch/AttributeKey.luau(AttributeKeyHandlersetAttribute(inst,name,v)v가 뭐든 무조건 호출 + 이름 claim(nameClaims Relate, 다른 키 객체가 같은 이름에 들어오면 즉시 error, 반환 클로저는 자기 claim만 반납하고 엔진 부작용 없음). 알고리즘 구현일 뿐 스스로 등록되진 않음(아래 AttributeKeyFallbackHandler 항목 참고). question.md 0-Z 결정 — base/attribute-plan.md "이름 소유권" 절)
  • [2026-08-14 열두 번째 세션 신설] quad-base/Dispatch/ AttributeKeyFallback.luau(AttributeKeyFallbackHandler — 위 AttributeKeyHandler를 그대로 감싸 HANDLER_PRIORITY_FALLBACK으로 등록되는 별도 이름의 엔티티. [재역전, 2026-08-18] 등록 주체는 RobloxFactory가 아니라 quad-base 자신base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
  • Attribute.luau(quad-base — 그룹 값 타입+API: Attribute(store1, store2, ...)/Merged/Overridden/:NameMap, Tag와 동형 array-part 값 객체, base/attribute-plan.md. [2026-08-18] Merged는 이름이 겹치면 error, Overridden은 뒤가 이김 — 둘 다 제공)
  • 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 "메커니즘" 절. 알고리즘 구현일 뿐 스스로 등록되진 않음, 아래 AttributeGroupFallbackHandler 항목 참고)
  • [2026-08-14 열두 번째 세션 신설] quad-base/Dispatch/ AttributeGroupFallback.luau(AttributeGroupFallbackHandler — 위 AttributeGroupHandler를 그대로 감싸 HANDLER_PRIORITY_FALLBACK으로 등록되는 별도 이름의 엔티티, 등록 주체는 AttributeKeyFallbackHandler와 동일하게 quad-base 자신 — [재역전, 2026-08-18])
  • 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를 직접 캡처) — 2026-08-12 열한 번째 / 2026-08-13 네·다섯·열네 번째 세션, base/tag-plan.md. 알고리즘 구현일 뿐 스스로 등록되진 않음, 아래 TagFallbackHandler 항목 참고)
  • [2026-08-14 열두 번째 세션 신설] quad-base/Dispatch/ TagFallback.luau(TagFallbackHandler — 위 TagHandler를 그대로 감싸 HANDLER_PRIORITY_FALLBACK으로 등록되는 별도 이름의 엔티티, 등록 주체는 AttributeKeyFallbackHandler와 동일하게 quad-base 자신 — [재역전, 2026-08-18])
  • [2026-08-14 세션에 누락 발견, 신규] quad-roblox/Handlers/ InstanceShorthand.luau — UI 편의 숏핸드 UICorner/UIPadding (+UIPaddingOffset)/UIScale(base/ui-shorthand-plan.md). 이 마일스톤 전후로 구현하기로 그 문서가 이미 지정해뒀는데 체크리스트에 항목 자체가 없었음. 구현 포인트: (a) 재사용 대상은 quad가 만든 고정 이름(_quad_corner류) 자식으로 한정, (b) v == nil이면 그 자식 제거, (c) 자식 프로퍼티는 직접 대입하지 말고 Dispatch.process(child, prop, wrapped, 1)로 위임 — 이걸로 Tween이 공짜로 따라옴(해석은 PropertyHandler 하나에만 남음), (d) 스칼라→프로퍼티 타입 wrapTween<T>.Value에만 적용되도록 들어올릴 것, (e) UIPadding은 자식 프로퍼티 4개에 각각 위임, (f) 자식을 없앨 때 retractFrom(child, prop, 1)도 같이. M11(Tween) 이후에 하면 (c)~(d)를 바로 검증 가능

M11 — Tween

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

확정된 것 — 코드 아님, 구현 전 필독

아래는 설계가 확정됐다는 사실이지 짠 코드가 아니다 — 체크박스로 두면 [x]가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다 ([2026-08-22]).

  • override 정책 확정 완료(2026-08-12 세션, base/tween-plan.md "확정: Tween{...} 최종 모양" 절) — 검토했던 4가지가 Tween.Cancel (기본)/Tween.Finish 2값으로 압축됨(로블록스 TweenBase API 현실상 나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두 "정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정. 구현 시 순서 주의: 이전 트윈 정리 → 그 다음 새 값 세팅
  • 트윈 옵션 값 모양 확정 완료(2026-08-12 세션, base/tween-plan.md) — Info: TweenInfo? 우선 + 편의 필드(Time/Style/...) 폴백, 기본값은 로블록스 TweenInfo.new() 자체 기본값과 일치. 옵션 필드는 전부 plain만(State 불가)
  • initValue(진입 애니메이션) — 에이전트 범위 제외로 확정 (2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님

짜야 할 것

  • quad-base/Tween.luau(값 타입만 — Tween(opts) 팩토리, isTween/ TweenBrand, Value: T plain만 받고 State 재귀 없음)
  • Handlers/Property.luauisTween(realv) 분기 추가(기존 Handlers/Tween.luau 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 (RobloxTween | true | nilnil=첫 세팅, true=세팅됨/트윈 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
  • 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은 구현만 하면 됨

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

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

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

  • 범용 렌더 디버깅 도구로서의 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만이면 급하지 않음
  • [2026-08-14 신설, 2026-08-19 설계 전부 해소 후 base/로 승격] 시간 기반 전파 게이트 Debounce/Throttle(base/debounce-throttle-plan.md) — 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을 추가하지 않는 순수 슈가로 확인됨(Blocker의 gated state + Ref + 아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). [정정, 2026-08-24] 그 공용 Gate 추출은 M2(반응형 코어)에서 이뤄진다 — 2026-08-21에 "게이팅 먼저" 결정으로 디스패치 쪽에 앞당겨뒀다가, 2026-08-24 마일스톤 순서 교체(위 M2 배너)로 반응형이 먼저가 되면서 앞당길 필요 자체가 사라졌다. Blocker/Debounce/Throttle이 공유할 노드를 거기서 같이 빼둔다(따로 하면 같은 설계를 두 번 함). [2026-08-21] 표면 확정 — state:Gate(setup) 메소드, base/gate-plan.md. 프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M2를 막지 않음. 주입 op 2개(setTimeout(func, delay) -> Timeout / clearTimeout, Roblox는 task.delay/task.cancel로 배선 — 인자 순서가 반대라 주의)가 bindLifetime/canExecute와 같은 base 범용 유틸 그룹에 추가될 예정이라는 것만 M1 설계 시 인지. os.clock()은 Luau 표준 라이브러리라 주입 대상 아님(단 절대 시각이 아니라 diff 전용)
  • [2026-08-14 아홉 번째 세션 신설] 생명주기 훅 슈가 OnCreated/OnRendered/OnDestroyed(base/lifecycle-hooks-plan.md) — 각각 PreRef():Callback(guard(fn))/PostRef():Callback(guard(fn))/ Effect(function() return fn end)를 반환하는 순수 팩토리 함수 (⚠️ [2026-08-26, 8라운드 H-120] guard는 생략할 수 없다Ref 콜백은 "등록 즉시 1회, 값이 nil이어도" 호출되므로 default 없는 PreRef()에 맨 fn을 걸면 생성 시점에 fn(nil)이 먼저 불려 inst를 바로 쓰는 콜백이 pre-pass 전에 죽는다. guard(fn) = function(v, r) if v ~= nil then fn(v, r) end end2-인자를 그대로 흘린다, Ref 콜백이 fn(value, ref)라 1-인자로 짜면 Epoch를 조용히 삼킨다) 3개라, 착수 시점에 그 문서의 코드 스케치를 그대로 옮기면 끝(새 타입/ Dispatch 개념 없음, 패키지는 quad-base 확정). 설계는 확정됐지만 구현은 형제 백로그(quad-mock/quad-debug/Operator/Fallback)와 동급으로 맨 뒤 — 없어도 프리미티브를 직접 쓰면 되므로 기능 격차 없음. 단 이들이 얹히는 PostRef 자신은 슈가가 아니라 디스패치 코어의 일부라 M8에서 PreRef와 같이 구현됨(위 M8 참고).