Merge pull request #8 from qwreey-bot/main

Sync 08.19 02:19 KST - Dev docs
This commit is contained in:
qwreey 2026-08-19 02:21:23 +09:00 committed by GitHub
commit 4474b69ab1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4771 additions and 537 deletions

File diff suppressed because one or more lines are too long

View file

@ -20,6 +20,114 @@
> 유효한 규약은 `base/typing-limits.md`. 나머지(0-Z/0-A/0-B 등)는
> 여전히 `question.md`가 소스.
## [해소됨, 2026-08-18 구현 전 QA] `DI``D`(Declarative) 리네임 확정
2026-08-08 용어 정리 라운드부터 `question.md` **1순위**로 열려 있던 항목.
사용자가 구현 전 QA 라운드 중 직접 확정("이거 하면서 DI => D 확정하자").
- **확정 내용 두 갈래**: (1) 네임스페이스/모듈 자체는 `DI`**`D`**
(`D.Frame` / `D/init.luau` / `D.InstSlot` / `D.FrameModifier`),
(2) "특수 DI 키"라는 **설명용 표현**은 `D`로 바꾸지 않고 **"특수 키"로
단순화**(수식어를 빼도 문맥상 통한다는 판단).
- **`D`로 가는 근거**: (1) "Instance" 전용 개념이 아니라 quad-* 전반의
declare 요소로 확장 가능한 이름, (2) 엔진 종속 없이 다른 백엔드에서도
재사용 가능, (3) `D.FrameModifier`류 타입 프리픽스가 짧아야 한다는 실용적
제약. 원래 이름 `DI`의 문제는 **"Dependency Injection"과 완전히 겹쳐 실제로
오해가 있었던 전례**.
- **2026-08-08에 확정을 미룬 유일한 사유였던 "한 글자 식별자의 검색성/
자기설명력"은 표기 규약으로 보완** — 문서에서 `D`가 처음 나오는 자리에서는
항상 `D`(Declarative)로 풀어쓴다(`base/architecture.md`의 "코드 스타일 —
네이밍 케이싱" 절).
- 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트
네이밍 인체공학" 절이 소스.
## [해소됨, 2026-08-18 구현 전 QA 2라운드 후속] `RC-1``recompute` 트리거 모델 재설계
`base/dispatch-core-plan.md``recompute`가 배열 위치를 순차 등록하는
동안(`Frame{A,B}`처럼 정적 자식 2개짜리도) 아직 등록 안 된 자리를 `nil`
읽어 산술 에러를 내던 크래시(`qa-request/pre-implementation-qa-round2.md`의
"RC-1" 절에서 손 트레이싱으로 발견) — 같은 날 후속 세션에서 사용자가
Blocker 재사용 설계를 직접 제시해 해결됨.
- **핵심**: owner(물리 `inst` 또는 Slot)마다 `Relate`로 들고 있는 전용
`Blocker`를 배치(`Dispatch.drive`의 배열 파트 순회, `attachSlot`의 자기
`_elements` flush) 시작 시 `On`, `setLength`의 Observer 콜백(등록 즉시
1회 실행 포함)이 `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가
끝나면 `blocker:OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
`setOffsetSource`는 등록되는 그 자리에서 앞선 형제들의 길이 합을 직접
계산해 즉시 `:Set`해서(recompute를 안 기다림) 배치 도중 `:List`
실체화되며 옛 offset을 읽는 문제도 같이 없앰.
- **Blocker에 신설된 API**: `IsOn()`(`IsBlocked` 조회 얇은 래퍼),
`OffWithoutEmit()`(끄되 gated state의 대기 emit은 흘려보내지 않음,
`HasBlockedEmit`은 그대로 리셋) — `state:Block()`을 거치지 않고 직접
쓰는 첫 사례. 처음 요청됐던 `HasBlocked`(Blocker 자신의 새 최상위
플래그)는 논의 중 불필요함이 확인돼 신설 안 함.
- **중첩 Slot마다 별도 Blocker**(부모와 공유 금지, `blocker-plan.md`
재진입 미지원 규칙 그대로), **런타임 단건 `slot:Add()`는 게이팅 불필요**
(이미 안정된 앞선 position만 참조하므로 무관) — 둘 다 사용자가 직접
확인.
- **후속 정정 — `attachSlot` 호출 순서**: 기존 의사코드는 `setLength`
먼저 불렀는데, "호출 순서는 `setOffsetSource``setLength`" 일반
규칙과 어긋나 있었음이 드러남(RC-1로 `setOffsetSource`가 즉시 계산을
하게 되며 순서가 겉으로 드러났기 때문) — Slot의 진짜 `.Length`
`activateList` 실체화 뒤에야 확정되므로 `setOffsetSource` → 실체화 →
`setLength` → 물리 마운트 순으로 바로잡음. 같은 논의에서 **코루틴 yield
금지 불변식**도 확정(`Dispatch.process`/`attachSlot` 호출 체인 도중
yield는 UB).
- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게
만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절.
## [해소됨, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 + `RC-3`/`RC-4`(`attachSlot`의 `:List` 초기 population 중복 처리)
`recompute`가 순회 상한으로 쓰는 `bk.N`이 Slot을 ownerKey로 재사용할 때
무엇이고 언제 갱신되는지 문서에 없던 갭(`qa-request/
pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`
`:List`의 최초 population을 이중 처리하는 결함(`RC-3`/`RC-4`)도 같이
발견됐고, 셋 다 같은 세션에 사용자가 직접 해법을 제시해 해결됨.
- **`bk.N` = 그때그때 실제 개수**, `inst`/Slot 두 owner 타입 동일 규칙 —
`Dispatch.setLength`(항상 뒤에 불림, `setOffsetSource`는 안 건드림)가
이전에 없던 더 큰 position을
등록할 때마다 늘고, `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`)이
위치를 구조적으로 지울 때 줄어듦. **최초 분석 오류를 사용자가 직접
정정**: 필자는 "그때그때 실제 개수"면 배치 등록 중 `RC-1`과 같은
크래시가 되돌아온다고 판단했으나, Blocker 게이팅은 `bk.N`이 아니라
`blocker:IsOn()`만 확인하므로 배치 중엔 `recompute` 자체가 안 돌아
`bk.N`이 무엇이든 무관함 — `RC-1`의 원래 크래시는 "`bk.N`이 배치 전에
이미 최종 크기로 고정"이라는, 이제는 사라진 전제의 부산물이었을 뿐.
Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 아니라 배치 비용
(O(N²)→O(N)).
- **`RC-3`/`RC-4`**: `attachSlot``slot._mounted = true`를 맨 위에서
세팅해뒀던 탓에, `:List`의 최초 reconcile(`activateList`, 아직
flush 루프의 Blocker가 켜지기 전)이 부르는 `rawAdd`가 "이미 마운트됨"
경로를 타 항목마다 무게이팅 `recompute`가 돌고(`RC-3`), nested Slot
요소는 그 자리에서 이미 `attachSlot`된 뒤 뒤이은 flush 루프가 같은
요소를 또 `attachSlot`해 이중 실행됐다(`RC-4`). **해법(사용자 제시)**:
`slot._mounted = true``activateList` 호출 **뒤**로 옮기면 끝 —
`activateList` 도중엔 `rawAdd`가 "아직 마운트 전" 경로(그냥
`_elements`에만 push)를 타고, flush 루프가 `:List`/수동 CRUD 구분 없이
모든 요소를 처음이자 한 번만 물리 마운트한다. `rawAdd`의 "이미
마운트됨" 즉시-`attachSlot` 분기 자체는 그대로 남음 — 최초 flush
이후의 런타임 갱신(예: `:List``data`가 나중에 바뀌어 nested Slot이
새로 추가되는 경우)엔 여전히 필요한 경로이기 때문.
- **부수 발견**: `spliceArraysDown`이 밀어야 할 배열 목록에
`bk.observers`가 빠져 있었음(`_elements`/`lengthList`/`sourceList`
셋만 서술돼 있었음) — 같이 반영.
- **`ROADMAP.md` 마일스톤 정합성**: `RC-1`의 Blocker 게이팅으로 M2
(`Dispatch.setLength`/`setOffsetSource`)가 M3 체크박스에 있는
`Blocker.luau`에 구조적으로 의존하게 됐는데 로드맵 어디에도 이 순서
의존이 명시가 안 돼 있던 것도 발견 — 가장 보수적인 조치(마일스톤
재편 없이 M2 체크박스에 각주만 추가)로 우선 반영, 재편 여부는 열려
있음.
- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "저장 위치"/"배치
등록을 안전하게 만드는 Blocker 게이팅" 절, `base/slot-plan.md`
"재귀 메커니즘"/"파괴" 절, `base/blocker-plan.md`의 "두 번째 용례"
절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은
`qa-request/pre-implementation-qa-round3.md`.
---
# 확인/결정 필요 목록

View file

@ -1,8 +1,23 @@
# [역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음
# [역전됨 → 절반 재역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음
**상태**: archive — 2026-08-14 열두 번째 세션(이 대화)에서 역전. 정본은
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절.
> **⚠️ [재역전, 2026-08-18 구현 전 QA] 이 문서가 폐기했던 두 결론 중
> "등록 주체" 쪽은 다시 뒤집혀 현행이 됐다.** 사용자 확정 —
> Fallback Handler는 **quad-base 자신이 등록**한다. 이유는 백엔드를
> 로드하지 않았을 때 "provider가 초기화됐는지 확인하라"는 안내 경로가
> 아예 안 도는 것("quad-roblox 를 로드하지 않았을 때 로드했는지 물어보는
> 요소가 처리가 안 된다"), 그리고 `InitNamespace` 거부 원칙은 *남의 상태를
> 건드리는 top-level 부작용*과 *사용자 수동 init*을 금지한 것이지 모듈이
> 자기 레지스트리를 채우는 걸 금지한 게 아니라는 것. 근거 전문은
> `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op"
> 절이 소스.
> **이 문서의 "이름" 쪽 결론(등록되는 건 `TagHandler`가 아니라
> `TagFallbackHandler`)은 그대로 유효**하다 — 아래 2번 항목.
> 즉 이 문서는 이제 "폐기된 설계의 보존"이 아니라 **한 번 뒤집혔다가
> 절반이 되돌아온 경위 기록**이다.
## 뒤집힌 주장 (2026-08-14 열한 번째 세션, "네 번째, 최종 정정")
`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 **quad-base

View file

@ -77,10 +77,12 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아
- **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용**`bindLifetime`/
`unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection
메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신]** 게이트는
`canBound(value)`이고(`if canBound(v) then error(...) end`, `canExecute`
emit 게이팅 전용으로 분리 — `lifecycle-pattern.md` "`canBound` vs
`canExecute`" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진
메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신, 2026-08-18
방향 정정]** 게이트는 `canBound(value)`이고(`if not canBound(v) then
error(...) end` — `canBound` 참 = "지금 묶어도 됨", `canExecute`
emit 게이팅 전용으로 분리되며 둘은 서로의 부정 —
`lifecycle-pattern.md` "`canBound` vs `canExecute`" 절),
검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진
값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는
통과, (c) **`inst`가 Destroy된 뒤에도 통과**(모델이 명시적으로 허용).
전부 미해소이고, 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수

View file

@ -34,12 +34,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기.
store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) —
부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사.
4. **PA님 스타일 DI 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`,
4. **PA님 스타일 특수 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`,
2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와 이름 충돌 방지로
리네임) 같은 특수 바인드 키, store 컴퓨티드 바인드도 가능해야 함
(`retract`, 구 cleanup, `base/lifecycle-pattern.md` 참고). **[정정,
2026-08-08 세 번째 세션]** `Tag`는 더 이상 `[Tag ""] = true` 해시 파트
DI 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨,
특수 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨,
`base/tag-plan.md` 참고(`archive/tag-hash-key-model-reversed.md`에 구
모델 보존). **[2026-08-11 아홉 번째 세션]** `Attribute(...)`도 여러
Store를 한 번에 attribute로 묶는 array-part 값 객체로 신설(`Tag`와
@ -98,16 +98,74 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도
프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은
`quad-roblox`가 담당.
13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox
프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()`
추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라
기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`
격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가
`BaseModule`을 뮤테이션" 패턴(14번) 그대로, `New()`가 생기면 매번 새
`BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler
레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy`
마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는
13. **모듈은 기본 싱글톤, `New()`는 추가 인스턴스가 필요할 때만.**
**[재정정, 2026-08-19 — 이전 정정(`New()`→`Quad()` 전면 치환)이
부정확했음, `/code-review high` 이후 사용자가 직접 바로잡음]** `New`
`Quad`가 이름이 다투는 게 아니다 — 이전 정정이 "미래 API 이름은
`Quad()`"라고 단정하며 `New()`를 전부 `Quad()`로 바꿨던 게 틀렸다.
사용자 원문: *"내가 말한건 Quad() 만 제공하면 항상 모든 Quad 요구처에서
각각의 Quad() 를 수행해서 새로운 모듈 스코프가 나온다는게 문제였어.
그래서 Quad 는 기본적으로 생성된걸 리턴하긴 하는데, Quad.New() 도
제공하는거 어떻냐는거였음, 즉 New() 는 존재하고 기본 리턴은 New() 해서
주는건데, 리턴 안에 New 필드가 있고 그 함수를 쓰면 하나의 새로운 Quad
네임스페이스가 만들어지는식. 안 그러면 모든 컴포넌트를 나눈 모델
등에서 Quad() 를 해서 새로운 모듈 스코프가 나와서, 래핑된 Quad() 를
수행하는 부분을 따로 작성해두어야한다던가 해질꺼임."*
- **`Quad`(`require`의 반환값, 호출 아님)는 이미 만들어진 기본
인스턴스 자체다** — 모듈 로드 시점에 그 모듈 자신이 내부적으로
`New()`를 한 번 불러 만든 결과를 그대로 top-level 반환값으로 내보냄.
그래서 평범한 소비자는 아무것도 호출할 필요 없이 `require(quad)`
돌려준 걸 바로 `Quad.Dispatch`처럼 씀(지금 싱글톤 단계와 동일한
접근 방식 — 달라지는 게 없음).
- **`Quad.New()`가 명시적 opt-in으로 설계됨**(**구현은 아직 미착수** —
지금은 싱글톤 단계라 `New` 필드 자체가 아직 안 노출됨, 바로 아래
"M0 스캐폴딩에 주는 함의" 참고) — 다중 인스턴스화가 실제로 붙는
시점엔 반환된 기본 인스턴스 **안에** `New` 필드(함수)가 생기고,
이걸 실제로 호출하면 완전히 **별도의 새 Quad 네임스페이스**
(자기만의 Dispatch 레지스트리 등)가 나오는 모양으로 짠다. Roblox +
비-Roblox 프로바이더를 진짜로 동시에 써야 하는 드문 경우에만 이걸
쓰게 될 것.
- **왜 "그냥 `Quad()`를 부르면 새 인스턴스"로 안 하는가** — 컴포넌트를
여러 파일/모듈로 쪼갠 실제 앱에서는 각 파일이 독립적으로 "Quad
인스턴스 하나 줘"를 요청하게 되는데, 그 요청 방법 자체가 "새로
만들기"라면 **파일마다 서로 다른, 서로 공유 안 되는 인스턴스**를
만들게 되는 사고가 난다(Dispatch 레지스트리가 안 공유되는 등) —
그러면 앱 전체가 하나의 공유 부트스트랩을 따로 작성해서 그걸 통해서만
Quad를 얻도록 강제해야 함. `require`가 이미 인스턴스화된 기본값을
주면 이 위험 자체가 원천적으로 없음 — 새 인스턴스가 필요한 그 드문
경우만 명시적으로 `.New()`를 부르면 되므로 실수로 스코프가 갈라질
일이 없다.
한 Lua 스레드에서 Roblox/비-Roblox 프로바이더를 동시에 쓸 일이 거의
없을 거라 판단해 지금은 `New()` 없이 싱글톤만 두고, 필요해지면 그때
`New()`를 노출한다. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션,
새 설계 아니라 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸
`Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는
"팩토리가 `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새
`BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. 상세 근거는
`base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절.
**[정정, 2026-08-18 구현 전 QA]** 옛 서술은 그렇게만 하면 지금
module-level state로 사는 모든 것(`_initializedBy` 마커, Dispatch
레지스트리 등)이 **"자동으로" 테이블별 스코핑된다**고 했는데, 사용자
판정은 다르다 — *"모듈이 하나의 인스턴스(dispatch 레지스트리 하나,
canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 …
require 를 감싸지는 않고 단순히 InitModule(module) 등을 받도록 각
코드들을 약간 고쳐서 이것을 해결함."* 즉 **코드 변경 없이 자동으로**
되는 게 아니라, module-level state를 참조하는 코드들이 모듈 인스턴스를
인자로 받도록 **손을 대야** 한다 — `New()`가 실제로 호출되면(위 opt-in
경로) 그 순간 만들어지는 새 `BaseModule` 테이블에 대해 이 손질이 필요.
**지금은 `New()` 자체가 노출 안 된 싱글톤 단계라 `Quad.Dispatch`
바로 접근**한다. `New()` 자신이 내부적으로 어떤 형태로 조립되는지(v1
스타일 `InitXxx(module)` 팩토리 체이닝, 타입 재익스포트)는
`module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고.
- **M0 스캐폴딩에 주는 함의 — [2026-08-19] 정해짐, 실제로는 M0가 아니라
`ROADMAP.md` M1(실제 스캐폴딩)에 적용됨.** 레지스트리를 module-level
upvalue로 직접 잡아두면 나중에 다중 인스턴스화할 때 전면 수정이
된다는 우려가 있었는데, 바로 위에서 가리키는 InitXxx 패턴(각
`InitXxx(module)``module`을 upvalue가 아니라 **파라미터로 받아**
뮤테이션)이 처음부터 그 형태다 — 나중에 바꿀 일 자체가 없게 M1
스캐폴딩부터 이 모양으로 짠다. M0는 독립 스파이크 파일로 개별
가설만 검증하는 단계라 이 구조 자체를 아직 안 씀(`ROADMAP.md`의
"M0 — 스켈레톤 + 기술검증" 절 참고).
14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동
init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고,
`InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를
@ -167,9 +225,10 @@ quad/
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive``None` 스킵 분기 폐기)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`백엔드 팩토리 뮤테이션 시점에 등록됨(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`백엔드 팩토리 뮤테이션 시점에 등록됨
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**)
│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
@ -184,16 +243,16 @@ quad/
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
│ ├── Event.luau # ReflectionService 기반 자동 판별
│ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── OnChange.luau # `OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`)
├── DI/
│ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances)
├── D/
│ └── init.luau # **전량 코드 생성 산출물** — 제네릭 생성자 `New`(커링: `New "Frame" {...}`) + 클래스별 정적 별칭 필드(`D.Frame = New<<Frame>> "Frame" :: (({...}) -> Frame)`). 생성 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입/`State<T>`/`None`까지 타입으로 찍음(**[2026-08-18 확정]** `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)
└── init.luau
```
@ -221,10 +280,26 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
`:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/
`:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝).
3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 —
`Modifier.Overridden(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는
아니지만(여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안
됨) `Modifier` 타입 고유의 공개 연산이라는 점에서 1/2과 같은 부류 —
`Modifier()` 생성자와 같은 이유로 대문자.
`Modifier.Overridden(mod1, mod2, ...)`, `Attribute.Merged(...)`/
`Attribute.Overridden(...)`. **그 프리미티브 타입 고유의 공개 연산**
이라는 점에서 1/2과 같은 부류 — `Modifier()`/`Attribute()` 생성자와
같은 이유로 대문자.
**[정정, 2026-08-18 구현 전 QA]** 옛 서술은 이 분류를 만든 근거로
*"콜론 메서드는 아니지만 (여러 Modifier를 동등한 인자로 받아야 해서
self 하나로 안 됨)"* 을 들었는데 **그 근거는 성립하지 않는다**
`Overridden`은 **콜론 체이닝(`a:Overridden(b)`)으로도 제공**된다
(사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서
직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함"*). 분류 자체는
"닷 접근으로도 부를 수 있는 정적 결합 함수"라는 표면 차이로 유지하되,
2번(콜론 메서드)과 배타적이지 않다는 점에 유의.
4. **`D`(Declarative) 네임스페이스와 그 필드**(`D.Frame`/`D.InstSlot`/
`D.FrameModifier`) 및 생성자 `New`**[2026-08-18 신설]** 프리미티브
타입은 아니지만 사용자가 직접 쓰는 선언형 표면이라 대문자.
**표기 규약: 문서에서 `D`가 처음 나오는 자리에서는 항상
`D`(Declarative)로 풀어쓸 것** — 한 글자 식별자라 grep이 어렵고
이름만으로 뜻이 안 드러난다는 게 2026-08-08부터 개명을 미뤄온 유일한
사유였고, 이 표기 규약이 그 보완책으로 같이 확정됐다
(`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절).
- **소문자 시작(camelCase)** — 특정 프리미티브 타입 하나에 안 묶이고 여러
타입을 넘나드는 범용 유틸(`isState`/`isSource`/`isRef`/`isPreRef`/
`isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/
@ -331,7 +406,7 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬
`archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만
읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만
다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀.
`store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서
정식 확정됨 — **더 이상 열린 질문 아님**, [2026-08-04 기준] 남은 건 정확한

View file

@ -30,7 +30,7 @@ Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attri
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
`Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
여전히 `.claude/question.md` 용어정리 대기열). `[AttributeKey "Name"]`(구
`[Attribute "Name"]`) DI 키의 존재 자체는 `architecture.md` 4번 항목에서
`[Attribute "Name"]`) 특수 키의 존재 자체는 `architecture.md` 4번 항목에서
이미 확정. UICorner 숏핸드/Tween처럼
별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1
파일" 관례를 Tag/Attribute에도 적용해야 한다는 사용자 지적) —
@ -63,15 +63,19 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
`Attribute`가 아니라 이미 타입별로 갈라져 있어 아래 그룹 `Attribute(...)`
겹치지 않음 — 리네임 대상 아님.**
**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스
**근거**: 이미 확정된 `D`(Declarative) 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스
생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론
재사용 — `new<ClassName>(className)` 제네릭 생성자 + 자주 쓰는 ~25개는
정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히
재사용 — 제네릭 생성자 하나 + 클래스별 정적 필드를 미리 바인딩하는
것과 동일한 절충(**[정정, 2026-08-18 구현 전 QA]** 그 생성자의 이름은
`New`이고 타입은 `new<ClassName>(className): from<index<...>>` 같은 타입
레벨 인덱싱이 **아니라 생성기가 찍어내며**, 범위도 "자주 쓰는 ~25개"가
아니라 "GUI에 쓰이는 모든 인스턴스"다 — 재사용하는 건 절충의 **모양**이지
그 수치가 아님). **내부 구현은 완전히
동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히
호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느
쪽을 쓰든 런타임 동작에 차이 없음.
**[실측 필요, M0/M10]** `[AttributeKey<<boolean>> "name"] = value`처럼 DI
**[실측 필요, M0/M10]** `[AttributeKey<<boolean>> "name"] = value`처럼 특수
키 제네릭 파라미터로 `=``value`의 타입까지 실제로 좁혀지는지는
미검증 — Luau 솔버가 이 조합을 못 풀면 `value``any`로 남을 수 있음.
단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적
@ -218,6 +222,30 @@ function AttributeKeyHandler.process(inst, k, v, index)
end
```
- **⚠️ [열린 항목, 2026-08-18 구현 전 QA] 같은 그룹 객체를 두 위치에 놓는
경우(`Frame { a, a }`)는 이 claim으로 안 잡힌다 — 위치별 claim이 따로
필요하다.** `groupKey(v, name)`이 **그룹 값 객체별·이름별 메모이즈**라,
같은 객체 `a``k=1``k=2`에 놓이면 양쪽이 **완전히 같은 키 객체**로
위임한다. 그러면 위 `nameClaims` 체크는 `cur == k`라 **통과**하고, 두
위치가 `(inst, 같은 key)`라는 **하나의 체인을 공유**하게 된다 — 더
치명적인 건 철거로, `k=1`이 retract되면 그 클로저가
`Dispatch.retractFrom(inst, key, 1)`을 불러 **`k=2`가 아직 쓰고 있는
바인딩까지 통째로 철거**한다.
- **`Ref`의 "이중 배치 방지"(`base/ref-plan.md`)와 같은 클래스의
문제지만 같은 해법을 쓸 수 없다** — 사용자 판정: *"Attribute 는
bindLifetime 를 못함. Ref 와 다르게 여기저기서 사용 가능하기 때문. 한
곳에서 바운딩 했다고 다시 바운딩 못할 순 없음. 따라서 위치별 claim 을
하나 두어야한다고 생각함."* `Ref`는 "한 곳에만 배치"가 규칙이지만
그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다.
- **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹
객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다.
- **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지
(`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존
`nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠.
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
`Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다.
- **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는
`slot.retractor(v)``h.process(...)` 순서이고, 핸들러가 바뀌는 경우는
`retractFrom``process` 순서(`base/dispatch-core-plan.md` "Dispatch
@ -270,7 +298,8 @@ Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으
```
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성)
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
```
@ -284,6 +313,26 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
Store 기각과 안 부딪히나" 참고.
**[해소, 2026-08-18 구현 전 QA] 이름 겹침 정책은 `Merged`/`Overridden` 둘
다 제공하는 것으로 확정** — 열려 있던 "겹치면 error냐 뒤가 이기냐"는
**둘 중 하나를 고르는 문제가 아니었다**. 사용자 판정: *"차라리 Merged,
Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자는 그냥 조용히
덮어써주는것. 사용자 의도에 따라 달라질 부분이라 분리해주는것이
이로워보임."*
- `Attribute.Merged(a, b, ...)` — 같은 이름이 겹치면 **즉시 error**(합성
시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시
error)과 결이 같음.
- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**.
- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지
`Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기,
`Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선
**충돌 시 정책**(error냐 덮어쓰기냐)을 가른다. `base/tag-plan.md`
`Tag.Merged` 코드 주석에서 두 이름을 "집합 합치기 vs 이미 계산된 것
합치기"로 대조해둔 서술과 같이 읽을 것.
- **`Tag`에는 `Overridden`이 필요 없다** — `Tag`는 이름 집합의 합집합이라
애초에 "충돌"이라는 개념이 없다(겹치면 그냥 하나로 합쳐지는 게 정답).
### 메커니즘 — 그룹 전용 키로 단일 키 경로에 위임 (2026-08-13 열네 번째 세션 확정)
**[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler,
@ -445,8 +494,8 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건
| 그룹 값 타입+API(`Attribute(...)`/`Merged`/`:NameMap`) | quad-base |
| 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시 | quad-base |
| 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) | quad-base |
| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨(아래 참고) |
| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) |
| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 등록됨 — **[재역전, 2026-08-18] 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절) |
| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D` 층) |
| **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 |
- **왜 타입 패밀리만 갈리는가**: Roblox attribute가 받는 타입 집합
@ -458,9 +507,10 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건
claim 알고리즘 구현일 뿐, 스스로 등록되는 주체가 아님(2026-08-14 열두
번째 세션 정정).** `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건
이걸 감싸는 `AttributeKeyFallbackHandler`/
`AttributeGroupFallbackHandler` — 등록 주체는 quad-base 모듈 자체가
아니라 백엔드 팩토리(`BaseModule` 뮤테이션 시점, 자기 전용 Handler들과
같이 등록). 옛 "quad-base 모듈 로드 시점에 스스로 등록" 모델은
`AttributeGroupFallbackHandler`**[재역전, 2026-08-18 구현 전 QA]
등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드
상태에서도 안내 에러 경로가 돌아야 하기 때문, `base/dispatch-core-plan.md`
"base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스). 경위는
`archive/tag-attribute-load-time-registration-reversed.md`.
`setAttribute`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의
base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 진짜
@ -481,16 +531,13 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건
하강 diff 재디스패치(0-A)는 확정·반영 완료** — 위 "이름 소유권"/
"메커니즘" 절이 정본, 뒤집힌 옛 모델은
`archive/dispatch-hintvalue-model-reversed.md`.
- **[열림, 사소함, 2026-08-13 열네 번째 세션 신설] `Attribute.Merged`에서
두 Store가 같은 이름을 가지면 지금은 조용히 하나가 이김** —
`:NameMap()` 평탄화가 dispatch 이전 단계라 위 이름 claim이 못 잡는
자리. 이름 겹침을 error로 잡는 게 이 문서의 다른 결정들과 결이 같고
구현도 싸지만(합성 시점 1회 체크), "Merged는 뒤가 이긴다"를 의도된
override로 볼 여지도 있어서 사용자 확인 대기 — `question.md` 3번.
- **[해소, 2026-08-18 구현 전 QA] `Attribute.Merged`의 이름 겹침 정책** —
`Merged`(error)와 `Overridden`(뒤가 이김)을 **둘 다 제공**하는 것으로
확정. 상세는 위 "채택안 — `Tag`와 동형인 array-part 값 객체" 절.
- **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은
`Attribute`, 단일 키는 `AttributeKey<<T>>`로 코드/문서 전체 통일해서
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/
`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`Slot`/
`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
대기열에 있음, 나중에 한꺼번에 재검토.
- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도
`setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`"

View file

@ -8,7 +8,7 @@
> | 나간 것 | 어디로 | 단계 |
> |---|---|---|
> | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | 1단계(9차 세션) |
> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | 1단계 |
> | 이벤트 바인딩(self 미전달, `None`/`nil`로 disconnect) | `base/event-plan.md` | 1단계 |
> | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | 1단계 |
> | **디스패치 코어**(핸들러 계약 / 디스패치 모델 / `chains`·`retractFrom` / 체크리스트 / Length·Offset) | **`base/dispatch-core-plan.md`** | **2단계(14차 세션)** |
> | **반응형 코어**(Source/State 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`/`previous`, `Observer`, 구독·생명주기 게이트) | **`base/source-state-plan.md`** | **3단계(2026-08-14)** |
@ -53,7 +53,7 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
- **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API,
`Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**.
- **이벤트 바인딩** — 핸들러가 self(Instance)를 안 받는다는 확정, 이벤트도
store-bind 가능(`false`로 disconnect) → **`base/event-plan.md`**. 단 이벤트
store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트
*네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 /
이벤트 네이밍 인체공학" 절에 그대로 있음.
- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`,
@ -112,9 +112,15 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들
아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는
서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가
초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만
두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은
열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도
테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요.
두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도
실은 열려있던 게 아니라 자연히 풀림 — `New()`가 실제로 호출되면 그
호출이 만드는 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로
독립적으로 스코핑됨(**[재정정, 2026-08-19 — `architecture.md` 13번의
재정정과 맞춤]** `Quad`는 이미 만들어진 기본 인스턴스이고 `New()`는 그
안의 opt-in 필드다, "`Quad()`를 부르면 매번 새 인스턴스"가 아님. 단
"자동으로"는 아님 — module-level state를 참조하는 코드들이 모듈
인스턴스를 인자로 받도록 손을 봐야 하는 건 architecture.md 13번의 정정
그대로, 여기서 반복 안 함).
## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨)
@ -123,19 +129,73 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들
문제와 같은 원인). 사용자가 실제 참고 코드를
`.claude/initreq/artworks/DeclarativeProgramming/
DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한
데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라
"Declarative Instance"(선언형 인스턴스 생성)**.
데모 모듈)에 공유해줘서 직접 확인 — 원래 가칭 `DI`는 Dependency Injection이
아니라 "Declarative Instance"(선언형 인스턴스 생성)의 약자였음.
**[2026-08-18 확정] 네임스페이스 이름은 `D`(Declarative)** — `DI`는 Dependency
Injection과 완전히 겹쳐 실제로 오해가 있었던 전례가 있고, `D`는 (1)
"Instance" 전용 개념이 아니라 quad-* 전반의 declare 요소로 확장 가능하며,
(2) 엔진 종속 없이 다른 백엔드에서도 재사용 가능하고, (3) `D.FrameModifier`
타입 프리픽스가 짧아야 한다는 실용적 제약을 만족한다. 한 글자 식별자라
grep이 어렵고 이름만으로 뜻이 안 드러나는 게 유일한 단점이었으므로,
**문서에서 `D`가 처음 나오는 자리에서는 항상 `D`(Declarative)로 풀어쓴다**
(표기 규약은 `base/architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절).
**인스턴스 생성 — PA님 코드 그대로 채택**: 처음 제안했던 "필드=1급 타입
경로, 문자열=폴백"이라는 2트랙(`DI.Frame` vs `DI.New<<Frame>> "Frame"`) 구상
보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) —
**제네릭 생성자 함수 하나(`new<ClassName>(className): from<index<UIInstances,
ClassName>>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 UI에서 자주
쓰는 클래스 ~25개(`Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입
테이블에 등록된 것들)만 모듈 로드 시점에 **즉시(eager)** `constructor.Frame =
new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지연 생성이
아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭
생성자 + 자주 쓰는 것만 정적으로 미리 바인딩.
**인스턴스 생성 — 호출 모양은 PA님 코드 그대로, 타입은 생성기가 만든다
([2026-08-18 구현 전 QA에서 후자를 정정])**: 처음 제안했던 "필드=1급 타입
경로, 문자열=폴백"이라는 2트랙 구상보다 실제 호출 모양은 더 단순했음
(`DeclarativeInstance.luau:104-160`) — **제네릭 생성자 함수 하나 +
자주 쓰는 클래스를 필드로 미리 채운 정적 테이블**(`constructor.Frame =
new("Frame")`, `__index` 지연 생성이 아니라 eager). quad-v2도 이 모양을
그대로 채택한다. **다만 PA님 코드가 그 필드 타입을 뽑는 방식**
(`new<ClassName>(className): from<index<UIInstances, ClassName>>` — 타입
레벨 인덱싱)**은 채택하지 않는다**:
1. **이벤트 필드가 콜백 타입이 안 나온다.** Roblox 타입 정의에서
`MouseButton1Click`은 시그널 계열 타입이라, 인덱싱으로 뽑으면
`RBXScriptSignal`이 그대로 나오고 quad가 원하는 `((...) -> ())?` 콜백
시그니처가 안 나옴.
2. **LSP마다 `Frame` 타입을 다루는 방식이 다를 수 있어** 타입 함수/인덱싱에
의존하는 게 위험하다.
3. **`T | State<T>`(그리고 `T | Tween<T>`, `None`/`nil` 등)까지 타입 함수로
조립해야 하는데**, 그럴 바엔 `D` 파일을 통째로 생성하는 쪽이 단순하다.
**따라서 `D`는 전량 코드 생성 산출물이다** — 타입뿐 아니라 `New` 호출문까지
생성기가 찍어낸다(사용자: *"전부 코드 생성이나, New 같은것도 생성기에서
같이 적어주어야할 부분"*). 손으로 쓰지 않는다.
**`New`는 커링, `D`는 처리 없는 별칭 테이블 (2026-08-18 확정)**:
```luau
-- New(name)이 생성자 함수를 반환하고, 그걸 props 테이블로 다시 호출
New "Frame" { ... } -- == New("Frame")({ ... })
New<<Frame>> "Frame" { ... } -- 직접 사용도 같은 모양
-- D는 그 결과에 캐스트만 얹은 순수 별칭 테이블 (생성기 산출물)
D.Frame = New<<Frame>> "Frame" :: (({ ...타입명시 }) -> Frame)
```
- **이름은 대문자 `New`로 통일**(사용자 확정: *"2. New입니다."*) — PA님 코드
인용의 소문자 `new`와 섞여 있던 것을 정리.
- **뒤집는 게 아니라 명시화**다 — PA님 패턴의 `constructor.Frame =
new("Frame")`이 이미 사실상 커링이었고, 다만 (a) "커링이다", (b) 2단계 호출
계약, (c) `New "Name" {...}`라는 직접 호출 형태가 문서에 적힌 적이 없었다.
- **기각된 "2트랙"과 혼동하지 말 것** — 기각된 건 *"필드=1급 타입 경로,
문자열=폴백"* 이라는 **능력 차이**였지 `New`라는 이름이나 문자열 호출
자체가 아니다. 이 확정은 오히려 두 형태가 **완전히 같은 것**(하나가 다른
하나의 미리 적용된 결과)임을 못박는다.
- **생성 범위는 "GUI에 쓰이는 모든 인스턴스"**(사용자 확정) — 예전 서술의
"자주 쓰는 ~25개"도 아니고 Roblox 전체 클래스도 아님. 전량 생성하면 `D`
파일이 너무 커진다는 게 이유. "GUI에 쓰이는"의 정확한 판정 기준(API
덤프에서 `GuiObject` 하위 + `UIComponent` 하위 + `LayerCollector`류 등)은
생성기 구현 시점에 정한다.
- **범위 밖 클래스는 느슨하게 `any`**(사용자 확정: *"느슨하게 any 로 하고,
필요하면 이를 직접 구현 가능하게 둡니다. cast 를 하든, 유저의 자유"*) —
`New<<X>> "X" {...}`를 직접 쓰면 props 타입은 `any`이고, 필요하면 사용자가
`::` 캐스트로 좁힌다. 새 확장 지점을 만드는 게 아니라 `D.Frame` 자신이
이미 캐스트 한 줄이므로 **같은 한 줄을 사용자가 직접 쓰면 되는 것**.
따라서 **"제네릭 생성자 함수 하나가 알려진 타입과 모르는 타입을 전부
커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만
정확하고 밖은 `any`다.
**이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열
키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`
@ -143,25 +203,51 @@ new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지
`GetEventsOfClass`로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가
`RBXScriptSignal` 타입이면 자동으로 `instance[key]:Connect(value)`로 처리함
`Frame { MouseButton1Click = fn }`처럼 별도 네임스페이스 없이 그냥 문자열
키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지
Luau가 검증 못 함 — `apply<T,U>(instance: T, properties: U): T & U`가 스키마
검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값
타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, `On.` 접두어 없이
문법이 더 간결해짐 — **사용자 확정**("PA 님 방식 괜찮은듯. 타이핑은 인라인이
되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가"
키로 씀. `On.` 접두어 없이 문법이 더 간결해짐 — **사용자 확정**("PA 님
방식 괜찮은듯. 타이핑은 인라인이 되긴 하겠지 정도면 괜찮다").
**[정정, 2026-08-18 구현 전 QA] "콜백 시그니처까지 Luau가 검증 못 한다"는
서술은 거짓이었음.** 옛 문장은 이 방식이 *"타입 안전성을 어느 정도 포기하는
대가"* 이고 *"콜백 시그니처까지 Luau가 검증 못 함"* 이라고 적었는데, 사용자가
직접 반례를 작성해 보여줬다:
```luau
function Frame (prop: {MouseButton1Click: ((a: number)->())?})
end
Frame{
MouseButton1Click = function(a) -- a: number 로 추론됨
end
}
```
props 테이블 **타입에 필드로 선언돼 있으면 콜백 파라미터가 그대로
추론된다.** 런타임 판별을 `ReflectionService`로 하는 것과 **타입을 생성기가
제공하는 것은 완전히 별개 축**인데 옛 서술이 둘을 묶어버린 것.
따라서 이건 "감수하는 대가"가 아니라 **`D` 생성기가 챙겨야 하는 구현
체크리스트 항목**이다 — 생성기는 클래스별 props 타입에 **이벤트 필드까지
정확한 콜백 타입으로** 포함시켜야 하고, 값 타입은 콜백뿐 아니라
`State<...>`와 disconnect 센티널(`None`/`nil`, `base/event-plan.md`)까지
포함하는 유니온이어야 한다. 이건 위 "타입은 생성기가 만든다"의 직접적
근거이기도 하다(인덱싱으로는 시그널 타입이 그대로 나와서 안 됨) — 두 항목은
같은 문제의 양면이므로 같이 볼 것. quad-v2 구현에서는 이 "키가 이벤트인가"
판별을 `isHandlable`로 감싼 pluggable 핸들러(`quad-roblox`가 `Reflection
Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 구조 자체가
불필요해짐.
**Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/
`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가
**Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가
타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source<number>, ...}`
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로
유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가
깨진 건 아님.
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지.
**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로
같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source<T>`
`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상
필요 없어짐). 동적 키는 명시적 `store:GetDynamic<<T>>(name)`으로 간다 —
`base/store-plan.md`가 소스.
**이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라
**이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다).
**`GetPropertyChangedSignal`은 이 문자열 키 패턴이 안 통함 — 별도 `OnChange`
DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라
특수 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라
그대로 `Connect`하면 되지만, `GetPropertyChangedSignal(name)`은 프로퍼티
이름을 인자로 받아야 하고 그 이름이 "값 세팅" 키 네임스페이스와 겹쳐서
평범한 문자열 키로는 세팅과 리스닝을 구분할 수 없음 — 상세는
@ -196,10 +282,10 @@ DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal
`RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 그 라운드들 기준으로
남았던 건 순수 API 표면 이름뿐이었음:
- **`DI`(또는 다른 이름) 등 정확한 모듈 이름** — 방향은 전부 확정, 이름만
구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예
불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). Source/State
쪽 이름 문제는 `base/source-state-plan.md`가 소스.
- **[해소, 2026-08-18] 모듈 이름은 `D`로 확정** — 옛 항목("`DI`(또는 다른
이름) 등 정확한 모듈 이름")은 닫혔다. 근거는 위 "인스턴스 생성 / 이벤트
네이밍 인체공학" 절. Source/State 쪽 이름 문제는
`base/source-state-plan.md`가 소스.
- **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서
확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현
검증 대상).

View file

@ -38,7 +38,15 @@ debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸
Blocker() -> blocker -- 생성자
blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함
blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된
-- onunblock 핸들 전부 실행(순서 무관, idempotent)
-- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent)
blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음
-- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이
-- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀.
-- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고),
-- 차이는 넘기는 emit 플래그 하나뿐.
blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는
-- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래
-- "이름 확정" 참고), 호출부 가독성만을 위한 추가.
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을
@ -49,9 +57,14 @@ gated state의 동작:
- 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도.
- `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅.
- `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과).
- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해
true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면
아무 것도 안 함(idempotent).
- **onunblock 핸들은 이제 `emit: boolean` 인자를 받는다**(`blocker:Off()`/
`:OffWithoutEmit()`가 공유하는 내부 실행 경로, 2026-08-18 신설) —
`HasBlockedEmit`을 확인해 true면 `emit`이 참일 때만 그제서야 정확히
1회 전파(emit)하고, `emit`이 거짓이면 전파 없이 플래그만 리셋. 이미
`HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함
(idempotent). 즉 `Off()`는 "밀린 전파를 흘려보내며 끈다",
`OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기
상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨.
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서
@ -77,6 +90,32 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번
바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.
## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설)
지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를
경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`
"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다
**[정정, 2026-08-18 구현 전 QA 3라운드]** 그 크래시 자체는 이후
`bk.N`(순회 상한)의 정의를 고치며 사라졌지만(`base/dispatch-core-plan.md`
"저장 위치" 절), 이 용례는 그대로 유효하다 — 이유가 크래시 방지에서
배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서
`blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는
방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end`
형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —
`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두
메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기
위해 `OffWithoutEmit()`을 쓴다**("이 배치가 끝나면 무엇이든 자동으로
흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도
표현). 상세 메커니즘·`Dispatch.setLength`/`setOffsetSource`가 이 Blocker를
어떻게 만들고 어디에 저장하는지는 `base/dispatch-core-plan.md`의 "배치
등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음.
**재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용** — 중첩된 owner
(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를
새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절).
## 이름 확정
- 클래스: `Blocker``Observer`/`Modifier`/`Ref`와 같은 명사-행위자
@ -89,6 +128,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`**
(gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌).
- 메소드: `state:Block(blocker) -> state`.
- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는
얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들"
참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함.
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 즉 `IsBlocked`/
`HasBlockedEmit` 필드는 그대로 유지하고, 별도 `HasBlocked`(Blocker
자신의 새 최상위 플래그)는 **신설하지 않는다**`OffWithoutEmit()`
각 gated state의 기존 `HasBlockedEmit`을 그대로 리셋해주는 것으로
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
"RC-1" 절에 논의 경위 기록).
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
@ -104,7 +153,22 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면
조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.
## 상태: 핵심 메커니즘+이름 확정. [2026-08-07 기준] 남은 건 문서화뿐
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다**
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이
경고하는 실패 모드의 구체 사례.
## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐
**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과
`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API
확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과
메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신.
`quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는
게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용).

View file

@ -125,15 +125,25 @@ wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의
들어오면 즉시 error" 절이 필요로 해서 이번에
같이 적음.
**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.**
`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`
quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None`
레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든
값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가
"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`
빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는
특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`
바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음).
**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 —
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용
introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이
내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에
레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다.
사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에
의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v ==
None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."*
- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장
밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다.
- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이
레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단
자체는 그대로 유효.
- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가
허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉
"범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한
등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는
것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다.
**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는
이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의

View file

@ -238,9 +238,16 @@ return Frame { props.Modifier or None, props.Ref or None, child }
필요 없고, 기존 메커니즘을 그대로 재사용함 — `flatten` 단계는 애초에
`isModifier(v)`가 거짓인 값은 그냥 건드리지 않고 통과시키므로
(`None`은 Modifier가 아니라서 자동으로 이 경로), `props.Modifier or
None`이 최종적으로 배열 파트에 `None`인 채로 남으면 두 패스 루프
자신의 array-part `None`-스킵 규칙(위 "PreRef" 절)이 그대로 적용돼
아무 일도 안 일어남 — 새 특수 케이스 코드가 하나도 안 늘어남.
None`이 최종적으로 배열 파트에 `None`인 채로 남으면 **그 자리는 기여
0으로 정상 처리된다** — 새 특수 케이스 코드가 하나도 안 늘어남.
**[근거 정정, 2026-08-18 구현 전 QA]** 예전엔 근거를 "두 패스 루프
자신의 array-part `None`-스킵 규칙"으로 적었는데, 그 스킵 규칙 자체가
폐기됐다(반응형 값이 내놓는 `None`은 어차피 `Dispatch.process`
도착하므로 — `base/dispatch-core-plan.md`의 "`None` 센티널" 절). 지금은
`NoneHandler`가 매치돼 `nil`로 재귀하고 `NilHandler``setLength(0)`/
`setOffsetSource(None)`을 등록한다. **결론(`or None`을 쓰는 것)은 안
바뀜** — 여전히 "아무것도 안 놓은 것과 같은 효과"이고, 오히려 리터럴
경로와 반응형 경로가 같은 핸들러로 수렴해 더 단순해졌다.
- 이 관용구는 컴포넌트 저작자가 **직접 챙겨야 하는 규율**(base가 강제로
검증해줄 방법은 없음, Lua는 이런 걸 린트로만 잡을 수 있음) — quad
문서화(초심자 가이드/`props.Modifier`/`props.Ref` 절)에 필수 패턴으로

View file

@ -127,14 +127,34 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
**이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부
해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가
호출되면?" 케이스(`pre-implementation-audit.md`
1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는
1-4)도 별도 분기 없이 자동으로 해소됨** — **backend가 직접 소유하는
핸들러(`Property`/`Event`/`Slot`류)에 한해** provider 미주입 상태는
결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로
"매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider
미주입을 서로 다른 에러 종류로 구분할 필요가 없음.
"매치 실패"와 정확히 같은 경로로 수렴함. **[한정, 2026-08-18 `/code-review
high` — `D-7` 재역전과의 정합성]** `Tag`/`Attribute`처럼 **base가
Fallback Handler를 자기 로드 시점에 스스로 등록하는 것**(위 문단,
"base가 소유하는 핸들러와 주입되는 엔진 op" 절)은 이 일반화의 예외다 —
백엔드가 하나도 없어도 그 Fallback Handler는 이미 레지스트리에 있으므로
**매치는 되고**, 실패는 "매치 실패" 에러가 아니라 그 자리에서 실행되는
주입 op 스텁의 명시적 에러(`addTag가 구현되지 않음...` 류)로 남
— "provider 미주입"과 "매치 실패"가 **에러 경로 자체는 다르지만 둘
다 명확한 에러로 수렴한다"**는 결론은 안 바뀜, 다만 오타 키/미지원
조합과 provider 미주입을 구분할 필요가 없다는 문장은 backend 소유
핸들러에만 해당한다.
- **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 +
전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로
sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은
두 핸들러가 등록되면 콘솔에 경고를 찍고, `Dispatch.listHandlers()`
두 핸들러가 등록되면 콘솔에 경고를 찍되, **[요구 추가, 2026-08-18 구현 전
QA] 무조건 찍는 게 아니라 모듈 표면의 불리언 플래그 `Quad.debug`(기본
`false`)가 `true`일 때만 찍는다**(사용자: *"동률 print 는 라이브러리가
debug 모드일 때만. (Quad.debug: boolean = default false) 식이고, true 로
하면 디버깅 가능"*). `Quad.debug`는 **새 공개 API 표면**이라
`base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고,
다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시
이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다.
`Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게
항상 호출 가능한지) 구현 시 정할 것.
그리고 `Dispatch.listHandlers()`
함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함.
구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라
M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인
@ -312,7 +332,7 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로
고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키,
프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau
프로퍼티/이벤트/특수 키) 나중(2026-08-07 세 번째 세션).** Luau
테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다
먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`,
`b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고,
@ -323,9 +343,17 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열
키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는
참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열
슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상
먼저 처리된다는 게 base 자체의 보장**이 됨 — `ref-plan.md`의 "Ref 일반화" 절
뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제
슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상
먼저 처리된다는 게 base 자체의 보장**이 됨.
**[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서
성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장
위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도
pre-pass for 문**에서 먼저 처리되고 `flattened`에는 소진
마커(`ProcessedPreRef`/`ProcessedPostRef`)만 남는다(사용자: *"preref 랑
postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은
**서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이
아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제
Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
@ -355,34 +383,49 @@ end
바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의
라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) —
KV 매치와 무관.
**이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서
`None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef"
절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`(`props.Ref or
None`처럼 애초에 아무것도 놓인 적 없는 자리)은 "빈 슬롯"
표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스
루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로
건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리
경로가 다르다는 점에 유의. **[정정, 2026-08-14 두 번째 세션] "PreRef
pre-pass가 소진시킨 자리"는 이 규칙의 예가 아님** — 그 자리는 `None`
아니라 별도 센티널 `ProcessedPreRef`로 소진되고, `ProcessedPreRefHandler`
(`base/ref-plan.md`의 "PreRef" 절)를 통해 정상 `Dispatch.process`
경로를 그대로 탐(아래 "Length/Offset" 절 참고). **[2026-08-14 아홉 번째
세션] `PostRef`가 소진시킨 자리(`ProcessedPostRef`)도 완전히 같은 취급**
— 전용 센티널 + 전용 `ProcessedPostRefHandler`(`base/ref-plan.md`의
"`PostRef`" 절), 즉 "정말 빈 자리인 `None`"만 두 패스 루프가 직접
건너뜀 — 예전엔 이 둘(원래부터
빈 자리 vs 한때 PreRef였다가 소진된 자리)이 똑같이 `None`으로 뭉뚱그려져
`setLength`/`setOffsetSource` 등록 책임 소재가 불분명한 갭이 있었음
(2026-08-14 첫 번째 세션 조사에서 발견), 지금은 서로 다른 센티널로
명확히 분리됨.
**[재설계, 2026-08-18 구현 전 QA] `NoneHandler`는 해시 파트 전용이
아니고, `Dispatch.drive``None`을 건너뛰지 않는다.** 옛 서술은
"배열 파트의 `None`은 두 패스 루프가 `Dispatch.process`를 거치지 않고
바로 건너뛴다"였는데, 그 전제 자체가 거짓이었음 — 리터럴
`Frame{None}`만 생각하면 루프가 걸러내면 그만이지만
**`Frame{ State<Slot|None> }`처럼 반응형 값이 `None`을 내놓으면 그
`None``StoreBind`의 재귀를 타고 `Dispatch.process`에 그대로
도착**하기 때문. 사용자 판정: *"drive 는 v == None 인지 확인 안하고
그냥 프로세스 태우는게 가장 적절한 처리로 보임"*. 따라서:
- **`Dispatch.drive``None` 특수 분기는 없다** — 배열이든 해시든
모든 `(k,v)``Dispatch.process(inst,k,v,1)`을 탄다.
- **`NoneHandler`가 하는 일은 재귀 하나뿐** — `v == None`을 매치해
`Dispatch.process(inst, k, nil, index+1)`로 내려보내는 것. 배열/해시
구분도 하지 않는다.
- **실질 정리(그리고 `setLength(0)`/`setOffsetSource(None)` 등록)는
아래 `NilHandler`가 맡는다** — 사용자 선택(2026-08-18): *"NoneHandler는
재귀만, NilHandler가 실질 담당"*. 즉 배열 자리가 비는 처리 로직은
`None` 경로든 진짜 `nil` 경로든 **한 곳에만** 있다.
- **`process` 자체가 이전 것을 걷어낸다** — `Tag``None` 전환에서
이전 `Tag` 기여가 실제로 사라져야 하는데, 이건 하강 diff가 자동으로
해준다(핸들러가 `TagHandler`에서 `NoneHandler`로 바뀌므로 아래
"Dispatch 체인" 절 (B) 분기가 `retractFrom`을 부름). `NoneHandler`
반환하는 retractor 자체는 no-op이어도 된다.
**`ProcessedPreRef`/`ProcessedPostRef`는 그대로 별개다** — pre-pass가
소진시킨 자리는 `None`이 아니라 전용 센티널로 채워지고 전용 nop
핸들러(`ProcessedPreRefHandler`/`ProcessedPostRefHandler`,
`base/ref-plan.md`의 "PreRef"/"`PostRef`" 절)가 정상 `Dispatch.process`
경로에서 캐치한다. 예전엔 "원래부터 빈 자리"와 "한때 PreRef였다가 소진된
자리"가 똑같이 `None`으로 뭉뚱그려져 등록 책임 소재가 불분명한 갭이
있었고(2026-08-14 첫 번째 세션 조사), 지금은 서로 다른 센티널로 명확히
분리돼 있음.
`NoneHandler.isHandlable``v == None`(센티널 자체)을 잡는 것이지
`v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게
이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커.
`v == nil`이 아님 — 진짜 `nil`은 테이블 순회로 나올 수 없다는 게
이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커(반응형 값이
내놓는 진짜 `nil`은 아래 `NilHandler`가 받는다).
`Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상
존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`
원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 —
`StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는
것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음.
존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 그
`nil`을 담당하는 핸들러로 흘러감 — 배열 자리(`k`가 숫자)면 `NilHandler`,
해시 자리면 키 `k`를 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand
등)로. `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로
좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음.
- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로
해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래
"확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리
@ -404,9 +447,10 @@ end
OnChangeHandler/UICornerHandler 등)은 팩토리가 `BaseModule`
뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과
같은 패턴, 새 메커니즘 아님). **`Tag`/`Attribute`의 base 소유
Fallback Handler들(`TagFallbackHandler` 등)도 같은 팩토리 뮤테이션
시점에 같이 등록됨** — quad-base 모듈 로드 자체의 부작용이 아님,
상세는 아래 "base가 소유하는 핸들러와 주입되는 엔진 op" 절.
Fallback Handler들(`TagFallbackHandler` 등)은 이와 달리 quad-base
자신이 등록함**(**[재역전, 2026-08-18 구현 전 QA]** — 백엔드가 하나도
안 붙은 상태에서도 안내 에러 경로가 돌아야 하기 때문), 상세는 아래
"base가 소유하는 핸들러와 주입되는 엔진 op" 절.
- Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름,
`question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) —
겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상
@ -425,6 +469,13 @@ end
`PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass
하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는
`base/ref-plan.md`의 "`PostRef`" 절.
**[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회
전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에
`Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가
(pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤
`recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/
`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절이 소스.
**진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입
후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을
처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와
@ -463,6 +514,52 @@ end
재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매
사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요).
### `NilHandler` — 배열 자리의 진짜 `nil`을 받는 짝 핸들러 (2026-08-18 신설, 사용자 요구)
**왜 필요한가**: 반응형 값이 `None`이 아니라 **진짜 `nil`** 을 내놓는
경우(`State<Slot|nil>`)도 정상 동작해야 한다는 사용자 요구. `None`
쓰라고 강제하지 않는다 — *"State<Slot|None> 일 수도 있지만,
State<Slot|nil> 이여도 작동은 함"*.
```lua
NilHandler.priority = <매우 높음>
NilHandler.isHandlable(inst, k, v) = (type(k) == "number" and v == nil)
function NilHandler.process(inst, k, v, index)
-- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다.
-- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가
-- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 —
-- setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- 반대로 하면 죽는 중인 서브트리의 Source에 :Set()이 날아간다).
-- [2026-08-18 감사에서 순서 정정]
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0)
return function() end
end
```
- **매치 범위는 `k`가 숫자인 자리로 한정** — 해시 자리의 `nil`은 그 키를
원래 담당하던 핸들러(프로퍼티/이벤트)의 몫이다(`None` 재귀가 도착하는
기존 경로 그대로, 위 절). 이벤트 키에서 `nil`이 disconnect를 뜻한다는
규정은 `base/event-plan.md`가 소스.
- **재귀는 하지 않는다** — 이미 `nil`이라 더 내려보낼 곳이 없다.
`NoneHandler`가 재귀만 담당하고 여기로 흘려보내므로, 배열 자리가 비는
처리 로직은 **이 한 곳에만** 있다(사용자 선택, 2026-08-18).
- **호출 순서는 `setOffsetSource` → `setLength`** — 아래 "해제(그 자리가 더
이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절이 계약으로
고정해둔 순서를 그대로 따른다. (`base/ref-plan.md`의
`ProcessedPreRefHandler`/`ProcessedPostRefHandler` 의사코드는 아직 반대
순서로 적혀 있음 — 이 세션 이전부터 있던 것이라 같이 고쳤다.)
- **`setLength(0)` / `setOffsetSource(None)`의 비대칭은 의도된 것** —
타입이 각각 `number | State<number>``Source<number> | None`이라서
(`base/ref-plan.md`의 "왜 `None`이 아니라 `nil`인가" 절, 아래
"Length/Offset" 절).
- **retractor는 no-op이어도 된다** — 이전 것의 철거는 하강 diff가
`retractFrom`으로 해준다(위 `NoneHandler` 항목과 같은 이유).
- **"중간 노드는 `inst`에 부작용을 가하지 않는다"(아래 "Dispatch 체인" 절)와
충돌하지 않는다** — `setLength`/`setOffsetSource`는 `inst`의 프로퍼티를
건드리는 게 아니라 Dispatch 자신의 순서 부기이고, 애초에 `NilHandler`
재위임을 하지 않는 **말단** 핸들러다.
### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션)
`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/
@ -503,18 +600,35 @@ end
더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서
소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`).
- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히
풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
풀림.** (**[재정정, 2026-08-19]** 이 헤딩을 한때 `Quad()`로 바꿨던 게
틀렸음 — `New()`가 맞는 이름, `architecture.md` "확정된 결정" 13번의
재정정이 소스. 요지: `Quad`(`require`의 반환값)는 이미 만들어진 기본
인스턴스이고, 그 안의 `New` 필드를 명시적으로 호출해야만 별도의 새
Quad 네임스페이스가 생긴다 — "그냥 `Quad()`를 부르면 매번 새 인스턴스"가
아니다.) v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며
기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가
`BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을
그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에
딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된
것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가
생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로
스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule
전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이
딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스
테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.
실제로 호출되면 그 호출이 만드는 인스턴스가 별도 테이블이 되므로 이
마커도 테이블별로 독립적으로 스코핑됨" — 단 아래 "[한정]" 문단대로 코드
손질은 필요, 재설계까지는 불필요). 다중 인스턴스화가 실제로 생기면 그
시점에 BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도
자연히 같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그
인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가
없음. **[한정, 2026-08-18 구현 전 QA]** 다만 "재설계 불필요"가 **"코드
변경 불필요"는 아니다** — 사용자 판정에 따르면 그때는 module-level
state를 참조하는 코드들이 모듈 인스턴스를 인자로 받도록
(`InitModule(module)` 류) 손을 봐야 한다(`base/architecture.md` "확정된
결정" 13번). 지금은 `New()` 자체가 노출 안 된 싱글톤 단계라
`Quad.Dispatch`로 바로 접근한다. **[2026-08-19 추가]** 이 문단이 말하는
"`InitModule(module)` 류"의 정확한 형태(각 서브시스템별 `InitXxx(module)`
팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 가드)가
`module-lifecycle-plan.md`의 "New()의 내부 구성" 절에 구체화됨 —
`Dispatch/init.luau`도 그 패턴을 따르는 `InitDispatch(module)` 하나로
구현된다.
### base가 소유하는 핸들러와 주입되는 엔진 op (2026-08-13 열네 번째 세션 신설)
@ -565,24 +679,42 @@ setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름
실제로 꽂히는 건 그 알고리즘을 그대로 감싸는 **별도 이름의 엔티티**
(`TagFallbackHandler`/`AttributeKeyFallbackHandler`/
`AttributeGroupFallbackHandler`) — "이게 기본 안전망으로 자동 설치되는
대상"임을 이름 자체로 구분한다. **등록 주체는 quad-base 모듈 자체가
아니라 필요한 엔진(백엔드 팩토리)** — quad-roblox 같은 백엔드가
`BaseModule`을 구성할 때 자기 전용 Handler들(Property/Event/OnChange/
UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다(위
`Dispatch.addHandler` 절과 같은 경로, `base/module-lifecycle-plan.md`
이미 확정해둔 "base는 인터페이스만, 등록/구현은 백엔드 팩토리가
`BaseModule`을 뮤테이션하는 시점에" 원칙을 그대로 따르는 것뿐 — 새
예외가 아님). "quad-base가 자기 모듈 로드 시점에 스스로 등록"이라던
옛 모델은 정확히 `base/lifecycle-pattern.md`가 이미 거부해둔
`InitNamespace`류 top-level 부작용 패턴과 같은 클래스라 틀렸음 — 원문·
근거는 `archive/tag-attribute-load-time-registration-reversed.md`.
대상"임을 이름 자체로 구분한다.
**[재역전, 2026-08-18 구현 전 QA — 사용자 확정] 등록 주체는 다시
quad-base 자신이다(모듈이 자기 레지스트리를 구성하는 시점).** 2026-08-14
열두 번째 세션은 이걸 "백엔드 팩토리가 자기 Handler들과 같이 등록한다"로
뒤집었었는데, 그러면 **quad-roblox를 아예 로드하지 않은 상태에서는 이
Fallback Handler들도 존재하지 않아**, 위 "매치 실패는 즉시 `error`" 절이
약속한 *"provider가 초기화됐는지 확인하라"* 안내 경로 자체가 동작하지
않는다(사용자: *"안 그러면 quad-roblox 를 로드하지 않았을 때 로드했는지
물어보는 요소가 처리가 안 된다"*). Fallback 밴드의 존재 이유가 "아무도 이
자리를 안 가져갔을 때"인데, 그 등록을 "누군가 자리를 가져가는 시점"에
의존시키면 밴드가 가장 필요한 상황에서 비어 있게 된다.
**`InitNamespace` 거부 원칙과 충돌하지 않는 이유**: 그 원칙이 금지한 건
**라이브러리마다 사용자가 수동으로 init을 호출하게 만드는 것**과 **모듈이
로드되면서 *남의* 상태를 건드리는 것**이다(`base/lifecycle-pattern.md`의
"rbvm에서 그대로 가져오면 안 되는 것" 절). base가 **자기 모듈 안의 자기
레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니다 — 외부에 노출되는 init
표면이 늘지 않고, 순서 의존도 없고(레지스트리와 등록 코드가 같은 모듈),
사용자가 할 일도 없다. 백엔드가 나중에 자기 Handler를 등록해 이기는 구조도
그대로다(Fallback 밴드는 항상 최하위). A-3의 다중 인스턴스화(`New()`)로
가더라도 자리는 그대로 — 그때는 "모듈 로드 시"가 "인스턴스 생성 시"가 될
뿐이다.
옛 역전 원문은 `archive/tag-attribute-load-time-registration-reversed.md`
(그 문서 자체가 이번에 재역전됐다는 배너를 달아뒀음). **그 역전이 같이
고쳤던 "이름" 쪽 결론은 그대로 유효** — 등록되는 엔티티는 알고리즘 구현체
(`TagHandler` 등)가 아니라 그걸 감싼 `*FallbackHandler`다.
`HANDLER_PRIORITY_FALLBACK`이라는 밴드 자체가 정확히 이런 용도 —
"아무도 이 자리를 안 가져갔을 때의 안전한 기본 동작"을 base가 값싸게
제공하는 것. 엔진 저자 입장에서 "자동/공짜"인 이유는 직접 알고리즘을
안 짜도 되기 때문이지 quad-base 모듈 자체가 부작용을 내서가 아님 —
모든 백엔드가 (자기 팩토리 뮤테이션 한 번으로) `Tag`/`Attribute` 부기를
얻고, 특별히 뭔가를 더 하지 않아도 이 값들이 어떤 자리에 놓이든 최소한
매치는 됨.
안 짜도 되기 때문이고, **백엔드를 아직 안 붙였어도 이 밴드는 이미 채워져
있다**(위 재역전) — 그래서 모든 백엔드가 `Tag`/`Attribute` 부기를 공짜로
얻고, 백엔드가 하나도 없을 때조차 "이 값이 어떤 자리에 놓이든 최소한
매치는 되고, 엔진 op이 없으면 그 자리에서 명확한 에러가 난다"가 성립한다.
`addTag`/`removeTag`/`setAttribute`는 base가 시그니처만 소유하고
실제 구현은 팩토리가 뮤테이션으로 주입하는 **타입 계약**(`bindLifetime`/
@ -625,7 +757,7 @@ UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다(
- **타입 패밀리는 백엔드 몫**: `AttributeKey<<T>>` 제네릭 생성자와
스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`)
까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인
패밀리는 그 백엔드(quad-roblox의 `D`/`DI` 층)가 자기 것으로 추가함 —
패밀리는 그 백엔드(quad-roblox의 `D` 층)가 자기 것으로 추가함 —
"이 값이 이 백엔드에서 표현 가능한가"라는 검증도 base가 아니라 주입된
`setAttribute`의 몫(`base/attribute-plan.md` "패키지 배치" 절).
@ -730,6 +862,14 @@ end
정의상 그 핸들러의 `isHandlable`을 만족함. 즉 말단 핸들러는 **`nil` 여부만
구분하면 되고**, 옛 모델이 요구하던 `isX(hintValue)` 방어 가드는 필요
없어짐(옛 규칙은 힌트의 타입 미보장을 메우던 임시방편이었음).
**[한정, 2026-08-18 구현 전 QA] 보장 범위는 "같은 핸들러"까지지 "같은 값
모양"까지가 아니다** — `isHandlable`이 **여러 모양의 값**을 받아들이는
핸들러라면 그 안에서 어느 모양인지 가르는 `is` 판별은 **여전히 필수**이고,
그건 그 핸들러 자신의 몫이다(사용자: *"처음부터 한 핸들러가 여러 값을
가질 수 있어 is 처리가 필요한건, 그 핸들러의 몫입니다"*). 실제 사례가
이미 있음 — `PropertyHandler`는 평범한 값과 `Tween<T>` 래퍼를 **둘 다**
받아 `isTween(realv)`로 분기한다(`base/tween-plan.md`). 없어진 건
**타입 미보장을 메우려던 방어 가드**뿐이다.
- **깊은 체인에서도 힌트가 안 사라짐** — 힌트를 위에서 아래로 실어
보내는 게 아니라 **각 레벨이 자기 재프로세스에서 자기 힌트를 받기**
때문. `State<State<Tag>>`에서 바깥이 새 inner State를 내놓아도 인덱스 2는
@ -758,6 +898,7 @@ end
|---|---|---|
| `StoreBind` | 중간 | 없음(구독 + 재위임만) |
| `NoneHandler` | 중간 | 없음(재위임만) |
| `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) |
| `PropertyHandler` | 말단 | 프로퍼티 세팅 |
| `TagHandler` | 말단 | `addTag`/`removeTag` |
| `AttributeKeyHandler` | 말단 | `setAttribute` |
@ -908,8 +1049,13 @@ end
깊은 인덱스엔 안 옴 / `nil`이라 가정 금지") 중 앞의 둘은 하강 diff로
구조적으로 사라졌음:
- 값이 넘어오는 건 **오직 같은 핸들러로 재프로세스될 때**이므로 그 값은
정의상 `isHandlable`을 만족함 → `isTag(...)` 같은 **방어 가드는 이제
불필요**(넣어도 무해하지만 죽은 코드).
정의상 `isHandlable`을 만족함 → **타입 미보장을 메우려던 방어 가드**
(`isTag(...)`를 "혹시 래퍼가 새어 들어왔을까 봐" 부르는 것)는 이제
불필요. **[한정, 2026-08-18 구현 전 QA] 다만 한 핸들러가 여러 값 모양을
받는다면 그 판별은 여전히 필수이고, 그건 그 핸들러 자신의 책임**
(`PropertyHandler`의 `isTween(realv)` 분기가 실제 사례 — 위 "Dispatch
체인" 절의 같은 한정 참고). 보장 범위는 "같은 핸들러"까지지 "같은 값
모양"까지가 아니다.
- 깊이와 무관하게 **각 레벨이 자기 인자를 받음** → 깜빡임 방지 최적화가
깊은 체인에서도 유효.
- 다만 **`nil`이라고 가정하는 것은 여전히 금지**(단순 철거일 때만 `nil`).
@ -967,6 +1113,16 @@ end
중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐.
조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서
`Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것.
**9. `process` 안에서(또는 `process`가 부르는 컴포넌트 함수/`updateFn`
안에서) 코루틴 yield 금지(2026-08-18 신설, `/code-review high`로 이
불변식이 "Length/Offset" 절에만 묻혀 있던 걸 발견해 여기로도 끌어올림).**
아래 "Length/Offset" 절의 배치 게이팅(`Blocker`)이 "position이 항상
순서대로, 다른 코드가 끼어들 틈 없이 동기로 처리된다"는 전제 위에
서 있음 — 이 체인 도중 yield가 끼면 같은 owner의 `Blocker`를 다른
코드가 그 사이에 건드릴 수 있어 게이팅 순서 보장이 깨짐. 상세 근거는
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절.
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문,
@ -1005,16 +1161,33 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
`1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State<number>`,
아래 참고), `state<Frame>`처럼 store-bind로 오가는 단일 위치는 그
store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot`
자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/
Slot.luau`)** — `Slot``inst`/`i`를 모르는 독립 값(어디 마운트될지
자신의 `:List`/CRUD가 아니라 그 위치의 체인을 실제로 끝내는 말단
Handler(`Dispatch/Slot.luau`)** — **[정정, 2026-08-18 구현 전 QA]**
옛 서술은 "그 위치를 **처음** 매치한 Handler"였는데 부정확했다: 배열
위치에 `State<Slot>`이 오면 처음 매치하는 건 `StoreBind`(중간 노드)이고,
중간 노드는 `inst`에 부작용을 가하지 않는다는 계약(아래 "Dispatch 체인"
절)과 정면으로 어긋난다. 사용자 판정은 *"최종 말단 요소가 이를
처리하는게 더 올바른것으로 보이는데"* — 재귀가 끝나 실제 값을 받은
말단 Handler가 등록한다(`State<Slot>`이면 재귀 끝의 `Dispatch/Slot.luau`,
빈 자리면 `NilHandler`, `PreRef`/`PostRef` 소진 자리면 각 nop Handler).
같이 검토 대상이던 *"단순히 모든 핸들러가 `k=number`일 때 처리하도록
두는"* 안은 채택 안 함 — 그 안이 메우려던 갭(`State<Slot|None>`에서
`None`이 올 때 아무도 `0`을 안 채우는 것)이 위 `NilHandler` 신설로 이미
닫혔고, 말단 규칙 하나로 전부 커버되기 때문. `Slot``inst`/`i`를
모르는 독립 값(어디 마운트될지
자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는
시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`
1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`
`State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state<Slot>`
교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출.
- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source<number>`
**스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만
하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우
**스스로 만들어서** 등록. **[2026-08-18 구현 전 QA 2라운드 후속 —
`RC-1` 해결]** 예전엔 "Dispatch는 그냥 레지스트리에 넣어두기만 하고
`recompute`가 그 자리에 값을 `:Set()`한다"였는데, 이제 **등록되는 그
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 즉시
`:Set()`한다**(아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의
"`setOffsetSource`의 즉시 계산" 참고) — `recompute`는 이후 값이 바뀔 때
전체를 다시 계산하는 역할로 남는다. Slot이 매치되는 경우
이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래
참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이
등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정,
@ -1045,17 +1218,24 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
첫 번째 세션 조사에서 발견). 지금은 그 슬롯이 전용 센티널
`ProcessedPreRef`로 소진되고, **`ProcessedPreRefHandler`(`base/
ref-plan.md`의 "PreRef" 절)가 정상 매치 과정에서 직접 `setLength(0)`/
`setOffsetSource(None)`을 등록** — "이 위치를 처음 매치한 Handler가
등록 책임을 진다"는 위 원칙을 특수 취급 없이 그대로 만족.
`setOffsetSource(None)`을 등록** — "그 위치의 말단 Handler가 등록 책임을
진다"는 위 원칙을 특수 취급 없이 그대로 만족.
**[2026-08-14 아홉 번째 세션] `PostRef` 소진 자리도 동일** —
`ProcessedPostRefHandler`(`base/ref-plan.md`의 "`PostRef`" 절)가 같은
두 등록을 하는 거울상 Handler라, 새 규칙 없이 그대로 맞물림.
**[정정, 2026-08-18 구현 전 QA] 값 자체가 `None`/`nil`인 자리도 이제
같은 원칙으로 덮인다** — `Dispatch.drive``None`을 건너뛰지 않으므로
그 자리는 `NoneHandler`(재귀만) → `NilHandler`(말단)를 거치고,
**등록을 실제로 하는 건 `NilHandler`**(위 "`NilHandler`" 절).
`State<Slot|None>`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도
같은 자리로 수렴한다.
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
`setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저
부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는
반대면 위험함**: `setLength`가 끝에서 `gatedRecompute`를 경유해(배치
게이팅 중이 아니면) `recompute`를 돌리므로, 먼저 부르면 그 `recompute`
아직 남아있는 옛 `Source`(지금 막 떼어내는
서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이
캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`
`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이
@ -1069,10 +1249,49 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는
이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨.
**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그
`inst`의 array part 크기 `N``bk.N`으로 같이 저장, `Dispatch.drive`
최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`
lazy 생성.
**저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에
귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장)
`Relate(parentInst)`에 lazy 생성.
**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner
타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔
`bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작
시점에 고정된 값"으로만 서술했는데, `base/slot-plan.md`의 "재귀
메커니즘" 절이 같은 `recompute`/`getBookkeeping`을 **Slot 자신**을
ownerKey로 재사용하면서 이 전제(N이 고정)가 안 맞는 케이스가 생겼다 —
Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이유). **사용자
확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될
때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에
동일하게 적용"* — 즉:
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`
등록할 때마다 `bk.N``i`로 늘어난다(`Dispatch.drive`의 배열 파트
순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
하나의 규칙) — **`Dispatch.setOffsetSource``bk.N`을 건드리지
않는다**, 호출 순서가 항상 `setOffsetSource(i)``setLength(i)`라서
(아래 "`setLength` 구현" 절) `bk.N``setLength`에서만 올려야
`lengthList[i]`가 아직 안 채워진 채로 `bk.N`만 먼저 커지는 창이 안
생긴다.
- `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`가 부름, `base/
slot-plan.md` "파괴" 절)이 position 하나를 구조적으로 제거할 때마다
`bk.N`이 그만큼 줄어든다.
- `Dispatch.drive``inst`에서는 이 규칙이 사실상 안 보인다 — 최상위
배열 리터럴은 구조적으로 늘거나 줄지 않으므로(재-dispatch는 전체
교체) `bk.N`이 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 이미
최종 크기로 고정돼 있었던 것**의 부산물이었을 뿐 — 지금은 그 전제 자체가
없다. 그런데도 Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가
아니라 **비용**이다(등록마다 `recompute`가 한 번씩 도는 O(N²) 대신
배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 사용자가 직접 지적한 "이러면
첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. 상세 트레이싱은
`qa-request/pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기가
명세에 없음" 절.
**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트
원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙)
@ -1094,6 +1313,14 @@ lazy 생성.
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래 의사코드를 배치
등록 중 안전하지 않게 만들던 크래시(`RC-1`)는 해결됨 — 해법은 "배치
등록을 안전하게 만드는 Blocker 게이팅" 절(바로 아래)이 소스, 여기
`recompute` 자체의 코드는 안 바뀜(off-by-one 수정 버전 그대로). 바뀐
건 **언제 호출되는가**뿐 — `setLength`/`setOffsetSource`가 새로 개입한다.
트레이싱 경위·논의 원문은 `qa-request/pre-implementation-qa-round2.md`
"RC-1" 절.
**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어
있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한
`offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한
@ -1166,18 +1393,32 @@ end
문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치
vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것.
전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴
길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브
전체 순회의 O(N) 비용은 무시 가능(`Dispatch.drive`의 최상위 `inst`
기준으로는 `N`이 저작 시점에 고정된 배열 리터럴 길이, 보통 작음 —
Slot 자신이 `ownerKey`인 재귀 케이스는 `N`이 생애주기 내내 바뀌지만
그 실제 개수 자체도 보통 작아서 결론은 같음, `N`의 정확한 수명주기는
위 "저장 위치" 절 참고) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브
캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라,
`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안
일어나게 막음.
**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`),
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**:
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션).** **[재작성, 2026-08-18
구현 전 QA 2라운드 후속 — `RC-1` 해결]** `setLength`는 더 이상 `recompute`
직접 부르지 않는다 — State든 상수든 항상 아래 `gatedRecompute` 하나를
경유하고, 그 함수가 `blocker:IsOn()`을 확인해 배치 등록 중이면 건너뛴다
(Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이
게이트를 통과한다 — 사용자: *"setLength 는 recompute 를 직접 수행하진
않고, Observer 에서 recompute 를 수행해. 맨 처음 emit 에서도 blocker 가
on 이면 무시하는식"*). `blocker`가 무엇이고 어디서 오는지는 바로 아래
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절 참고 — 이 함수는 그
Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 않음,
그건 호출하는 배치 쪽 책임):
```lua
function Dispatch.setLength(inst, i, len)
local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성
function Dispatch.setLength(ownerKey, i, len)
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
local oldObserver = bk.observers[i]
if oldObserver then
@ -1186,24 +1427,132 @@ function Dispatch.setLength(inst, i, len)
end
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
if isState(len) then
local observer = len:Observer(function()
recompute(inst, bk)
end)
bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님
bk.observers[i] = observer
local function gatedRecompute()
if not blocker:IsOn() then
recompute(ownerKey, bk)
end
end
recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해)
if isState(len) then
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
bk.observers[i] = observer
else
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
end
end
```
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때
같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안
끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native,
`inst` 생명주기에 자동 귀속)를 충족.
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
`:Unsubscribe()`가 없으면 안 끊기므로 안 맞음. `bindLifetime`/
`unbindLifetime`이 이미 이 요구(GC-native, `ownerKey` 생명주기에 자동
귀속)를 충족.
### 배치 등록을 안전하게 만드는 Blocker 게이팅 (2026-08-18, `RC-1` 해결)
**문제 재확인**: `bk.N`(그 owner의 array part 크기)은 배치가 시작되는
시점에 이미 정해져 있는데, `bk.lengthList[1..N]`은 각 position이 처리될
때마다 하나씩 채워진다 — 순차 처리 도중에 `recompute`가 돌면 아직 안
채워진 뒤쪽 position을 `nil`로 읽어 산술 에러가 난다(`Frame{A,B}`처럼
정적 자식 2개짜리도 재현됨, 트레이싱 상세는
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절).
**[정정, 2026-08-18 구현 전 QA 3라운드] 위 크래시는 `bk.N`이 "배치 시작
전에 이미 최종 크기로 고정"이라는, 그때 당시의 전제 위에서만 성립한다 —
그 전제 자체가 위 "저장 위치" 절에서 뒤집혔다(`bk.N`은 이제 그때그때
실제 개수). 아래 게이팅은 여전히 필요하지만, 지금은 **크래시 방지가
아니라 비용** 때문이다 — 게이팅 없이 등록마다 `recompute`가 한 번씩
돌면 O(N²), 게이팅으로 배치 끝에 한 번만 돌면 O(N). 상세는 "저장 위치"
절 참고.
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
직접 쓰는 두 번째 용례" 절 참고.
2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는
`gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉
**배치 도중엔 `recompute`가 단 한 번도 안 돈다**, 그래서
`bk.lengthList`의 빈 자리를 읽을 일 자체가 없다.
3. **`setOffsetSource`는 그동안 손 놓고 있지 않는다 — 등록되는 그
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 `:Set`한다**
(아래 "`setOffsetSource`의 즉시 계산" 참고). 배치가 항상 position을
순서대로(1,2,...,N) 처리하므로, position `i`를 등록하는 시점엔 `1..i-1`
이미 전부 끝나 있어 이 합산이 항상 정확하다. **이게 "recompute를
미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
배치 중이라도 항상 최신값을 보게 됨.
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
재귀 케이스)도 같이 확정시킨다.
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
스킵 — 참여 안 하는 자리는 계산할 게 없음):
```lua
function Dispatch.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source ~= None then
local sum = 0
for j = 1, i - 1 do
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
sum += (if isState(v) then v:Get() else v)
end
if source:Get() ~= sum then
source:Set(sum)
end
end
end
```
이건 `recompute`의 로직을 대체하는 게 아니라 **보완**한다 — 이 즉시
계산은 "지금 막 등록되는 이 position의 초기값"만 맞춰줄 뿐이고, 이후 어느
position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보다 뒤에
있는 모든 position의 offset을 다시 계산해야 하므로 여전히 `recompute`
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
**적용 지점 — `Dispatch.drive``attachSlot`, 각각 자기 owner 키로
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
중첩된 Slot마다 그 Slot 자신의 owner 키로 **별도** Blocker를 새로 만듦,
부모 Blocker 재사용 금지는 `base/blocker-plan.md`의 "재진입" 절 그대로).
**런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 흔한 패턴은
이 게이팅이 필요 없다** — 사용자 확인: *"그건 이미 마운트가 된
이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위
요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서,
setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함"* — 이미
마운트된 배치 밖에서 하나씩 추가되는 position은 그 앞의 모든 position이
이미 안정적으로 등록돼 있어 `nil` 자리를 만들 여지가 없고, 그 owner의
Blocker는 이미 `OffWithoutEmit()`으로 꺼진 채라 `gatedRecompute`
평소처럼 즉시 돈다.
**⚠️ 불변식 — `Dispatch.process`/`attachSlot` 호출 체인 도중에는 코루틴
yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체가
"position이 항상 1,2,...,N 순서대로, 다른 코드가 끼어들 틈 없이 동기로
처리된다"는 전제 위에 서 있다 — 이 체인 도중 어딘가(컴포넌트 함수,
`updateFn`, Handler 등) yield가 끼면, 아직 배치가 안 끝난 owner의 같은
`Blocker`를 다른 코드가 그 사이에 건드릴 수 있어(예: 다른 이벤트 콜백이
같은 owner에 `setLength`를 부르는 것) 배치 도중/직후의 게이팅 순서 보장이
깨진다. 사용자: *"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야
할듯. 안 그럼 꼬이는 문제가 발생하지 않나 생각함"* — 웹 백엔드처럼
`setLength`가 뒤섞이면 특히 골치 아파짐. 새 방어 로직을 넣는다는 뜻이
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:

View file

@ -77,8 +77,10 @@ source-state-plan.md`의 "동적 경로 가드" 절 참고.) `EffectHandle`도
children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로
흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isEffect(v) end, process =
function(inst,k,v) error("EffectHandle은 children 배열 리터럴에만 놓을
수 있음") end }`. `FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에
function(inst,k,v) error(`Effect binding should be array index item, but
got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입을
실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절).
`FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에
named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로
값싸게 override 가능한 자리로 열어둠.
@ -149,8 +151,31 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자
문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그
핸들의 생애주기는 전적으로 수동 관리 대상이 됨).
- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect
계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만
- **⚠️ [축소, 2026-08-18 구현 전 QA] `:Unsubscribe()``:Subscribe()`
짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는
**`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`
부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나,
최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe
한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선
그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."*
- **왜 위험한가**: leaf 바인딩 + `State<Effect>`/`State<Observer>`
조합에서, 값이 실제로 안 바뀌면 **dedup 최적화 때문에 retract가 아무
일도 안 한다**(`base/source-state-plan.md`의 "Observer/Effect Leaf
dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리
실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안
일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
- **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가
아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이
실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것).
`ObserverEffectLeafHandler` 의사코드 기준으론 `process`
`if old ~= v then bindLifetime(...) end`와 클로저의
`if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만,
**`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그
cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다.
M3 착수 전 확인할 것.
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의
`:Unsubscribe()`는 "미래 재실행만
끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"
이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`

View file

@ -1,4 +1,4 @@
# 이벤트 바인딩 — self 미전달, `false`로 disconnect
# 이벤트 바인딩 — self 미전달, `None`/`nil`로 disconnect
> **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.**
> 사용자가 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접
@ -52,8 +52,7 @@ SyntheticEvent만 주는 것과 같은 모양).
해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에
불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를
반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브
지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`
disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에
지원하기로 확정** — 아래 "이벤트도 store-bind 가능" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에
확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치
메커니즘 불필요).
@ -64,7 +63,7 @@ SyntheticEvent만 주는 것과 같은 모양).
문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때
참고할 만한 템플릿 정도로만 취급.
## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션)
## 이벤트도 store-bind 가능 — `None`/`nil`로 disconnect (2026-08-06 후속 세션, **센티널은 2026-08-18에 `false`→`None`/`nil`로 정정**)
**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을
바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음.
@ -80,13 +79,39 @@ SyntheticEvent만 주는 것과 같은 모양).
다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate`
저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐).
**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예
없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는
신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤
타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면
`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함.
이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른
boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음.
**[재확정, 2026-08-18 구현 전 QA] 센티널은 `None`(그리고 그 재귀가 만드는
`nil`)이다 — `false` 아님.** 원래 결정(2026-08-06)은 *"`nil`은 Lua
테이블에서 '키가 아예 없음'과 구별이 안 되니 실재하는 싱글톤 `false`
센티널로 쓴다"*였는데, **그건 `None` 센티널이 확정되기 전의 선택**이다.
지금은 `None`이 정확히 그 역할("테이블에 실재하면서 '없음'을 뜻하는 값")로
도입돼 있으므로(`base/modifier-plan.md` 2-1, `base/dispatch-core-plan.md`
"`None` 센티널" 절), 같은 문제를 푸는 센티널이 두 개가 되는 셈이라 이벤트만
다른 걸 쓸 이유가 없음. 사용자 판정: *"이젠 None 이 있어서 false 을
사용해야할 이유가 없어졌다고 봄 … 일관적이게 None/nil 을 주는게 맞다는
생각"*.
동작:
- `MouseButton1Click = None`이면 `NoneHandler`가 매우 높은 우선순위로 먼저
매치해 `Dispatch.process(inst, k, nil, index+1)`로 재귀한다 — 즉
**`EventHandler`가 실제로 받는 값은 `nil`**이다.
- 따라서 **`EventHandler.isHandlable``v == nil`인 경우에도 매치돼야
한다** — 매치 판정은 값이 아니라 키(리플렉션으로 이벤트 이름인지 판별)로
하므로 원래도 값 모양에 의존하지 않았지만, "`nil`이면 매치 안 함" 같은
가드를 넣으면 안 된다는 게 이제 명시적 계약이다.
- `(k=이벤트키, v=nil)`을 받으면 `retract`가 하던 일(기존 Connection 해제)만
하고 새로 Connect 하지 않는다.
- **배열 자리의 `nil`을 잡는 `NilHandler`와 겹치지 않는다**
`NilHandler``type(k) == "number"` 전용이고 이벤트 키는 문자열이다
(`base/dispatch-core-plan.md`의 "`NilHandler`" 절).
- 옛 근거였던 *"이벤트인지 여부는 키로 결정되므로 다른 boolean 프로퍼티
핸들러와 `(k, false)` 매칭이 겹칠 위험이 없다"* 는 `false`를 안 쓰는
이상 필요 없어져 삭제됨.
**store-bind될 때의 타입도 같이 바뀐다** — 값 타입이 `((...) -> ())? |
false`가 아니라 `((...) -> ()) | None | nil`(그리고 `State<...>`)이다.
`D` 생성기가 이벤트 필드 타입을 찍을 때 이 유니온을 포함해야 함
(`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절).
**quad가 미는 기본 패턴은 아님 — 부차적 옵션.** 저빈도 UI 이벤트(클릭류)를
조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러

View file

@ -25,7 +25,7 @@
React/Vue류 프레임워크의 `OnCreated`/`OnRendered`/`OnDisposed` 생명주기
훅을 quad에도 두면 좋겠다는 제안. 처음엔 `Frame{[OnCreated] = fn}`처럼
싱글톤 프리미티브를 해시 파트 DI 키로 쓰는 안을 검토했으나, `:Compute`
싱글톤 프리미티브를 해시 파트 특수 키로 쓰는 안을 검토했으나, `:Compute`
콜백에 `State<function>`이 들어올 때의 처리가 까다로워질 것 같다는 우려로
스스로 기각 — 대신 `OnCreated(fn)`이 이미 있는 `PreRef` 인스턴스를 반환하는
**순수 팩토리 함수**(children 배열에 놓는 슈가)라면 그 우려 자체가 안
@ -79,8 +79,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
이게 바로 사용자가 처음에 걱정했던 **"`:Compute` 콜백에 `State<function>`
들어오면 처리가 까다로워지지 않을까"** 문제가 애초에 안 생기는 이유와
정확히 같은 뿌리: 그 우려는 `OnCreated`**해시 파트 DI 키**(예:
`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — DI 키는 Store/Dispatch
정확히 같은 뿌리: 그 우려는 `OnCreated`**해시 파트 특수 키**(예:
`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — 특수 키는 Store/Dispatch
디스패치 경로를 거쳐야 하고, 그 값이 `State<function>`으로 감싸이는
경우까지 핸들러가 다뤄야 함. 반면 팩토리 함수 호출은 **Store/Dispatch
경로를 아예 안 탐** — 순수 Lua 함수 호출이 즉시 평가되어 끝나고, 그
@ -103,12 +103,12 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
"`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절에 이미 이렇게 확정돼
있음:
> quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.
> quad v1의 `OnCreated` 특수 키는 이식하지 않는다.
> `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로
> 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1
> 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠.
이 문장이 거부한 건 v1식 **"특수 DI 키"** 메커니즘(해시 파트에 매직
이 문장이 거부한 건 v1식 **"특수 키"** 메커니즘(해시 파트에 매직
키를 두고 Dispatch가 그 키를 특별 취급하는 것)이지, **"팩토리 함수가
기존 `Ref`/`PreRef`를 반환해서 children 배열에 놓는 것"**과는 층위가
다름 — 이 문서의 `OnCreated(fn)`은 정확히 저 문단이 이미 권장한
@ -183,7 +183,7 @@ Frame {
**생성자가 매번 새로 불려 독립된 인스턴스**를 만들어냄 — children
배열의 서로 다른 숫자 슬롯에 놓이므로, 같은 인스턴스에 여러 개를
나란히 등록하는 게 자연히 지원됨. 이건 `Ref():Callback(fn)` 단일
슈가 관용구나 v1의 단일 DI 키 관례와 달리, **팩토리-함수 접근이 주는
슈가 관용구나 v1의 단일 특수 키 관례와 달리, **팩토리-함수 접근이 주는
공짜 이점**임(v1처럼 "이 키엔 콜백 하나만" 같은 제약이 아예 성립할
자리가 없음 — 애초에 키가 아니라 매번 새로 만들어지는 값이므로).
@ -291,16 +291,16 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
## 이름 컨벤션
- **`On` 접두 자체는 이미 선례가 있음** — `base/onchange-plan.md`
`OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 DI 키). 단
`OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 특수 키). 단
**메커니즘은 다름**: `OnChange`는 이름을 인자로 받아 캐시된 키 객체를
반환하는 **해시 파트 DI 키 팩토리**(`base/onchange-plan.md` "확정"
반환하는 **해시 파트 특수 키 팩토리**(`base/onchange-plan.md` "확정"
절)인 반면, 이 문서의 `OnCreated`/`OnRendered`/`OnDestroyed`는 **배열
파트에 놓이는 값(`PreRef`/`PostRef`/`EffectHandle`)을 만드는 팩토리**라
이름 패턴만
같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 DI 키와의 대조"
같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 키와의 대조"
표에 이 둘을 끼워 넣을 필요는 없어 보임(별도 표로 다루는 게 맞음).
- `OnCreated`/`OnDestroyed` **이름 확정** — 다만 v1이 이미
`OnCreated`라는 이름을 다른 메커니즘(특수 DI 키)으로 썼던 전례가
`OnCreated`라는 이름을 다른 메커니즘(특수 키)으로 썼던 전례가
있어 위 "①" 절의 대조 설명 없이 이름만 보면 헷갈릴 수 있음, 문서화
시 명시할 것.
- `OnDestroyed`는 최초 가칭이던 `OnDisposed`보다 사용자가 선호 —

View file

@ -135,6 +135,16 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한
번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절)
> **[정정, 2026-08-18 구현 전 QA]** `canBound`의 **판정 방향이 뒤집혀
> 있었다** — 이름 그대로 "지금 묶을 수 있는가"(참 = 아직 안 묶여 있어서
> 묶어도 됨)여야 하는데, 문서 전체가 참 = "이미 묶여 있음"으로 쓰고
> 게이트를 `if canBound(v) then error(...)`로 적어뒀었다. 그대로 구현하면
> **정상적인 첫 바인드가 전부 에러나고 이중 바인드는 무사통과**한다.
> 아래 (1)~(3) 절은 전부 정정된 방향(`canBound(v) == not isBoundAlive(v)`,
> 게이트는 `if not canBound(v) then error(...)`)으로 다시 쓰여 있다.
> 사용자 판정 원문과 파급 목록은
> `.claude/qa-request/pre-implementation-qa-round1.md``S-1`.
**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/
`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/
`canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼
@ -145,7 +155,7 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
```lua
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
unbindLifetime(value: any): ()
canBound(value: any): boolean -- "이미 유효하게 묶여 있는가" — 구조적 점유 확인
canBound(value: any): boolean -- "지금 묶어도 되는가" — 참이면 아직 안 묶여 있음(구조적 점유 없음)
canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅
```
@ -247,8 +257,8 @@ local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐)
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
-- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정.
-- 이 값이 "구조적으로 이미 살아있는 바인딩을 갖고 있는가"는 어느 쪽
-- 진입점에서 물어도 항상 같은 값이라, 판정 로직은 여기 하나만 있음.
-- "이 값이 구조적으로 이미 살아있는 바인딩을 갖고 있는가" 하나만 답한다.
-- 두 공개 진입점은 이걸 서로 반대 방향으로 감싼다(아래 "(3)" 절).
local function isBoundAlive(value)
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음.
-- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움
@ -266,9 +276,9 @@ end
function bindLifetime(inst, value)
-- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound.
-- "이미 유효한 바인딩을 갖고 있다"를 묻는 자리이지 "지금 발화해도
-- 되는가"를 묻는 자리가 아님(둘의 구분은 아래 "(3)" 절 참고).
if canBound(value) then
-- "지금 묶어도 되는가"를 묻는 자리이지 "지금 발화해도 되는가"를 묻는
-- 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). 못 묶는 경우만 에러.
if not canBound(value) then
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
-- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한
-- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러).
@ -296,18 +306,19 @@ function unbindLifetime(value)
BindData:SetWeak(value, "gcconn", nil)
end
-- "이미 유효하게 묶여 있는가" — 구조적 점유 확인용. bindLifetime의 이중
-- 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, Ref가 두 자리에
-- 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`)처럼
-- "이 값이 이미 다른 어딘가에 물려 있는가"를 묻는 자리는 전부 이걸 씀.
-- "지금 묶어도 되는가" — 참이면 아직 아무 데도 안 묶여 있다는 뜻.
-- bindLifetime의 이중 바인딩 가드, Observer:Subscribe()의 이중 등록 가드,
-- Ref가 두 자리에 동시에 놓이는 걸 막는 가드(`base/ref-plan.md`)처럼
-- "이 값을 지금 묶어도 되는가"를 묻는 자리는 전부 이걸 씀. 호출부는
-- 항상 `if not canBound(v) then error(...) end` 모양이 된다.
function canBound(value)
return isBoundAlive(value)
return not isBoundAlive(value)
end
-- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할
-- 때만 씀(아래 "(4) 실제 호출부" 절). 오늘은 canBound와 판정값이 항상
-- 같지만(같은 isBoundAlive를 공유), 호출부의 질문 자체가 다르므로
-- 이름을 분리해둔다.
-- 때만 씀(아래 "(4) 실제 호출부" 절). 같은 isBoundAlive를 공유하지만
-- canBound와는 **반대 방향**(canBound(v) == not canExecute(v))이고,
-- 호출부의 질문 자체도 다르므로 이름을 분리해둔다.
function canExecute(value)
return isBoundAlive(value)
end
@ -318,7 +329,8 @@ end
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다**`gchold[value]`
강참조가 그것.
2. **`value``inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`
복사된 gcconn 참조가 그것. `canBound`/`canExecute`가 `inst` 없이 성립하는 이유.
복사된 gcconn 참조가 그것. `isBoundAlive`(따라서 `canBound`/`canExecute`)가
`inst` 없이 성립하는 이유.
**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로
전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도
@ -337,7 +349,7 @@ end
local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적)
function Observer:Subscribe()
if canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유)
if not canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유)
error(if self.Subscribed
then "이미 :Subscribe()된 값"
else "이미 Instance에 바인딩된 값")
@ -384,28 +396,36 @@ end
전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) —
`Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음.
**오늘 두 문맥의 판정값은 우연히 같다**(둘 다 `isBoundAlive` 하나로
귀결 — gcconn이 살아있는가 OR `.Subscribed`인가). 다섯 번째 세션은 이
우연한 일치를 "애초에 같은 질문"으로 결론지어 하나로 합쳤지만, 호출부가
왜 그 질문을 묻는지는 서로 다름 — `Ref`처럼 발화라는 개념 자체가 없는
값에게 "발화해도 되는가"(`canExecute`)를 묻는 건 개념이 안 맞고, 나중에
"구조적으로는 묶여 있지만 일시적으로 발화만 멈춘" 상태가 생기면(지금은
없음) `canBound`는 참인데 `canExecute`는 거짓이어야 하는 경우도 생길 수
있음 — 판정값이 갈라질 여지 자체가 원래 있었다는 뜻.
**[정정, 2026-08-18 구현 전 QA] 두 판정값은 같은 게 아니라 서로의
부정이다** — `canBound(v) == not isBoundAlive(v)`, `canExecute(v) ==
isBoundAlive(v)`. 열한 번째 세션은 "판정 로직도 같고 값도 항상 같은데
호출부의 질문만 다르다"를 이름 분리의 근거로 적었는데, 그건 `canBound`
"이미 묶여 있는가"로 잘못 읽은 결과였다. 이름 그대로 읽으면 두 질문은
**반대 방향**이고, 공유하는 건 판정 **로직**(`isBoundAlive`) 하나뿐이다.
**부정 관계라는 사실은 이름 분리의 명분을 오히려 강화한다** — 같은 값을
두 이름으로 부르는 게 아니라, 서로 다른 방향을 묻는 두 predicate이기
때문에 호출부가 `not`을 붙이는지 여부로 의도가 드러난다.
여전히 유효한 것 — **호출부가 왜 묻는지가 서로 다르다**: `Ref`처럼
발화라는 개념 자체가 없는 값에게 "발화해도 되는가"(`canExecute`)를 묻는
건 개념이 안 맞고, 나중에 "구조적으로는 묶여 있지만 일시적으로 발화만
멈춘" 상태가 생기면(지금은 없음) 둘의 관계가 단순 부정에서 더 벌어질
여지도 있다.
**해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/
`.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드
블록) 하나에만 있고, `canBound`/`canExecute`는 둘 다 그 헬퍼를 그대로
호출하는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. **바뀐
호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`
가드(위 (2))는 이제 `canBound`를 씀 — `canExecute`를 쓰던 옛 코드에서
이름만 바뀜, 동작은 동일. **안 바뀐 호출부**: State 전파 루프(아래
"(4)")만 여전히 `canExecute`를 씀.
블록) 하나에만 있고, `canBound`/`canExecute`는 그 헬퍼를 각각 부정해서/
그대로 감싸는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨.
**바뀐 호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`
가드(위 (2))는 이제 `canBound`를 씀 — 형태는 항상 **`if not canBound(v)
then error(...) end`**(못 묶는 경우에만 에러). **안 바뀐 호출부**: State
전파 루프(아래 "(4)")만 여전히 `canExecute`를 쓰고, 거기선 부정 없이
그대로 씀.
부수 효과(다섯 번째 세션 결론과 값은 동일, 이름만 갈라짐): **"바인딩이
죽은 뒤의 재사용은 허용"** — `inst`Destroy됐거나 `unbindLifetime`
`value``canBound`가 거짓이라 게이트를 통과함(다시 다른 `inst`에 걸
수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도.
부수 효과: **"바인딩이 죽은 뒤의 재사용은 허용"** — `inst`
Destroy됐거나 `unbindLifetime` `value``canBound`가 **참**이라
게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는
게 이 게이트의 의도.
#### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다

View file

@ -80,7 +80,7 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
**결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는
전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널"
(`false`로 disconnect, `base/event-plan.md` "이벤트도 store-bind
(`None`/`nil`로 disconnect, `base/event-plan.md` "이벤트도 store-bind
가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라
**디스패치 단계**에서 풀린다:
@ -308,13 +308,20 @@ Modifier에는 없음).
### 5. 타입 출처는 이미 확정된 dot-access 관습 재사용
"누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에
이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트
전역 관습(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면
됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수
하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용.
이미 적용한 **"정적으로 알려진 건 dot-access"** 프로젝트 전역 관습
(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 됨 —
`mod:UICorner(8)`/`mod:FontSize(...)`처럼 `D`(Declarative) 쪽 "제네릭 생성자
함수 하나 + 클래스별 정적 필드" 패턴 재사용.
**[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번
라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백
(`store "key"` 문자열 커링)은 **기각**됐고 동적 키는
`store:GetDynamic<<T>>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이
아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄**
(`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그
**패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님.
(주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은
PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/event-plan.md`
"이벤트 바인딩 — self 미전달, false로 disconnect" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스
"이벤트 바인딩 — self 미전달" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스
생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.)
`mod:UICorner(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의
@ -410,11 +417,17 @@ immutable clone 체이닝(3번)에 얹는 얇은 sugar라 구현/개념 비용
호출해 clone된 새 Modifier를 반환하므로, `Apply` 자체는 clone할 필요조차
없음(`factory(self)`가 이미 새 값을 만들어 줌).
**구현 시 주의**: `Apply`는 제네릭 `__index`가 즉석에서 만들어주는 필드
**구현 시 주의**: 고정 메소드는 제네릭 `__index`가 즉석에서 만들어주는 필드
setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 메소드
테이블(현재는 `Apply` 하나)을 먼저 확인하고, 없을 때만 필드 setter를
합성하도록 구현. 따라서 **`Apply`는 Modifier 필드 이름으로 예약됨**(실제
스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요).
테이블을 먼저 확인하고, 없을 때만 필드 setter를 합성하도록 구현.
**[정정, 2026-08-18 구현 전 QA] 고정 메소드는 `Apply` 하나가 아니라
`Apply` / `Peek` / `Overridden` 셋이고, 셋 다 Modifier 필드 이름으로
예약된다.** 옛 서술("현재는 `Apply` 하나")은 9번 절이 `:Peek(key)`
추가하던 시점에 갱신되지 않은 stale이고, `Overridden`도 콜론 호출을
지원한다(사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함 …
콜론과 닷 둘다 가능함"*). 실제 스타일 프로퍼티 이름과 겹칠 일은 거의
없어 보이지만 문서화 필요 — 특히 **`FrameModifier`류 타입 생성 스크립트의
제외 목록에 셋 다 들어가야 함**(M7).
**권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를
계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상

View file

@ -30,6 +30,123 @@ RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecy
주고 사용자가 호출하도록. `base/architecture.md` 14번 항목과 동일한 결정 —
여기서는 "왜"만 보강.
## New()의 내부 구성 — InitXxx 팩토리 체이닝 (2026-08-19 신설)
바로 위 절이 확정한 `InitRoblox(Module)` 패턴(팩토리가 모듈 테이블을
뮤테이션)은 지금까지 서술상 **backend 주입**에만 적용되는 것처럼 보였는데,
`New()` **자신**이 quad-base 내부 서브시스템(Dispatch 등)을 구성하는
방식도 대칭적으로 같은 패턴을 쓴다 — 새 설계가 아니라 이미 확정된 원칙을
quad-base 자기 자신에도 적용한 구체화. 논의 원문은
`session/2026-08-19-01-new-initxxx-composition-relate-guard.md`.
**사용자 제안 원문 요지**(2026-08-19): *"생성형식 자체는 비싱글톤이고,
Dispatch 같은것도 `Init(module)` 을 받는 함수로써 ... `module.Dispatch = ...`
형식들로 구현되고 ... 재익스포트식으로 구현하겠다는 이야기였음. 처음부터
`InitModuleName` 식으로 구현하여 팩토리를 쌓아 모듈을 리턴하는 방식으로,
quad v1 의 방식을 가져와봄직 하다."*
**형태**(각 서브시스템 파일이 `Init` 함수를 export하고, 타입도 재익스포트):
```lua
-- Dispatch/init.luau
local function Init(module)
local ... -- 여기서 레지스트리/릴레이션 생성
module.Dispatch = ...
end
export type Dispatch = ...
return Init
```
```lua
-- 최상위 init.luau
local InitDispatch = require(...)
type Dispatch = InitDispatch.Dispatch -- 재익스포트
local function New(): { Dispatch: Dispatch, New: typeof(New), ... }
local module = { New = New }
InitDispatch(module)
-- 서브시스템 개수만큼 InitXxx(module) 을 순서대로 쌓음
return module
end
return New()
```
`module = {New = New}` 자기참조는 `base/architecture.md` "확정된 결정"
13번이 이미 확정해둔 것과 정확히 같은 형태 — 별도 결정이 아니라 그 결정이
실제로 어떻게 코드로 나오는지를 구체화한 것뿐.
**타입 재익스포트는 실측 확인됨**(2026-08-19, 사용자: "그거 타입 익스포트
잘 됨") — `type Dispatch = InitDispatch.Dispatch` 형태로 서브모듈의
export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제없이 동작한다.
`base/typing-limits.md`가 우려하는 "명시 바인딩 필요" 케이스와는 다른
자리라는 뜻 — 거긴 재귀 제네릭이 자기를 다른 타입 인자로 반환하는 게
문제였고, 여긴 단순 alias라 그 한계에 안 걸린다.
**순서 의존성은 각 `InitXxx``require`처럼 멱등하게 만들어서 해소한다**
(2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`
직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저
**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`
weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미
됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require`
캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는
한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을
여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프
캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다.
```lua
-- Dispatch/init.luau
local Relate = require(...)
local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스
local function Init(module)
if relate:GetStrong(module, INITED) then
return -- 이미 이 module 인스턴스엔 Init됨, no-op
end
relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고)
-- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음
-- 예: InitLifetime(module)
local ...
module.Dispatch = ...
end
return Init
```
이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`require`가
의존 그래프를 알아서 풀어주는 것과 같은 감각), 최상위 `New()`는 순서를
신경 쓰지 않고 아는 `InitXxx(module)`을 전부 호출해도 된다 — 이미 누가
먼저 채웠으면 알아서 스킵된다.
- **GC와도 자연히 맞물림**: `relate-plan.md`의 "API" 절에 따르면 `Relate`
**첫 인자(`inst`, 여기선 `module`)는 항상 weak**다(선택의 여지가 없는
고정 동작 — `Weak`/`Strong` 구분은 오직 `value` 쪽 보관 방식만 가리킴).
그래서 `value` 쪽을 `SetWeak`으로 두든 `SetStrong`으로 두든 상관없이,
어떤 `Quad` 인스턴스(전체 `module` 테이블)가 더 이상 참조되지 않아
수거되면 이 Init-완료 기록도 같이 사라진다 — 별도 정리 로직 불필요.
`relate-plan.md`가 이미 확정해둔 "각 모듈이 자기 톱레벨에 `Relate()`
하나를 두고 재사용" 관례를 그대로 쓰는 것이라 새 메커니즘 아님.
(`relate-plan.md`가 별도로 명시한 "명시적으로 만든 기록은 명시적으로
지울 것" 원칙과 충돌하는 게 아니라 — 이 경우는 기록의 키 자체가 죽으면
그 기록을 다시 조회할 주체 자체가 사라지므로 지울 대상이 없어지는,
원칙이 애초에 상정하지 않은 자리다.)
- **`value``SetStrong`으로 통일**: 위 문단대로 GC 결과엔 차이가 없지만
(boolean 리터럴은 애초에 GC 대상이 아님), `relate-plan.md`의 일반 규칙
"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 기준으로는 이 `true`
플래그를 다른 어디도 붙잡고 있지 않으므로 `SetStrong`이 그 규칙에 맞는
선택이다.
- **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제
정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend
팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개
계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base
내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현
디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지
않게 구분해서 쓸 것.
- **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `InitA`↔`InitB`처럼
상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저
표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시
미완성 exports를 돌려주는 것과 같은 이유로, 실제 작업 시작 전에 먼저
"완료"로 표시해둔다.
## Bind는 누가, 어떻게 구현하는가
인터페이스 상 `bind`를 두고 이것도 pluggable하게 할지 고민 — 단 **1개만 존재할
@ -77,7 +194,29 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
## 모듈 스코핑 (참고, 확정은 `base/architecture.md` 13번)
한 Lua 스레드에서 둘 이상의 모듈 분화체(Roblox+비Roblox 동시)를 쓸 일이
거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 `New()` 추가.
거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 다중 인스턴스화를
추가. **[정정, 2026-08-18, 재정정 2026-08-19]** "코드 변경 없이 자동으로
스코핑"되는 게 아니라 module-level state를 참조하는 코드들이 모듈
인스턴스를 인자로 받도록 손봐야 한다. 이름은 `Quad()`가 아니라
**`New()`가 맞음**(2026-08-18에 한 차례 `Quad()`로 잘못 정정됐다가
바로잡힘) — `Quad`(`require`의 반환값)는 이미 만들어진 기본 인스턴스고,
`New()`는 그 안에서 명시적으로만 부르는 opt-in 필드다. 상세는
`base/architecture.md` "확정된 결정" 13번이 소스.
## 모듈 표면의 디버그 플래그 — `Quad.debug` (2026-08-18 신설, 사용자 요구)
**`Quad.debug: boolean`(기본 `false`)** — 라이브러리 자체의 디버그 모드
스위치. 지금 이 플래그가 게이팅하는 것은 **핸들러 우선순위 동률 경고
print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
"개발 중에만 켜고 싶은" 진단 출력은 전부 여기 얹는다. 사용자 요구
원문과 배경은 그 문서에 있음.
- **기본이 `false`인 이유**: 라이브러리가 사용자 콘솔에 아무것도 안 찍는
게 기본이어야 함. 켜는 건 명시적 opt-in.
- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑"
절과 같이 정할 것.
- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도
같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임).
## Quad는 스크립트인가 라이브러리인가 (확정, 참고용)
@ -141,9 +280,13 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
`archive/tag-attribute-load-time-registration-reversed.md`) —
`HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는
`TagFallbackHandler`/`AttributeKeyFallbackHandler`/
`AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈 자체가
아니라 백엔드 팩토리(바로 위 문단과 같은 `BaseModule` 뮤테이션 경로 —
새 예외 아님).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가
`AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA]
등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드
상태에서도 안내 에러 경로가 돌아야 하기 때문 — `base/dispatch-core-plan.md`
"base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반
원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인
이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가
뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의
기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한
no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
@ -154,7 +297,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 —
바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`
생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로
스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도
실제로 호출되면 그 인스턴스별 테이블이 분리되므로 이 가드도 자연히
인스턴스별로 스코핑됨(**[한정, 2026-08-18 `/code-review high`, 이름
재정정 2026-08-19]** 위 "모듈 스코핑" 절의 정정과 맞춰 — "자동으로"는
아니고 module-level state를 참조하는 코드는 손을 봐야 함, 그 손질까지
하고 나면 이 가드 자체는 재설계 불필요라는 뜻). **이 결론이 Dispatch의
handler 레지스트리에도
그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** —
`base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절.

View file

@ -17,7 +17,7 @@
## 확정
- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI
- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 특수
팩토리, `AttributeKey(name)`/`Tag(...)`와 같은 패턴(`AttributeKey`는 구
`Attribute` — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹
`Attribute(...)` 프리미티브가 신설되며 이름 충돌 방지로 리네임됨,
@ -26,12 +26,17 @@
- **제네릭 타입 파라미터 없음 — `OnChange<<T>>` 같은 타입 파라미터화는 안
함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시
(`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과
일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를
Luau가 검증 못 하는 대가를 받아들인다"는 결정(`base/bind-system-plan.md` "이벤트
바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도
포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려
`AttributeKey<<T>>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더
엄격한 걸 요구하는 셈이라 일관성이 깨짐.
일치하는지 검증해주지 않음.
**[근거 교체, 2026-08-18 구현 전 QA — 결론은 그대로]** 옛 근거는 *"이벤트
바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다는 결정과
같은 급"* 이었는데, **그 전제가 거짓**이다(이벤트는 props 타입의 **필드**라
`D` 생성기가 콜백 타입을 정확히 줄 수 있음 —
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절).
진짜 이유는 **`OnChange(name)`이 이름을 인자로 받는 팩토리라 그 경로가
없다는 것** — 필드가 아니므로 생성기가 미리 타입을 찍어둘 자리가 없고,
프로퍼티별로 전량 생성하는 안은 이미 기각돼 있다(아래 항목). 그래서
`AttributeKey<<T>>`처럼 제네릭으로 맞추려는 시도만 남는데, 그건 호출부가
매번 타입을 두 번 적게 만들 뿐이라 채택 안 함.
- **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**:
`archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의
"제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만
@ -58,7 +63,7 @@
캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`dispatch-core-plan.md`
"핸들러 내부 상태 저장" 절).
- **`State<function>` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도
store-bind 가능`false`로 disconnect" 메커니즘(`bind-system-plan.md`)이
store-bind 가능" 메커니즘(`base/event-plan.md`)이
`OnChange` 키에도 그대로 적용됨 — `OnChangeHandler``process`(와 그
반환 클로저)만 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서
언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기
@ -73,14 +78,16 @@
의도적으로 허용해도 되는 동작 — 문제 없음(사용자 확인). `Handlers/OnChange.luau`
`OnChange(name)` 팩토리에 `AttributeKey`와 동일한 캐시 구현.
## 다른 특수 DI 키와의 대조
## 다른 특수 키와의 대조
| | 소스 | 값 타입 | 패키지 경계 |
|---|---|---|---|
| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) |
| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백**[2026-08-18 정정] 타입 검증됨**(props 타입의 필드라 `D` 생성기가 콜백 시그니처를 찍어줌) | 판별은 quad-roblox(`Handlers/Event.luau`), 타입은 `D` 생성기 |
| `AttributeKey(name)` | 주입된 `setAttribute` op | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | **quad-base**(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드 |
| `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) |
`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는
성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가
헷갈리지 않도록 표로 명확히 구분해둠.
`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 위 "확정" 절의 정정된
근거대로 **이름을 인자로 받는 팩토리라 타입을 미리 찍어둘 필드가 없기**
때문 — "콜백을 받는다"는 성질이 이벤트에 가깝다는 분류 자체는 그대로지만,
이벤트 쪽은 필드라서 타입이 나온다는 게 2026-08-18에 확인됐으므로 그
유사성이 근거가 되지는 못한다.

View file

@ -105,20 +105,24 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current`
존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우
채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상
필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용
가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고,
필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 배열
(`self.Callbacks`) 하나를 재사용(발화 후 해당 인덱스만 **`nil`로 소진**
— 아래 구현 디테일 참고,
**[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**,
바로 아래 캐비엇 참고).
- **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고
`__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** —
Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`
`self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에
같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스,
위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do`
같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이
경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`
오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면
이 충돌 자체가 안 생김.
- **[재설계, 2026-08-18 구현 전 QA] 콜백/대기자는 별도 필드
`.Callbacks` 테이블에 담고, `.Value`는 그냥 평범한 hash 필드로 둔다.**
옛 설계(2026-08-09 열한 번째 세션 보강)는 **Ref 객체 자신이 곧
콜백/대기자 배열**(숫자 키 색인)이라, `T`가 함수/스레드일 때
`for i, v in self do` 순회가 hash 파트의 `.Value`까지 훑어 오분류되는
걸 막으려고 **`.Value``__index` 메타메소드로** 구현해야 했다.
사용자 판정: *"단순히 .Callbacks: {fun, thread} 등이 있는게 맞지
않나라는 생각임. .Value 는 단순 해시필드로 주는게 더 간단해보임.
엔지니어링 난이도구 단순 테이블 하나 더 만드는게 쉽고, 크게 비싸지도
않다고 생각됨."* — 순회 대상이 `self`가 아니라 `self.Callbacks`
되므로 **hash 파트 충돌 자체가 안 생기고, `__index` 우회 기법을 쓸
이유가 사라진다.** 대가는 Ref 하나당 테이블 하나가 더 만들어지는 것뿐
(`.Callbacks`를 첫 등록 시점에 lazy로 만들지는 구현 재량).
- **`:Wait(thread?)``thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
@ -133,8 +137,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
`nil`이면 yield, 있으면 yield 안 함.
- **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume
payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로
`:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만
순회하면서 `type(v) == "thread"``:Wait()`가 만든 대기자로 보고
`:Set()`될 때, 같은 배열 하나를 `for i, v in self.Callbacks do ... end`
한 번만 순회하면서(**[2026-08-18]** 순회 대상은 Ref 객체 자신이 아니라
`.Callbacks` 테이블 — 위 재설계) `type(v) == "thread"``:Wait()`가 만든 대기자로 보고
**`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을
resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield
경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`
@ -167,10 +172,18 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
`table.insert``#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈
슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던
이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이
필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열
(PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속
`None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할
이유가 없었음.
필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열은
실재하는 센티널로 소진** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐,
하나로 통일할 이유가 없었음.
**[정정, 2026-08-18 구현 전 QA] 후자의 예시가 부정확했음** — 옛
문장은 순서가 중요한 배열의 예로 "`PreRef` pre-pass 소진 슬롯,
Length/Offset `sourceList`"를 들며 둘 다 `None`으로 채운다고 적었는데,
**pre-pass가 소진시킨 자리는 `None`이 아니라 전용 센티널
`ProcessedPreRef`/`ProcessedPostRef`** 로 채워지고 전용 nop
핸들러가 정상 `Dispatch.process` 경로에서 그걸 캐치한다(아래 "PreRef"/
"`PostRef`" 절, `base/dispatch-core-plan.md`는 2026-08-14에 이미 이렇게
정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList``None`인 것은
맞음.
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를
`:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건
다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로
@ -254,9 +267,14 @@ skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
-- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가)
RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) and not isPostRef(v)
RefLeafHandler.isHandlable(inst, k, v) =
type(k) == "number" and isRef(v) and not isPreRef(v) and not isPostRef(v)
-- [2026-08-14 열두 번째 세션 정정] PostRef 도입(아홉 번째 세션) 당시 이 자리가
-- 안 갱신돼 있었음 — 아래 "타입/판별" 절의 최종 공식과 일치시킴
-- [2026-08-18 구현 전 QA] type(k) == "number" 체크가 빠져 있었음 — leaf 바인딩은
-- 배열 전용이고(사용자 확정: "배열 전용이 맞음"), 짝인 ObserverEffectLeafHandler엔
-- 이 체크가 필수라고 이미 명시돼 있었음. 빠지면 named 자리로 흘러온 Ref를 잡으려는
-- HANDLER_PRIORITY_FALLBACK 가드(아래 "동적 경로 가드")가 죽은 코드가 된다.
function RefLeafHandler.process(inst, k, v, index)
local old = relate:GetStrong(inst, k)
@ -477,10 +495,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스
루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤
Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). 진짜로
원래부터 빈 자리인 `None`(`props.Ref or None` 등)은 여전히 두 패스
루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로
혼동 금지. "호이스팅"은 PreRef를 배열의 맨
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정,
2026-08-18 구현 전 QA] 원래부터 빈 자리인 `None`도 이제 정상 경로를
탄다** — 여기 "여전히 두 패스 루프가 직접 건너뜀"이라고 적혀 있었으나
그 스킵 분기 자체가 폐기됐다(아래 "[전면 정정, 2026-08-18 …]" 항목).
지금은 `NoneHandler`(재귀만) → `NilHandler`(등록 담당)를 거친다.
두 센티널은 **매치되는 Handler가 다를 뿐** 둘 다 정상
`Dispatch.process` 경로다. "호이스팅"은 PreRef를 배열의 맨
앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가
통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서
소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저
@ -499,33 +520,45 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급>
ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
function ProcessedPreRefHandler.process(inst, i, v)
Dispatch.setLength(inst, i, 0)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가
-- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- (`base/dispatch-core-plan.md`의 해제 순서 계약)
Dispatch.setOffsetSource(inst, i, None)
Dispatch.setLength(inst, i, 0)
return function() end -- no-op retract, 이 자리는 fire가 끝나
-- 되돌릴 상태 자체가 없음
end
```
`isHandlable``v == ProcessedPreRef`만 잡으므로 배열 파트 전용(해시
파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 `base/
dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 "이
위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급
없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가
dispatch-core-plan.md`의 "Length/Offset" 절이 확정해둔 "그 위치의
말단 Handler가 등록 책임을 진다"는 계약을 특수 취급
없이 그대로 만족시킴(**[정정, 2026-08-18]** 그 계약은 예전엔 "처음
매치한 Handler"라고 적혀 있었으나 중간 노드가 매치되는 경우가 있어
말단 기준으로 정정됨) — 매치되는 Handler 자신이 곧 등록자라 "누가
등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된
no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과
같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가
dispatch 체인에 실제로 올라가 있어도(**[정정] 예전 서술과 달리 이제는
올라가 있음** — 아래 참고) retract가 할 일이 없는 것뿐.
- **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) —
`NoneHandler.isHandlable(inst,k,v) = (v == None)``k` 타입을 전혀
안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만,
실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의
`None`**애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스
루프가 `Dispatch.process` 호출 전에 자기 스스로
`if v == None then continue end`로 걸러냄). `NoneHandler`
`Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의
`None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로
`NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가
정확한 설명.
- **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`
`Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는
전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에
`Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가
`if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number`
조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데,
리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State<Slot|None> }`
처럼 반응형 값이 `None`을 내놓으면 그 `None``StoreBind`의 재귀를
타고 `Dispatch.process`에 그대로 도착**한다(사용자 지적).
지금 확정된 모델은:
- `Dispatch.drive``None` 스킵 분기가 **없다** — 전부 `process`를 탄다.
- `NoneHandler``k` 타입과 무관하게 매치되고, 하는 일은
**`nil`로 바꿔 재귀하는 것 하나뿐**.
- `k`가 숫자면 그 재귀가 **`NilHandler`**(`k=number and v==nil` 전용,
`base/dispatch-core-plan.md`의 "`NilHandler`" 절)에 도착해 거기서
`setLength(0)`/`setOffsetSource(None)`을 등록한다.
즉 이 자리에서 "매치되는 경우가 없다"가 아니라 **매치되고, 정상
경로로 0 기여가 등록된다**가 정확한 설명이다.
- **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props
순회 순서" 절은 `{a=1, 2, b=3}`**구멍 없는** 테이블에서 배열
파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세
@ -574,9 +607,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함.
전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isPreRef(v) end, process =
function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수
있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)`
보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler"
function(inst,k,v) error(`PreRef binding should be array index item,
but got {typeof(k)}`) end }`(**[2026-08-18, `/code-review high`
누락 발견 — `PostRef`의 "동적 경로 가드 Handler도 거울상으로 하나 더" 절/
`effect-plan.md`의 "동적 경로 가드" 절과 짝을 맞춤]** 에러 메시지에
실제 `k` 타입을 실을 것) — `k` 타입은 안 가림(숫자든 문자열이든
`isPreRef(v)`만 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만
전담하는 Handler"
패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는
`HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라
`Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서
@ -631,10 +668,21 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위
"요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는
자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.)
- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게
들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간
- **일반 `Ref`도 값으로서는 Modifier 필드/Store 값 어디로든 전달될 수
있음** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간
처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분).
- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.**
**[한정, 2026-08-18 구현 전 QA] 다만 leaf 바인딩이 일어나는 자리는
배열(숫자 키) 전용이다** — 옛 문장("Modifier/Store 어디든 자유롭게
들어감")은 "named 해시 키에 놓아도 leaf 바인딩이 된다"로 읽혔는데 그건
틀리다. 어디로든 **흘러갈** 수 있다는 뜻이고, 실제로 `Ref:Set(inst)`
일어나는 건 배열 자리뿐(아래 `RefLeafHandler.isHandlable``k` 체크).
**왜 배열 전용인가(사용자 논거, 그동안 어디에도 안 적혀 있던 것)**:
`Ref`끼리는 **배열 index 순서가 통하므로**, "다른 `Ref` 처리를 먼저 해야
하는 순서 의존"이 있을 때도 그걸 표현할 수 있게 하려는 것 —
`PreRef`/`PostRef`의 "계열 안 fire 순서는 배열 index 순서" 보장과 같은
결의 근거다. 컴포넌트 함수에 `Ref`를 named 파라미터로 넘기는 건 이와
무관하게 얼마든지 가능(그건 그 함수의 인자일 뿐 leaf 바인딩이 아님).
- **quad v1의 `OnCreated` 특수 키는 이식하지 않는다.**
`Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로
완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) —
v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠.
@ -747,8 +795,9 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급>
ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef)
function ProcessedPostRefHandler.process(inst, i, v)
Dispatch.setLength(inst, i, 0)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유)
Dispatch.setOffsetSource(inst, i, None)
Dispatch.setLength(inst, i, 0)
return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음)
end
```
@ -768,8 +817,9 @@ dispatch-core-plan.md` "Length/Offset" 절의 계약을 특수 취급 없이 그
Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는
정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위
레지스트리에 `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable =
isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을
수 있음") }` Handler를 등록(`k` 타입 안 가림 — `PreRef`의 "동적 경로
isPostRef(v), process = error(`PostRef binding should be array index item,
but got {typeof(k)}`) }` Handler를 등록(**[2026-08-18]** 에러 메시지에
실제 `k` 타입을 실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절)(`k` 타입 안 가림 — `PreRef`의 "동적 경로
가드" 절과 완전히 같은 이유로 `HANDLER_PRIORITY_FALLBACK`, 2026-08-14
열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입
차단을 우회한 버그라는 뜻.

View file

@ -166,6 +166,11 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌
- **소유권/멤버십 전역 판정**`Slot``elementOwner`.
- **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute`
(`base/lifecycle-pattern.md`). 애초에 클로저 수명과 무관한 질문.
- **"이 인스턴스에 이미 했는가"류 인스턴스별 멱등 가드**(2026-08-19
신설) — `New()`가 만드는 각 `module` 인스턴스별로 `InitXxx(module)`
이미 실행됐는지 기록하는 것도 여러 호출 지점(다른 `InitXxx`가 자기
의존성으로 호출하는 경우 포함)을 가로질러야 해서 클로저 캡처로 대체
불가 — `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절.
**쓸 때 같이 지킬 것**:
- **정리 조건을 실제 정리와 묶을 것.** `Relate` 엔트리를 지우는 코드가
@ -214,9 +219,17 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌
- `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)`
placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로.
- `base/lifecycle-pattern.md``bindLifetime`/`canExecute` — gcconn/gchold를
`Relate``SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로
strong).
- `base/lifecycle-pattern.md``bindLifetime`/`canBound`/`canExecute` —
gcconn/gchold를 `Relate`**`SetWeak`**으로 저장.
**[정정, 2026-08-18 구현 전 QA]** 옛 서술은 `SetStrong`("둘 다 존재
이유가 '안 죽는 것'이므로 strong")이었는데 **근거까지 통째로 틀렸다**
둘의 생존은 gcconn 클로저의 upvalue와 `gchold[1]`이 이미 보장하므로,
위 "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 절의 규칙이
그대로 적용된다. strong으로 구현하면 `gchold``value`를 강하게 잡고
`BindData``value`를 키로 `gchold`를 강하게 잡는 모양이 되어, 이
문서가 경고하는 **두-`Relate` 상호 강참조 순환**(Luau에 ephemeron이
없어 실제 누수)에 정확히 걸린다. 구현 스케치는 이미 전부 `SetWeak`으로
적혀 있었고(`base/lifecycle-pattern.md`) 이 요약 줄만 어긋나 있었음.
## 이름

View file

@ -91,12 +91,16 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을
가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 —
특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에
Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`).
Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })` — **여기서 `Frame`
`Ref`라는 named 파라미터를 받는 컴포넌트 함수다.** Instance 리터럴에
`Ref = ...`를 named 키로 놓는 건 leaf 바인딩이 아니고, 실제로는
`HANDLER_PRIORITY_FALLBACK` 가드가 잡아 에러를 낸다 — leaf 바인딩은
배열(숫자 키) 전용, `base/ref-plan.md`. **[명시 추가, 2026-08-18 구현 전
QA]**).
- **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종
마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상
`T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라
`DI.InstSlot = Slot<<Instance>>`(`DI` 네임스페이스 이름 자체는
`question.md` 1번 용어정리 대기 중, 여기선 잠정 표기)가 사실상 "그"
`D.InstSlot = Slot<<Instance>>`가 사실상 "그"
Slot 타입. `Slot<T>()`
기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선
`any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것
@ -355,8 +359,9 @@ function SlotHandler.process(inst, k, slotValue, index)
-- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음.
unmountSlotTree(slotValue)
-- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정**
-- (setLength가 끝에서 recompute를 돌리므로 offsetSource를 먼저 비워야
-- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고)
-- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감,
-- 아래 ⚠️ 절 참고)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0)
unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
@ -843,9 +848,10 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
되는 식):
- **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환.
"지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면
**[정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트됨**(단순
`Visible = false`도 아님 — 위 "구현" 절 `rawUnmount` 참고, 이
문단 작성 당시엔 아직 destroy 모델이었음). 편의상
**[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전
— "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible =
false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시
반환해야 대신 언마운트+재사용됨). 편의상
`nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
`nil`/`None` 금지와 안 부딪힘).
@ -970,12 +976,26 @@ end
**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면
그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil`
반환으로 **[정정, 2026-08-13 3차 감사] 언마운트**되게 함(위 캐비엇대로
파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만
통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로
존재하지 않음(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한
GC되기 전까지 `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수
있음 — 위 캐비엇 참고).
반환으로 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로
재역전 — 아래 참고] 언마운트**되게 함(위 캐비엇대로 파괴 아님) — Visible
토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면
20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음
(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지
`nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇
참고).
**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale
서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로
쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil`
리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소,
`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로
`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고,
"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면
`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이
절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제
자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은
`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위
"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것.
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
@ -1077,6 +1097,10 @@ function activateList(self, inst)
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
-- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시.
-- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다.
local popOnly = (result == PopOnly)
if popOnly then result = nil end
if result ~= nil then
-- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그
@ -1087,11 +1111,16 @@ function activateList(self, inst)
end
if result ~= prev then
-- [정정, 2026-08-13 여섯 번째 세션] 파괴가 아니라 **언마운트**
-- rawUnmount는 rawRemove와 같되 element를 Destroy하지 않고
-- 소유권만 반납(unmountSlotTree 사용, 아래 "파괴" 절).
-- 명시적 CRUD Remove/Clear/dispose만 여전히 파괴.
if prev ~= nil then rawUnmount(self, prev) end
-- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래
-- "`nil` 리턴은 파괴가 기본" 절이 소스:
-- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만**
-- — state<Frame> 교체와 동형, 지우라고 한 적이 없음.
-- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드.
-- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시.
if prev ~= nil then
if result ~= nil or popOnly then rawUnmount(self, prev)
else rawRemove(self, prev) end
end
if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
mounted[key] = result
elseif prev ~= nil and keyIndex[key] ~= pos then
@ -1099,12 +1128,13 @@ function activateList(self, inst)
end
userdata[key] = ud -- result와 무관, 그대로 기록
-- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당)
newKeyIndex[key] = pos
end
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
if not seen[key] then
local prev = mounted[key]
if prev ~= nil then rawUnmount(self, prev) end -- 위와 같은 이유로 비파괴
if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴
mounted[key], userdata[key] = nil, nil
end
end
@ -1177,19 +1207,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
같이 GC됨(아래 "구독 시점" 절).
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawMove`뿐**
(**[정정, 2026-08-13 여섯 번째 세션]** 예전엔 `rawRemove`(파괴)였으나
언마운트 전환으로 바뀜 — 데이터에서 빠진 아이템도 파괴되지 않고
언마운트만 되며, 아무도 안 들고 있으면 GC) —
`rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임"
구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그
셋을 직접 호출할 일이 없을 뿐 — **[정정, 2026-08-13 감사] "제거는
항상 파괴 확정이라 Extract 아닌 Remove 경로"라는 예전 근거는 언마운트
전환으로 이제 틀림**(바로 위에서 정정했듯 reconcile의 제거는 이제
비파괴 `rawUnmount`이고, 이 함수 자체가 `rawRemove`의 비파괴
짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/
`rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째
세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을
**부분적으로 되돌림**`nil` 리턴/키 소멸은 다시 **파괴**가 기본이고,
값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이
소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는
가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘
자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount``rawRemove`
비파괴 짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드
블록의 "rawRemove의 비파괴 짝 — `:List`의 reconcile과 `Extract`
계열이 씀" 주석 참고). reconcile이 공개 `Slot:Extract` 대신
계열이 씀" 주석 참고. reconcile이 공개 `Slot:Extract` 대신
`rawUnmount`를 직접 부르는 진짜 이유는 파괴 여부가 아니라, reconcile이
이미 자기 `mounted` 맵으로 element를 추적 중이라 `Extract`의 "제거한
element를 호출자에게 반환" 계약이 불필요하고, 공개 CRUD의 가드/에러
@ -1200,6 +1228,60 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로
미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.
### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기)
**[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로
지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지
전부 비파괴로 바꿨는데, **`:List`에는 그 일반화가 안 맞는다**는 게 사용자
판정: *"List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지,
파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데…"*
**세 경로로 갈린다**(위 `reconcile` 의사코드):
| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 |
|---|---|---|
| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state<Frame>` 교체와 동형이고, `Slot { State<Slot> }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State<Slot>` 교체" 절의 확정도 그대로 유지됨 |
| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 |
| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) |
**`PopOnly`(가칭) — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.**
`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번
파괴/재생성하는 건 비싸다. 그래서 `updateFn`**`PopOnly`와 함께 userdata를
반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다:
```lua
-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다
return PopOnly, { old = prev, source = ... }
```
- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐
`userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의
`old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`
같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내
그대로 반환하면 **재마운트**된다(`rawAdd` 경로).
- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기
전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다.
- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서
발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를
**둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미
`nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는
**파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이
된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고
못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다.
**세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번):
(a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴,
(b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침),
(c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다.
**지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지.
- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수
있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에
올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정.
- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가
이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가
직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서
선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다.
### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime`
(2026-08-09 일곱 번째 세션)
@ -1391,11 +1473,48 @@ nil/None 금지)는 그대로.
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그
해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래
코드의 `blocker` 관련 줄).
`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와
중첩 마운트가 완전히 같은 함수 호출**이 됩니다.
**[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음
`setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.**
`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출
순서는 `setOffsetSource``setLength`"** 일반 규칙(해제 시점 계약에서
나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속
어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각
요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset
전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length``activateList`
자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그
전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는
**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야
확정된 값으로 등록) → 물리 마운트**.
**[재정정, 2026-08-18 구현 전 QA 3라운드] "확정된 값으로 등록"은 값
자체가 아니라 이 순서를 지키는 한 자연히 따라오는 결과를 가리킨 표현이지,
`setLength` 호출 시점에 `slot.Length`의 **값**이 반드시 최종값이어야
한다는 뜻은 아니다.** 실체화(`activateList`)와 물리 마운트(flush 루프)의
순서를 더 트레이싱하며 `RC-3`/`RC-4`(둘 다 `activateList` 도중 아직
`self._mounted`가 안 세팅된 상태를 요구한다는 게 드러남 — 아래 코드의
"`_mounted`는 여기서 아직 세팅하지 않는다" 주석 참고)를 고치는 과정에서,
`slot.Length`가 실제로 최종값으로 안정되는 시점은 `activateList` 직후가
아니라 **flush 루프가 끝난 뒤의 마지막 `recompute`**로 한 단계 더
밀렸다 — `Dispatch.setLength(ownerKey, position, slot.Length)`가 넘기는
건 **State 객체 자신**이라, 등록 시점에 값이 아직 안 굳어 있어도
무해하다(부모는 객체를 구독해뒀다가 나중에 값이 바뀌면 정상 반응).
`setOffsetSource → setLength` 순서 자체(왜 `setOffsetSource`가 먼저여야
하는지)는 안 바뀜 — 상세 트레이싱은
`qa-request/pre-implementation-qa-round3.md``RC-3`/`RC-4` 절.
```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position)
@ -1406,30 +1525,89 @@ local function attachSlot(slot, physicalTarget, ownerKey, position)
-- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
-- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
-- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
slot.Offset = offsetSource
-- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 —
-- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야
-- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속
-- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트
-- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리
-- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`
-- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다
-- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가
-- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직
-- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅
-- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에
-- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를
-- 다시 순회하며 `attachSlot`**또** 불러 이중 실행됨(`RC-4`).
-- `_mounted``activateList` 뒤로 미루면 이 함수 안에서 실제
-- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 —
-- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은
-- `qa-request/pre-implementation-qa-round3.md``RC-3`/`RC-4` 절.
if slot._listed then
activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고)
end
slot._mounted = true
slot._mountedInst = physicalTarget
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
-- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직
-- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`
-- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함:
-- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면
-- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의
-- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음"
-- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가
-- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고
-- 이제는 안 맞아 정정.
Dispatch.setLength(ownerKey, position, slot.Length)
if slot._listed then
activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜
end
-- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에
-- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님)
-- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`
-- 것) **및** 방금 `activateList``_elements`에만 채워둔 `:List`
-- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한
-- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채
-- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시
-- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로
-- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
-- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
blocker:On()
for i, element in ipairs(slot._elements) do
if isSlot(element) then
attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신
else
-- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
-- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1)
element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행
end
end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨
end
```
**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서
이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은
`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey,
position, slot.Length)`(위 코드)는 `slot.Length``State`라 등록 즉시
1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`
배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어
안전하게 스킵되지만, **배치 밖**(예: `state<Slot>` 값이 steady state에서
반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의
`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한
번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면
정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고
최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이
자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은
`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함
없음" 절.
**최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:**
```lua
-- process(inst, k, slotValue, index)
@ -1447,6 +1625,15 @@ end
-- attachSlot될 때 위 flush 루프가 처리
```
**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인,
2026-08-18)** — *"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운
개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷
요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안
받고 수행 가능함"* — 이 시점엔 `self`의 Blocker가 이미 flush 배치를
끝내고 `OffWithoutEmit()`으로 꺼져 있고, 새로 등록되는 position보다
앞선 모든 position은 이미 안정적으로 채워져 있어 `nil` 자리가 생길
여지 자체가 없다.
`recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록
확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더
이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested
@ -1533,7 +1720,7 @@ function rawUnmount(self, index)
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
spliceArraysDown(self, index)
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk)
end
@ -1550,11 +1737,52 @@ function rawRemove(self, index)
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
end
```
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
이 코퍼스 어디에도 명시된 적이 없었음.**
- **`bk.observers`도 같이 당겨야 함** — 위 코드가 이미 `bk.observers[index]`
읽어 `unbindLifetime`하지만(제거되는 그 위치의 것), `spliceArraysDown`
자신이 이동시켜야 하는 배열 목록에 지금까지 `observers`가 빠져 있었다
(`_elements`/`lengthList`/`sourceList` 셋만 언급됨). `bk.observers[i]`
`Dispatch.setLength`가 그 자리 length가 `State`일 때만 채우는(위
"`setLength` 구현" 절) position-indexed 배열이라, 나머지 셋과 똑같이
뒤 position들이 한 칸씩 당겨질 때 같이 안 당기면 이후 그 position의
observer가 엉뚱한 것(옛 이웃의 observer)을 가리키게 된다.
- **`bk.N`도 여기서 하나 줄여야 함** — `recompute`(`base/dispatch-core-plan.md`
"Length/Offset" 절)가 `for i = 1, bk.N do`로 순회하는 그 상한. **`bk.N`
정의 자체가 이 코퍼스 어디에도 없던 갭**이었다(`qa-request/
pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기" 절 — **사용자
확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될
때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에
동일하게 적용"*). 즉 `bk.N``Dispatch.setLength`가 이전에 본 적
없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는
건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔
`lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush
배치도, Slot의 런타임 단건
`rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를
물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`
`inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은
구조적으로 늘거나 줄지 않으므로(전체 재-dispatch만 있음) `bk.N`
등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은
규칙의 특수한 안정 상태다.
- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/
`attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker
게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) —
그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에
이미 최종 크기로 고정돼 있었던 것**의 부산물이었다는 게 이번에 다시
확인됨 — 지금은 그 전제 자체가 없다. 그 대신 Blocker 게이팅이 여전히
필요한 이유는 크래시 방지가 아니라 **비용**(등록마다 `recompute`
한 번씩 도는 O(N²) 대신 배치 끝에 O(1)번만) — `RC-1` 해결 논의에서
사용자가 직접 지적한 "이러면 첫 실행에서 계속 recompute 비용이 쌓임"
문제 그대로.
**왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target
인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리
target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로
@ -1855,6 +2083,25 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
엔진 자체 `:Destroy()` 메소드와 동명이라 사용자가 "그냥 `:Destroy()`
부르는 거 아님?"으로 착각할 위험이 있어 기각 — `dispose` 유지.
**[백로그 후보, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터.**
위 "`Set`(언마운트) → 그 다음 정리" 순서 요구 때문에 호출부가 매번
**`Get()`으로 이전 값을 미리 잡아두고 → `Set(new)` → 잡아둔 옛 값을
`dispose`** 하는 3단계를 손으로 써야 해서 편의성이 떨어진다는 사용자 지적:
*"source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 생각해보았음(단
여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). Get해놓고 Set 이후
나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 source 자체에 :콜론
메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음."* 후보 둘:
1. `source:Apply(SetAndDispose(new))` — 콤비네이터. **단 여기서의 `Apply`
`State`가 아니라 `Source`를 넘겨주는 함수여야 함**(사용자 명시) — 지금
확정된 `state:Apply(factory)``factory(self)``State`를 넘기므로,
`Source` 전용 변형이 필요한지 같이 정해야 한다.
2. `Source`에 콜론 메서드로 직접 얹기(`source:SetAndDispose(new)`).
**미결**: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지.
`state:Apply`의 시그니처(`(State<T>) -> U`)에 영향이 갈 수 있으므로 **M3
착수 전에 방향만이라도 정해둘 것**. `question.md`에 올려둠.
#### 구현상 바뀌어야 하는 것
**[반영 완료, 2026-08-13 감사 후속]** 비파괴 경로를 `unmountSlotTree`
@ -1866,14 +2113,20 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의
코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` +
`unbindLifetime` + `releaseOwner`를 부름.
2. **`:List``reconcile`** — 교체/소멸 시 `rawRemove`(파괴) 대신 같은
비파괴 경로. 데이터에서 빠진 아이템도 파괴되지 않고 언마운트만 되며,
아무도 안 들고 있으면 GC(quad 전역의 GC-native 원칙 그대로).
2. **`:List``reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서
비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn``nil`/`None`을
반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자
판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스.
2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은
`:List`에는 안 맞는 일반화였음.
**여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()`
(CRUD 표가 "제거 **+ 파괴**"로 이미 정의)와 `dispose`. 즉 **"자동 경로는
언마운트, 명시적으로 지우라고 한 것만 파괴"**로 갈림 — `Ref`/`Attribute`의
"지울 거면 명시적으로" 철학과 정확히 같은 결.
(CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의
`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로
지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가
"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로"
철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를
명시한다.
`unmountSlotTree``destroySlotTree`가 하는 일 중 **실제 파괴와 자식
소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`,
@ -1892,9 +2145,12 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
(2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할
때는 이 순서를 반드시 지켜야 함**:
- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`
`sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함
(`base/dispatch-core-plan.md` "Length/Offset" 절).
- `Dispatch.setLength`는 끝에서 `gatedRecompute`를 경유해(배치 게이팅
중이 아니면) `recompute`를 돌리고, `recompute``sourceList`
순회하며 각 자리의 `offset:Set(sum)`을 호출함(`base/dispatch-core-plan.md`
"Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히
일어나므로 이 경로에서는 `gatedRecompute`가 거의 항상 즉시 `recompute`
이어짐).
- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는
시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset
`Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에

View file

@ -78,8 +78,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable<T> extends
Readable<T>`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`,
`:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을
추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고
`Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — 아래
추가로 가짐([정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외되고
`Get()`으로 통일됨, 그 표기는 Ref의 `.Value` 전용으로 좁혀짐 — 아래
"`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절 참고).
- **`:With`/`:Compute`는 Source에서도 항상 `State<U>`를 반환** — Source
자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는
@ -98,9 +98,10 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류
매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임
구현 델리게이션 포함)이라 그 금지와 충돌하지 않음.
- **동적 키 폴백(`store "key"`)은 이제 `State<any>`가 아니라 `Source<any>`
반환**하는 것으로 자연히 갱신됨(`base/store-plan.md`의 "타입 추론
문제" 절과 연동).
- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히
갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이
아니라 `store:GetDynamic<<T>>(name): Source<T>`다(문자열 커링은 기각,
`base/store-plan.md`의 "타입 추론 문제" 절).
**[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로
만족하는 제네릭 메소드 체이닝)은 `08-type-source-satisfies-state.luau`
@ -292,6 +293,38 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로
유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더
강해짐.
### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M3 착수 전 결론 필요**)
**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State ->
State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지
않음이 명확해야함. 물론 compute 등의 callback 상 가져서 안전할 수 있지만,
With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지고 있어야
할것임. 이게 되는지는 더 알아봐야할 필요가 있음."*
확정된 두 서술을 겹치면 체인이 끊길 수 있다:
- State는 자기 **구독자(하류)를 weak로** 담는다(위 문단, 그리고
`base/lifecycle-pattern.md`의 "(4) 실제 호출부" 절).
- Observer는 `gchold`(leaf) 또는 전역 레지스트리가 살려준다.
- 그러면 `A → B → C → Observer` 체인에서 **중간 노드 `B`/`C`를 강하게
붙잡는 주체가 지금 문서 어디에도 명시돼 있지 않다.** 체이닝을 한 줄로
쓰는 흔한 형태에선 아무도 `B`/`C`를 로컬 변수로 안 들고 있고, 하류 weak
링크만으로는 생존이 보장되지 않아 **중간 State가 수거되고 전파가 조용히
끊길** 수 있다.
**사용자가 지목한 해법 방향 — 구독 엣지는 하류로 weak, 상류로 strong.**
각 노드가 자기 parent(상류)를 강참조로 들고 있으면 leaf에서 root까지가
한 줄로 살아있게 된다. `:Compute`는 콜백 클로저가 상류를 캡처해 **우연히**
안전할 수 있지만, **`:With`가 만드는 pass-through 노드는 계산 함수가
없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을
불변식으로 못박아야 한다.
**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의
불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
**미검증 상태로 M3에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은
작음"은 이 항목이 닫히기 전까지는 잠정이다.
**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가
플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는
것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음.
@ -451,20 +484,23 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
`self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한
lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은
불필요, `Compute` 하나로 일관.
- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.**
- **[정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외, `:Get()`만 지원.**
이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의
`Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로
`.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나,
`.value`/`:Get()` 둘 다 지원하고 프로퍼티 읽기를 관용적 표기로 앞세웠으나,
"관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서
프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출
`:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value`
표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라
`:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. 프로퍼티
표기 자체는 폐기하지 않고 **Ref의 `.Value` 전용으로 좁힘**(**[표기 정정,
2026-08-18]** 이 문단이 소문자 `.value`로 쓰고 있었음 — 실제 필드는
대문자 `.Value`. Ref는 lazy가 아니라
값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 —
`base/ref-plan.md``.Value`가 그대로 유일한 존재가 됨, 이름
충돌 자체가 사라져 별도 표기 정리 불필요).
- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return
- 예시 갱신: `store.key1:With(store.key2):Compute(function(key1) return
key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라
State.
State. (**[2026-08-18]** 예시가 기각된 `store "key1"` 문자열 커링 문법으로
쓰여 있던 걸 dot-access로 고침.)
**[2026-08-12 세션 감사에서 확인] `:Compute` 콜백 인자에 `:Get()`을 빠뜨리는
실수가 반복되기 쉬움 — 실제로 `.claude/` 문서 예시 코드 4곳(`tag-plan.md`,
@ -597,9 +633,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그
객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable`
Instance로 변환하는 경우 — `LocalizationTable``Set`/`Get`/`List`로
부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로
재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로
**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게
한다.
재사용해 필드만 patch하고 싶을 때를 위해, **직전에 이 Compute 함수가
반환했던 값**을 두 번째 인자로 받을 수 있게 한다.
**[표기 정정, 2026-08-18 구현 전 QA]** 이 절만 옛 표기 `fn(value,
previous)`로 남아 있었는데, 최종 시그니처는 **`fn(self, previous?,
...deps)`** 다 — 첫 인자는 raw 값이 아니라 **lazy 핸들**이라 안에서
`self:Get()`을 불러야 한다(위 "`:With`/`:Compute` — self 인자도 lazy
핸들로 통일" 절이 소스, `:Get()` 누락이 반복되는 실수라 별도 절까지 있음).
- **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 —
비용 0. 대부분의 Compute는 이걸 쓸 필요 없음.
@ -906,8 +946,17 @@ retract/Destroy되면 자동으로 정리됨.
등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 —
전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isObserver(v) end, process =
function(inst,k,v) error("Observer는 children 배열 리터럴에만 놓을 수
있음") end }`. `HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는
function(inst,k,v) error(`Ref/Observer binding should be array index item,
but got {typeof(k)}`) end }`.
**[요구 추가, 2026-08-18 구현 전 QA] 에러 메시지에 실제 `k`의 타입을
실을 것.** 사용자 요구: *"Priority Fallback 이 type(k) == "string" 인
상황에서는 가장 위에 Ref/Observer binding should be array index item, but
got typeof k 처럼 알려줄 필요는 있는듯"*. 근거는 **메시지에 `k` 타입이
없으면 최종 사용자가 두 원인을 구분할 수 없다는 것** — (a) 핸들러가
등록이 안 된 것인지, (b) `MyRef = Ref(...)`처럼 named 자리에 잘못 쓴
것인지. 같은 규칙이 `base/ref-plan.md``PreRef`/`PostRef` 동적 경로
가드와 `base/effect-plan.md``Effect` 가드에도 그대로 적용된다.
`HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는
하드 블록이 아니라 `Tag`/`Attribute`/`PreRef`와 같은 "base가 소유하되
평범한 우선순위로 등록된 다른 Handler가 있으면 그쪽이 이기는" 자리이기
때문(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는
@ -1087,28 +1136,30 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti
```lua
-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침)
-- — 둘 다 진입 전 동일하게 확인
if canBound(self) then
-- — 둘 다 진입 전 동일하게 확인. [정정, 2026-08-18 구현 전 QA] canBound는
-- "지금 묶어도 되는가"(참 = 아직 안 묶임)라 게이트는 `not`이 붙는다.
if not canBound(self) then
error(if self.Subscribed
then "이미 :Subscribe()로 전역 바인딩된 값"
else "이미 다른 Instance에 바인딩된 값")
end
```
- **"이미 유효하게 묶여 있다"(`canBound`)와 "지금 실행 가능하다"
(`canExecute`)는 판정 로직이 같아서**(둘 다 비공개 헬퍼
`isBoundAlive`를 그대로 부름, `base/lifecycle-pattern.md`) 값은 항상
같지만, 호출부의 질문이 서로 달라 이름은 분리돼 있음 — 이 절(이중
바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`
씀.
- **[정정, 2026-08-18 구현 전 QA] `canBound``canExecute`는 값이 같은 게
아니라 서로의 부정이다** — `canBound(v) == not canExecute(v)`. 둘이
공유하는 건 판정 **로직**(비공개 헬퍼 `isBoundAlive`,
`base/lifecycle-pattern.md`)이지 판정 **값**이 아니다. 옛 서술("판정
로직이 같아서 값도 항상 같지만 호출부의 질문만 다르다")은 `canBound`
"이미 묶여 있는가"로 잘못 읽은 것이었음. 이 절(이중 바인딩 금지)은
`canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 씀.
- **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는
**전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역,
거짓인데 `canBound`이면 leaf 경로.
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한
바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로
순서와 무관하게 대칭적으로 막힘.
거짓인데 `canBound`**거짓**이면 leaf 경로.
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "지금 묶어도 되는가"만
답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게
대칭적으로 막힘.
- **죽은 바인딩의 재사용은 허용**`inst`가 Destroy됐거나
`unbindLifetime`된 값은 `canBound`거짓이라 게이트를 통과함(다른
`unbindLifetime`된 값은 `canBound`**참**이라 게이트를 통과함(다른
`inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐.
**[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는
@ -1119,9 +1170,9 @@ end
그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`
`value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`).
옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된
Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 일어남
`canBound`(와 `canExecute`가 공유하는 `isBoundAlive`)가 gcconn 경로를
**먼저** 보기 때문. 역전 원문·오염 경로·교훈은
Observer가 **안 묶인 것으로 오판**됨"은 실제로는 안 일어남 —
`canBound`/`canExecute`가 공유하는 `isBoundAlive`가 gcconn 경로를
**먼저** 보므로, 그런 Observer는 `canBound`가 제대로 거짓이 된다. 역전 원문·오염 경로·교훈은
`archive/canexecute-inst-arg-reversed.md`(그 문서 하단에 이 재분리
경위도 추가돼 있음).
- **`:Unsubscribe()``:Subscribe()` 경로의 해제만 담당, `bindLifetime`
@ -1153,8 +1204,8 @@ Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안
`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때
쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은
`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고,
통과하면 gchold 등록 + gcconn 참조 복사를 수행.
`canBound` 게이트를 확인** — 진입 전 `canBound(value)`를 확인하고,
참이면(=아직 안 묶여 있으면) gchold 등록 + gcconn 참조 복사를 수행.
**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** —
`Dispatch/Leaf.luau``(i:number, v=Observer/Effect)`를 매치하면
그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용"
@ -1166,8 +1217,10 @@ leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만
```lua
function bindLifetime(inst, value)
if canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로
if not canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로
-- bindLifetime의 게이트는 canBound, canExecute 아님
-- [정정, 2026-08-18 구현 전 QA] 방향이 뒤집혀 있었음 —
-- canBound 참 = 묶어도 됨이라 에러는 not 쪽
error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고
end
... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md)
@ -1181,11 +1234,11 @@ end
- **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** —
옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용
predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데,
`canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는
바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는
것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를
`bindLifetime` 층위에서도 공짜로 잡아줌.
- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그
공유 헬퍼 `isBoundAlive`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미
살아있는 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중
마운트하는 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은
실수를 `bindLifetime` 층위에서도 공짜로 잡아줌.
- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 **거짓**이 되므로, 그
뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존
체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립.

View file

@ -58,6 +58,18 @@ State를 만족함" 절).
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를
무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라
타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는
타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금
설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는
이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입
에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로
확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인
상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지.
`luau-test/done/16-*`(type function으로 `Store<T>` 레코드 필드 합성)에
이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는
경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던
@ -105,9 +117,15 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게
아니라 기존 원칙의 정상적인 적용.
**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키
전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지.
`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지.
**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지.
**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** —
여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나,
사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된
바임. 저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥
`string`으로 들어가 `Source<T>``T`를 알 수 없고, (b) dot-access +
`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는
아래 `:GetDynamic` 항목으로 간다.
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
@ -117,7 +135,8 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드)
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정**
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
위 절), **`store.key`(dot-access)를 1급 경로로 확정**
— Store 타입을 `{key: Source<number>, other: Source<string>}`류 평범한
레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열
리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State<T>`
@ -125,8 +144,37 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가
더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이
해소됨, 위 "Store 값 설정 문법" 절 참고).
`store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source<any>`)
폴백으로 격하.
**[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** —
`store:GetDynamic<<T>>(name): Source<T>`. 런타임 동작 자체는 원래도
dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서
Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다:
선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입
에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이
정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를
둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐.
타입 애러가 난다는 점인데, 이는 GetDynamic<T>(name): Source<T>
제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을
포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점.
- **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의
lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은
모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`
만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면
`store.GetDynamic`**`"GetDynamic"`이라는 이름의 새 `Source`
만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서
**`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy
`Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의
예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦).
`Modifier``Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과
정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만
Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일
프로퍼티 이름)보다 충돌 확률이 높다는 게 차이.
- **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면
**탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/
`bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 —
네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는
`GetDynamic<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되,
M3/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 3번).
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).

View file

@ -269,12 +269,13 @@ removeTag(inst: any, names: {string}): ()
- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK` — 단 여기 꽂히는 건
`TagHandler` 자신이 아니라 그걸 감싸는 `TagFallbackHandler`(2026-08-14
열두 번째 세션 정정 — `TagHandler`는 참조 카운트 알고리즘 구현일
뿐, 스스로 등록되는 주체가 아님).** 등록 주체는 quad-base 모듈 자체가
아니라 백엔드 팩토리 — quad-roblox 같은 백엔드가 `BaseModule`
구성할 때 자기 전용 Handler들과 같이 이 `TagFallbackHandler`도 등록해줌
(`base/module-lifecycle-plan.md`가 이미 확정해둔 "base는 인터페이스만,
등록은 팩토리 뮤테이션 시점" 원칙 그대로). 옛 "quad-base 모듈 로드
시점에 스스로 등록" 모델은
뿐, 스스로 등록되는 주체가 아님).** **[재역전, 2026-08-18 구현 전 QA]
등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(모듈이 자기
레지스트리를 구성하는 시점) — 백엔드를 아직 로드하지 않은 상태에서도
"provider가 초기화됐는지 확인하라"는 안내 에러 경로가 돌아야 하기
때문이고, 이건 `InitNamespace` 거부 원칙과 충돌하지 않는다(근거는
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는
엔진 op" 절이 소스). 경위는
`archive/tag-attribute-load-time-registration-reversed.md`.
`addTag`/`removeTag`만 백엔드 팩토리가 채우는 타입 계약, 안 채운
슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나

View file

@ -71,7 +71,7 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에
`UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼
있어야 함 — 순수 런타임 관점(제네릭 `__index`가 처리)에선 문제없지만,
타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(DI
타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(`D`
인스턴스 타입, Modifier 타입 등)이 Roblox API 덤프를 읽어 Luau 타입
파일을 구워내는 스크립트로 생성될 예정이라(구현 단계 결정 사항) — 이
스크립트가 실제 Roblox 프로퍼티뿐 아니라 이 3개 숏핸드 키도 각
@ -84,6 +84,30 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로
않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을
피하기 위함.
**[요구 추가, 2026-08-18 구현 전 QA] 다시 찾을 때 `FindFirstChild` 대신
`Relate`에 저장해둔 참조를 쓸 것.** 사용자 요구: *"FindFirstChild 는 비용이
ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 릴레이션으로
저장하는것도 좋은 생각."*
- **조회 경로**: `(inst, 숏핸드키) → child``Relate`에 저장해두고 그걸로
되찾는다. `Relate``inst`를 weak 키로 쓰므로 부모가 죽으면 항목도
자연히 빠진다(`base/relate-plan.md`).
- **고정 이름 규약을 없애자는 뜻은 아님** — 이름(`_quad_corner`류)은
디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든
`UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은
표시·판정용, `Relate`는 조회용**으로 역할이 갈린다.
- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는
`Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata
동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`).
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지
구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다.
- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**:
*"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운
dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."*
즉 숏핸드 Handler가 자식 프로퍼티를 직접 쓰지 말고
`Dispatch.process(child, k, v, 1)`로 넘기면 `State`/`Tween` 래핑이
공짜로 따라온다.
### `v``nil`인 경우 — `process`가 직접 자식 제거, 반환 클로저는 관여 안 함 (2026-08-07 여덟 번째 세션)
`modifier-plan.md``None` 센티널(`base/dispatch-core-plan.md`의

View file

@ -134,6 +134,19 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
조사하게 되는 재작업을 막기 위함. `.claude/base/`로 승격, `.claude/qa-request/`
이동, 또는 문서 자체를 갱신. code-docker/webmanager의 `.claude/` 관리 방식이
좋은 예시(`.claude/initreq/code-docker/webmanager/.claude/README.md` 참고).
- **⭐ [2026-08-19 신설] 설계 결정이 오간 세션은 끝나기 전에
`session/YYYY-MM-DD-NN-slug.md` 원문을 반드시 남길 것.** 2026-08-19
세션이 점검해보니 2026-08-18(커밋 10개, QA 1~2라운드 포함)엔 `session/`
파일이 1개뿐이었고 2026-08-19는 이 점검 전까지 0개였다 — 실제로 여러
세션이 이 규율을 빠뜨렸다는 뜻(경위는
`session/2026-08-19-01-new-initxxx-composition-relate-guard.md`). **사용자
판단(2026-08-19): 이미 지나간 공백은 재구성하지 말고 인정하고 넘어간다**
(그 시점 대화 원문에 접근할 수 없는 채로 "원문"을 지어내면 그 자체가
허위 기록이 됨) — 대신 **앞으로 같은 공백이 반복되지 않도록** 이 항목을
명문화한다. `base/`/`research/`에 실제로 반영되는 설계 결정이 있었던
세션은, 커밋 직전이 아니라 **작업이 일단락되는 시점마다** session/ 파일을
쓸 것 — 핸드오버 체크리스트(바로 아래)가 요구하는 감사·커밋 절차와
별개로, 이건 그 이전에 지켜야 할 절차다.
- **⭐ 중대 변경 핸드오버 체크리스트 — 확정된 결정을 뒤집거나 문서를
쪼갤 때 반드시 이 순서를 밟을 것.** 2026-08-13 일곱/여덟 번째 세션에
6+6라운드 수동 감사로 55건을 찾았는데, **거의 전부가 "변경한 세션이
@ -189,9 +202,10 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
짜는 대신 고정 정의로 옮긴 것. **아주 큰 변경**(설계 반전 규모)엔 이걸로
대체하지 말고 `/code-review`(diff 기반)와 사용자의 직접 diff 검토를
병행할 것 — 이 서브에이전트는 diff가 아니라 코퍼스 전체의 정합성만 봄.
- **⭐ [2026-08-16 재설계] "핸드오버 준비하고 커밋해" 류 요청엔 되묻지 말고
아래 감사 루프를 돌릴 것.** 단일 `quad-doc-auditor` 패스는 비결정적이라
매번 다 잡는다는 보장이 없다는 게 사용자 지적 — 그래서 병렬 다회로 돌린다.
- **⭐ [2026-08-16 신설, 2026-08-18 재설계] "핸드오버 준비하고 커밋해" 류
요청엔 되묻지 말고 아래 감사 루프를 돌릴 것.** 단일 `quad-doc-auditor`
패스는 비결정적이라 매번 다 잡는다는 보장이 없다 — 그래서 **여러 번**
돌린다. 다만 **여러 개를 동시에 띄우지는 않는다**(아래 1번).
**처음엔 이걸 Workflow(`quad-handover-audit.js`)로 만들어 픽스까지
서브에이전트에 맡겼으나, 첫 실동에서 에이전트 67개·4.6M 토큰을 쓰고도
수렴하지 못해 2026-08-16에 폐기**(원문은 `archive/`가 아니라 git 히스토리와
@ -199,34 +213,61 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
갈림, (2) 파일별로 픽스 에이전트를 쪼개니 **그 에이전트들이 또 부정확한
서술을 새로 만들어냄**, (3) 서브에이전트는 사용자에게 물을 수가 없어서
판단이 필요한 항목이 그냥 임의 처리됨. 지금 절차:
1. **`quad-doc-auditor`를 한 메시지에서 병렬 호출**(Agent 도구 직접 호출
— Workflow 안 씀). **최소 2개, 변경 사항이 많으면 그만큼 늘릴 것**
사용자 지침(2026-08-16): "최소 2에, 변경 사항이 많아진다면 batch 수가
늘어나는게 맞다고 봄". 감사자는 sonnet이라 패스당 비용이 낮고 역할이
"빠르게 전체를 훑는 것"이라, 커버리지를 넓히는 쪽이 이득인 구조다
(사실 검증은 상위 모델인 메인이 한다). 기준은 라이브 문서 몇 개가
바뀌었나 — 두어 개 고친 정도면 2개, 설계 반전이나 문서 분할처럼
코퍼스 전반을 건드린 변경이면 3~4개.
1. **`quad-doc-auditor`는 한 턴에 하나만 호출한다 — 병렬 금지**
(Agent 도구 직접 호출, Workflow 안 씀). **사용자 지침(2026-08-18):
"시간이 걸려도 상관 없으니, 차라리 병렬 에이전트를 덜 써줘. 그냥 한
턴에 하나씩만 사용하고, 0 이 나올때 까지 턴 수를 늘리는게 나아보임.
토큰을 너무 많이 소비해서 다른 작업을 못 하고, 세션 한도에 닿더라고."**
이건 2026-08-16의 "최소 2개 병렬, 변경이 많으면 3~4개로 늘림" 지침을
**대체한다** — 커버리지를 병렬 폭이 아니라 **턴 수**로 얻는다.
근거가 된 실측(2026-08-18): 전 코퍼스 감사 **한 패스가 서브에이전트
토큰 21만 / 툴 호출 82회**였고, 계획대로 4개를 병렬로 돌렸으면 한
번에 80만 토큰대였다 — 세션 한도를 그 자리에서 태워 정작 본 작업을
못 하게 된다.
**감사자 모델은 `sonnet` 유지 — haiku로 더 낮추지 말 것**(사용자
판단, 2026-08-16). 실제로 이 감사가 잡아온 것들(재역전된 배너, 자기
메모리의 stale 서술)은 문맥 추론이 꽤 들어간 발견이었다.
2. **두 리포트를 메인 세션이 합쳐서 검토하고, 수정도 메인이 일괄로 한다.**
2. **⭐ [2026-08-18 신설] 프롬프트로 감사 범위를 좁힐 것 — 기본은 diff
범위다.** 감사자 정의 자체는 "코퍼스 전체를 신선한 맥락에서 다시
읽는다"인데(라이브 문서 90개 이상, `base/`만 1만 줄 이상), 대부분의
핸드오버에서 실제로 위험한 건 **이번에 바뀐 파일과 그걸 인용하는
곳**이다. 그래서 프롬프트에 (a) 바뀐 파일 목록, (b) 이번에 뒤집힌
결정 목록, (c) "그 파일들을 인용하는 곳까지만 확장해서 보라"를 명시할
것. 전 코퍼스 스윕은 **오래 안 돌렸을 때 한 번씩**만.
라운드마다 각도를 바꾸면(예: 1라운드는 `base/` 정합성, 2라운드는
인덱스 레이어(README/ROADMAP/question/todos), 3라운드는
archive/luau-test/audit) 병렬로 폭을 넓히던 효과를 턴으로 얻을 수 있다.
3. **리포트는 메인 세션이 검토하고, 수정도 메인이 한다.**
픽스를 서브에이전트에 위임하지 말 것(위 (2)번 이유). 감사자는 읽기
전용이고 발견만 리포트한다.
3. **애매하면 임의로 정하지 말고 그 자리에서 사용자에게 보고할 것.**
4. **애매하면 임의로 정하지 말고 그 자리에서 사용자에게 보고할 것.**
메인 세션은 사용자에게 물을 수 있다는 게 서브에이전트 대비 유일한
강점이니 그걸 쓸 것(어떤 발견이 여기 해당하는지 판정 기준은
`.claude/agents/quad-doc-auditor.md`의 "출력 형식" 절이 소스 — 여기서
다시 나열하지 않음).
4. **새 발견이 없는 라운드가 연속 2번 나올 때까지 1~2를 반복**(보통
2~3라운드). **수렴 안 하면 조용히 멈추지 말고 사용자에게 보고할 것**
5. **새 발견이 0건인 라운드가 나올 때까지 1~3을 반복**(사용자 지침
2026-08-18의 "0 이 나올때 까지 턴 수를 늘리는게"). 옛 규칙이던 "무발견
**2연속**"은 병렬 다패스를 전제한 것이라 같이 완화됐다 — 대신 라운드마다
각도를 바꾸는 것(위 2번)이 그 역할을 대신한다.
**수렴 안 하면 조용히 멈추지 말고 사용자에게 보고할 것**
첫 실동이 실제로 6라운드를 소진하고도 수렴 못 했고(새 발견
28→15→16→7→11→6, 단조 감소가 아니었음), 그때 조용히 끊으면
"감사 통과"로 오독된다.
5. `python3 .claude/tools/doc-check.py`로 ERROR 0을 최종 확인한 뒤 평소
"감사 통과"로 오독된다. 비용 때문에 중간에 멈출 때도 마찬가지로
"몇 라운드에서 왜 멈췄는지"를 반드시 보고할 것.
6. `python3 .claude/tools/doc-check.py`로 ERROR 0을 최종 확인한 뒤 평소
커밋 절차(git status/diff 검토, 메시지 작성)로 넘어갈 것.
**`git commit`은 항상 메인 세션이 직접 함** — 커밋 전 diff 재검토는
대화형 맥락이 필요해서 위임하지 않는다.
- **⭐ [2026-08-18 실측] `/code-review`는 감사자를 대체하지 않는다 — 큰
변경에는 둘 다 필요하다.** 2026-08-18 핸드오버에서 감사자 1패스가 1건을
잡은 뒤 사용자가 `/code-review high`를 직접 돌리자 **10건이 더 나왔고
전부 유효**했다. 둘이 보는 축이 다르기 때문 — 감사자는 **코퍼스 전체의
의미론적 정합성**(A 문서의 결정과 B 문서의 서술이 어긋나는가), code-review는
**diff 자체의 결함**(이번에 새로 쓴 서술 안의 모순, 새 API가 기존 계약과
충돌하는가). 실제로 그 10건엔 "새로 확정한 `store:GetDynamic`이 Store의
lazy `__index`와 충돌해 그대로 구현하면 런타임 에러"처럼 감사자 각도에선
안 보이는 것이 있었다. **`/code-review`는 사용자만 호출할 수 있으므로**,
큰 변경을 커밋하기 전엔 그걸 돌릴지 사용자에게 물어볼 것.
- **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할
것.** 다만 위 체크리스트+`doc-check.py`+`quad-doc-auditor`가 자리잡으면
이 감사는 "기계도 서브에이전트도 못 보는 것"(설계 자체의 자기모순, 의사코드
@ -259,4 +300,22 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
**로컬 git 커밋까지만** 하고 원격 추가/푸시는 하지 말 것. (2) Roblox Studio는
메인 계정이 아닌 별도 계정으로만 사용 — 로그인 계정 전환을 사용자가 안 해줬다면
Studio 관련 작업(MCP 연결 등)을 진행하지 말고 대기.
- **⭐ [2026-08-18 신설, 같은 날 정정] 이 레포의 모든 커밋에 사용자 GitHub
계정(`github.com/qwreey`)을 co-author로 추가할 것 — 커밋 메시지 끝에
`Co-authored-by: qwreey <me@qwreey.moe>`만 넣는다. 하네스 기본
`Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>` 트레일러는 이
레포에 한해 넣지 말 것.** **사용자 확정**(2026-08-18) — 커밋 계정 자체가
`qwreey-bot`이라 이미 에이전트 커밋임이 드러나므로 Claude 트레일러까지
이중으로 밝힐 필요는 없고, 대신 해당 GitHub 계정 프로필에 Claude를
사용한다고 명시해둘 예정("어차피 qwreey-bot 자체가 에이전트가 커밋했다고
알려주는지라 큰 필요는 없어"). `.claude/settings.json`
`"includeCoAuthoredBy": false`도 선언해뒀지만 **[2026-08-18 확인] 하네스
버그로 무시됨**(`includeCoAuthoredBy`/`attribution.commit` 둘 다 알려진
미해결 버그 — GitHub Issue #7543/#65657) — 그래서 실제 억제는 이 문장을
세션이 읽고 지키는 방식으로만 보장된다. **qwreey 트레일러는 계속
추가한다** — 위 문단이 이미 그 근거(GitHub 기여 그래프 반영)를 서술함,
여기서 반복 안 함. `github` 리모트로 싱크되어
`upstream`(`github.com/qwreey/quad`, 사용자 본인 소유)에 PR이 merge될 때
GitHub 기여 그래프에 반영되게 하기 위함 — 관련 리모트 정책은 메모리
`git-remote-push-policy` 및 위 `SAFETY.md` 항목 참고.

View file

@ -78,7 +78,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime``value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.md` 2026-08-06 세션, `debug-tooling-plan.md` |
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) |
| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 |
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
@ -114,7 +114,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
계속 `None` — 두 카테고리로 나눠 각각 재현).
- `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요
항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute
제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 +
제네릭 특수 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 +
`isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을
오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요"
로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정
@ -239,13 +239,14 @@ Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴
재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이
조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`),
재확인 불필요.
- **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는
**`canBound(value)`**다(`if canBound(v) then error(...) end`) —
- **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중
바인딩 게이트는 **`canBound(value)`**다(`if not canBound(v) then
error(...) end` — `canBound` 참 = "지금 묶어도 됨") —
`canExecute`는 State emit 전파 게이팅 전용으로 남고, `canBound`
별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유,
`lifecycle-pattern.md` "`canBound` vs `canExecute`" 절). 판정
기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시
`bindLifetime`할 수 있어야 하고(게이트가 `canBound` 거짓이라
별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유하되
**서로의 부정**, `lifecycle-pattern.md` "`canBound` vs `canExecute`"
절). 판정 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시
`bindLifetime`할 수 있어야 하고(게이트가 `canBound` **참**이라
통과), **`inst`가 Destroy된 뒤의 재바인딩도 명시적으로 허용**임
(살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이
재분리 설계 자체를 재검토해야 함 — **아직 미확인.**

View file

@ -72,7 +72,7 @@
| `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) |
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 `canBound(value)`(`if canBound(v) then error(...) end`) — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end``canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
## ⚪ `not-run/` — 이 환경에서 못 돌림

View file

@ -69,9 +69,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
결론은 `base/typing-limits.md`로 승격 — 이후 신설된 폴더들도 같은
구성 관례를 따름(`type-recursive-issue-with-typeof/`,
`type-recursive-issue-try-callback/` 등).
- `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
**[2026-08-16 기준] 아직 비어 있음**(`feedback/`은 폴더 자체가 아직
없음). `.claude/archive/`는 원래 같은 취급이었으나
- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/
`pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
**[2026-08-18 기준] 폴더 자체가 아직 없음**.
`.claude/archive/`는 원래 같은 취급이었으나
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이
아님) — `archive/store-source-proxy-reversed.md`가 첫 사례, 나중

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,340 @@
# 구현 전 QA **2라운드** — 의사코드 손 트레이싱
**상태**: **완료 — 핵심 발견(`RC-1`)까지 같은 날 후속 세션에서 해결·
`base/` 반영 완료.** 1라운드는 "확정된 주장이 맞는가"를 물었을 뿐 의사코드를
실제로 실행해보진 않았음(`pre-implementation-qa-round1.md` 맨 아래 "진행
로그" 절). 2라운드는 그 갭을 메우는 작업 — `base/slot-plan.md``:List`
`reconcile``base/dispatch-core-plan.md``recompute`를 구체 시나리오로
직접 손으로 실행해보고, 부수로 `reference/` 인용 정확성과 `ROADMAP.md`
마일스톤 정합성도 훑었다.
**이 문서의 용도**: 1라운드와 달리 "예/아니오 판정" 문항이 아니라 트레이싱
결과 자체가 산출물 — 버그를 찾으면 그 자리에서 기록하고, 방향이 갈리는
것만 사용자에게 물었다. 새로 발견된 결함은 `RC-1` 하나뿐이고, 나머지는
"트레이싱했지만 문제 없음 확인"으로 아래 각 절에 남긴다(재작업 방지용
기록 — `conventions.md`의 "작업이 끝나면(또는 방향이 바뀌면) 항상 자기
문서화" 원칙).
---
## RC-1 — `recompute`의 트리거 모델 자체가 재검토 대상 ✅ 해결(2026-08-18 후속 세션)
**판정**: 트레이싱으로 실제 크래시 경로를 확인 — 사용자도 진단은 맞다고
동의했고, 같은 날 후속 대화에서 **Blocker를 재사용하는 배치 게이팅
설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제
반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는
Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다.
### 발견한 크래시 경로
`base/dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때
순서 보장" 절의 `recompute` 의사코드:
```lua
local function recompute(ownerKey, bk)
local sum = 0
for i = 1, bk.N do
local offset = bk.sourceList[i]
if offset ~= nil and offset ~= None and offset:Get() ~= sum then
offset:Set(sum)
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v) -- ← v가 nil이면 여기서 산술 에러
end
...
end
```
**구체 시나리오 — `Frame{A, B}`(정적 자식 2개, 반응형 없음, `N=2`).**
1. `Dispatch.drive(inst, flattened)`가 배열 파트를 순서대로 순회 —
`bk.N`은 "그 `inst`의 array part 크기" 이므로 순회를 **시작하는 시점에
이미 `2`로 정해져 있음**(같은 절의 "저장 위치" 문단).
2. position 1(`A`)을 담당하는 말단 Handler가 `Dispatch.setLength(inst,1,1)`
부름. `setLength`의 의사코드는 **끝에서 무조건 `recompute(inst,bk)`
호출**(같은 절 "`setLength` 구현" 문단 — "등록 즉시 1회(Observer 자체의
'등록 즉시 1회 실행'과 겹쳐도 무해)").
3. 이 시점의 `recompute` 루프가 `i=1..bk.N(=2)`을 돎 — `i=1`
`bk.lengthList[1]=1`이라 정상. `i=2`는 **position 2(`B`)가 아직 처리되기
전이라 `bk.lengthList[2]``nil`** — `sum += (if isState(v) then
v:Get() else v)`에서 `sum += nil`이 되어 산술 에러로 크래시.
`sourceList`(offset) 쪽은 같은 절에 이미 nil 가드가 있음(*"[방어,
2026-08-13 여섯 번째 세션] `nil`도 같이 배제"*) — `lengthList` 쪽엔 그
대칭 가드가 없다. 정적 자식 2개짜리 `Frame`처럼 **가장 흔한 경우**에서
바로 재현되는 경로라, 별도 `Slot`/반응형 값 없이도 걸림.
### 왜 지금까지 안 잡혔나
기존 `luau-test/` 스파이크 중 이 함수를 다루는 건
`done/20-slot-splice-index-arithmetic.luau` 하나뿐인데, 이건 `Splice`
순수 배열 인덱스 산술(제거/삽입 시 뒤 요소가 몇 칸 밀리는지)만 검증하고
**`Dispatch.drive`가 여러 position을 순차 처리하며 `bk.lengthList`를 점진적으로
채우는 과정 자체는 다루지 않음** — 이 경로를 실행해본 스파이크가 없었다.
같은 함수의 다른 버그(offset이 자기 자신을 포함해 누적되던 off-by-one)는
2026-08-11 세션에 이미 한 번 발견·수정됐지만(같은 문서 "recompute — 매번
전체 순회" 문단), 이번 것은 그와 별개의 문제.
### 사용자 답변 원문 — 진단은 맞다고 확인, 그러나 더 큰 재설계 필요
> setLength/setOffset 자체는 리레이아웃을 트리거하진 않아야한다고 생각함.
> 명시 리트리거를 해야하는게, 이러면 첫 실행에서 계속 recompute 비용이
> 쌓임. 옵져버 생성 시 클로저 위쪽에 init = true 두고 처리할 필요가
> 있는듯 하고, 각각의 process 가 setLength 를 나중에 수행했을 때는
> recompute 를 어떻게 할지 생각해봐야할듯. 등록을 배치로 미룸은 맞는데,
> State<None|Slot> 이 오는건 어쩌냐를 잘 모르겠음. 각각 처음에 바운딩
> 할 땐 그럼 offset/length 어떻게 계산할지는 이것도 아직 모르겠음..
> 생각을 더 해
**애초에 열려 있던 세 갈래**(사용자가 처음엔 하나로 안 좁힘) — (1)
`setLength`/`setOffsetSource` 자체는 `recompute`를 트리거하지 않아야
한다(명시적 리트리거 필요), (2) 등록을 배치로 미루는 방향은 맞지만
`State<Slot|None>`처럼 값이 나중에 도착하는 경우를 어떻게 커버할지 불명,
(3) 최초 바인딩 시점의 offset/length 계산 방식 자체가 안 잡힘 — 아래가
같은 날 후속 대화에서 이 세 갈래를 하나로 합친 결과다.
### 해결 — Blocker 게이팅 (2026-08-18, 같은 날 후속 세션)
**사용자가 직접 제시한 설계 원문**:
> 각각의 length 들을 그냥 받아서 바인딩 하는게 아니야. 각 Frame 에 대한
> Blocker 를 릴레이션으로 가지고 있고 이건 드라이빙 함수가 실행될 때
> 생성돼. Offset/Length 소스가 설정 될 때, 특히 Length 에 있어서는 이
> Frame->Blocker 로 있는걸 얻어와서 한번 적용하고 넣어둬. 그리고 Observer
> callback 에서도 Blocker 가 IsOn 상태면 무시해줘. 드라이브 함수가
> 실행되어 Blocker 가 생성될 때 기본으로 On 을 해줘. 이러면 length 가
> 변경되며 계속 recompute 되지 않아. 그런 다음 OffWithoutEmit 을 해.
> 맨 마지막으로 recompute 를 한번 하면 되는식이야.
>
> 다만, 이러면 초기에 레이아웃이 이상해져. 주의할 점은 setOffsetSource
> 를 하게 되면 이건 자기 자신 위쪽으로 있는 요소들의 length 를 합해서
> 설정해줘야할거야. 이건 첫 for 루프에서도 작동한다고 봄. 즉, 사실 첫
> 루프 상 recompute 자체는 필요 없어. 그리고 setOffsetSource 는 여전히
> slot 의 실체화로 List 가 수행되기 전에 설정되어야하고, length 가 확정
> 된 다음에 다음 요소로 넘어가야해.
**요지 두 갈래로 나뉜다**:
1. **`recompute`를 배치가 끝날 때까지 아예 안 돈다** — owner(inst 또는
Slot)마다 `Relate`로 들고 있는 전용 `Blocker`를 배치 시작 시 `On`,
`setLength`의 Observer 콜백(등록 즉시 1회 실행 포함)이
`blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 끝나면
`OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
2. **`setOffsetSource`는 그 즉시 앞선 형제들의 길이 합을 직접 계산해
`:Set`한다** — recompute를 미루는 것만으로는 "초기 레이아웃이
이상해지는" 문제(배치 도중 `:List`가 실체화되며 옛/기본값 offset을
읽어버림)가 남기 때문에, offset 자체는 즉시 계산으로 옮겨 recompute를
기다리지 않게 함.
**뒤이은 확인 질문 3개와 답변**(`AskUserQuestion`, 같은 세션):
- **중첩된 Slot이 `attachSlot`을 재귀할 때도 각자 별도 Blocker가
필요한가?** → *"예 — 중첩마다 별도 Blocker (권장)"*
`base/blocker-plan.md`의 재진입(네스팅) 미지원 규칙 그대로 적용.
- **런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 경우도
같은 위험이 있는가?** → *"그건 이미 마운트가 된 이후라서 별 상관
없음. 가장 큰 문제는 마운트 중간에 후행 nil 이 있는데 recompute 가
난다는게 문제... 정확한 내 의견은 이래: setLength 는 recompute 를
직접 수행하진 않고, Observer 에서 recompute 를 수행해. 맨 처음 emit
에서도 blocker 가 on 이면 무시하는식. 그럼에도 새로운 개체가 뒤에
붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를
밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고
수행 가능함"* — 크래시는 오직 "N이 미리 정해진 채 배치로 등록"되는
두 자리(`Dispatch.drive`, `attachSlot`의 flush)에서만 나고, 런타임
단건 append는 이미 안정된 앞선 position만 참조하므로 무관함이 확정.
`setLength``recompute`를 직접 안 부르고 Observer 콜백(첫 실행
포함)만을 경유한다는 것도 이 답변에서 확정됨.
- **`IsOn`/`HasBlocked`를 기존 `IsBlocked`/`HasBlockedEmit`과 어떻게
관계지을까?** → *"IsBlocked가 있다면 그냥 두어도 될듯 함.
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 기존 필드는 그대로 두고,
`IsOn()``IsBlocked`를 읽는 얇은 조회 메소드로만 추가. 처음 요청했던
`HasBlocked`(Blocker 자신의 새 최상위 플래그)는 **신설하지 않음**
`OffWithoutEmit()`이 각 gated state의 기존 `HasBlockedEmit`을 그대로
리셋해주는 것으로 충분하다고 판단.
### 후속 정정 — `attachSlot`의 호출 순서가 뒤집혀 있었음 (같은 세션, RC-1 반영 직후)
위 설계를 `attachSlot`에 실제로 반영하는 과정에서 사용자가 직접 짚은
추가 결함: `attachSlot`의 기존 의사코드는 `setLength`를 먼저, `setOffsetSource`
나중에 불렀는데, 이건 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이
이미 확정해둔 **"호출 순서는 `setOffsetSource``setLength`"** 일반
규칙과 어긋나 있었다(RC-1로 `setOffsetSource`가 즉시 계산을 하게 되면서
이 불일치가 드러남 — 그 전엔 둘 다 `recompute`에 얹혀 있어서 순서가
겉으로 안 드러났었다). 사용자 확정 원문:
> length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset
> 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨. 따라서 위가
> 맞음
즉 Slot의 진짜 `.Length``activateList`가 자기 `:List`를 최초
reconcile한 **뒤에야** 확정되므로, 순서는 **`setOffsetSource`(즉시 계산)
→ (Slot이면) `activateList` 실체화 → `setLength`(그제서야 확정된 값으로
등록) → 물리 마운트**여야 한다 — `setLength`를 실체화 전에 부르면 등록
직후 값이 또 바뀌어 전파가 한 번 낭비된다. 평범한 Instance 요소도 같은
순서(`setOffsetSource(None)` → `setLength(1)``Parent` 대입)를 따르며,
이 경로는 기존 의사코드에 아예 안 보이던 것도 이번에 같이 채워짐.
**부수 확정 — 코루틴 yield 금지 불변식.** 사용자가 이 논의 말미에 지적:
> 모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 할듯. 안 그럼
> 꼬이는 문제가 발생하지 않나 생각함
이 배치 게이팅 전체가 "position이 항상 순서대로, 끼어드는 코드 없이
동기로 처리된다"는 전제 위에 있어서, `Dispatch.process`/`attachSlot`
호출 체인 도중 코루틴 yield가 끼면 같은 owner의 Blocker를 다른 코드가
그 사이에 건드릴 수 있다 — 명시적 불변식(UB 선언)으로 문서화하기로 확정.
**반영된 곳**:
- `base/blocker-plan.md``IsOn()`/`OffWithoutEmit()` 신설, onunblock
핸들이 `emit: boolean`을 받도록 변경, `state:Block()` 없이 직접 쓰는
용례 신설, 재진입 규칙에 이 용례의 실제 사례 추가.
- `base/dispatch-core-plan.md` — "Length/Offset" 절에 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절 신설(`setLength`/`setOffsetSource`
재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈), 코루틴
yield 금지 불변식 추가.
- `base/slot-plan.md` — "재귀 메커니즘" 절의 `attachSlot`이 자기 flush
루프를 자기 자신의 Blocker로 감싸도록, 그리고 `setOffsetSource`→`setLength`
순서로 재작성(평범한 Instance 요소의 등록도 명시적으로 채움), 런타임
단건 `Add` 경로는 게이팅 불필요함을 명시.
---
## SL — `base/slot-plan.md``:List` reconcile 트레이싱 — 새 결함 없음, 기존 미해결 갭만 재확인
`base/slot-plan.md`의 "구현" 절(`activateList`/`reconcile` 의사코드)을 아래
시나리오로 손으로 실행:
- **재정렬**(`[A,B,C]` → `[C,A,B]`, 값 동일) — `pos`/`keyIndex` 비교가
`rawMove`를 정확한 절대 위치로 호출, 정상.
- **키 제거**(`[A,B,C]` → `[A,C]`) — 소멸 루프가 `keyIndex`(직전 사이클 전체
key 집합)를 순회해 `B``rawRemove`, 나머지 `pos` 압축도 정상.
- **필터 토글**(`updateFn`이 특정 key에 `nil` 반환) — `rawRemove`(파괴)
경로를 타고, `pos`가 그 키만큼 증가 안 해 뒤 요소가 정상 압축됨.
- **`PopOnly` 반환 후 재등장** — `mounted[key]=nil`이지만 `userdata[key]`
`{old=...}`를 강하게 붙잡아 GC를 막고, 다음 사이클에 `prev=nil`
받은 `updateFn``ud.old`를 그대로 반환하면 `rawAdd`로 재마운트됨 — 문서
서술대로 정상 동작.
- **nested Slot 반환**(`isSlot(result)`) — `pos = candidateIndex - 1 +
result.Length:Get()`으로 다음 형제의 위치가 정확히 밀림, "`:List`의
`index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함" 절의 결론과
일치.
- **중복 key**`seen[key]` 체크가 `updateFn` 호출 *전에* 있어 즉시
`error`, 상태 오염 없음.
**PopOnly 홀드 중 키가 데이터에서 완전히 사라지는 경우**만 트레이싱으로도
재현됨 — 소멸 루프가 `mounted[key]`(이미 `nil`)만 보고 `rawRemove`
건너뛰어, 그 요소가 파괴도 반환도 안 되고 참조만 끊겨 GC된다. 이건 **이미
`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절과 `question.md`
3번(`PopOnly`로 홀드 중이던 요소의 키가 사라지면 어떻게 처분하는가)이
알고 있는 미해결 갭** — 이번 트레이싱은 그 갭이 실제로 재현됨을 손으로
다시 확인했을 뿐, 새 발견이 아니다. 결론은 그대로 `question.md` 3번의
(a)/(b)/(c) 선택지에 맡긴다.
---
## D — `base/dispatch-core-plan.md`의 하강 diff 재디스패치 트레이싱 — 문제 없음
"Dispatch 체인" 절의 `Dispatch.process`/`Dispatch.retractFrom`을 아래
시나리오로 트레이싱:
- **최초 마운트**(`store.key = a`, `a: State<Tag>`) — `StoreBind``index=1`
잡고 `a:Get()`을 들고 `Dispatch.process(inst,k,realv,2)`를 재귀, `chains`
`[1]=StoreBind`, `[2]=TagHandler`가 순서대로 쌓임. 정상.
- **같은 핸들러로 값 교체**(`a`가 새 `Tag`를 내놓음) — `Dispatch.process`
(A) 분기가 인덱스 2 슬롯의 기존 `retractor`에 새 값을 넘기고 클로저를
교체, 인덱스 1은 안 건드림. `chains` 구조·재귀 깊이 전부 문서 서술과
일치.
- **`State<State<Tag>>`** — 안쪽 재귀가 `index+1`이라는 별개 슬롯을 쓰므로
StoreBind 싱글톤이 같은 `(inst,k)`에 두 번 매치돼도 슬롯이 안 겹침 —
"`State<State<T>>`는 정상 지원 대상" 절의 결론과 일치.
새로 발견된 문제 없음.
---
## `reference/` 인용 정확성 — 표본 점검, 불일치 없음
`base/` 문서가 `reference/quad-v1-architecture.md`/`reference/
comparison-fusion-vide.md`를 인용하는 자리 중 표본 3곳을 원문과 대조:
- `base/lifecycle-pattern.md`가 v1의 GC 방지 핫팩을 인용한 자리 — 원문
(`PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 관용구)과
일치.
- `base/slot-plan.md`가 v1 `mount.lua`를 분석한 자리("부모/자식 부기까지
했지만 다중 마운트 방지는 없었음") — 원문(`mount.lua`는 실제로 부모/자식
부기(bookkeeping) + 라이프사이클 파괴까지 담당하는 무거운 모듈)과
정합(다중 마운트 방지 부재는 문서 전체 맥락상 타당한 요약).
- `base/tween-plan.md`가 Fusion의 Tween/Spring을 반면교사로 인용한 자리 —
`reference/comparison-fusion-vide.md`의 해당 문구와 일치.
전수 대조는 아니고(`reference/`를 인용하는 자리 전체는 `base/*.md`에서
`reference/`로 grep한 결과 10여 곳 — 정확한 개수는 grep 결과 자체가
소스), 표본에서 불일치가 안 나와 전수 대조로 확장하지 않음.
## `ROADMAP.md` 마일스톤 정합성 — 1라운드 반영분 확인, 불일치 없음
1라운드에서 뒤집힌 것 중 `ROADMAP.md`가 언급하는 것들이 갱신됐는지 확인:
`NilHandler`/`NoneHandler` 분리(**[정정, 2026-08-18 `/code-review high`]
M2가 아니라 M7 — Modifier 마일스톤에 체크박스가 있음, M2엔 서술 문단
안에서만 이름이 언급될 뿐 별도 체크리스트 항목이 없음**), `PopOnly` 반환
경로(M6), `store:GetDynamic` 탑레벨/콜론 미정 표시(M3), `DI`→`D`
리네임(전역) — 전부 반영 확인됨.
`D-6`(`setLength`/`setOffsetSource` 호출 책임자를 "처음 매치한 Handler"로
서술하던 옛 오류)의 흔적도 `ROADMAP.md`엔 없음(애초에 그 정도 구현
디테일은 `base/`만 갖고 있고 `ROADMAP.md`는 체크박스 수준이라 옮겨붙을
자리가 없었음).
---
## [중간 상태 기록, Blocker 해법 확정 이전] 반영
**⚠️ 이 절은 낡았다 — RC-1이 아직 "미해결"이던 시점(사용자가 "생각을 더
해"라며 방향을 안 정했을 때)에 세워둔 임시 조치 목록이다.** 그 뒤 같은
세션 후속 대화에서 Blocker 게이팅 설계가 확정되며 아래 내용 대부분이
다시 뒤집히거나 대체됐다 — **지금 유효한 반영 목록은 위 "해결 — Blocker
게이팅" 절의 "반영된 곳" 문단이 소스**, 여기서 다시 정리하지 않는다.
`question.md`는 이 중간 단계에서 `RC-1`을 추가했다가 해소 단계에서
다시 뺀 것이라 최종 diff엔 흔적이 안 남는다(2026-08-18 감사에서 확인) —
다음 세션이 "question.md가 바뀌었어야 하는데 안 바뀌었다"고 오해하지
않도록 남겨둠. 아래는 그 시점의 원문 그대로 보존(역사 기록):
- `question.md` 3번에 `RC-1`을 M2 착수 전 결론 필요 항목으로 추가(→ 이후
해소되며 제거, `archive/question-resolved.md`로).
- `.claude/todos.md` 00번(구현 전 QA 결과 요약)의 미해결 목록에 `RC-1`
추가, 머리말을 "M2/M3 착수 전 필요"로 갱신(→ 이후 해소 반영으로 다시
갱신).
- `base/dispatch-core-plan.md`의 "Length/Offset" 절 `recompute`/`setLength`
**의사코드 자체엔 손대지 않음**(nil 가드 같은 국소 수선도 넣지 않기로
함) — 대신 그 절 바로 위에 미해결 배너를 추가(→ 이후 의사코드 자체가
Blocker 게이팅으로 재작성됨).
- `base/slot-plan.md`의 "재귀 메커니즘" 절 서두에 `RC-1` 포인터 추가(→
이후 `attachSlot` 의사코드 자체가 재작성됨).
- `ROADMAP.md` M2/M6 체크박스 3곳에 `RC-1` 경고/각주 추가(→ 이후 해결
표시로 갱신).
- `.claude/README.md`/`project-context.md`의 `qa-request/` 서술을 round2
진행 중 상태로 갱신(→ 이후 완료 상태로 갱신).
---
## 진행 로그
**2라운드(2026-08-18) — `:List` reconcile + `recompute` 손 트레이싱, `reference/`
표본 대조, `ROADMAP.md` 정합성 확인, 그리고 같은 날 후속 세션에서 `RC-1`
해결까지 완료.** 1라운드가 "아직 안 본 것"으로 남겨둔 항목 중:
- `slot-plan.md``:List` 내부 — **트레이싱 완료**(위 "SL" 절).
- `dispatch-core-plan.md``recompute` — **트레이싱 완료, `RC-1` 발견 →
같은 날 Blocker 게이팅 설계로 해결**(위 "해결 — Blocker 게이팅" 절).
- `reference/`**표본 점검 완료**(전수는 아님, 위 참고).
- `research/` 13개(1라운드 기록 당시 11개였으나 같은 날 세션 중
`fastscroll-plan.md`/`spring-plan.md`가 추가돼 지금은 13개 —
정확한 개수는 `research/` 폴더 자체가 소스) — **이번 라운드에서도 제외**,
확정 전 문서라 우선순위 낮음(`.claude/README.md`의 `research/` 표 참고).
- 루트 `ROADMAP.md` 마일스톤 분할 — **확인 완료**(위 "ROADMAP.md 마일스톤
정합성" 절).
**남은 일**: 없음 — `RC-1`까지 닫혀 2라운드 자체가 완료됐다. 3라운드가
필요해지면(예: 이번에 새로 들어간 `attachSlot`/`recompute`/`Blocker`
의사코드를 다시 손으로 트레이싱하는 검증) 새 파일
`pre-implementation-qa-round3.md`를 만들 것.

View file

@ -0,0 +1,422 @@
# 구현 전 QA **3라운드** — Blocker/`attachSlot`/`recompute` 손 트레이싱
**상태**: **완료 — `RC-3`/`RC-4`/`bk.N` 전부 사용자 확정을 거쳐 `base/`
반영 완료.** 2라운드가 발견·해결한 `RC-1`(Blocker 게이팅) 반영분을
대상으로, 이번엔 `attachSlot`이 그 게이팅을 실제로 어떻게 쓰는지와
`recompute`가 의존하는 `bk.N`의 수명주기를 손으로 실행해봤다. 부수로
`ROADMAP.md` 마일스톤 분할이 이번 라운드 발견과 맞는지도 검토했다
(1라운드가 미룬 항목).
**⚠️ 이 문서를 읽을 때 주의 — 아래 문제 서술 중 일부는 최초 작성 당시의
분석 오류를 포함한 채 그대로 남아 있다(의도적으로 안 고침, 논의 과정
보존).** 특히 `bk.N` 절의 "(a)/(b) 두 갈래 다 깨진다"는 최초 분석은
**틀렸다** — 사용자가 직접 지적해 정정됐다("해결 — `bk.N`" 절 참고).
지금 유효한 결론은 각 절의 "해결" 소제목 아래만 — 그 위 문제 서술은
"당시엔 이렇게 봤다"는 트레이싱 원문으로만 읽을 것.
**이 문서의 용도**: 2라운드와 같은 톤 — 트레이싱 결과 자체가 산출물이고,
버그는 그 자리에서 기록, 방향이 갈리는 것만 사용자에게 물었다.
---
## RC-3 — `activateList`가 자기 Slot의 Blocker보다 먼저 실행됨
**대상**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` 의사코드.
**순서를 그대로 읽으면**:
```lua
slot._mounted = true -- (1) 이 시점부터 이미 "마운트됨"
...
if slot._listed then
activateList(slot, physicalTarget) -- (2) reconcile 실행 — 아직 Blocker 없음
end
Dispatch.setLength(ownerKey, position, slot.Length) -- (3)
local blocker = getBlocker(slot) -- (4) Blocker가 여기서야 생성됨
blocker:On()
for i, element in ipairs(slot._elements) do ... end -- (5)
blocker:OffWithoutEmit()
```
**문제**: (1)에서 `slot._mounted = true`가 이미 세팅된 채로 (2)의
`activateList`가 실행된다. `activateList``reconcile`은 새 항목마다
`rawAdd(self, result, pos)`를 부르는데(`slot-plan.md`의 `:List` "구현"
절), "이미 마운트된 outer에 나중에 `Add`" 절이 명시하듯 `self._mounted`
참이면 `rawAdd`는 그 자리에서 즉시 물리 마운트 경로를 탄다 —
`isSlot(element)``attachSlot(element, ...)` 재귀, 아니면(대칭적으로)
`Dispatch.setOffsetSource(self,index,None)` + `Dispatch.setLength(self,index,1)`
+ `element.Parent = physicalTarget`. `Dispatch.setLength`는 끝에서
`gatedRecompute`를 부르고, `gatedRecompute``getBlocker(ownerKey=self)`
`IsOn()`을 확인하는데 — **(4)의 `getBlocker(slot):On()`이 아직 실행되기
전이므로, 새로 생성된 Blocker는 기본 off 상태고 게이트가 그냥 통과된다.**
`:List`의 초기 reconcile이 채우는 **모든 항목마다** `recompute`
그 자리에서 즉시(게이팅 없이) 돈다 — 이건 정확히 `RC-1`이 막으려던
"배치 등록 중 매 position마다 recompute가 도는" 모양이다. 사용자가
`RC-1` 논의에서 직접 지적한 문제("이러면 첫 실행에서 계속 recompute
비용이 쌓임")가 `:List`의 초기 population 경로에서는 그 처방이 적용되기
**전** 자리에서 그대로 재현된다.
**크래시로 이어지는지는 `bk.N`에 달려 있다** — 아래 "`bk.N` 수명주기"
절 참고. `bk.N`이 이 시점에 아직 `0`(또는 `nil`)이면 크래시는 안 나고
그냥 매 항목마다 무의미한 `recompute(self,bk)` 호출만 쌓인다(루프가
`for i=1,0`이라 즉시 반환). `bk.N`이 최종 개수로 미리 정해져 있는
모델이면 `RC-1`과 완전히 같은 모양(뒤쪽 `lengthList[i]`가 아직 `nil`)의
크래시가 난다.
---
## RC-4 — flush 루프가 `:List`로 이미 마운트된 요소를 다시 처리함
**같은 `attachSlot`에서 이어지는 문제**: (5)의 flush 루프
(`for i, element in ipairs(slot._elements) do ... end`)는 `slot._listed`
여부를 확인하지 않고 **항상** 돈다. 그런데 `_listed`/`_crudUsed`는 상호
배타(`slot-plan.md`의 "CRUD API 확정" 절 — `_crudUsed`/`_listed` 역방향
가드)이므로, `:List`가 설치된 Slot의
`_elements`는 수동 `:Add()`가 아니라 **오직 (2)의 `activateList`
채운 것뿐**이다 — 그리고 위 `RC-3`에서 확인했듯 그 채움 과정 자체가
이미 각 항목을 물리적으로 마운트(`element.Parent = physicalTarget`)하고
`Dispatch.setOffsetSource`/`setLength`를 등록까지 마친 상태다.
flush 루프는 이 사실을 모르고 **같은 요소들을 처음 보는 것처럼** 다시
처리한다 — `Dispatch.setOffsetSource(slot, i, None)`/
`Dispatch.setLength(slot, i, 1)`을 중복 호출하고, 이미 부모에 붙어있는
`element``element.Parent = physicalTarget`를 다시 대입한다(Roblox라면
`AncestryChanged`가 불필요하게 한 번 더 발화). 값 자체는(멱등하게) 결국
맞게 수렴하겠지만, `:List`가 nested Slot을 요소로 반환한 경우
(`isSlot(element)`)는 **`attachSlot(element, physicalTarget, slot, i)`
통째로 두 번 실행**된다 — 이건 멱등하지 않다: `slot._mounted = true`
다시 세팅하는 정도는 무해해 보여도, "마운트된 Slot의 재마운트는 즉시
throw" 규칙(`slot-plan.md` "마운트된 Slot의 재마운트는 즉시 throw" 절)에
비춰보면 **nested Slot이 자기 자신을 향해 재귀적으로 이미 마운트된
채로 다시 `attachSlot`되는 것 자체가 그 규칙이 막으려는 상황과 같은
모양**이라, 최소한 이 규칙과의 정합성을 다시 검토해야 한다.
**추정 원인**: flush 루프의 주석("attach 전에 이미 들어와있던 요소들
flush")이 밝히듯, 이 루프는 **수동 CRUD로 마운트 전에 `:Add()`
요소**만 염두에 두고 `RC-1` 해결 과정에서 추가된 것 — `:List` 케이스가
같은 함수를 통과한다는 걸 놓친 것으로 보인다(`RC-1` 자체는
`Dispatch.drive`/`attachSlot`의 flush 두 자리만 위험하다고 확인했고,
`activateList`는 그 확인 대상에 없었다).
**참고**: 이 두 결함(`RC-3`/`RC-4`)은 정확한 크래시/오작동 심각도가
`bk.N`의 수명주기에 좌우되므로, 아래 질문의 답이 나온 뒤 같은 자리에서
같이 고치는 게 맞아 보인다(둘 다 "`_listed`면 flush 루프를 건너뛰고,
`activateList` 자체를 `blocker:On()`/`OffWithoutEmit()`으로 감싼다"는
같은 방향의 수정으로 닫힐 가능성이 높음 — 다만 이건 제안이지 확정
아님, 사용자 확인 필요).
### 해결 — `_mounted``activateList` 뒤로 미룸 (2026-08-18, 같은 세션 후속, 사용자 설계)
위에서 제안했던 "`_listed`면 flush 루프를 건너뛴다"는 **채택 안 됨**
사용자가 더 단순한 대안을 직접 제시했다:
> if not slot._listed then ... end 로 감싸면 안 되는거 아닌가요? 그냥
> _mounted 를 activateList 아래 두는게 안되는 이유가 있어요? 만일,
> 그렇게 감싼다면 그건 blocker 를 안 타니까요. 그리고 또, attachSlot 은
> 런타임 상 발생할 수 있는게 맞긴 하죠? 왜냐면, 안 그러면 List 에서
> Frame 만 던질 수 있어요. nested slot 을 던지는 컴포넌트는 사용
> 못하게 될텐데요.
**`slot._mounted = true`/`slot._mountedInst = physicalTarget`를
`attachSlot` 맨 위에서 `activateList` 호출 **뒤**(flush 루프 바로 전)로
옮기면 `RC-3`/`RC-4`가 한 번에 닫힌다**:
- `activateList`가 실행되는 동안 `self._mounted`가 계속 `false`이므로,
`:List`의 reconcile이 부르는 `rawAdd`는 "아직 마운트 전" 경로
(`_elements`에만 넣고 물리 마운트/Dispatch 등록은 안 함,
`slot-plan.md`가 이미 "self가 아직 마운트 전이면 _elements에만
들어가고, self가 나중에 attachSlot될 때 위 flush 루프가 처리"로
명시해둔 바로 그 경로)를 탄다. → `RC-3`(항목마다 무게이팅
`recompute`) 자체가 안 생김 — flush 루프 전엔 어떤 Dispatch 등록도
없으므로.
- flush 루프가 `slot._elements`(이제 `:List`든 수동 CRUD든 항상 여기에만
쌓여 있음)를 순회하며 **처음이자 유일하게** 각 요소를 물리
마운트한다 — nested Slot이면 `attachSlot`도 여기서 **딱 한 번만**
불린다. → `RC-4`(이중 실행) 자체가 안 생김. `_listed` 분기가 필요
없어짐 — flush 루프가 두 경로(`:List`/수동 CRUD) 모두에 대해 이미
동일하게 옳은 유일한 마운트 지점이 됨.
- **`rawAdd``self._mounted` 즉시-마운트 분기 자체는 그대로 남는다** —
사용자가 확인한 대로 이건 삭제 대상이 아니라 **런타임에 실제로 필요한
경로**다: `attachSlot`으로 최초 마운트가 끝난 **뒤**(예: `data`
나중에 바뀌어 `:List`의 reconcile이 다시 실행될 때) `self._mounted`
이미 `true`이므로, 그 시점에 새로 추가되는 nested Slot 항목은 이
분기를 통해 정상적으로 즉시 `attachSlot`된다 — 그래서 `:List`
nested Slot을 반환하는 컴포넌트를 계속 지원한다. 이번에 바뀐 건 오직
"`attachSlot` 자기 자신의 **최초** flush 이전엔 이 분기가 안 타야
한다"는 타이밍 하나뿐.
**반영**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot`
의사코드(`_mounted` 위치 이동 + 주석), `base/dispatch-core-plan.md`
"저장 위치"/"배치 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/
blocker-plan.md`의 "두 번째 용례" 절(아래 `bk.N` 해결과 같이 반영).
---
## `bk.N`의 수명주기가 명세에 없음 — 판단 필요
`recompute`(`base/dispatch-core-plan.md` "Length/Offset" 절)는
`for i = 1, bk.N do`로 순회한다. `bk.N`의 정의는 문서에 **딱 한 곳**뿐:
> **저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그
> `inst`의 array part 크기 `N`으로 같이 저장, `Dispatch.drive`가 최초
> 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`
> lazy 생성.
이건 **`Dispatch.drive`가 순회하는 최상위 `inst`** 전용 서술이다 —
그 경우 `N`은 저작 시점에 고정된 배열 리터럴 길이라 정말로 "한 번 알면
끝"이다. 그런데 `base/slot-plan.md`의 "재귀 메커니즘" 절이 **같은
`recompute`/`getBookkeeping`을 Slot 자신을 ownerKey로 재사용**하면서
(`Dispatch.setLength`/`setOffsetSource`가 "owner 키(`inst`)가 물리
Instance일 필요가 없음"을 근거로), Slot의 경우 `bk.N`이 무엇이고 언제
갱신되는지는 **어디에도 안 적혀 있다**. `getBookkeeping`/
`spliceArraysDown` 자체도 이 코퍼스 전체에서 정의된 적이 없는(호출만
되는) 헬퍼다(grep 확인, `bk.N =` 대입 자체가 코퍼스에 0건).
**왜 이게 그냥 구현 디테일이 아니라 지금 결정이 필요한가**: `Dispatch.drive`
`inst`와 달리, **Slot의 자식 개수는 Slot 전체 생애주기 동안 계속
바뀐다**(그게 Slot의 존재 이유) — "한 번 알면 끝"이라는 `inst` 쪽 전제가
Slot에는 애초에 성립하지 않는다. 두 갈래 다 손으로 트레이싱해보면 각각
다른 방식으로 깨진다:
**(a) `bk.N`이 "고정값"이라면(배치 시작 시 저장, 이후 안 바뀜)**:
`rawRemove`(`slot-plan.md` "파괴" 절)를 트레이싱하면 —
```lua
function rawRemove(self, index)
...
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 한 칸씩 당김
recompute(self, bk) -- bk.N은 그대로(감소 안 함)
end
```
`spliceArraysDown`이 배열을 한 칸씩 당기고(마지막 자리는 비거나 stale
복제값으로 남음, 정의가 없어 어느 쪽인지도 불명) `bk.N` 자체를 줄이지
않으므로, **2개짜리 Slot에서 요소 하나를 `Remove`하기만 해도** 다음
`recompute``for i=1,bk.N(=2)`가 이제 존재하지 않는 위치 2를 읽는다
`spliceArraysDown`이 그 자리를 `nil`로 비운다면 `RC-1`과 정확히 같은
`sum += nil` 산술 에러, 옛 값을 그대로 둔 복제라면 그 값을 이중으로
합산하는 조용한 오계산이다. 어느 쪽이든 **가장 흔한 조작(2개 이상인
Slot에서 하나 제거)에서 매번 재현**된다 — `RC-1`이 "정적 자식 2개짜리
`Frame`에서도 재현"이라고 짚었던 것과 같은 급의 흔함.
**(b) `bk.N`이 "그때그때 실제 개수"라면(예: `#ownerKey._elements`로 매번
파생, 또는 매 `setLength`/`spliceArraysDown` 호출마다 갱신)**: 위
`rawRemove` 크래시는 없어진다. 대신 마운트 시점 배치(`Dispatch.drive`
최상위, `attachSlot`의 flush)에서 `RC-1`이 막으려던 **바로 그 크래시가
되돌아온다** — 배치 도중 `bk.N`이 이미 등록된 position 개수만큼만
증가한 상태라면 recompute 자체는 안전해지지만(순회 범위가 실제 채워진
자리까지만), 반대로 **Blocker 게이팅이 애초에 막으려던 "배치 끝나기
전엔 recompute 안 돈다"는 전제가 필요 없어진다는 뜻**이라 — `RC-1`
해법 전체가 어떤 `bk.N` 모델을 전제하는지부터 다시 맞춰야 한다.
(자세히 보면 게이팅으로 `recompute` 호출 자체를 스킵하므로 (b) 모델이어도
크래시는 안 나지만, **배치 종료 후 딱 1회 도는 마지막 `recompute`가 이번엔
반대로 부족한 `N`을 볼 수 있다** — 예: `attachSlot` flush 루프 중간에
어떤 position의 `setLength`가 **State**를 받아 `Observer`의 "등록 즉시
1회 실행"이 배치 밖 시점까지 늦게 도착하는 경합이 있다면.)
**분기점 — 사용자 판단 필요(아래 질문 참고)**: `bk.N`을 그때그때 실제
개수로 둘지, 배치 시작 시 저장해두는 값으로 둘지에 따라 고칠 자리가
갈린다 — 전자면 `rawRemove`/`rawUnmount`/런타임 `rawAdd`가 문제,
후자면 `RC-3`/`RC-4`가 이미 지적한 자리가 문제. 어느 쪽이든 `RC-3`/
`RC-4`는 별도로 고쳐야 하지만, `bk.N` 자체의 수명주기 규칙은 이
문서가 결정하지 않는다 — 아래에서 직접 여쭤본다.
### 해결 — `bk.N` = 그때그때 실제 개수, 위 (b) 분석은 틀렸음 (2026-08-18, 같은 세션 후속, 사용자 지적)
위 (b) 갈래("`bk.N`이 그때그때 실제 개수면 마운트 배치에서 `RC-1`
막으려던 크래시가 되돌아온다")는 **분석 오류였다.** 사용자가 직접
잡아냄:
> 그때그때 실제 개수를 전부 적용하는건 안 돼? 사실 전부 똑같은
> 방법으로 구현해도 상관 없지 않아? 그리고 drive 중에는 recompute
> 안나지 않아? 계속 후행 붙이기라서 약간 다를텐
**틀렸던 지점**: `Dispatch.drive`/`attachSlot`의 배치 등록 중
`recompute`가 안 도는 이유는 **`bk.N`이 아니라 Blocker 게이팅**
(`blocker:IsOn()`만 확인하는 `gatedRecompute`)이다 — 이 게이트는
`bk.N`이 무엇이든 **전혀 상관하지 않는다**. 그러므로 `bk.N`이 배치
도중 계속 늘어나는 중이어도(아직 최종 크기가 아니어도) 배치 안에서
`recompute` 자체가 안 도니 크래시도, 부정확한 계산도 안 생긴다 —
필자가 (b)를 쓰며 "게이팅이 배치 끝나기 전엔 recompute 안 돈다는
전제가 필요 없어진다"고 적었던 건 스스로 반대 결론(게이팅이 여전히
작동 중이라는 사실)을 옆에 적어두고도 놓친 것.
**결론**: `bk.N` = **그때그때 실제 개수**로 두 owner 타입(`inst`,
Slot 자신) 모두에 동일한 규칙 적용 — `Dispatch.setLength`/
`setOffsetSource`가 이전에 없던 더 큰 position을 등록할 때마다
`bk.N`이 그 값으로 늘어나고, `spliceArraysDown`(Slot의 `rawRemove`/
`rawUnmount`)이 위치를 구조적으로 지울 때 그만큼 줄어든다. `Dispatch.drive`
`inst`에서는 최상위 배열이 구조적으로 안 바뀌므로 이 규칙이 그냥
"등록 끝나면 고정값처럼 보이는" 특수한 안정 상태가 될 뿐, 별도 모델이
필요 없다 — **두 owner 타입에 정말로 똑같은 구현**(사용자가 지적한
그대로).
**`RC-1`의 원래 크래시가 실제로 뭐였는지 다시 정리하면**: `bk.N`
"배치가 시작되기도 전에 이미 최종 크기로 고정"돼 있었던 것의 부산물
— 그 전제 자체가 이번에 사라졌다. Blocker 게이팅이 지금도 필요한
이유는 크래시 방지가 아니라 **비용**이다: 게이팅 없이 매 position
등록마다 `recompute`가 한 번씩 돌면 배치당 O(N²), 게이팅으로 배치 끝에
한 번만 돌면 O(N) — `RC-1` 최초 논의에서 사용자가 직접 지적한 "이러면
첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로.
**추가로 확인된 갭 — `spliceArraysDown`이 미는 배열 목록에 `bk.observers`
빠져 있었음.** `rawRemove`/`rawUnmount`가 제거되는 위치의
`bk.observers[index]``unbindLifetime`하긴 하지만, `spliceArraysDown`
자신이 밀어야 할 배열로 지금까지 `_elements`/`lengthList`/`sourceList`
셋만 서술돼 있었다 — `observers`도 같이 밀지 않으면 이후 그 위치의
observer가 옛 이웃 것을 계속 가리키게 된다. `base/slot-plan.md`
반영.
**반영**: `base/dispatch-core-plan.md`의 "저장 위치" 절(`bk.N`
수명주기 정의 신설), "배치 등록을 안전하게 만드는 Blocker 게이팅"
절의 "문제 재확인" 문단(크래시 전제가 바뀌었다는 정정 추가),
`base/slot-plan.md``spliceArraysDown`/`rawRemove`/`rawUnmount`
근처(`bk.observers`/`bk.N` 갱신 명문화), `base/blocker-plan.md`
"두 번째 용례" 절(같은 정정), `ROADMAP.md` M2 체크박스(같은 정정 +
M2/M3 교차 의존 각주).
---
## 확인만 하고 새 결함 없음 — 재검증
- **중첩 Slot의 `Length:Set`이 부모 Blocker가 켜져 있는 동안 나가는
경우** — `attachSlot`이 재귀로 `attachSlot(element, physicalTarget,
slot, i)`를 부를 때, 안쪽 재귀도 자기 자신의 `getBlocker(element)`
별도 Blocker를 새로 만들어(부모 Blocker와 무관) 자기 flush를 감싼다.
안쪽 재귀 끝의 `recompute(element, bk)``element.Length:Set(sum)`
호출하면, 이건 **부모 쪽 관점에서 보면 `Dispatch.setLength(parentSlot,
i, element.Length)`가 이미 등록해둔 그 State 객체의 값이 바뀌는 것** —
부모의 `gatedRecompute`가 이 변화를 받지만, 부모의 Blocker가 아직
켜져 있으면(외곽 배치가 안 끝났으면) 정상적으로 스킵되고, 부모 배치가
끝난 뒤 마지막 `recompute(parentSlot, parentBk)` 한 번에 자연스럽게
반영된다 — 설계 의도대로 동작, 새 문제 없음.
- **`getBlocker`의 lazy 생성 기본값이 off라는 전제가 런타임 단건 경로를
성립시킴** — "이미 마운트가 된 이후"의 단건 `:Add()`가 게이팅 없이
바로 `gatedRecompute`를 태우는 게 안전한 이유는, 그 시점 Blocker가
(flush 때 만들어져 `OffWithoutEmit()`으로 꺼진 채 남아있으므로) 항상
off 상태이기 때문 — 트레이싱으로 재확인, 새 발견 아님.
- **⚠️ [신설, 반영 후 자체 재검토] 재정렬로 새로 생긴 좁은 엣지 케이스 —
배치 밖(steady state)에서 Slot이 단독으로 (재)마운트될 때, 부모의
`recompute`가 아직 안 굳은 `slot.Length` 값으로 한 번 먼저 돌 수
있음.** `Dispatch.setLength(ownerKey, position, slot.Length)`(위 해결
절의 재정렬 뒤 코드)은 `slot.Length``State``Observer` "등록 즉시
1회 실행"을 그 자리에서 동기로 태운다 — 이게 부모의 `gatedRecompute`
부르는데, **이 Slot 마운트가 `Dispatch.drive`의 배치나 부모 Slot의
flush 루프 **안**이면** 부모의 Blocker가 아직 켜져 있어 안전하게
스킵되지만(트레이싱 확인, 새 결함 아님), **배치 밖에서 이
`attachSlot`이 단독으로 불리는 경우**(예: `state<Slot>` 값이 steady
state에서 반응형으로 교체돼 재-dispatch되는 경우, 부모 owner의
Blocker는 이미 예전에 `OffWithoutEmit()`으로 꺼진 채)엔 부모의
`gatedRecompute`가 즉시 실행돼, 아직 flush가 안 끝나 최종값이 아닌
`slot.Length`로 부모가 한 번 (헛되이) 재계산한다 — 뒤이어 flush가
끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 부모가 다시 정확하게
재계산해 값 자체는 스스로 바로잡힌다. **크래시도 영구적으로 틀린
값도 아니고**, `Get()~=sum` 가드 때문에 실제로 `:Set`이 두 번 나가는
것도 조건부(첫 번째 계산이 우연히 맞을 수도 있음)라 — Roblox 기준
최악의 경우 한 프레임짜리 낭비 재계산 정도. 재정렬 이전 코드(`_mounted`가
`activateList`보다 먼저)에는 이 경로 자체가 없었음(`slot.Length`가
이미 등록 시점에 확정돼 있었으므로) — 그래서 완전히 새로 생긴 특성.
**크래시급이 아니라 이 라운드를 다시 열진 않지만, 다음에 이 자리를
만지는 세션이 알아야 할 사실로 기록.**
- **`Dispatch.drive` 자신은 코드 블록이 없다** — 이 문서 전체에서
`Dispatch.drive`는 항상 산문으로만 서술되고(`Dispatch.drive(inst,
flattened)`가 배열→해시 두 패스로 `Dispatch.process`를 부른다는 것),
Blocker 게이팅을 그 함수 **자신**이 어떻게 여닫는지 보여주는 의사코드는
없다(`attachSlot`만 실제 코드로 있음). 버그는 아님 — `Dispatch.drive`
자체가 이 코퍼스 어디에도 전체 코드로 나온 적이 없어서(항상 서술뿐),
이번에 새로 생긴 갭이 아니라 원래부터 그랬던 문서화 수준의 차이일
뿐이다. 실제 구현 시(M2) `attachSlot`과 같은 패턴(자기 owner=inst의
Blocker를 `:On()` → 배열 파트 순회 → `:OffWithoutEmit()`
`recompute` 1회)으로 쓰면 될 걸로 보이나, 코드로 명문화돼 있지 않다는
점만 기록.
---
## `ROADMAP.md` 마일스톤 정합성 — 새 불일치 발견
1라운드가 "다음에 검토"로 미뤄뒀던 항목. 이번 라운드는 `RC-1`의 Blocker
게이팅 해법이 실제로 마일스톤 순서와 맞물리는지를 봤다.
**문제 — M2가 M3의 산출물(`Blocker`)에 구조적으로 의존하게 됐다.**
`ROADMAP.md` M2(디스패치 엔진)의 `Dispatch.setLength`/`setOffsetSource`
체크박스(90번대 줄)는 이렇게 적혀 있다:
> **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음
> 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로
> 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 `recompute`
> 미루고 배치가 끝나면 명시적으로 한 번만 돎
즉 M2 체크박스 자체가 "`setLength`/`setOffsetSource`를 구현하려면
`Blocker`가 있어야 한다"고 명시한다. 그런데 `Blocker.luau`는 M3
체크박스(`## M3 — Store/State/Source` 절)에 있고, 그 근거는:
> `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에
> 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와
> 밀접히 연관돼 있어 같은 마일스톤에서 개발)
이 근거("State와 밀접히 연관돼 있어서")는 `RC-1` 이전의 오래된 이유
그대로다(`base/blocker-plan.md` 자신도 "store 개발(M3)과 밀접하게
연관됨... 별도 파일로 두되 State와 같은 마일스톤에서 함께 구현할 것"이라고
써 있음, `.claude/todos.md` 4번의 "M3에서 `Blocker`를 구현할 때"도 동일).
`RC-1`로 생긴 **M2 → Blocker** 의존은 그 뒤에 어디에도 반영이 안 됐다 —
M2가 M3보다 먼저 오는 로드맵 순서상, **M2를 그대로 순서대로 구현하면
아직 존재하지 않는 `Blocker.luau`를 참조하게 된다.**
이건 2라운드가 확인한 "`RC-1` 언급이 텍스트로는 반영됐는가"(반영됨,
확인 완료)와는 다른 질문 — **텍스트는 맞는데 그 텍스트가 만드는
마일스톤 간 순서 요구가 로드맵 구조와 어긋난다.**
**참고로 M6(Slot)의 두 자리(368번대 줄 근처)는 이미 "위 M2 항목 참고"로
정확히 교차 참조돼 있어 문제 없음** — M6는 M3보다 뒤라 Blocker가 이미
존재한다는 전제가 깨지지 않는다. 문제는 오직 M2 하나.
**선택지는 여기서 결정하지 않는다** — 가능한 방향만 짚어둔다(사용자
판단 필요):
1. `Blocker.luau`(또는 그 최소 부분집합 — `On`/`Off`/`IsOn`/
`OffWithoutEmit`만)를 M2로 옮기거나 M2 시작 부분에 선행 항목으로 추가.
2. M2 체크박스에 "M3의 `Blocker.luau`를 먼저(또는 병행) 구현해야 함"이라는
명시적 순서 각주를 달아, 로드맵 순서 자체는 유지하되 M2 착수 시
이 사실을 놓치지 않게 한다.
3. M2/M3 마일스톤 경계를 재검토(예: Blocker를 M2로 통째로 승격) —
가장 큰 변경이라 신중히.
**임시로 2번(각주) 채택** — 마일스톤 경계 자체를 바꾸는 1/3번은
설계·일정에 영향이 가는 결정이라 사용자 확인 없이 고르지 않았다. 2번은
로드맵 구조를 안 바꾸면서 "M2가 M3의 산출물에 기대고 있다"는 사실만
빠짐없이 남기는 가장 보수적인 조치라 우선 적용해뒀음(`ROADMAP.md` M2
체크박스) — 1/3번을 원하면 언제든 다시 정리 가능, 아직 최종 확정
아님.
---
## 진행 로그
**3라운드(2026-08-18) — `attachSlot`/`recompute`/Blocker 게이팅 손
트레이싱, `ROADMAP.md` 마일스톤 정합성 재검토, 같은 세션에 전부 해결·
`base/` 반영까지 완료.** 발견 순서대로:
1. `RC-3`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행돼 항목마다
무게이팅 `recompute`가 도는 것으로 보였음)와 `RC-4`(flush 루프가
`:List`로 이미 마운트된 요소를 중복 처리 — nested Slot이면 이중
`attachSlot`)를 발견.
2. `bk.N`(순회 상한)의 수명주기가 문서 어디에도 없다는 것도 발견, 최초
분석은 "고정값/그때그때 실제 개수 두 갈래 다 각기 다른 방식으로
깨진다"고 판단해 사용자에게 물음.
3. **사용자가 그 분석 자체를 정정** — Blocker 게이팅은 `bk.N`이 아니라
`blocker:IsOn()`만 보므로, "그때그때 실제 개수" 모델이 배치 중
크래시를 되돌린다는 결론은 틀렸음을 지적("그때그때 실제 개수를 전부
적용하는건 안 돼? ... 그리고 drive 중에는 recompute 안나지 않아?").
`bk.N` = 그때그때 실제 개수로 두 owner 타입에 동일 적용 확정.
4. `RC-3`/`RC-4`도 사용자가 더 단순한 해법을 직접 제시 — flush 루프를
`_listed`로 분기하는 대신, `attachSlot``slot._mounted = true`
`activateList` 호출 **뒤**로 옮기는 것 하나로 둘 다 닫힘("_mounted
를 activateList 아래 두는게 안되는 이유가 있어요?").
5. 부수로 `spliceArraysDown`이 밀어야 할 배열 목록에 `bk.observers`
빠져 있던 것도 같이 발견·반영.
6. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 구조적으로 의존하게 된
불일치는 각주로 반영(가장 보수적인 조치, 마일스톤 재편 여부는 열림).
**반영 완료**: `base/slot-plan.md`(`attachSlot` 의사코드 재작성,
`spliceArraysDown`/`bk.N`/`bk.observers` 명문화), `base/
dispatch-core-plan.md`(`bk.N` 수명주기 신설, 크래시 전제 정정),
`base/blocker-plan.md`(게이팅 존재 이유 정정), `ROADMAP.md`(M2 체크박스
정정 + M2/M3 교차 의존 각주). `python3 .claude/tools/doc-check.py`
ERROR 0 확인 완료.

View file

@ -40,18 +40,19 @@
`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절,
`archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.)
- **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계
표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가
있었던 전례(`base/bind-system-plan.md`의 "인스턴스 생성" 절 참고).
**파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`
Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의
때 이 연쇄까지 같이 고려할 것. **(2026-08-08 추가)** 사용자가 `D`(Declarative
만 남김)로 축약하는 안을 제안 — 근거: (1) "Instance" 전용 개념이 아니라
quad-* 전반의 declare 요소로 확장해도 되는 이름, (2) 엔진 종속 없이 다른
백엔드에서도 재사용 가능, (3) 어차피 `D.FrameModifier`류 타입 프리픽스가
길면 못 쓰므로 짧아야 한다는 실용적 제약. 아직 최종 확정 아님 — 다음
세션에서 마저 논의(한 글자 식별자의 검색성/자기설명력 트레이드오프를
문서에서 어떻게 보완할지도 같이).
- **[해소됨, 2026-08-18] `DI``D`(Declarative) 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과
완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare
요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게
유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
보완하기로 같이 확정. 코퍼스 반영 완료 —
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고
자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은
파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자:
*"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더
생각해보아야함"*.
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
헷갈릴 수 있음.
@ -113,6 +114,17 @@
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`
구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지,
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level
@ -159,6 +171,46 @@
남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라
우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는
그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`
콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의
lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와
부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과
**`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는
dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌
확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던
요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
`mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
`userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
기본" 절.
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 —
`base/attribute-plan.md`의 "이름 소유권" 절.
- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** —
`Get()``Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게
불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))`
(단 이때 `Apply``State`가 아니라 `Source`를 넘겨야 함)와
`source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번
범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것
(`state:Apply` 시그니처에 영향), `base/slot-plan.md``dispose` 절.
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
지목했고, **명문화 여부 결정 + `luau-test` 실측이 M3 착수 전에 필요**
`base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절.
- **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패
롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이
이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스
@ -166,12 +218,10 @@
문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적
롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정
불필요 — M10 구현 시점에 판단.
- **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** —
두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서
조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리).
합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이
같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어
사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절.
- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면
error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정
(제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인
array-part 값 객체" 절.
- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고.
채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를
넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은
@ -180,7 +230,7 @@
채택 안 함으로 확정, `base/event-plan.md` "이벤트 핸들러는
self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔
착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M2 Dispatch/
M3 Source/M5 DI 생성자) 시점에 훅 확장 지점만 고려해두면 됨.
M3 Source/M5 `D` 생성자) 시점에 훅 확장 지점만 고려해두면 됨.
- **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는
패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로
올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요.

View file

@ -18,7 +18,7 @@ context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아
사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것
같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된
독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`DI`)+파생 데이터
독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`D`)+파생 데이터
(`State`/`Observer`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게
재검토해달라는 요청.

View file

@ -2,7 +2,7 @@
**상태**: research — 착수 전, 사용자와 계속 논의 필요. 사용자가 "quad 개발이
어느 정도 끝날 때까지는 실제 구현에 못 들어갈 것"이라고 스스로 판단한 후순위
항목이지만, **base 설계(디스패치 엔진/Source/DI 생성자) 시점에 훅 확장
항목이지만, **base 설계(디스패치 엔진/Source/`D` 생성자) 시점에 훅 확장
지점만 미리 고려해두면 나중에 훨씬 싸게 먹힌다**는 문제의식으로 지금 미리
정리해둠. `ROADMAP.md` 백로그 항목("범용 렌더 디버깅 도구로서의 quad-mock")과
목적이 다름 — 아래 "quad-mock 백로그와의 관계" 절 참고.
@ -123,7 +123,7 @@ Compute 함수가 어디서 생성됐는지"를 보여주는 **연결 그래프*
등록하는 훅(기본 no-op). 켜졌을 때만 이 레지스트리가 존재하므로 GC-native
원칙(`lifecycle-pattern.md`)과 충돌 없음 — 꺼져 있으면 레지스트리 자체가
안 만들어짐.
- **quad-roblox `DI/init.luau`의 제네릭 생성자(`new(className)`)** — 인스턴스
- **quad-roblox `D/init.luau`의 제네릭 생성자(`New(className)`)** — 인스턴스
생성 순간 `debug.info(2, "sl")`로 caller의 script+line을 얻어 기록하는
훅(기본 no-op). Instance 생성은 프로퍼티 변경보다 훨씬 드물게 일어나므로
(렌더 타임 1회), 여기서만 비교적 비싼 `debug.info` 호출을 해도 부담 적음.
@ -376,7 +376,7 @@ columnNumber}`를 리터럴로 박아 넣는 컴파일타임 트랜스폼. 런
값으로 존재.
**quad-debug 적용 후보**: 위 "계측 지점 3곳"에서 제안한
`debug.info(2, "sl")` 런타임 캡처(DI 제네릭 생성자, 호출 시점 caller
`debug.info(2, "sl")` 런타임 캡처(`D` 제네릭 생성자, 호출 시점 caller
위치)의 대안/보완으로, **darklua** 같은 빌드타임 Luau 변환기로 quad
생성자 호출부를 순회하며 파일/라인 리터럴을 인자로 미리 주입하는 방식을
검토할 만함. `debug.info`가 "호출자(caller)의 정확한 라인"을 항상
@ -457,7 +457,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름:
나중에 완전 제거하고 싶을 때도 별도 패키지면 그냥 require 자체를 안 하면
끝).
- **`quad-debug-roblox`** — 게임(클라이언트) 쪽에서 require하는 provider.
quad-roblox의 Dispatch/DI에 실제 훅을 꽂고, BindableEvent/Function을
quad-roblox의 Dispatch/`D`에 실제 훅을 꽂고, BindableEvent/Function을
**quad 모듈 자신의 Instance 트리 안에** 만들어 CollectionService
태그로 노출(위 "데이터 채널" 절 — `ReplicatedStorage` 등 게임 트리에
별도 주입 안 함), `IsStudio` 가드 포함.
@ -476,7 +476,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름:
만들 필요는 없음).
- M3(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기
쉬운 생성자 모양인지만 유의.
- M5(quad-roblox DI 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기
- M5(quad-roblox `D` 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기
쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미
`bind-system-plan.md`가 "제네릭 생성자 함수 하나로 통일"이라 확정해둔
것과 자연히 맞아떨어짐, 별도 조치 불필요할 가능성이 큼.

View file

@ -36,7 +36,7 @@
목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안):
1. **초기화**`RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`)
2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new<Class>` + 자주 쓰는 ~25개 클래스 정적 필드(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`)
2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`)
3. **속성 채우기**`[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`)
4. **반응형 기초**`Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜)
5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`)

View file

@ -0,0 +1,37 @@
# `quad-roblox-fastscroll` — 스크롤 최적화 유틸 (아이디어 메모)
**상태**: research 착수 전 — 사용자 노트를 그대로 옮겨 적은 **아이디어
메모**. 설계 논의는 아직 없었고, 사용자가 "quad가 잘 작동하게 될 때"
직접 검토하겠다고 못박은 **아주 나중** 항목. 지금은 잊지 않게 적어두는
것 자체가 목적.
## 아이디어
리스트/그리드 컨테이너 안에서 각 항목의 상대 위치를 구해, **실제로
움직여야 하는 것만** 같이 움직이게 하는 유틸. 스크롤 자체는 배경 위의
**빈 공간만** 스크롤시키고, 그 결과 absolute position이 필요한 요소만
갱신되게 한다 — 이 방향이 성립하려면 항목들의 원래 레이아웃을 계산해줄
**가상 레이아웃(virtual layout) 유틸**이 선행 요구사항으로 보인다(원문:
"이걸 위하여 가상 레이아웃 유틸이 필요하긴 함").
## 패키지 경계
**완전 외부 요소**로 `quad-roblox-fastscroll`(quad 코어/quad-roblox 코어와
분리된 별도 패키지) — 원문 그대로. quad 코어 설계에 영향을 주는 항목이
아니라, quad 위에 얹는 최적화 유틸리티로 스코프됨.
## 선행 확인 필요 — 실측 전제
사용자가 명시적으로 지목한 첫 확인 사항: **`Visible = false`일 때
`AbsoluteSize`/`AbsolutePosition`이 계속 업데이트되는지 여부**부터
확인해야 한다. 이 최적화 전략 전체가 "안 보이는 요소는 레이아웃 계산에서
빼도 좌표가 정확한가"에 의존하므로, Roblox UI 레이아웃 엔진이
`Visible = false`인 인스턴스도 계속 계산하는지 아니면 멈추는지가 접근
방식을 가른다. 이건 `.claude/luau-test/`류 추론이 아니라 **Roblox Studio
실측**이 필요한 항목 — quad-doc-auditor/luau-test 스파이크로 대신할 수
없고, MCP 연결 이후 사용자가 직접(또는 요청 시) 확인해야 함.
## 우선순위
최하 — 사용자가 "아주 나중에 quad가 잘 작동하게 될 때 내가 검토해볼
예정"으로 직접 후순위 지정. 지금 단계에서 추가 설계 논의 불필요.

View file

@ -534,11 +534,11 @@ Modifier를 합친다"는 시나리오가 `Overridden`의 가장 그럴듯한
**문제**: Modifier의 런타임 체이닝 엔진은 quad-base 소유가 맞지만, 클래스별
정적 타입 안전성(`mod:UICorner(8)`가 `FrameModifier` 타입으로 추론되는
것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴
재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게
것)은 "`D` 쪽 '제네릭 생성자 함수 하나 + 정적 별칭 필드' 패턴
재사용"이라 문서 스스로 밝히듯 quad-roblox의 `D` 타입 생성 계층(M5)에 강하게
결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch,
`Modifier.Overridden`, `State<Modifier>` 차단)엔 이 클래스별 타입 생성 작업이
전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음.
전혀 없고, M5 `D` 체크리스트에도 Modifier 언급이 없음.
**제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier
생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것.

View file

@ -0,0 +1,87 @@
# `quad-spring` — 스프링 물리 기반 지속 업데이트 프리미티브 (아이디어 메모)
**상태**: research 착수 전 — 사용자 노트를 그대로 옮겨 적은 **아이디어
메모**. 설계 논의는 아직 없었고, 사용자가 "모든게 완성된 후, 별도
모듈로 분화"라고 직접 못박은 **아주 나중** 항목.
## 아이디어
특정 Spring 설정을 받아 이전 상태와 비교해 스프링 물리 연산을 수행해주는
**중간 핸들러**(intermediate handler). StoreBind와 비슷한 구조로 별도
핸들러 하나를 만들 수 있을 것으로 보인다는 원문 메모(원문: "스토어바인드도
비슷하게 핸들러 하나 생성 가능할 수 있음").
기각된 직접 `Tween` 접근(과거 특수 bind key 모델, `archive/tween-special-bind-key-reversed.md`)과는
성격이 다르다 — 이건 `Tween`만으로 간결히 해결되지 않는 **인터랙티브
디자인** 상황(사용자 입력에 실시간으로 반응하는 감쇠/타겟 추적)을 위한
**지속 업데이트되는** primitive를 염두에 둔 것. 확정된 `Tween<T>` 모델
(`base/tween-plan.md`)과는 별도 트랙으로 취급.
## 참고 구현
[qwreey/spring.lua](https://github.com/qwreey/spring.lua/blob/master/main.lua) —
사용자 본인의 기존 구현. 사용 가능 여부(라이선스/의존성/quad 아키텍처와의
정합성) 확인이 필요하다고 원문에 명시. **[2026-08-18 추가]** 산술 연산
자체는 100줄 내외로 짧고 바뀌지 않는 코드라, 재구현하지 않고 이 파일을
그대로 **임베딩**하고 싶다는 게 사용자 의도. 다만 **그 코드가 정확한지는
아직 검증되지 않았고**, quad에 들여오기 전에 에이전트가 확인하는 절차를
거쳐야 한다(사용자가 직접 "해당 코드가 정확한지는 나중에 에이전트가
확인하도록 둬야해"라고 명시) — 지금 이 메모 단계에서 검증까지 마친 것은
아니다.
## 구현 형태 아이디어 (2026-08-18 추가, 사용자 메모)
실제 구현 시 순서와 계층 구조에 대한 사용자의 구체적인 구상. 여전히
**아이디어 메모** 단계이고(원문 표현: "실제 구현할 때에는 ... 것 같아",
"이건 더 검토해 보아야해") 확정된 설계가 아니지만, 아래 "구현 방향 후보"의
세 번째 갈래(`Source<number>` 기반 자기-emit 프리미티브)를 훨씬
구체화한 내용이라 이 갈래가 지금은 유력한 후보로 보인다 — 그래도 결정
시점이 오기 전까지는 다른 갈래를 공식적으로 배제하지 않는다.
1. **코어 프리미티브**: `Spring impl Source<number>` 형태 — 스프링 하나가
그 자체로 `Source<number>`를 구현하며, `:SetTarget()` / `:GetTarget()`
같은 연산을 제공한다. `quad-spring`에 위치.
2. **`:Apply()` 체인 슈가**: `state:Apply(Springify { 상수... })` 형태로
기존 `:Apply()` 체인에 스프링을 끼워 넣는 편의 도구. `Springify`
가칭이고 이름은 나중에 확정. 이것도 `quad-spring`에 위치.
3. **다필드 타입은 별도 바인딩이 필요하다.** `UDim2`처럼 필드가 여럿인
값은 전체를 하나로 lerp할 수 없고 **필드마다 독립적으로 스프링
타겟이 되어야** 한다 — 그래서 엔진(또는 타입)별로 실 구현체가 따로
있어야 한다는 게 핵심 근거. `Springify`를 통해 필요한 만큼 내부
스프링을 여러 개 생성하고, 그 스프링들의 목표값을 갱신하고, 매 프레임
dt에 맞춰 각 스프링의 실값을 얻은 뒤 하나의 합성 객체(예: `UDim2`)로
묶어 던지는 `UDimSpringify` 같은 타입별 헬퍼가 필요하다.
4. **패키지 분리 방향 (미정, 더 검토 필요)**: 위 이유로 `quad-spring`
(엔진 중립 코어: `Spring` 프리미티브 + `Springify` 슈가)과
`quad-spring-roblox`(Roblox 타입별 바인딩, 예: `UDimSpringify`)로
나누는 방식이 사용자가 지금 기울어 있는 안이다. 근거는 패키지 의존
방향 — 만약 `quad-spring`에 대한 바인딩 구현을 `quad-roblox` 안에서
직접 한다면, `quad-roblox`가 (타입만이라도) `quad-spring`에 의존하게
되어버린다는 게 사용자 우려(원문: "quad-roblox 는 quad-spring 을
타입이라도 의존하게 되니까"). **다만 사용자 본인이 "이건 더 검토해
보아야해"라고 명시 — 확정 아님.**
## 구현 방향 후보 (미정)
두 갈래가 거론됐고 아직 어느 쪽도 확정되지 않았다:
1. **엔진 중립 후킹**: `quad-base``onStep`류 프레임 단위 처리 후킹
인터페이스를 제공하고, 그 위에 스프링 primitive를 얹는 방식. 원문에서
사용자가 "기본 생각으론 후킹이 맞다 보긴 하는데"로 약하게 기운
방향이지만 확정은 아님.
2. **엔진별 개별 구현**: `quad-roblox-spring`처럼 엔진마다 각자 맞게
개발하는 방식.
또는 별도 방향으로 — `Source<number>`를 확장해 damping/target 등 스프링
파라미터를 가진, **자기 자신에 계속 emit하는** 프리미티브를 primitive
레벨에 두는 안도 언급됨. 이 형태면 다른 `:Compute` 체인과 자연스럽게
엮일 수 있다는 게 사용자 관찰 — 위 "구현 형태 아이디어" 절이 이 갈래를
구체화한 내용이다.
**결정 시점**: quad 코어가 충분히 개발되어 이런 pluggable 요소가 더
필요해지는 시점에 판단 — 지금은 방향을 좁히지 않고 후보만 기록.
## 우선순위
최하 — "모든게 완성된 후, 별도 모듈로 분화"라고 사용자가 직접 명시. M0
설계 게이트와 무관.

View file

@ -1436,3 +1436,71 @@ blob과 바이트 단위로 동일, 메인이 `git rev-parse`로 독립 확인).
도움이 되는 정보에 가깝지 이게 warn을 만들지는 못할듯" — 기계 검사 대상이
아니라 읽는 쪽 판단 재료), (3) (C) 추적은 컨텍스트 보호를 위해 서브에이전트
위임. `conventions.md`에 "문서 표기 규약" 절 신설.
## 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영
원문: `session/2026-08-18-01-pre-implementation-qa-applied.md`
`.claude/qa-request/pre-implementation-qa-round1.md`(사용자가 `base/` 확정 문서를 문항으로
재심사해 "아니오"가 나온 것만 모아둔 문서)를 실제 문서에 반영. **그대로
구현하면 반대로 돌던 두 건**이 닫혔다 — `canBound`가 이름과 반대 방향으로
쓰이고 있어 정상 첫 바인드가 전부 에러날 뻔한 것(정정 결과 `canBound`
`canExecute`는 값이 같은 게 아니라 **서로의 부정**이고, 그게 오히려 이름
분리의 명분이 됨), 그리고 gcconn/gchold를 `SetStrong`으로 적어 같은 문서가
경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸리던 것.
설계가 바뀐 것: `Dispatch.drive``None` 스킵 폐기 → `NoneHandler`는 재귀
전담 + **`NilHandler` 신설**(깨진 전제는 "배열 파트의 `None``process`
안 탄다" — `Frame{State<Slot|None>}`이면 탄다), 이벤트 disconnect 센티널
`false`→`None`/`nil`, `Ref` 내부 구조를 `.Callbacks` 분리로 단순화,
`:List` reconcile의 `nil` 리턴을 **다시 파괴**로 되돌리고 `PopOnly`(가칭)
신설, base Fallback Handler 등록 주체를 **quad-base 로드 시로 재역전**
(백엔드 미로드 상태에서 안내 에러 경로가 안 도는 게 이유 — `InitNamespace`
거부 원칙과의 양립 근거를 새로 씀). **"이벤트 콜백 시그니처는 Luau가 검증
못 한다"가 거짓**임이 사용자 반례로 확인돼 `onchange-plan.md`가 그걸 근거로
쓰던 자리까지 같이 무너졌고(결론은 유지, 근거만 교체), 겸해서 `New` 커링과
"`D`는 전량 코드 생성된 순수 별칭 테이블"이 명문화됨.
이름 쪽: **`DI``D`(Declarative) 확정**(2026-08-08부터 1순위로 열려 있던
항목) — 코퍼스 전수 반영, 미뤄온 유일한 사유였던 한 글자 식별자의 검색성은
"처음 나올 때 항상 `D`(Declarative)로 풀어쓴다" 표기 규약으로 보완.
`Attribute.Merged`/`Overridden`을 **둘 다 제공**하는 제3안으로 이름 겹침
정책도 해소.
판단이 갈리던 네 건(`PopOnly` 채택, D-7 재역전, `NoneHandler`/`NilHandler`
역할 분담, 동적 키 `GetDynamic`)은 그 자리에서 사용자에게 물어 확정.
남은 착수 금지 게이트(중간 State GC 미검증 등)는 `question.md` 3번과
`todos.md` 00번이 소스. `doc-check.py` ERROR 0.
**커밋 전 검증에서 배운 것**: `quad-doc-auditor` 1패스가 "배너는 고쳤는데
그 배너가 부정하는 본문 bullet은 안 고친" 건을 하나 잡았고, 이어서 사용자가
직접 돌린 `/code-review high`**10건을 더** 잡았다(전부 유효, 전부 반영) —
감사자가 못 본 것들이라 **두 도구가 서로를 대체하지 않는다는 게 실측으로
드러났다**(감사자는 코퍼스 전체 정합성, code-review는 diff 자체의 결함).
그중엔 ROADMAP이 SL-3 역전을 안 따라와 M8 체크리스트대로 짜면 방금 되돌린
결함을 다시 만드는 건, 그리고 **설계 갭 2건**(`GetDynamic` 콜론 메소드가
Store의 lazy `__index`와 충돌 / `PopOnly` 홀드 중 키가 사라지면 파괴도 반환도
안 됨)이 있어 새 열린 질문으로 올렸다. **감사 비용 메모**: 감사자 한 패스가
서브에이전트 토큰 21만이라(코퍼스 전체를 다시 읽는 정의라서) 계획했던 4패스를
중단했음. **그래서 사용자 지침으로 감사 절차 자체를 바꿨다** — 병렬 금지(한
턴에 하나), 범위는 diff로 좁히고 라운드마다 각도를 바꿈, 종료 조건은 무발견
1회(옛 "2연속"은 병렬 전제라 완화). `conventions.md`의 감사 루프 절이 소스.
## 2026-08-19 — `New()` 내부 구성(InitXxx + Relate 멱등 가드) 확정, 세션 기록 공백 발견
원문: `session/2026-08-19-01-new-initxxx-composition-relate-guard.md`
사용자가 `New()`를 v1 스타일 `InitXxx(module)` 팩토리 체이닝으로 짜자고
제안(이미 확정된 `InitRoblox(Module)` backend 주입 패턴을 quad-base 자기
내부에도 대칭 적용) → 채택, `module-lifecycle-plan.md`에 "New()의 내부
구성" 절 신설. 이어서 서브시스템 간 호출 순서 문제를 "Init을 `require`처럼
멱등하게"(각 `InitXxx` 파일 톱레벨에 `Relate()` 하나 두고 `module`을 weak
key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도 제안·반영 —
`relate-plan.md`의 기존 확정 API/관례와 정확히 부합함을 확인. 핸드오버
감사 2라운드를 거치며 라운드 1의 수정 자체가 절 인용 사각지대를 새로
만든 걸 라운드 2가 잡는 등 실제로 반복 라운드가 필요함을 다시 확인.
**부수 발견 — session/ 기록 공백**: 2026-08-18에 커밋 10개(QA 1~2라운드
포함)가 있었는데 그날 session/ 파일은 1개뿐, 2026-08-19는 이 세션 전까지
(QA 3라운드 커밋 1개가 있었음에도) 0개였음. 과거 대화 트랜스크립트에 접근 불가라 그 공백을 사후 재구성하는 건
허위 기록 위험이 있어 보류 — 처리 방침은 사용자 확인 대기.

View file

@ -0,0 +1,179 @@
# 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영
**요청**: "pre-implementation-qa 의 적용을 수행하자."
`.claude/qa-request/pre-implementation-qa-round1.md`(같은 날 앞선 세션이
만든, `base/` 확정
문서를 사용자에게 문항으로 재심사한 결과)의 항목을 실제 문서에 반영한 세션.
그 문서는 "여기서 정정하지 않는다, 사용자 정정 회신이 오면 반영한다"고
적혀 있었지만 **각 항목에 이미 사용자 답변 원문과 논거가 붙어 있었고**,
사용자가 적용을 지시했으므로 그 답변을 정정 근거로 삼아 반영했다.
## 먼저 사용자에게 물은 네 가지
QA 문서가 "결론 없음"으로 남겨둔 항목 중, 임의로 정하면 안 되는 것만
`AskUserQuestion`으로 물었다(나머지는 답변 원문이 이미 방향을 확정함):
1. **SL-3 — `:List` reconcile의 `nil` 리턴** → *"PopOnly 확정. 다만 이름은
변경될 수 있음. 이름에 대해서는 더 생각해보아야함"* → 파괴가 기본으로
되돌리고 `PopOnly`(가칭)를 이번 설계에 넣음.
2. **D-7 — base Fallback Handler 등록 주체** → **quad-base 로드 시 등록으로
재역전**(2026-08-14의 역전을 다시 뒤집음).
3. **N-4/RF-4 — `None`/`nil` 배열 슬롯 처리 책임** → *"NoneHandler는
재귀만, NilHandler가 실질 담당"*.
4. **ST-2 파급 — 동적 키 경로** → *"동적히는 여전히 그냥 Store.Name 하면
얻어는 짐. 타입 애러가 난다는 점인데, 이는 GetDynamic<T>(name):
Source<T> 로 제공하는게 최선으로 보임."*
## 반영한 것 — 성격별
**(1) 그대로 구현하면 반대로 도는 것**
- **S-1 `canBound` 방향 반전**: `canBound(v) == not isBoundAlive(v)`,
게이트는 전부 `if not canBound(v) then error(...)`. 진원지
`lifecycle-pattern.md`의 (1)(2)(3) 절을 다시 쓰고,
`source-state-plan.md`/`ROADMAP.md`/`luau-test`(README·STATUS)/
`audit/gcconn-trick-verification.md`까지 같은 방향으로 정정.
**부수 발견**: 열한 번째 세션이 이름 분리의 근거로 적은 "판정 로직도
같고 값도 항상 같다"가 무너짐 — 실제로는 **서로의 부정**이고, 그게
오히려 이름 분리의 명분을 강화한다는 쪽으로 절을 다시 씀.
- **RE-1 `SetStrong` → `SetWeak`**: `relate-plan.md`의 "대체하는 것" 절과
`architecture.md` 소스 트리 주석. 근거 문장("둘 다 존재 이유가 '안 죽는
것'이므로 strong")까지 통째로 틀렸던 것이라 근거도 교체 — 그대로 짰으면
같은 문서가 경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸렸다.
**(2) 설계가 바뀐 것**
- **RF-4+N-4**: `Dispatch.drive``None` 스킵 분기 폐기 → `NoneHandler`
재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단,
`setLength(0)`/`setOffsetSource(None)` 등록). 깨진 전제는 "배열 파트의
`None``process`를 절대 안 탄다"였는데 `Frame{ State<Slot|None> }`이면
탄다는 것.
- **D-6 파생**: Length/Offset 등록 책임이 "그 위치를 **처음** 매치한
Handler"에서 **말단 Handler**로 정정(중간 노드는 `inst`에 부작용을 안
가한다는 D-3 계약과 충돌했음). 같이 검토 대상이던 "모든 핸들러가
`k=number`일 때 처리" 안은 `NilHandler`가 갭을 닫아 채택 안 함 —
**이건 사용자 답변에서 바로 나온 결론이 아니라 두 답변을 합친 추론이라
세션 보고에서 따로 짚었다.**
- **EV-1**: 이벤트 disconnect 센티널 `false``None`/`nil`. `EventHandler`
`v == nil`에도 매치돼야 한다는 계약이 새로 생김.
- **D-7**: Fallback Handler 등록 주체 재역전. `InitNamespace` 거부 원칙과의
양립 근거를 새로 씀 — 그 원칙이 금지한 건 *사용자 수동 init*과 *남의
상태를 건드리는 top-level 부작용*이지, 모듈이 자기 레지스트리를 채우는
게 아니다. `archive/tag-attribute-load-time-registration-reversed.md`
"절반 재역전" 배너를 달았다(이름 쪽 결론은 그대로 유효).
- **R-1**: `Ref` 내부 구조가 `.Callbacks` 별도 테이블 + 평범한 `.Value`
필드로 단순화 → `__index` 우회 기법의 존재 이유 자체가 사라짐.
- **SL-3**: `:List` reconcile의 `nil`/키 소멸은 다시 파괴, 값 교체와
`PopOnly`만 비파괴. `State<Slot>` 교체가 언마운트인 것은 그대로 유지되게
세 경로를 표로 갈랐다(`:Single` sugar가 교체 경로를 타므로 자동으로 안전).
- **BS-2+N-9**: "이벤트 콜백 시그니처는 Luau가 검증 못 한다"가 거짓 —
사용자가 반례 코드를 직접 작성해 보여줌. `onchange-plan.md`가 이 전제를
근거로 쓰던 자리도 근거만 교체(결론은 유지: `OnChange`는 필드가 아니라
팩토리라 타입을 미리 찍어둘 자리가 없다). 겸해서 `New` 커링 계약과
"`D`는 전량 코드 생성된 순수 별칭 테이블"을 명문화.
**(3) 이름/표면**
- **N-8 `DI` → `D`(Declarative)** 확정 — 코퍼스 전수 반영(네임스페이스는
`D`, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화). 2026-08-08부터
개명을 미뤄온 유일한 사유(한 글자 식별자의 검색성)는 **"문서에서 처음
나올 때 항상 `D`(Declarative)로 풀어쓴다"** 표기 규약으로 보완하고
`architecture.md`의 네이밍 케이싱 절에 4번 항목으로 넣었다.
`question.md`의 1순위 항목은 `archive/question-resolved.md`로 이전.
- **N-5** `Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가
이김) **둘 다 제공** — 열려 있던 "error냐 override냐"가 제3안으로 해소.
덤으로 `Merged`/`Overridden`이라는 이름 쌍의 의미가 코퍼스에서 재정렬됨
(연산의 종류 → 충돌 시 정책).
**(4) 나머지** — A-3(`New()` 자동 스코핑이 아니라 `Quad()` + 코드 수정
필요), D-1(방어 가드 "죽은 코드"에 한정 추가), D-5(`PreRef`는 배열 우선
보장 위가 아니라 별도 pre-pass), M-3(예약 필드가 `Apply` 하나가 아니라
셋 + `Overridden`은 콜론도 가능), B-1(`Brand`는 무의존 — `None` 특수 분기
기각), R-3(`ProcessedPreRef` 센티널), SL-1(`RefLeafHandler`에 `k` 체크
추가 + 배열 전용 근거 명문화), E-2(`:Unsubscribe()`는 `:Subscribe()`
짝으로 축소), N-1(FALLBACK 에러에 `k` 타입), N-2(타입이 방어선), N-3
(`Quad.debug`), N-6(`SetAndDispose` 후보), N-7(UI 숏핸드는 `Relate`
조회), 부수 오탈자 2건(`.value` 케이싱, `fn(value, previous)` 표기).
## 열어둔 것 (착수 금지 게이트)
`question.md` 3번과 `todos.md` 00번이 소스 — 중간 State GC 미검증(M3),
그룹 `Attribute` 위치별 claim 키 설계(M10), `SetAndDispose` 방향(M3 전),
dedup 경로의 process/retract 대칭 확인(M3 전), `PopOnly` 이름,
`Store` 미선언 키의 타입 에러 실측(M0).
## 커밋 전 검증 — 감사자 1패스 + `/code-review high`
**`quad-doc-auditor` 1패스(base 코퍼스 각도)**: 확실 발견 1건 —
`ref-plan.md`가 "원래부터 빈 자리인 `None`**여전히** 두 패스 루프가 직접
건너뜀"이라고 남겨둔 문장이 같은 파일의 2026-08-18 배너와 정면 모순
(정확히 "배너는 고쳤는데 그 배너가 부정하는 본문 bullet은 안 고친" 실패
패턴). 수정 완료.
**감사 비용 이슈로 나머지 각도는 중단** — 한 패스가 서브에이전트 토큰
21만/툴 호출 82회였다. 감사자 정의가 "코퍼스 **전체**를 신선한 맥락에서
다시 읽는다"인 데다(라이브 문서 91개, `base/`만 ~12,000줄) 이번 프롬프트가
바뀐 결정 16개를 교차 검증하라고 시켜서, 계획대로 4개를 돌렸으면 80만
토큰대였을 것. **다음에 큰 변경을 감사할 때는 전 코퍼스가 아니라 diff가
건드린 파일 + 그걸 인용하는 곳으로 범위를 좁혀 프롬프트할 것.**
(부수: `/model`이 opus로 보여 감사자가 opus로 도는지 의심됐는데, 정의
frontmatter는 `model: sonnet`이고 오버라이드도 안 넘겼다. 다만 **이번
실행이 실제 sonnet이었는지는 확인 못 함** — 이 세션 트랜스크립트에
sidechain 레코드가 안 남았다. `todos.md` 7번의 "정의가 언제/얼마나
반영되는지 모른다"가 여전히 유효.)
**사용자가 `/code-review high`를 직접 돌림 — 10건 전부 유효**했고 전부
반영했다. 감사자가 못 잡은 것들이라 **두 도구가 서로를 대체하지 않는다는
게 실측으로 드러난 라운드**(감사자는 코퍼스 전체 정합성, code-review는
diff 자체의 결함):
- **[high] `ROADMAP.md`가 SL-3 역전을 안 따라옴** — `unmountSlotTree`
"`:List`의 reconcile"이 쓴다고 그대로 적혀 있었음. M8 체크리스트를 보고
구현하면 정확히 이번에 되돌린 결함을 다시 만든다.
- **[medium] `modifier-plan.md` §5 / `attribute-plan.md` 근거 문단**이
기각된 "문자열 폴백"과 "자주 쓰는 ~25개"를 근거로 계속 인용.
- **[medium] `GetDynamic` 콜론 메소드가 Store의 lazy `__index`와 충돌** —
아무 장치 없이 부르면 `"GetDynamic"`이라는 이름의 Source를 만들어 함수로
호출하게 됨. 예약 키가 되거나 탑레벨 함수여야 함 → **새 열린 질문**.
- **[medium] ROADMAP에 이번 라운드의 새 표면이 통째로 누락**
(`Attribute.Overridden`/`Quad.debug`/`GetDynamic`/`PopOnly`) → 전부 추가.
- **[medium] `PopOnly` 계약과 의사코드 불일치** — 키가 사라지면 홀드 중이던
요소가 파괴도 반환도 안 되고 참조만 끊김 → **새 열린 질문**.
- **[low] `NilHandler``setLength`/`setOffsetSource` 호출 순서가 같은
문서의 해제 순서 계약과 반대** → 뒤집음. `ProcessedPreRef`/`PostRef`
핸들러도 같은 순서 오류가 **이번 세션 이전부터** 있어서 같이 고침.
- **[low]** `architecture.md` 정정 배너가 원문을 "콜론"이 아니라 "콜백"
메서드로 오인용(정정하려는 문장의 뜻이 뒤집힘), ROADMAP 433행에 리네임
전 "대기 중/잠정 표기" 잔여, `documentation-content-map.md``D` 스윕에서
누락.
## 절차 변경 — 감사 루프를 병렬에서 순차로 (사용자 지침)
이번 라운드의 토큰 소비를 보고 사용자가 감사 절차 자체를 바꿨다:
> "시간이 걸려도 상관 없으니, 차라리 병렬 에이전트를 덜 써줘. 그냥 한 턴에
> 하나씩만 사용하고, 0 이 나올때 까지 턴 수를 늘리는게 나아보임. 토큰을
> 너무 많이 소비해서 다른 작업을 못 하고, 세션 한도에 닿더라고."
`conventions.md`의 감사 루프 절을 그에 맞게 재작성했다 — (1) **병렬 금지,
한 턴에 하나**(2026-08-16의 "최소 2개 병렬" 지침을 대체), (2) **프롬프트로
범위를 diff로 좁힐 것**(전 코퍼스 스윕은 오래 안 돌렸을 때만), 라운드마다
각도를 바꿔 병렬로 얻던 폭을 턴으로 얻는다, (3) 종료 조건은 **무발견 1회**
(옛 "무발견 2연속"은 병렬 다패스 전제라 같이 완화), (4) 비용 때문에 중간에
멈출 때도 "몇 라운드에서 왜 멈췄는지" 반드시 보고.
같이 명문화한 것: **`/code-review`는 감사자를 대체하지 않는다** — 이번에
감사자 1패스(1건) 뒤 `/code-review high`가 10건을 더 잡았고 전부 유효했다.
보는 축이 달라서다(감사자=코퍼스 전체 정합성, code-review=diff 자체의 결함).
`/code-review`는 사용자만 호출할 수 있으므로, 큰 변경 커밋 전엔 돌릴지
물어보는 것을 관례로 넣었다.
## 도구/절차 메모
- `doc-check.py`: 처음 돌렸을 때 ERROR 6건 — 전부 **내가 절 제목을 바꾸는
바람에 다른 문서의 인용이 깨진 것**과, ROADMAP blockquote 안에서 인용을
줄바꿈에 걸친 것(`conventions.md`가 이미 경고한 실패 모드를 그대로 밟음).
고쳐서 **ERROR 0**, WARN 8은 전부 이 세션 이전부터 있던 것.
- 그 QA 문서는 지우지 않고 **근거 기록으로 격하**(상단
배너 교체) — 사용자 답변 원문이 그대로 남아 있어야 나중에 되짚을 수 있음.

View file

@ -0,0 +1,115 @@
# 2026-08-19 — `New()`의 내부 구성: InitXxx 팩토리 체이닝 + `Relate` 기반 멱등 Init 가드
**요청**: `Quad.New()`가 실제로 어떻게 구현돼야 하는지에 대한 사용자
아이디어 검토 요청으로 시작 — 결론까지 나서 `base/`에 반영, 이어서
핸드오버 감사 루프와 세션 기록 공백 점검까지 같은 세션에서 처리.
## 1. 제안 — `New()`를 InitXxx 팩토리 체이닝으로
사용자 원문: *"New() 가 실행되면 Quad 를 만드는 함수가 있는것으로 처음부터
구현하는게 맞음. ... New 결과 안에 .New 함수를 넣어줌. 즉, 생성형식 자체는
비싱글톤이고, Dispatch 같은것도 Init(module) 을 받는 함수로써 ...
module.Dispatch = ... 형식들로 구현되고 ... 재익스포트식으로 구현하겠다는
이야기였음. 처음부터 InitModuleName 식으로 구현하여 팩토리를 쌓아 모듈을
리턴하는 방식으로, quad v1 의 방식을 가져와봄직 하다는것."*
**조사**: 기존 `base/architecture.md` 13번("모듈은 기본 싱글톤, `New()`
추가 인스턴스가 필요할 때만")과 14번("pluggable 초기화는 팩토리 함수로"),
`module-lifecycle-plan.md`가 이미 `InitRoblox(Module)` 형태의 backend 주입
패턴을 확정해뒀다는 걸 확인 — 이번 제안은 그 패턴을 quad-base **자기
자신의 내부 구성**(Dispatch 등)에도 대칭 적용하자는 것이라 새 설계가
아니라 기존 원칙의 자연스러운 확장으로 판단. `lifecycle-pattern.md`
거부한 rbvm `InitNamespace` 패턴(소비자가 라이브러리마다 수동으로 init을
부르는 것)과도 안 겹침 — 여기선 `New()` 하나만 외부에 노출되고 내부에서만
`InitXxx(module)`를 부름.
**결론**: 채택 추천 — `module = {New = New}` 자기참조도 이미 확정된 결정과
정확히 일치, `type Dispatch = InitDispatch.Dispatch` 재익스포트만 실제
Luau 동작 확인 필요하다고 남겨둠.
**사용자 확인**: "그거 타입 익스포트 잘 됨. 구체화 반영해줘." → 실측
확인됐다는 뜻으로 받아 `module-lifecycle-plan.md`에 "New()의 내부 구성"
절 신설, `architecture.md` 13번에서 포인터 연결.
## 2. 정제 — Init을 `require`처럼 멱등하게
사용자 원문: *"Init 은 require 처럼 생각 가능한듯. Init 여러번은 한번만
작동하게 자신 모듈 최상단에 Relate 를 (함수 안 아님. 클로저 바깥) 놓고,
자신 모듈의 init 여부를 저장해. 그리고 한번만 작동하도록 두고, 자신 init
에선 필요한것들을 init 해줘. 디펜던시 느낌인거지. ... 맨 바깥 quad-base
진입점의 New 에선 모든 Init 을 그냥 실행해도 돼."*
이게 1절 문서화 때 "구현 단계에서 정할 것"으로 남겨뒀던 "서브시스템 간
`InitXxx` 호출 순서 의존성" 문제를 실제로 푼다 — 각 `InitXxx` 파일이 자기
톱레벨(클로저 밖)에 `local relate = Relate()`를 두고 `module`을 weak key로
"이미 이 인스턴스에 Init됐는지"를 기록하면, 의존하는 쪽이 자기 의존성을
직접 호출해도 중복/순서 걱정이 없어짐(멱등) — `require`가 파일 단위로 하는
캐싱을, `New()`가 여러 `module` 인스턴스를 만들 수 있다는 차이 때문에
인스턴스 단위로 다시 구현하는 것.
`relate-plan.md`의 확정 API(`Relate()`/`SetWeak`/`GetWeak`/`SetStrong`/
`GetStrong`, "각 모듈이 자기 톱레벨에 `Relate()` 하나 재사용" 관례)와
정확히 부합함을 확인 — 새 메커니즘이 아니라 기존 프리미티브의 정확한
용례. `module-lifecycle-plan.md`에 반영, 순환 의존 대비를 위해 플래그를
실제 작업 전에 먼저 세우는 규칙도 같이 명문화.
## 3. 핸드오버 감사 루프 (2라운드, `conventions.md`의 "핸드오버 준비하고
커밋해" 절차)
바뀐 파일: `base/architecture.md`, `base/module-lifecycle-plan.md`,
`base/dispatch-core-plan.md`, `README.md`, `ROADMAP.md`.
**라운드 1**(`quad-doc-auditor`, agentId `ad95c24230306ab0f`) — 확실 3건 +
의심 3건 + 사용자판단 1건:
- 확실: `architecture.md`의 "M0 스캐폴딩에 주는 함의" 불릿이 이미 InitXxx
절이 답한 질문을 여전히 미결정으로 서술 / `README.md` 색인에 새 절 요약
누락 / `module-lifecycle-plan.md`의 "지금은 없음"이 날짜 없는 시한부
주장.
- 의심: `_initializedBy` 상호 참조가 정의를 못 찾게 함(`bind-system-plan.md`
누락) / GC 인과 서술이 `relate-plan.md`의 "`inst`는 항상 weak" 규칙과
어긋나게 읽힘 / `dispatch-core-plan.md`가 새 절을 안 가리켜 상호참조 누락.
- 사용자판단(문서만으론 못 정함, 이번엔 직접 판단해 처리): InitXxx 구조가
M0/M1 중 어느 마일스톤부터인지 → `ROADMAP.md`의 "M0 — 스켈레톤 +
기술검증" 절 실제 내용(스파이크 전용, "진짜 마일스톤 아님")을 근거로
**M1**로 확정, `ROADMAP.md` M1 체크리스트에 항목 신설.
전부 반영(위 6곳 수정 + M1 체크박스 추가).
**라운드 2**(agentId `a1d60d10fc36621ed`) — 라운드 1 수정 자체가 새 stale
2건을 만든 걸 발견:
- `architecture.md`가 인용하던 "M0 스캐폴딩에 주는 함의"라는 절 제목
문구를 라운드 1 수정이 지워버려 절 인용 규약 사각지대(파일명 없는 같은
문서 내 인용이라 `doc-check.py`가 안 잡음) 발생 → 원래 제목 문구를 불릿
맨 앞에 복원하면서 내용만 정정.
- "위 'M0 — 스켈레톤 + 기술검증' 절 참고"가 실제로는 `ROADMAP.md` 안의
절인데 파일명이 빠져 같은 문서 안인 것처럼 읽힘 → `ROADMAP.md`의 명시.
- 추가로 "확실": 이 새 절 자체가 사용자 발언 3건을 인용하면서
`session/2026-08-19-*.md` 포인터가 없음(이 문서가 그 포인터).
- 의심: `relate-plan.md`의 "언제 Relate를 쓰는가" 체크리스트에 새 용례가
안 실림 → 다섯 번째 불릿 추가.
- 사용자판단: Init-완료 플래그 값(`true`)을 `SetWeak`/`SetStrong` 중 뭘로
적을지가 문서 간 안 맞음 → boolean은 GC 대상이 아니라 실질 차이는 없지만
`relate-plan.md`의 일반 규칙("다른 곳에서 안 붙잡는 값은 Strong") 기준
**`SetStrong`으로 통일**.
전부 반영. 라운드 3은 이 문서 작성 이후 진행 예정(아래 미해결 참고).
## 4. 세션 기록 공백 발견 (사용자가 감사 진행 중 별도로 제기)
사용자 질문: *"세션 기록들 요즘 왜 안 적어? ... 18일 자가 하나 뿐이네."*
확인 결과 — `git log`엔 2026-08-18에 커밋 10개(QA 1~2라운드, 감사 루프
재설계, GitHub co-author 정책, git 원격 정책 등), 2026-08-19에 커밋 1개
(QA 3라운드)가 있는데, `session/`엔 2026-08-18 파일이 `pre-implementation-qa-applied.md`
**하나뿐**이고 2026-08-19 파일은 이 문서 이전엔 **0개**였다. 즉 QA
2라운드/3라운드(`todos.md` 00번이 상세히 서술하는, RC-1/RC-3/RC-4를 실제로
찾아 해결한 세션들)와 tooling/research 커밋 다수가 session/ 원문 없이
커밋됨 — 실제 공백.
**한계**: 이 세션은 그 과거 대화의 실제 트랜스크립트에 접근할 수 없다(커밋
메시지와 현재 파일 상태만 볼 수 있음) — `session/`의 정의 자체가 "시행착오
포함 원문"이라, 원문을 못 본 채로 "raw log"를 지어내면 오히려 그 자체가
허위 기록이 된다. 그래서 이 문서는 **이번 세션분만** 원문으로 채웠고,
과거 공백(08-18 QA 2/3라운드 등)을 어떻게 처리할지는 사용자에게 별도로
물어야 함(요약만 `session-summary.md`에 사후 추가할지, 아예 공백으로
인정하고 넘어갈지 등 — 이 문서 자체가 그 판단의 근거 자료).

3
.claude/settings.json Normal file
View file

@ -0,0 +1,3 @@
{
"includeCoAuthoredBy": false
}

View file

@ -5,6 +5,72 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드
전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
`.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면
반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다.
2라운드는 `:List``reconcile`/`recompute` 같은 확정 의사코드를 실제로
손으로 실행해보는 작업(원본과 진행 로그는
`.claude/qa-request/pre-implementation-qa-round2.md`가 소스) — `recompute`
트레이싱에서 `Frame{A,B}`처럼 정적 자식 2개짜리도 첫 마운트에 크래시하는
경로(`RC-1`)를 찾았고, 같은 날 후속 대화에서 사용자가 직접 제시한
Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다
(`archive/question-resolved.md`의 `RC-1` 절).
**3라운드(완료, `.claude/qa-request/pre-implementation-qa-round3.md`
소스) — `RC-1` 해법이 실제로 `attachSlot`에 반영된 걸 트레이싱하다
새 문제 발견, 같은 세션에 전부 해결·반영까지 완료.** 처음엔 `activateList`
자기 Slot의 Blocker가 켜지기 **전에** 실행돼 `:List` 초기 population이
문제(`RC-3`/`RC-4`)를 낸다고 봤고, `recompute`가 의존하는 `bk.N`(순회
상한)의 수명주기도 문서에 없어 "고정값/그때그때 실제 개수 둘 다 각기
다른 방식으로 깨진다"고 판단했으나 — **사용자가 이 분석 자체를
정정**했다: Blocker 게이팅은 `bk.N`이 아니라 `blocker:IsOn()`만 보므로
"그때그때 실제 개수" 모델이 배치 크래시를 되돌린다는 결론은 틀렸었다
(`bk.N` = 그때그때 실제 개수로 확정). `RC-3`/`RC-4`도 사용자가 더
단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot`
`slot._mounted = true``activateList` 호출 뒤로 옮기는 것 하나로
둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에
`bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M2가 M3의
`Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영(마일스톤
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
마일스톤 정합성" 절 참고).
**아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀
있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
3번에도 올라가
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
전 필요**. `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
방향은 확정, 키 설계가 미정. M10 착수 전 필요.
- **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply`
시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도.
- **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md`
`:Unsubscribe()` 절) — M3 착수 전 확인.
- **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림.
- **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) —
지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정,
2026-08-18 `/code-review high``ROADMAP.md`의 M6 PopOnly 체크박스와
대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`)
아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
- **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`)
— M0에서 실측 확인.
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
핸들 계약)는 13차 세션에, `0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치
@ -17,7 +83,10 @@
재도입**됨(2026-08-14 다섯 번째 세션에 하나로 합쳤던 걸 부분적으로
되짚음 — "이미 묶여 있는가"(bound 문맥)와 "지금 발화해도 되는가"
(execute 문맥)는 판정 로직은 공유해도 호출부의 질문이 다르다는 사용자
지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절).
지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절.
**[정정, 2026-08-18] 두 predicate는 값이 같은 게 아니라 서로의 부정**이고
게이트는 항상 `if not canBound(v) then error(...)` 모양이다 — 그 문서의
같은 절이 소스).
`question.md`엔 이제 "결정 대기" 절 자체가 없음(비어서 헤딩째로 삭제).
**M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라
@ -72,7 +141,8 @@
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
— 아직 진짜로 열려있는 것만 짚으면: `DI`→`D`(1순위), `Slot`(2순위),
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영
완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위),
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
@ -113,6 +183,14 @@
백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는
네 라운드로 대부분 확정됐고 남은 열린 질문은 `question.md` 3번(개수는
거기도 반복 안 함 — 소스는 `research/debounce-throttle-plan.md` 12절).
**[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로
신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll`
(`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때
`AbsoluteSize`/`AbsolutePosition` 갱신 여부 실측 필요)과 스프링 물리
기반 지속 업데이트 프리미티브 `quad-spring`(`research/spring-plan.md`,
참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의
전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계
게이트와 무관.
5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중
(`HUMAN_TODO.md` 2번 항목).
6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include

View file

@ -85,6 +85,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
소스 트리 구조 확정" 절 그대로)
- [ ] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
`luau` CLI, `architecture.md` "테스트 전략" 절 참고)
- [ ] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
그대로, 2026-08-19 확정)
- [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작
## M2 — 디스패치 엔진
@ -132,7 +136,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
좁혀야 함(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로 제외
항 하나 추가, `isPostRef``isRef` 아래 형제로 신설). `isModifier`
여전히 단순 항등, 상위 개념 없음. **[정정, 2026-08-11 아홉 번째
세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 DI
세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 특수
predicate, 해시파트 `k`를 판별)와 `isAttribute`(그룹 값 predicate,
array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹
`Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두
@ -184,7 +188,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
(2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset"
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소)
절 — `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`
(`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는
`Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면
(`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행)
만들 필요가 있는지 사용자 판단 필요 —
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
정합성" 절.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
@ -200,7 +221,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider
미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md`
1-3/1-4), 핸들러 등록/정렬 시점 동률 감지 print 경고 +
`Dispatch.listHandlers()` 디버그 유틸
`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/
@ -234,6 +258,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M3 — Store/State/Source
- [ ] `Source.luau`/`State.luau`/`Store.luau`
- [ ] **[2026-08-18 신설]** `store:GetDynamic<<T>>(name): Source<T>` — 런타임에
이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은
기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저
확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지
아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 3번)
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
@ -295,9 +324,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
호출"로 정정 — 진짜 독립 경로는 둘뿐).
**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`
폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에
다시 갈라짐]** — "이미 유효하게 묶여 있다"(bound 문맥)와 "지금
발화해도 되는가"(execute 문맥)는 판정값은 같아도 호출부의 질문이
달라, `Ref` 이중 배치 방지(`question.md` 0-W)를 계기로 `canBound`
다시 갈라짐]** — "지금 묶어도 되는가"(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` 필드가
@ -330,7 +362,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M5 — quad-roblox 최소 프로바이더
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드)
- [ ] `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드)
- [ ] `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/
@ -362,8 +394,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 —
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
**쓰는 자리 둘**: `SlotHandler.process`가 반환하는 클로저, `:List`
`reconcile`. **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`.
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
`:List``reconcile`**값 교체와 `PopOnly`(가칭) 경로만**.
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
"reconcile은 전부 비파괴" 일반화가 `:List`엔 안 맞았음 —
`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이 소스).
- **해제 시 owner 등록 되돌리는 순서 고정**
`setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**.
반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset
@ -400,6 +437,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
@ -426,9 +465,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `DI.InstSlot =
Slot<<Instance>>`(`DI` 네임스페이스 이름 자체는 `question.md` 1번
용어정리 대기 중, 여기선 잠정 표기)가 quad-roblox의 사실상 유일한
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
`question.md` 1번 용어정리 항목은 해소되어
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를
@ -438,7 +478,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
userdata: UD?): (T|nil, UD?)`가 **매 reconcile 사이클마다 호출**
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
저비용 재사용 경로. 파라미터 순서는 반환값 순서(`prev`류 먼저,
저비용 재사용 경로.
**[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`
`PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지
않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한
userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트).
`Instance.new`/`Destroy` 비용을 아끼는 filter용 경로.
**⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드
중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요
(`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`
`prev`보다 앞이었음).
**`updateFn``index``keyFn`의 raw `index`(원본 `data` 배열
@ -503,6 +551,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
@ -584,7 +635,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`
쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨**
그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄
(`base/dispatch-core-plan.md`)
(`base/dispatch-core-plan.md`).
**[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive``None` 스킵
분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`
도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고,
실제 정리는 아래 `NilHandler`가 맡는다
- [ ] **[2026-08-18 신설]** `NilHandler``isHandlable`
`type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러.
`Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)`
등록이 이 핸들러의 일이고 재귀는 안 함(`State<Slot|nil>`도 정상
동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`
"`NilHandler`" 절)
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
@ -699,16 +760,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> `AttributeGroupHandler`는 참조 카운트/이름 claim **알고리즘 구현**일
> 뿐 — `HANDLER_PRIORITY_FALLBACK`에 실제로 등록되는 건 이를 감싸는
> 별도 파일 `TagFallbackHandler`/`AttributeKeyFallbackHandler`/
> `AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈
> 자체가 아니라 **백엔드 팩토리**(`RobloxFactory`가 `BaseModule`
> 뮤테이션 시점에 자기 전용 Handler들과 같이 등록). 아래 체크리스트의
> `AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA]
> 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드
> 상태에서도 안내 에러 경로가 돌아야 하기 때문 —
> `base/dispatch-core-plan.md`의 해당 절). 아래 체크리스트의
> `Handler` 파일 항목은 전부 이 구분을 반영하도록 갱신됨 — 뒤집힌
> 옛 모델은
> `archive/tag-attribute-load-time-registration-reversed.md`.
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
- [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler,
- [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler,
`GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인
명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
(`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
@ -723,7 +785,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` +
이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리
`String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리
(`Color3Attribute`류)만 quad-roblox의 `D`/`DI` 층에서 각자 추가.
(`Color3Attribute`류)만 quad-roblox의 `D`(Declarative) 층에서 각자 추가.
타입 파라미터화 이름만 착수 전 확인, `base/attribute-plan.md`)
- [ ] `quad-base/Dispatch/AttributeKey.luau`(`AttributeKeyHandler` —
`setAttribute(inst,name,v)``v`가 뭐든 무조건 호출 + **이름
@ -735,13 +797,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/
AttributeKeyFallback.luau`(`AttributeKeyFallbackHandler` — 위
`AttributeKeyHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로
등록되는 별도 이름의 엔티티. 등록 주체는 `RobloxFactory`
`BaseModule` 뮤테이션 시점에 자기 전용 Handler들과 같이
등록되는 별도 이름의 엔티티. **[재역전, 2026-08-18] 등록 주체는
`RobloxFactory`가 아니라 quad-base 자신**
`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는
엔진 op" 절)
- [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1,
store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형 array-part 값 객체,
`base/attribute-plan.md`)
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)`만 부르고, 반환 클로저가 자기가
@ -755,7 +818,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
AttributeGroupFallback.luau`(`AttributeGroupFallbackHandler` — 위
`AttributeGroupHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로
등록되는 별도 이름의 엔티티, 등록 주체는 `AttributeKeyFallbackHandler`
동일하게 `RobloxFactory`)
동일하게 **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 값 객체로
@ -772,7 +835,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/
TagFallback.luau`(`TagFallbackHandler` — 위 `TagHandler`를 그대로
감싸 `HANDLER_PRIORITY_FALLBACK`으로 등록되는 별도 이름의 엔티티,
등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 `RobloxFactory`)
등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 **quad-base
자신** — [재역전, 2026-08-18])
- [ ] **[2026-08-14 세션에 누락 발견, 신규]** `quad-roblox/Handlers/
InstanceShorthand.luau` — UI 편의 숏핸드 `UICorner`/`UIPadding`
(+`UIPaddingOffset`)/`UIScale`(`base/ui-shorthand-plan.md`). 이
@ -822,8 +886,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## 특정 마일스톤에 안 묶이고 병행 가능
- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`
대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로
- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`
대체·해소됨, `DI`→`D`는 2026-08-18 확정·반영 완료) — `.claude/question.md` 1번, 최종 이름 확정되는 대로
아무 시점에나
- [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록,
필요하면 `.claude/session-summary.md` "세션 히스토리"도 갱신(전체 원문은

View file

@ -1,2 +1,37 @@
- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용.
- Git 레포지토리 접근은 원격별로 용도가 분리되어 있음(2026-08-18 기준,
`qwreey-bot` 제한 계정 도입 후 갱신):
- `origin` (`https://git.qwreey.moe/qwreey-bot/quad.git`) — **메인
작업 공간.** 모델이 어디로 튈지 모르니 기본 작업은 GitHub이 아닌
여기(비-GitHub 제한 계정)에서만 함. 자유롭게 push 가능.
- `github` (`https://github.com/qwreey-bot/quad`) — `upstream`을 포크한,
같은 `qwreey-bot` 계정 소유 레포. **사용자가 명시적으로 "싱크"를
요청할 때만** push — 그 후 사용자가 GitHub 웹 GUI에서 이 포크 기준
PR을 만들어 변경사항을 검토함. 기본 push 대상 아님, 사용자 요청 없이
먼저 올리지 말 것.
- `upstream` (`https://github.com/qwreey/quad`) — 원본 레포(모델
계정 소유 아님). **pull/fetch 전용, 절대 push 금지** — 다른 경로로
생긴 변경사항을 당겨오기 위해서만 존재.
- Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함
- **[2026-08-18 직접 점검 확인]** 모델이 안전하게 쓸 수 있는 유틸 — 아래
둘 다 실제로 위험 옵션을 하나씩 시도해 authz/격리가 거부하는지 확인한
결과(세션 로그에 전체 시도 목록 있음, 여기는 결론만).
- **code-docker 컨테이너 자체**: 일반 로컬 작업(빌드/테스트/파일 조작)에
자유롭게 사용 가능. non-privileged, `SYS_ADMIN`/`NET_ADMIN`/
`SYS_MODULE`/`SYS_RAWIO` 등 위험 capability 없음, seccomp 필터
활성화, PID 네임스페이스 격리(호스트/다른 컨테이너 프로세스 안 보임),
`docker.sock` 등 호스트 제어 소켓 미장착, 루트 파일시스템은 overlay
(호스트 바인드 아님). 호스트 자체도 Proxmox VM이라 컨테이너 탈출
성공해도 물리 하이퍼바이저 직행은 아님. 남는 약점은 uid 0로 실행 +
AppArmor `unconfined`뿐 — 알려진 흔한 탈출 경로는 다 막혀 있음.
- **dind**(`DOCKER_HOST=tcp://dind:2375`, code-docker와 같은 호스트
네임스페이스 공유): 컨테이너 빌드/실행 용도로 사용 가능, `dind-authz`
플러그인이 위험 옵션을 화이트리스트 방식으로 차단함 — 직접 확인된
거부 목록: `--privileged`, `--cap-add=SYS_ADMIN`/`ALL`, `--pid=host`/
`--net=host`/`--ipc=host`, 호스트 임의 경로 마운트(`/`, `/etc`,
`docker.sock`, `-v`/`--mount` 문법 둘 다 — **경로 순회(`../..`)로
허용 prefix를 흉내내는 우회도 막힘**, 즉 문자열 prefix 매칭이 아니라
정규화된 경로로 검사함), `--device` passthrough,
`--security-opt seccomp=unconfined`/`apparmor=unconfined`. 허용되는
건 현재 프로젝트 디렉토리(`./`) 바인드 마운트뿐(편의 기능으로 의도된
것) — 이 범위를 벗어나는 새 시도를 할 땐 이 목록이 최신인지 다시
확인할 것(authz 정책이 바뀌면 이 서술도 갱신 필요).