design: 손 트레이싱 6라운드 전량 처리·반영 — H-1~H-54 확정, M2/M3 설계 게이트 해소

사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.

**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).

## 구조가 바뀐 것 넷

- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
  기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
  강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
  realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
  업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
  깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
  신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
  시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
  `getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
  되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
  Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다

요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).

## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿

`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.

## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건

**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.

**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).

**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.

## 백로그 하나

`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.

**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
This commit is contained in:
qwreey 2026-08-24 18:54:14 +09:00
parent 068971fd56
commit 19cd046275
Signed by: qwreey
GPG key ID: D28DB79297A214BD
30 changed files with 2440 additions and 310 deletions

File diff suppressed because one or more lines are too long

View file

@ -271,6 +271,8 @@ quad/
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
│ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨**`InitDebug(module)`, `module.debug = false`(`base/project-setup-plan.md`)
│ ├── Dispatch/
│ │ ├── 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 클로저를 반환)
@ -280,7 +282,8 @@ quad/
│ │ ├── 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 재조정 로직(추상 자식 참조 기준)
│ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`)
│ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
@ -292,7 +295,7 @@ quad/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절). **[2026-08-24 6라운드 신설] `isInst(value): boolean`**(`H-40` — 요소 타입 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, quad-roblox는 `typeof(value) == "Instance"`)**와 `onDestroying(inst, fn): Connection`**(`H-11` — `Effect`의 leaf 사망 cleanup을 발화시키는 훅, `bindLifetime``isEffect`일 때 부른다, quad-roblox는 `inst.Destroying:Connect(fn)`). **⚠️ 이 둘은 `native*`의 "미주입이면 조합 폴백" 규칙의 예외다 — 조작이 아니라 판정/훅이라 조합으로 만들 수 없어 미주입이면 명확한 에러**(`addTag`/`setAttribute`와 같은 취급)
├── 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`)

View file

@ -265,6 +265,37 @@ end
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
`Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다.
- **⚠️⚠️ [2026-08-24 신설, 6라운드 손 트레이싱 `H-18`/`H-45`] 이름을 **서로 다른
두 자리 사이에서** 옮기는 것은 **UB다.** 아래 "해제 → 재클레임 순서" 보장은
**한 `(inst,k)` 체인 안에서만** 성립한다("자기 자신과 충돌하는 일이 없음"의
마지막 다섯 글자가 정확히 그 뜻이다).
```lua
Frame {
groupA, -- k=1, State<Attribute>. 지금 {"Hp"}를 잡고 있음
groupB, -- k=2, State<Attribute>. 지금 {}
}
-- 런타임에: A는 "Hp"를 놓고, B가 "Hp"를 가져간다
```
두 자리는 **완전히 별개의 체인**이고 각자 자기 `StoreBind`의 Observer가
독립적으로 깨어난다. `groupB` 쪽 emit이 먼저 처리되면 `nameClaims`가 아직
`groupA`의 키를 가리키므로 **`attribute "Hp" is already bound by another
owner` error**가 난다. `groupA` 쪽이 먼저면 정상 동작한다 — **같은 코드가
emit 순서에 따라 성공하거나 크래시한다.** 단건 `AttributeKey` ↔ 그룹
사이의 이전(`H-45`)도 정확히 같은 모양이다.
**확정: 막지 않는다.** **사용자 판단**(2026-08-24): *"state<attribute> 로 그
경로가 열리는데 어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이
없음. 외부에서 먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임.
애초에 싱크이기도 하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한
예외들이 생기고 오버 엔지니어링으로 보임."*
- 검토했다 기각된 대안 둘: **claim 반납을 2단계로**(재프로세스 사이클 동안
"예약 해제" 상태를 두고 사이클 끝에 sweep — `Dispatch`에 사이클 개념을
들여야 해서 비쌈), **이전 소유자 생존 확인 후 인수**(판정 수단이 없고,
그걸 만들면 위 인용의 "예외들"이 정확히 그것이다).
- **저작 지침**: 이름을 다른 자리로 옮기려면 **놓는 쪽을 먼저 커밋**할 것.
한 프레임 안에서 동시에 하지 말 것.
- **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는
`slot.retractor(v)``h.process(...)` 순서이고, 핸들러가 바뀌는 경우는
`retractFrom``process` 순서(`base/dispatch-core-plan.md` "Dispatch
@ -390,7 +421,31 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
클로저가 "이 호출이 등록한 키 목록"을 직접 캡처:
```lua
-- [명시화, 2026-08-24 6라운드 `H-52`] `isHandlable`이 산문으로만 "배열 파트"라
-- 서술돼 있고 코드가 없었다 — `TagHandler`와 같은 모양으로 명시한다.
AttributeGroupHandler.isHandlable(inst, k, v) = (type(k) == "number" and isAttribute(v))
function AttributeGroupHandler.process(inst, k, v, index)
-- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다.
-- 그룹 핸들러는 "다른 키로 위임"하는 성격이라 층위 질문이 있었지만,
-- Tag/Attribute를 "Length/Offset 비참여 카테고리"로 재정의하면 `bk.N`
-- 의미가 바뀌어 파급이 커서 **다른 말단 핸들러와 똑같이 등록**하는 것으로
-- 확정(사용자, 2026-08-24). 없으면 `Frame { Attribute(store), Slot() }`
-- 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다.
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
-- [2026-08-24 6라운드 `H-41`] **`groupClaimKeys` 위치 claim** — 5라운드
-- `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고 의사코드에 배선되지
-- 않았다. `nameClaims`보다 **먼저**여야 한다(같은 그룹의 두 번째 위치는
-- 이름 claim까지 갈 것 없이 여기서 거부되어야 `nameClaims`에 절반만
-- 기록되는 중간 상태가 안 생긴다 — 위 그 항목이 소스).
local claimed = groupClaimKeys:GetStrong(inst, v)
if claimed ~= nil and claimed ~= k then
error("Attribute: 같은 그룹 값이 이 인스턴스의 두 위치에 놓임")
end
groupClaimKeys:SetStrong(inst, v, k)
local keys = {}
for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장
-- 그룹 전용 키(비공개) — 공개 AttributeKey(name)이 아님.
@ -407,6 +462,11 @@ function AttributeGroupHandler.process(inst, k, v, index)
for key in pairs(keys) do
Dispatch.retractFrom(inst, key, 1)
end
-- [2026-08-24 `H-41`] 위치 claim도 반납 — **자기 `k`일 때만**
-- (위 그 항목의 확정: "retract에서 자기 `k`일 때만 지운다").
if groupClaimKeys:GetStrong(inst, v) == k then
groupClaimKeys:SetStrong(inst, v, nil)
end
end
end
```

View file

@ -57,6 +57,15 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을
-- blocker의 weak 배열에 등록.
blocker:Policy(emit) -> onUpstreamEmit
-- [2026-08-24 신설] 이 blocker의 게이트 정책을
-- **값으로** 돌려준다. `state:Block(b)`
-- 내부에서 쓰는 바로 그것:
-- state:Block(b)
-- == state:Gate(function(emit) return b:Policy(emit) end)
-- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)`
-- 적었었는데 그건 언바운드 메소드라 `emit``self`
-- 자리에 들어가 게이트가 영원히 안 열린다.
```
gated state의 동작:
@ -85,8 +94,22 @@ gated state의 동작:
정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy`
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
**공개 표면(`Blocker()` 생성자와 `state:Block`)은 안 바뀐다** — 배선만
공용 노드를 쓰는 것.
**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로
꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고,
`Blocker()` 생성자와 `state:Block`은 그대로다.
- **왜 필요한가**: `Debounce`/`Throttle`이 "언제 통과시킬지"를 정하면서
실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수
있으면 그게 그냥 함수 합성이 된다 — `setup`이 곧
`(emit) -> onUpstreamEmit`이라 타입이 이미 맞는다.
- **정책이 하는 일은 안 바뀐다**`Policy(emit)`을 부르는 시점에 onunblock
핸들이 등록되므로(지금 `state:Block`이 하던 것과 같은 자리), `Off()`
풀 때 그 `emit`이 정확히 1회 불린다.
- **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당
하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성).
`pending` 같은 상태는 별도로 안 들고 `HasBlockedEmit`으로 흡수한다.
상세와 의사코드는 `base/gate-plan.md`의 5번 항목이 소스.
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서

View file

@ -227,7 +227,9 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴
있음(`base/ref-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은
부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라
테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자
배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건
집합은 반대로 `nil` 소진이 맞다는 정정이 따로 있음(**[2026-08-24 어휘 정정]**
한때 "배열"이라 적었으나 6라운드 `H-7``.Callbacks`는 **해시맵 셋**이 됐다 —
`t[k] = nil` 해제라 결론은 같고 이름만 바뀌었다) — 여기서 다루는 건
컴포넌트가 넘기는 **리터럴 children 배열**(순서가 중요한 배열)이라
그 정정과 무관하고 `None` 관용구가 계속 맞음).
그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**:

View file

@ -684,7 +684,7 @@ export type Timeout = {
호출 지점마다 캐스트를 흩뿌리는 방식은 나중에 누가 다른 필드를 더
끼워넣어도 아무도 모르는 게 문제였음 — quad가 타입 검사를 조용히 끄는
걸 싫어해온 것(`base/typing-limits.md`)과 결이 맞는 쪽으로 정리됨.
- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observer`,
- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observers`,
`slot._mountedInst`, `_fired`)와 일치.
- **`_native`에 뭘 담을지는 전적으로 백엔드 자유** — coroutine 하나일
수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는
@ -797,11 +797,67 @@ function clearTimeout(timeout: Timeout) timeout._native() end
## 7. 의사코드
> **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직
> 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미
> 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것.
> 아래 코드의 `registry`/`Handle` 배선도 마찬가지로 스케치 수준 — 실제
> 구현 시 이름은 자유.
> **⚠️ [2026-08-24 무효화 배너, 6라운드 손 트레이싱 `H-33`] 아래 의사코드의
> 골격은 확정된 API로는 성립하지 않는다 — 다시 쓸 것.** 옛 주의문은
> *"내부 훅 이름이 아직 가칭"*이라고만 말했는데, 문제는 이름이 아니라 **모양**
> 이다. 아래는 탑레벨 `Gate(self)` 생성자를 부르고 반환 객체에
> `.onUpstreamSignal`/`._flush`/`._cancel`을 **사후 대입**하는데, 확정된 형태는
> 정반대다:
>
> - `base/gate-plan.md`**`state:Gate(setup)`**(`setup: (emit) -> onUpstreamEmit`)로
> 못박으면서 *"탑레벨 `Gate(...)` 생성자는 안 만든다"*고 명시했다 →
> `Gate`라는 호출 가능한 값이 없어 `attempt to call a nil value`.
> - 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를 **반환**해야 하는
> 프로토콜과 안 맞는다.
> - 호출자는 State 하나만 받고 **노드 객체에 접근할 수 없으므로**
> `Handle:Set({ Flush = gate._flush, ... })`는 표현 자체가 불가능하다.
>
> **다시 쓸 방향은 정해져 있다**(사용자 확정 2026-08-24,
> `base/gate-plan.md`의 5번 항목이 소스): `Debounce`/`Throttle`은 **`emit`
> 아예 안 쥔다.** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나)
> **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는
> `blocker:Policy(emit)`이 돌려준 핸들에 위임한다:
>
> ```lua
> state:Gate(function(emit)
> local b = Blocker()
> local pass = b:Policy(emit)
> return function() -- 상류 emit 도착 (동기)
> -- 타이머/창 판단 후 b:On() / b:Off() / b:OffWithoutEmit()
> pass()
> end
> end)
> ```
>
> - `gate:passThrough()`**`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`.
> - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**.
> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`
> 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다.
> - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 —
> 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과
> 같은 우회).
>
> 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 —
> `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다.
**⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서
`pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를
`trailing`**무관하게** `pending = true`로 세우는데, `pending``false`
되돌리는 자리는 **전부** `if pending and trailing then` 안에 있다
(`onWindowEnd` / `MaxTime` 콜백 / `_flush` 셋 다). 그래서
`Debounce{Leading=true, Trailing=false, MaxTime=…}`(문서가 *"버스트 시작에 한
번만"*이라며 직접 드는 정상 사용례)에서 한 버스트에 신호가 둘 이상 오면:
- `MaxTime`이 매번 재무장되며 **영원히 아무 효과가 없고**,
- `opts.Handle`로 받은 `:Flush()`가 그 가드에 막혀 **영구 no-op**이 된다
(사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이
`pending = false`를 무조건 하므로 유일한 탈출구다.
**해소는 위 재작성에 흡수된다** — `pending`을 없애고 Blocker의
`HasBlockedEmit`을 쓰면 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어
이 결함이 구조적으로 성립하지 않는다(`Trailing = false`는 `OffWithoutEmit()`
으로 표현되고, 그건 보류분을 **버리면서** 상태도 같이 비운다). 아래 코드를
참고할 때 이 결함을 그대로 옮기지 말 것.
```lua
-- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절).
@ -936,15 +992,23 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
3.5 입력 → window == nil → leading 즉시 통과 ●
```
**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와
똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다** —
**(A) emit-gate가 붙는 자리(확정, 4절)**: `GateNode`는 다른 평범한 State와
똑같이 **매 `onUpstreamEmit` 진입 시 자기 `invalid`를 즉시 세운다** —
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 전파 규칙(**[2026-08-21]**
"항상"이 아니라 "같은 에포크의 두 번째만 접는다" — `base/state-epoch-plan.md`)이
게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`
실제로 미루는 건
**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려
있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한
채로 안 남음).
게이트 자신에게도 그대로 적용됨. 정책이 미루는 건 **다운스트림 통지(전파)뿐**
이지 invalid 세팅이 아님 — 그래서 창이 열려 있는 동안 `:Get()`을 불러도 항상
최신값이 계산됨(캐시가 stale한 채로 안 남음).
**⚠️ [용어 정정, 2026-08-24 6라운드 `H-33`] 이 문단도 위 무효화 배너의 적용
대상이다.** 원래 `gate.onUpstreamSignal` / `gate:passThrough()`라는 **가칭
스캐폴딩 이름**으로 쓰여 있었는데 둘 다 확정 API가 아니다 — 확정된 훅 이름은
`setup(emit) -> onUpstreamEmit`이고(`base/gate-plan.md`), 재작성 후
`Debounce`/`Throttle`은 `emit`을 직접 쥐지 않으므로 **`gate`라는 단일 객체를
호출부가 손에 쥐는 모양 자체가 없다**(대신 자기 `Blocker``On()`/`Off()`
한다). **이 문단이 말하는 의미론적 결론(즉시 invalidate, 전파만 지연 —
`Blocker`의 gated state와 동일)은 그대로 유효하고**, 바뀐 건 그걸 표현하는
이름뿐이다.
---

View file

@ -579,13 +579,31 @@ 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 게이팅" 절이 소스.
**[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결 / 범위 정정
2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** —
진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고,
**`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함)
`:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 —
상세 근거·`setLength`/`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는
아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스.
- **왜 "배열 파트"가 아니라 "`drive` 전체"인가**: 옛 문장은 *"배열 파트
순회 전체를 … (pre-pass/post-pass 포함)"*이었는데 두 군데가 안 맞았다.
(1) `F-4-1`이 확정한 **단일 일반화 `for` + `type(k) == "number"` 분기**
에선 "배열 파트가 끝나는 시점"이 루프 밖에서 관측되지 않는다 — 첫
비숫자 키를 만나는 순간이거나 루프 종료이고, 어느 쪽인지는 그 인스턴스의
props에 해시 키가 있느냐에 달렸다. (2) `postRefList` 소비는 애초에
**해시 파트보다 뒤**다(`base/ref-plan.md`의 `PostRef` 절). 그래서 실제로
감쌀 수 있는 범위는 `drive` 전체다. 넓어지는 것 자체는 무해하다 —
해시 파트는 `setLength`를 안 부른다.
- **⚠️ 따라서 `PostRef` 콜백은 게이트가 켜진 채로 실행된다.** 그 콜백은
사용자 코드이고, `base/ref-plan.md`가 드는 대표 용례부터가 "이 시점
이후의 동적 변경을 구분하는 플래그를 세우는 것"이라 **그 자리에서
`slot:Add(...)`류를 부르는 게 자연스럽다.** 그러면 그 Slot 자신의
blocker는 이미 꺼져 있어 자기 `recompute`는 정상적으로 돌지만, 바뀐
`slot.Length`가 emit되어 올라온 부모 `inst``gatedRecompute`
**여기서 조용히 스킵된다.** 정합성은 직후의 명시적 `recompute(inst, bk)`
한 번에 전적으로 의존한다 — **[2026-08-24] 이걸 계약으로 못박는다**
(지금까지는 우연히 맞고 있었을 뿐 어디에도 적혀 있지 않았다).
**진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입
후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을
처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와
@ -874,10 +892,20 @@ Fallback Handler들도 존재하지 않아**, 위 "매치 실패는 즉시 `erro
**선택적 업그레이드**일 뿐
기본 요구사항은 아님 — base 기본 스텁 하나로도 이미 충분히 안전하게
실패함(`AttributeGroupHandler`의 "부분 실패 경로" 절이 이미 정리한
"에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙 + `nameClaims`/
`tagNameMap``inst`에 대해 weak라 그 인스턴스가 GC되면 잔여 부기도
같이 사라지는 것으로 충분히 커버됨), 더 깔끔한 실패를 원하는 백엔드만
추가로 얹으면 됨.
"에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙), 더 깔끔한 실패를
원하는 백엔드만 추가로 얹으면 됨.
- **⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-26`] 여기 원래 근거로 적혀
있던 *"`nameClaims`/`tagNameMap`이 `inst`에 대해 weak라 그 인스턴스가
GC되면 잔여 부기도 같이 사라진다"*는 **틀린 안전망 주장이라 삭제했다.***
quad는 자기가 만든 Instance마다 생성 즉시 gcconn을 걸고 그 클로저가
`inst`를 캡처하므로(`base/lifecycle-pattern.md`의 "(0)" 절),
**참조를 놓는 것만으로는 회수되지 않고 반드시 `Destroy`로만 회수된다.**
부분 실패한 `inst`에 대해 `Destroy`를 부를 주체가 없으면 그 인스턴스는
안 죽고, 따라서 weak 테이블의 엔트리도 안 사라진다. 남는 근거는 위의
"에러=패닉" 원칙 하나뿐이고, 그건 그대로 유효하다.
(부분 생성 후 예외로 생긴 Instance 자체의 회수 문제는 **백로그**
`Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는
대표 사용처라 그 둘을 구현할 때 같이 다룬다.)
- **타입 패밀리는 백엔드 몫**: `AttributeKey<<T>>` 제네릭 생성자와
스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`)
까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인
@ -1024,13 +1052,24 @@ end
| `NoneHandler` | 중간 | 없음(재위임만) |
| `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) |
| `PropertyHandler` | 말단 | 프로퍼티 세팅 |
| `TagHandler` | 말단 | `addTag`/`removeTag` |
| `TagHandler` | 말단 | `addTag`/`removeTag` (+ 부기 — `H-39`) |
| `AttributeKeyHandler` | 말단 | `setAttribute` |
| `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) |
| `AttributeGroupHandler` | 자기 체인에선 말단 | 다른 키로 위임 (+ 부기 — `H-39`) |
| `SlotHandler` | 말단 | 마운트/언마운트 |
| `RefLeafHandler` | 말단 | `Ref:Set` |
| `RefLeafHandler` | 말단 | `Ref:Set` (+ 부기 — `H-39`) |
| `ObserverEffectLeafHandler` | 말단 | `bindLifetime` (+ 부기 — `H-39`) |
| `ProcessedPreRefHandler` / `ProcessedPostRefHandler` | 말단 | 없음(부기만) |
| `ProcessedModifierHandler` | 말단 | 없음(부기만 — `H-35`) |
| `UICornerHandler` | 말단 | 자식 Instance 생성/제거 |
**[2026-08-24 `H-39`/`H-35`] 이 표 자체가 비대칭을 드러내고 있었다** —
`NilHandler` 행엔 부기가 적혀 있는데 바로 아래 `RefLeafHandler` 행엔
`Ref:Set` 하나만 적혀 있었다. 말단 핸들러는 **예외 없이** 자기 배열 위치의
`setOffsetSource`/`setLength`를 등록한다(위 "짝을 맞춰 `0`" 문단의 `H-39`
블록). `ProcessedModifierHandler`는 이 표와 아래 등록 책임 열거 양쪽에서
통째로 빠져 있었다(`base/modifier-plan.md`의 flatten이 만드는 센티널의
전담 nop 핸들러 — `Modifier`가 하나라도 든 리터럴은 전부 이걸 거친다).
이 계약이 필요한 이유: (A) 분기는 **아래를 안 건드린 채** 중간 노드만
갈아치우므로, 중간 노드가 `inst`에 직접 손을 댔다면 그 흔적을 지울
주체가 없어짐.
@ -1378,6 +1417,11 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
**[2026-08-14 아홉 번째 세션] `PostRef` 소진 자리도 동일** —
`ProcessedPostRefHandler`(`base/ref-plan.md`의 "`PostRef`" 절)가 같은
두 등록을 하는 거울상 Handler라, 새 규칙 없이 그대로 맞물림.
**[2026-08-24 `H-35`] `Modifier` 소진 자리도 동일** —
`flatten`이 배열 자리를 `ProcessedModifier` 센티널로 소진하고
`ProcessedModifierHandler`(`base/modifier-plan.md`)가 같은 두 등록을 한다.
이 열거에서 통째로 빠져 있었다 — `Modifier`가 하나라도 든 리터럴은 **전부**
이 핸들러를 거치므로, 이 문서만 보고 구현하면 그 존재 자체를 놓친다.
**[정정, 2026-08-18 구현 전 QA] 값 자체가 `None`/`nil`인 자리도 이제
같은 원칙으로 덮인다** — `Dispatch.drive``None`을 건너뛰지 않으므로
그 자리는 `NoneHandler`(재귀만) → `NilHandler`(말단)를 거치고,
@ -1385,6 +1429,33 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
`State<Slot|None>`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도
같은 자리로 수렴한다.
**⭐⭐ [2026-08-24 6라운드 손 트레이싱 `H-39`] 말단 핸들러 넷이 이 계약을
지키지 않고 있었다 — 전부 등록을 추가한다.** 위 원칙이 *"둘 다 array part의
모든 number 인덱스에 대해 반드시 호출 — 생략은 UB"*이고 일반 `Ref`까지
명시적으로 지목해뒀는데, 전수 grep 결과 아래 넷은 `setLength`/`setOffsetSource`를
**한 번도 부르지 않았다**:
- `TagHandler`(`base/tag-plan.md`) — 0건
- `AttributeGroupHandler`(`base/attribute-plan.md`) — 0건
- `RefLeafHandler`(`base/ref-plan.md`) — `Processed*` 둘에만 있고 이쪽엔 없음
- `ObserverEffectLeafHandler`(`base/source-state-plan.md`) — 0건
`bk.N`**다른 위치가 등록될 때** `math.max`로 커지므로, 등록을 건너뛴
위치가 그 범위 안에 끼면 `recompute`가 그 자리에서 `sourceList[i] == nil`
만나 **명시적 error로 죽는다**(2026-08-20 `C-6`으로 관대한 skip에서 error로
승격된 그 자리). `Frame { Tag("card"), TextLabel { … } }`처럼 말단이 앞에
오는 아주 흔한 배치가 첫 마운트에 죽고, 같은 게
`Frame { Ref(myRef), Frame{} }` / `Frame { someObserver, Frame{} }` /
`Frame { Attribute(store), Slot() }`에서 그대로 재현된다. **해당 항목이 배열
맨 끝이면 `bk.N`이 거기까지 안 커져서 안 터지므로 "가끔 되고 가끔 터지는"
형태로 드러난다.**
확정: **넷 다 `process` 맨 앞에서 `setOffsetSource(inst, k, None)`
`setLength(inst, k, 0)`을 등록한다**(순서는 계약대로 offsetSource 먼저).
`AttributeGroupHandler`도 예외로 두지 않는다 — "그룹 핸들러는 다른 키로
위임하는 성격이라 층위가 다르지 않나"라는 갈래가 있었지만, Tag/Attribute를
"Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하면 `bk.N`의 의미가
바뀌어 파급이 크다(사용자 확정, 2026-08-24).
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
`setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
@ -1408,6 +1479,19 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장)
`Relate(parentInst)`에 lazy 생성.
**⭐ [2026-08-24 보강, 6라운드 손 트레이싱 `H-4`] 접두합 캐시 두 필드도 여기
소속이고, 초기값을 명시한다.** `offsetCache`/`invalidAfter`가 이 열거에
빠져 있어서 스펙상 존재하지 않는 필드였고, 초기값도 안 적혀 있었다.
`bk.N``nil`로 시작하는 lazy 규칙(그래서 `recompute``bk.N or 0`으로
방어한다)을 그대로 따르면 `invalidAfter``nil`인데, `getOffsetAt`
`if bk.invalidAfter == 0 then`으로 시작한다 — `nil == 0`은 거짓이라 다음 줄
`at <= bk.invalidAfter`에서 **`attempt to compare number with nil`**로 죽는다.
**첫 position의 `setOffsetSource`가 바로 이 경로**라 fresh `bk`에서 반드시 밟는다.
- **`getBookkeeping``bk`를 만들 때 `offsetCache = {}`, `invalidAfter = 0`으로
초기화한다.** (`bk.N`만 `nil` 시작을 유지한다 — 그쪽은 `or 0` 방어가 이미
자리를 잡았고 "아직 아무 자리도 등록 안 됨"과 "0번까지 유효"가 다른 뜻이다.)
**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner
타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔
`bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작
@ -1458,7 +1542,9 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
이전엔 여기도 `None`)
둘 다 실재하는 센티널로 채워 구멍을 피하는 것과 같은 원칙(`ref-plan.md`의
"Ref 일반화" 절 "왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서
최종적으로 `nil`로 되돌아간 건 Ref 콜백/대기자 배열 한정**이고
최종적으로 `nil`로 되돌아간 건 Ref 콜백/대기자 집합 한정**(**[2026-08-24]**
6라운드 `H-7`로 그건 배열이 아니라 **해시맵 셋**이 됐다 — 구멍 개념 자체가
없어져 이 대비가 오히려 더 선명해졌다)이고
`sourceList`/`flattened`처럼 순서가 실제로 중요하거나 "채워짐 여부"를
엄밀히 구별해야 하는 배열은 여전히 실재하는 센티널이 맞음, 헷갈리지
말 것). 다만 `recompute`
@ -1598,6 +1684,28 @@ end
| `spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제) | `i` | 같은 이유 — 삽입/삭제 후에도 `i` 자리의 offset은 여전히 `1..i-1`의 합이다 |
| owner의 베이스 변경(`ownerKey.Offset`이 바뀜 = `_baseObserver`가 도는 순간) | `0` | 1번 자리부터 전부 다시 |
**⭐ [2026-08-24 6라운드 손 트레이싱 `H-3`] 이 표는 산문으로만 있었고 실제
코드 경로가 하나도 없었다 — 배치할 자리를 명시한다.** 코퍼스 전체에서
`bk.invalidAfter`에 대입하는 코드는 `getOffsetAt` 안의 `= 1`/`= at` 둘뿐이었고,
`setLength``gatedRecompute``_baseObserver` 콜백도 `spliceArrays*`
캐시를 당기지 않았다. 그래서 `Frame { SlotA(Length 2), SlotB(Length 3) }`에서
`SlotA`가 3으로 커져도 `recompute``getOffsetAt(Frame, 2)`가 **캐시된 2를
그대로 반환**해 `SlotB.Offset`이 영원히 2에 고정된다(`sum`은 `lengthList`에서
매번 새로 더하므로 **위로는 맞고 옆으로만 틀린다** — 알아채기 특히 어렵다).
재마운트는 더 나쁘다: `bk``Relate(slot)` 위에 있어 언마운트를 넘어 살아남으므로
`offsetCache[1]`에 **옛 베이스**가 남는다.
세 자리 전부 **`recompute`보다 먼저** 당겨야 한다:
1. **`Dispatch.setLength(ownerKey, i, ...)` 본문** — `lengthList[i]`를 쓴 직후,
`gatedRecompute()`를 부르기 전에 `bk.invalidAfter = math.min(bk.invalidAfter, i)`.
**그 자리 length가 State일 때 등록하는 Observer 콜백 안에서도 같은 줄**이
필요하다(나중 emit이 같은 무효화를 요구한다).
2. **`spliceArraysUp`/`spliceArraysDown`** — 삽입·삭제한 위치 `i`로 같은 당김
(`base/slot-plan.md`의 그 함수들이 해야 하는 일 목록에 반영돼 있다).
3. **`slot._baseObserver` 콜백** — 베이스가 바뀐 경우라 `bk.invalidAfter = 0`.
`recompute`를 부르기 전에 당긴다.
**`recompute`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서
`getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로
두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 —
@ -1605,13 +1713,16 @@ end
```lua
local function recompute(ownerKey, bk)
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
-- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 —
-- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나
-- 전체 O(N).
-- 전체 O(N). 베이스(이 owner의 시작 offset)를 더하는 것도 `getOffsetAt`
-- 몫이다 — Slot이면 자기 `.Offset`, 최상위 물리 inst면 0.
-- **[주석 정정, 2026-08-24 6라운드 손 트레이싱 `H-10`]** 여기 원래
-- *"`0`이 아니라 이 owner의 베이스에서 시작한다"*고 적혀 있었는데, 접두합이
-- `getOffsetAt`으로 빠진 뒤로 `sum`**`Length` 전용**이 됐고 `0`에서
-- 시작하는 게 맞다(같은 함수 끝의 주석도 *"`Length`엔 base를 안 더한다"*로
-- 이미 그렇게 말한다). 옛 주석을 믿고 구현하면 `Length`에 베이스가 더해져
-- 상위 전체의 길이 합이 틀어진다.
local sum = 0
-- [2026-08-21 5라운드 감사] `bk.N or 0`**빈 Slot 크래시 방어**.
-- `bk.N``setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
@ -1693,8 +1804,12 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
-- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은
-- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표).
bk.invalidAfter = math.min(bk.invalidAfter, i)
local function gatedRecompute()
bk.invalidAfter = math.min(bk.invalidAfter, i) -- 나중 emit도 같은 무효화가 필요
if not blocker:IsOn() then
recompute(ownerKey, bk)
end
@ -1711,6 +1826,23 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
end
```
**⭐ [2026-08-24 6라운드 손 트레이싱 `H-19`] `recompute`를 부르는 책임은 여기
하나다 — 호출부의 명시 `recompute`는 지운다.** `setLength``len`이 상수여도
마지막에 `gatedRecompute()`를 부르고, 런타임 단건 경로에선 그 owner의 Blocker가
이미 꺼져 있으므로 그게 곧바로 `recompute`다. 그런데 `base/slot-plan.md`
`rawAdd`/`rawReplace` plain 분기는 **바로 다음 줄에서 또** `recompute(self, bk)`
불렀다. 크래시는 아니지만(두 번째 호출은 `offset:Get() ~= abs` 가드에 걸려
대체로 아무것도 안 쓴다) O(N) 순회가 두 번 돌고, 더 중요하게는 **"`recompute`를
누가 부르는가"의 소스가 두 곳**이 되어 위 `H-3`의 캐시 무효화를 어느 자리에
둘지가 애매해진다. 확정: **자리의 길이가 바뀌면 `setLength`가 책임진다.**
- **예외는 자리 자체가 없어지는 경로**`rawRemove`/`rawUnmount`/`rawDetach`는
`spliceArraysDown` 뒤에 `setLength`가 없으므로 거기선 명시 `recompute`
남는다(그 경로의 캐시 당김은 `spliceArraysDown`이 한다).
- **배치 경로도 그대로**`Dispatch.drive`/`materializeSlotTree`는 Blocker를
끈 뒤 마지막에 한 번 명시적으로 부른다(그 게이트가 존재하는 이유 자체가
등록마다 도는 걸 막는 것이다).
**⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 —
4라운드 `D-56`의 결론을 되돌린다.**

View file

@ -31,15 +31,20 @@ Effect(fn, ...deps) -> EffectHandle
이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음
(mount/unmount 전용, React `useEffect(fn, [])`와 동형).
**`state` 지정 시(2026-08-07 여섯 번째 세션 확정)**: Effect는 내부적으로
`state:Observer(...)`를 감싸는 걸로 구현 — `fn`은 포지셔널 인자로 `state`
받고(`fn(state)`, `:Compute``fn(self)` 포지셔널-self 패턴 재사용,
모듈화 목적 — 클로저 캡처 없이 `fn`을 독립적으로 정의/재사용 가능
**[2026-08-13 13차 세션 해소] 이 `fn(state)`가 lazy `State` 핸들을
받는다는 전제는 확정 유지.** 한때 `question.md` 0-Y가 `Effect`를 영향
대상으로 지목했으나, 실측 결과 **`Effect`는 애초에 무관**했음(자유
함수라 문제의 조건인 "재귀 타입의 필드 + 로컬 제네릭"에 안 걸림) —
`base/typing-limits.md` 1번 "영향 범위" 표 참고),
**dep 지정 시(2026-08-07 여섯 번째 세션 확정, 2026-08-21 `C-6`으로 N-deps 확장)**:
Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/Source면
`state:Observer(...)`, `Ref``:Callback`.
**⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로
`state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
**deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스,
dep 값은 사용자가 클로저로 직접 읽는다).
같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정
유지"*(2026-08-13 13차 세션)도 그 표기에 묶인 서술이라 함께 폐기된다 —
다만 그 항목이 실제로 확인한 것(**`Effect`는 재귀 제네릭 타입 누수와
애초에 무관**하다, 자유 함수라 "재귀 타입의 필드 + 로컬 제네릭" 조건에 안
걸림 — `base/typing-limits.md` 1번의 영향 범위 표)은 시그니처와 무관하게
그대로 유효하다.
Observer가 이제 등록 즉시 1회 실행되므로(아래 Observer 절 참고) 그 첫
실행이 "설치"를 겸함. 이후 `state`가 무효화될 때마다 **직전 `fn` 호출이
리턴한 cleanup을 먼저 호출한 뒤 `fn`을 재호출**, 그리고 Effect가 바인드된
@ -59,8 +64,9 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로
`source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의
근거가 됐다(인자 모양을 그 관용구 그대로 맞춤).
- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** —
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`
만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링 관용구(`modifier-plan.md`
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn`
만들어 반환하는 패턴(**[2026-08-24 표기 정정]** 그 `fn``fn(state)`
아니라 `fn(self)`다 — 위 배너), `Modifier``Boldify(10)` 커링 관용구(`modifier-plan.md`
8번)와 같은 결. `state:Observer(fn)`도 동일하게 커링 스타일을 권장 대상으로
같이 문서화(아래 Observer 절 참고) — 모듈화가 필요하면 둘 다 이 패턴을 쓸 것.
- **재실행이 필요 없는 케이스와 혼동하지 말 것**: 값 변화와 무관하게 설치+최종
@ -72,6 +78,140 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백
leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다
비쌈) — 필요할 때만 쓰는 걸로 충분.
### ⭐⭐ 그 `Destroying` 바인딩을 **누가 거는가** (2026-08-24 확정, 6라운드 손 트레이싱 `H-11`)
**갭이 실재했다 — cleanup을 발화시키는 코드가 코퍼스 어디에도 없었다.**
세 문서가 `Destroying`**전제로만** 쓰고 아무도 연결하지 않았다:
`base/lifecycle-pattern.md`는 *"인스턴스 라이프사이클 훅 지점은 `Destroying`
하나로 통일"*이라 못박고 `LP-2`**`Effect`가 그 훅을 쓰는 유일한 소비자**라
확정했으며, 위 문단은 비용까지 적어뒀다. 그런데 leaf가 실제로 붙는 유일한
경로는 `ObserverEffectLeafHandler.process``bindLifetime(inst, v)` 한 줄이고,
`bindLifetime`의 실 구현 스케치는 `gchold[value] = true` + `BindData`
gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그걸 부를
주체도 없었다.** 그래서 leaf가 죽으면 `canExecute`가 거짓이 되어 *"앞으로
발화하지 마라"*는 성립하지만 **cleanup은 영원히 안 불린다.**
**파급이 국소적이지 않았다** — 이 한 줄이 없으면 `slot._detachCleanup`
(`Detach`로 홀드된 요소를 파괴하는 **유일한** 경로,
`base/slot-plan.md`가 *"GC 폴백이 아예 없으므로 명시적 정리 경로가 필수"*라고
적어둔 그것)과 `OnDestroyed`(`base/lifecycle-hooks-plan.md`, `Effect` 위의 순수
슈가라 통째로 무동작)가 같이 죽는다.
**확정(사용자, 2026-08-24)**:
1. **`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 직접 처리한다.**
**[2026-08-24 재결정, `/code-review high` 지적]** 여기 한때 *"`EffectHandle`
쪽이 자기 `bindLifetime` 직후에 건다"*고 적었는데 **그 호출부가 실재하지
않는다** — 핸들은 남이 자기를 `bindLifetime`하는 걸 관측할 수 없다. 게다가
`Effect`가 바인드되는 경로는 **둘**이고(`ObserverEffectLeafHandler.process`의
children 배열 leaf, 그리고 `activateList``_detachCleanup`을 직접 바인드하는
내부 경로 — `base/slot-plan.md`) **`Destroying`이 가장 절실한 쪽이 후자**라,
핸들러 층에 분기를 둬도 안 덮인다.
**사용자 판단(2026-08-24)**: *"`Destroying` 자체가 엔진이 아는 요소이기
때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 때문에,
bind 상 옵져버 목록을 가져와 자신이 재귀하고, `bindLifetime` 이 처리하는게
나아보입니다."* — `bindLifetime`은 이미 `handle._observers`로 cascade하며
값을 들여다보는 자리이므로, 그 옆에 `Destroying` 배선을 두는 게 새 층을
만드는 것보다 낫다.
- **한때 근거로 든 *"게이트는 값 타입을 안 가린다"*는 이 자리에 안 맞는
인용이었다** — `base/source-state-plan.md`의 그 절이 말하는 건
**`canBound` 판정**이 `:Subscribe()`/`bindLifetime` 두 진입점에서 같다는
것이지, `bindLifetime`의 **부수 배선**이 값 종류를 못 본다는 게 아니다.
실제로 그 함수는 이미 `Effect`면 내부 Observer로 cascade한다.
2. **`unbindLifetime`은 cleanup을 부르지 않는다.** `Destroying` 커넥션을 끊고
**`Ref` 콜백도 같이 해제**하되(아래 `H-7` 절과 대칭), cleanup은 그대로
남긴다 — `destroySlotTree``_detachCleanup``unbindLifetime`하며 달아둔
주석(*"이미 손으로 비웠으니 Effect는 할 일 없음"*)과 `E-11`(leaf 바인딩엔
`:Unsubscribe()`가 안 먹는다)이 그 전제 위에 서 있다. **이 계약을 명시한다**
지금까진 어느 쪽도 안 적혀 있었다.
- **bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을
떼고 재마운트의 `bindLifetime`이 다시 건다(`_observers` cascade와 같은 결).
3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
`Rerun`이 같은 자리를 읽게 된다.
**의사코드 — `bindLifetime`이 부르는 두 훅**(**[2026-08-24 신설]** 감사 3라운드가
*"호출부만 있고 정의가 없다"*고 지적해 보강. 다른 신설 헬퍼는 전부 바디가
있는데 이 둘만 산문뿐이었다):
```lua
-- quad-base, Effect.luau — `base/lifecycle-pattern.md`의 bindLifetime에서만 불린다.
-- 비공개(`_` 접두사): 사용자 표면이 아니라 배관이다.
function EffectHandle:_bindDestroying(inst)
-- 재바인드(포탈 재마운트)면 옛 연결부터 끊는다 — 멱등.
self:_unbindDestroying()
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
self._destroyConn = onDestroying(inst, function()
self:_unbindDestroying() -- 자기 연결/콜백을 먼저 정리(재진입 방지)
local cleanup = self._cleanup
self._cleanup = nil -- 두 번 안 불리게
if cleanup then cleanup() end
end)
-- (2) `Ref` dep 콜백 (재)등록 — State/Source dep의 `_observers` cascade와 대칭.
-- `unbind`가 뗐던 걸 여기서 다시 건다(그래서 포탈이 성립한다).
for _, ref in ipairs(self._refDeps) do
local cb = function(value)
-- ⭐ 발화 게이트 — State dep이 전파 루프에서 `canExecute(observer)`
-- 걸러지는 것과 같은 자리(아래 `H-7` 절). 해제가 늦거나 누락되는
-- 창을 이게 덮는다.
if not canExecute(self) then return end
self:Rerun()
end
self._refCallbacks[ref] = cb -- 해제 때 정확히 이 클로저를 떼려면 보관해야 함
ref:Callback(cb)
end
end
function EffectHandle:_unbindDestroying()
if self._destroyConn then
self._destroyConn:Disconnect()
self._destroyConn = nil
end
for ref, cb in pairs(self._refCallbacks) do
ref:Uncallback(cb) -- `base/ref-plan.md`의 신설 표면(`H-7`)
self._refCallbacks[ref] = nil
end
-- **`_cleanup`은 안 부른다** — 위 2번 계약. 여기서 부르면 `destroySlotTree`
-- `_detachCleanup`을 손으로 비운 뒤 unbind하는 경로에서 이중 호출이 된다.
end
```
- **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`
모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은
`inst.Destroying:Connect(fn)` 한 줄. **[2026-08-24 반영 완료]** 주입 op
전체 목록의 단일 소스는 `base/architecture.md``EngineOps.luau` 줄이고
거기에 등재했다(`ROADMAP.md` M5 배너에도 같이 적었다) — 한때 여기
*"`ROADMAP.md` M5에 추가할 것"*이라고만 적어 **소스를 잘못 지목**했었다.
- **필드 셋이 새로 생긴다**: `_destroyConn`(연결 핸들), `_refDeps`(생성자가
`...deps``isRef`인 것만 모아둔 배열), `_refCallbacks`(`ref → 내가 건
클로저`). 앞의 둘은 `_observers`와 같은 층이고, 마지막 것은 **해제 시 정확히
자기 클로저만 떼기 위해** 필요하다(`Ref.Callbacks`가 셋이라 값으로 떼야 한다).
### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`)
`Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히
남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음)
`canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가
`EffectHandle`을 강참조하므로 누수이고, 이후 `ref:Set`마다 **이미 죽은 leaf의
직전 cleanup + `fn`을 계속 실행**한다. 같은 절이 *"leaf dedup/cascade가 전부를
덮어야 한다"*고 요구하는데 `Ref` 쪽엔 그걸 만족시킬 수단 자체가 없었다.
**확정: `Ref`에 콜백 해제 경로를 추가한다**(`base/ref-plan.md`가 소스).
`EffectHandle`은 자기가 건 `Ref` 콜백 핸들을 들고 있다가, `unbindLifetime`
`:Unsubscribe()`에서 같이 해제한다 — `_observers`(State/Source dep)와 대칭이다.
**⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도
발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가
구독자마다 `canExecute(observer)`를 보고 죽은 것을 건너뛰는데(`base/source-state-plan.md`),
`Ref` 경로엔 그 게이트가 아예 없었다. 해제가 늦거나 누락되는 창이 실재한다 —
예컨대 `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`
안 도는데도 `canExecute`가 거짓이다. **확정된 형태**: `Effect`가 거는 `Ref`
콜백은 본문 맨 앞에서 **자기 핸들에 대해** `canExecute(handle)`를 확인하고,
거짓이면 그대로 리턴한다. 그러면 두 dep 경로가 같은 게이트를 공유하게 되어
`Effect`의 발화 조건이 dep 종류와 무관해진다.
### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
(2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/
@ -89,13 +229,22 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
세션, 재확인 후 명시화)**:
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observer =
observer`(`state`가 주어진 경우만 존재). 이건 GC 방지가 목적이 아니라
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`] 이 문단 전체가 아직 "Observer 하나"
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패
모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로
고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이
배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스.
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] =
observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라
(그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime`
cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.
- **`bindLifetime(inst, handle)``state`가 있는 경우 내부 Observer도
같은 `inst``bindLifetime(inst, handle._observer)`를 cascade해야
함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
같은 `inst``handle._observers` **전부**에 대해
`bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
@ -110,8 +259,8 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observer`
둘 다, 또는 `handle._observer`만으로 충분한지는 구현 세부 — 어느 쪽이든
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers`
전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든
"`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면
안 됨).
@ -194,7 +343,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
dedup 되어야하는듯."*
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
`handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
`handle._observers`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
같은 `if` 안에 둘 것.
@ -249,9 +398,26 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
State/Source면 `Observer`, `Ref``:Callback`. `:With`로 합치지 않는다.
- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를
**lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing
deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다.
- **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는
`fn(self: EffectHandle) -> (() -> ())?` 이고, `...deps`는 의존성 선언일 뿐
`fn`에 넘어가지 않는다.**
**사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯.
Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고,
Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼 그냥 넣을 수
있게 하자는거였을 뿐임."*
- **여기 원래 적혀 있던 *"인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로 —
trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*는 삭제한다.** 그 선례의
확정 시그니처는 `fn(self, previous?, ...deps)`인데 `Effect`엔 **셋 다 안
맞는다**: `self` 자리에 올 리시버 State가 없고(자유 함수), `previous`
담길 캐시 슬롯이 없으며(파생값을 안 만드는 leaf), 오히려 **반환값이
cleanup이라 의미가 정반대**다. 위쪽에 남아 있던 옛 단수 시절 표기
`fn(state)`도 같이 폐기된다.
- **그 따름정리로 dep 읽는 법의 비대칭 문제가 사라진다** — State/Source dep은
`:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가
인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문
자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다.
- `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은
핸들 표면에 바로 닿는다.
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.

View file

@ -125,6 +125,61 @@ end
`xpcall`+`debug.traceback`으로 **에러 나는 순간에만** 스택을 찍는
패턴)를 그대로 재사용 — 새 트레이싱 메커니즘을 발명하지 않음.
## ⚠️ 미해결 — 실패 이전에 생성된 부분 트리는 회수되지 않는다 (2026-08-24 신설, 6라운드 손 트레이싱 `H-26`)
**상태: 백로그.** `Fallback`/`Traceback` 자체가 슈가라 **그 둘을 구현할 때 같이
다룬다**(사용자 판단, 2026-08-24: *"의도적으로 error 를 사용하고자 하는 경우
항상 컴포넌트들이 쌓이거든. 이건 후행에서 더 다뤄보도록 백로깅해줘.
fallback/traceback 자체가 슈거라서, 그 때 가서 생각해도 될듯"*).
**무엇이 문제인가**: quad는 자기가 만든 Instance마다 생성 즉시 gcconn을 걸고 그
클로저가 `inst`를 캡처하므로(`base/lifecycle-pattern.md`의 "(0)" 절)
**참조를 놓는 것만으로는 회수되지 않고 반드시 `Destroy`로만 회수된다.** 이
모델은 "만든 Instance는 언젠가 반드시 `Destroy`된다"를 전제하는데, 컴포넌트가
자기 리터럴을 만드는 도중 예외를 던지면 그때까지 완성된 형제/자손은 **트리에
붙지도, `Destroy`되지도 않은 채** 예외에 실려 스코프를 빠져나간다.
```lua
local function Broken(props) error("bug!") end
local function Parent(props)
return Frame {
Frame { Text = "child A" }, -- (1) 완주 — gcconn/gchold 확정
Broken {}, -- (2) error
Frame { Text = "child C" }, -- (3) 도달 안 함
}
end
local SafeParent = Fallback(Parent, function(err) return Frame { Text = tostring(err) } end)
```
Lua 테이블 생성자는 원소를 좌→우로 완전히 평가하므로 `child A`는 실제
Instance로 완성되고 gcconn이 걸린다. 바깥 `Frame(...)` 호출은 인자 테이블조차
완성 못 해 **호출되지 않는다.** `Fallback``pcall(base, ...)` 하나라 그
존재를 알 방법이 없고, `child A`는 (a) 어떤 지역 변수에도 안 남고 (b) 세팅된
적 없고 (c) 아무도 모르므로 — 자기 gcconn↔gchold 순환만이 그를 살려두고 그걸
끊는 유일한 수단(`Destroy`)을 부를 주체가 없다. **참조 0개인데 세션 끝까지 안
죽는다.** 실패 지점 앞에 중첩 서브트리가 있으면 그 전체가 대상이다.
**왜 `Fallback`/`Traceback`이 대표 사용처인가**: 이 문제는 이 둘 전용이 아니라
"부분 실패 후 아무도 `Destroy`를 안 부르는 모든 경로"의 일반적 위험인데,
**그 경로를 계속 살려두는 것을 존재 이유로 삼는** 게 정확히 이 둘이다.
예외가 밖으로 나가 아무것도 안 그려지는 기본 경로는 서술 정정으로 끝났지만
(같은 날 `base/dispatch-core-plan.md`에서 잔여 부기가 인스턴스 GC로 정리된다고
적은 **틀린 안전망 주장**을 삭제했다), 이쪽은 앱이 계속 도는 걸 약속하는
자리라 층위가 다르다.
**구현 시 검토할 갈래**(지금 정하지 않음):
1. `New`/`Dispatch.drive`가 "이번 construction에서 만든 것" 목록을 쌓고
`pcall` 실패 시 역순 `Destroy` — 누수를 실제로 닫지만 **정상 경로에도**
부기 비용이 붙고, 중첩 구성 경계를 어떻게 잡을지가 또 문제다.
2. gcconn 자기순환 재고(한 번도 Parent 안 된 채 참조가 끊기면 GC 가능하게) —
`lifecycle-pattern.md` (0)의 핵심 트레이드오프를 건드리므로 재설계 규모가 크다.
3. UB로 명문화 + 컴포넌트 저작자에게 "생성 전에 검증부터" 권고.
**참고 — 인접 사례는 이미 인정돼 있다**: `materializeSlotTree`의 예외 시
Blocker가 켜진 채 남는 갭은 사용자 판단으로 이미 *"마운트 도중 예외는 quad가
복구를 보장하지 않는 상태"*로 정리됐다(`base/slot-plan.md`). 다만 그건
마운트 경로에 한정된 국소적 결과다.
## 패키지 배치
`quad-base``base`를 그냥 호출하고 결과를 그대로 돌려주는 순수 함수라

View file

@ -173,9 +173,15 @@ end)
순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
```
local batch = self._withheld
self._withheld = {} -- 새 테이블. clear가 아니다
self._withheld = newWithheld() -- 새 테이블. clear가 아니다
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
```
**⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-9`] 그 새 테이블도 weak여야
한다.** 여기 원래 `self._withheld = {}`라고 적혀 있었는데, 그러면 위에서
**weak key로 확정한 집합이 첫 flush 스왑에서 평범한 테이블로 바뀐다**
그 뒤로는 그 게이트가 죽은 `Epoch`를 붙잡을 수 있다. `OffWithoutEmit`
스왑도 같다. `newWithheld()`(= `setmetatable({}, {__mode = "k"})`) 헬퍼
하나로 생성 지점을 통일한다.
하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다.
중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이
중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미
@ -226,8 +232,51 @@ end)
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
`debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을
둘지, 그건 정책(Debounce)만의 것으로 둘지.
`debounce-throttle-plan.md` 5-4).
**⭐ [2026-08-24 해소, 6라운드 손 트레이싱 `H-33`/`H-49`] `Gate` 노드엔
`Flush`/`Cancel` 같은 표면을 두지 않는다 — 정책이 `Blocker`를 조종한다.**
확정된 `state:Gate(setup)`**State 하나만** 돌려주고 노드 객체를 노출하지
않으므로, 제어 표면을 얹으려면 그 확정을 되돌려야 했다. 사용자 확정으로
방향이 반대로 잡혔다:
- **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.**
`state:Block(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다.
**⚠️ [2026-08-24 표기 정정, `/code-review high` 지적]** 여기 한때
`state:Gate(b:Policy)`라고 적었는데 **그건 문법 오류다**(인자 목록 없는
`b:Policy`). `b.Policy`로 고쳐도 **언바운드 메소드**라 `Gate`
`setup(emit)`으로 부르면 `emit``self` 자리에 들어가 정책의 `emit`
`nil`이 된다. 정확한 형태는 클로저로 묶는 것이다:
```lua
state:Block(b) == state:Gate(function(emit) return b:Policy(emit) end)
```
- **`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다.** 자기 `Blocker`
사적으로 하나 갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류
판정은 전부 Blocker에 위임한다. 동기 실행이라 같은 호출 안에서 정책이
바꾼 Blocker 상태를 그 다음 줄의 `pass()`가 그대로 본다:
```lua
state:Gate(function(emit)
local b = Blocker()
local pass = b:Policy(emit) -- 실제 emit/보류는 전부 여기 위임
return function() -- 상류 emit 도착 (동기)
-- ...타이머 리셋 / b:On() / b:Off() 시점 판단만...
pass()
end
end)
```
- **Blocker는 설정당 하나가 아니라 적용 핸들당 하나**다(사용자 지적) —
`Debounce{...}` 커링 결과는 여러 곳에 적용될 수 있으므로 `Apply` 시점에
생성된다.
- **`pending` 같은 정책 상태는 `HasBlockedEmit`으로 흡수한다** — "보류된 게
있는가"를 Blocker가 이미 들고 있으므로 중복 상태를 안 만든다.
`Trailing = false``OffWithoutEmit()`, `Flush``Off()`로 매핑된다.
- **정책 합성은 손으로 중첩한다**`setup`이 곧 `(emit) -> onUpstreamEmit`
이라 그 자체가 합성 가능한 타입이다. `state:Gate(p1, p2, ...)` 같은
가변인자 슈가는 **두지 않는다**(누가 상류인지가 코드에 그대로 드러나는
편이 낫다).
- **기각된 배선(기록)**: "블로커를 바깥에 중첩"
(`blocker:Policy(debounceOnEmit)`)은 unblock 시 흘러나온 emit이 디바운스
창을 **새로 시작**시켜 창이 안 끝난다(사용자 지적).
6. **[2026-08-21 정리 — 열린 항목 아님] 재진입.** 여기 한때 *"`onUpstreamEmit`
안에서 같은 게이트의 `emit()`을 재귀적으로 부르는 경우"*라고 적혀 있었는데
**잘못 옮긴 서술이었다**(사용자 지적). `blocker-plan.md`의 "재진입(네스팅)"

View file

@ -327,9 +327,42 @@ function bindLifetime(inst, value)
-- 둘 다 weak — gchold는 위 (0) 클로저가, gcconn은 gchold[1]이 이미 안전히 붙잡고 있음.
BindData:SetWeak(value, "gchold", gchold)
BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn"))
-- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-11`] `Effect`면 부수 배선까지
-- 여기서 한다.** `LP-2`가 *"`Effect`가 `Destroying` 훅을 쓰는 유일한
-- 소비자"*라고 확정해뒀는데 **그걸 실제로 거는 코드가 코퍼스 어디에도
-- 없었다** — 그래서 leaf가 죽어도 cleanup이 영영 안 불렸고,
-- `slot._detachCleanup`(Detach 요소를 파괴하는 유일 경로)과 `OnDestroyed`
-- 통째로 무동작이었다(`base/effect-plan.md`).
-- **왜 이 자리인가**(사용자 판단 2026-08-24): `Destroying`은 엔진이 아는
-- 요소라 엔진을 다루는 자리에 있어야 하고, 이 함수는 이미 `Effect`의 내부
-- Observer 목록으로 cascade하며 값을 들여다본다. `Effect`가 바인드되는
-- 경로가 둘(children 배열 leaf / `activateList``_detachCleanup` 직접
-- 바인드)이라 **호출부 쪽에 두면 반드시 한쪽이 샌다.**
if isEffect(value) then
for _, observer in ipairs(value._observers) do -- N-deps cascade
bindLifetime(inst, observer)
end
value:_bindDestroying(inst) -- Destroying 연결 + Ref dep 콜백 (재)등록
-- 의사코드는 `base/effect-plan.md`가 소스.
-- 그 안에서 주입 op `onDestroying(inst, fn)`
-- 부른다(base는 Instance를 모른다).
end
end
function unbindLifetime(value)
-- [2026-08-24 `H-11`] bind의 대칭 — **cleanup은 부르지 않는다.**
-- `Destroying` 연결을 끊고 `Ref` dep 콜백을 떼기만 한다. cleanup을 여기서
-- 부르면 `destroySlotTree``_detachCleanup`을 손으로 비운 뒤 unbind하는
-- 경로에서 이중 호출이 된다(`base/effect-plan.md`의 그 항목이 소스).
-- 이 대칭 덕에 포탈이 자연히 성립한다 — 언마운트가 떼고 재마운트가 다시 건다.
if isEffect(value) then
value:_unbindDestroying()
for _, observer in ipairs(value._observers) do
unbindLifetime(observer)
end
end
local gchold = BindData:GetWeak(value, "gchold")
if gchold then
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제

View file

@ -98,6 +98,30 @@ end
- **배열 파트를 훑으며 해시 키를 같이 쓰는 것은 안전** — 숫자 `for`의 상한이
루프 진입 시 한 번만 평가되고, 해시 파트 추가는 그 순회에 영향을 주지
않는다.
- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-35`] `ProcessedModifierHandler`
의사코드** — 확정은 위 한 줄(*"전담 nop Handler … 캐치해
`setOffsetSource(None)`/`setLength(0)`을 등록"*)로 끝나 있었는데, 지목된
본보기(`ProcessedPreRefHandler`/`ProcessedPostRefHandler`)엔 실제 골격이
있는 반면 이쪽엔 없었다. 게다가 **색인 두 곳에서도 빠져 있었다**
`base/dispatch-core-plan.md`의 Length/Offset 등록 책임 열거와 말단 핸들러
부작용 표, 그리고 `base/architecture.md``Dispatch/` 파일트리
(`Modifier.luau` 항목이 flatten/체이닝만 적고 자기가 만드는 센티널의
핸들러를 언급 안 함). `Modifier`가 하나라도 든 `Frame{...}` 호출은 **전부**
이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를
놓친다. 셋 다 반영했다.
```lua
ProcessedModifierHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급>
ProcessedModifierHandler.isHandlable(inst, k, v) = (v == ProcessedModifier)
function ProcessedModifierHandler.process(inst, k, v, index)
-- 순서는 늘 offsetSource 먼저(setLength가 끝에서 recompute를 태우므로)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음
end
```
- **`ProcessedModifier`의 공개 표면 위치**는 `Detach`/`None`과 같은 판단을
따른다 — 다만 이건 사용자가 볼 일이 전혀 없는 **내부 센티널**이라
최상위로 재노출할 이유가 없어 보인다(`ProcessedPreRef`/`ProcessedPostRef`가

View file

@ -251,8 +251,12 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
- **기본이 `false`인 이유**: 라이브러리가 사용자 콘솔에 아무것도 안 찍는
게 기본이어야 함. 켜는 건 명시적 opt-in.
- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑"
절과 같이 정할 것.
- **[해소, 2026-08-24 6라운드 손 트레이싱 `H-48`] 다중 인스턴스화 시
**인스턴스별**이다 — M1이 이미 그렇게 확정했다.** 여기 "미정"으로 남아
있었지만, 커밋된 `quad-base/src/Debug/init.luau``module.debug = false`
**인스턴스 필드**를 심고 `quad-types/src/init.luau``Quad` 타입도
`debug: boolean`을 필드로 갖는다. 결함은 아니지만 다음 세션이 "아직 정할
게 남았다"고 읽고 전역으로 바꾸려 들 수 있어 여기서 닫는다.
- **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`
이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고
스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다.

View file

@ -53,10 +53,26 @@
`GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에
둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와
다름.
- **`process(inst,k,v,index)`**: `inst:GetPropertyChangedSignal(name):Connect(
- **`process(inst,k,v,index)`**: **먼저 `v == nil`이면 Connect를 건너뛰고
no-op 클로저를 반환**하고, 아니면 `inst:GetPropertyChangedSignal(name):Connect(
function() v(inst[name]) end)` 후 **그 Connection을 `:Disconnect()`하는
클로저를 반환**. 일반 `Handlers/Event.luau`와 같은 결(Connection
관리뿐, 새 메커니즘 없음). **[정정, 2026-08-13 다섯 번째 세션]** 원래는
관리뿐, 새 메커니즘 없음).
- **⭐ [정정, 2026-08-24 6라운드 손 트레이싱 `H-27`] 그 `v == nil` 얼리리턴이
빠져 있었다.** 이 문서는 스스로 *"일반 `Handlers/Event.luau`와 같은 결"*이라
결론냈는데, `base/event-plan.md`가 확정한 그 "같은 결"의 핵심이 정확히
**`(k=이벤트키, v=nil)`을 받으면 기존 Connection 해제만 하고 새로 Connect하지
않는다**이다. 없으면 `Frame { [OnChange "Position"] = someState }`에서
`someState:Set(None)`으로 콜백을 끌 때, `NoneHandler``NilHandler`
거쳐 이 핸들러가 **`v == nil`로 다시 매치**되어(매치가 키 기반이라 `nil`
잡고, `NilHandler``type(k) == "number"` 전용이라 여기 안 걸린다)
`Connect(function() nil(inst.Position) end)`**실제로 심긴다.** 그 순간엔
아무 일도 안 일어나고, 나중에 `inst.Position`이 실제로 바뀌면
**`attempt to call a nil value`**로 터진다 — 즉 "콜백을 끈다"는 동작이
실제로는 **"나중에 터질 Connection을 새로 심는"** 동작이 된다.
`base/dispatch-core-plan.md`는 같은 종류의 방어를 `PropertyHandler`에는
이미 명시적으로 요구하고 있다(*"`v == nil`이면 셋을 건너뛰는 방어"*) —
`OnChange`만 빠져 있었다. **[정정, 2026-08-13 다섯 번째 세션]** 원래는
별도 `retract(inst,k,v)` 필드가 Disconnect를 담당한다고 적혀 있었으나,
Handler 계약이 `process` 1-메소드로 합쳐지며 그 로직이 반환 클로저로
이동 — `connection``process`의 로컬 변수를 클로저가 upvalue로 그대로

View file

@ -80,6 +80,41 @@ export type Quad = {
}
```
**⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25` — 실측] 이 레코드는 **닫혀
있고**, 마일스톤마다 서브시스템 필드를 여기 추가해야 한다.**
실제 커밋된 `quad-base/src/init.luau``New(): Quad`가 이 별칭을 그대로 반환
타입으로 쓰는데, `RunInit``(self: Quad, initFn: (Quad) -> any) -> ()`
**반환값이 없어 타입을 못 넓히고**, 타입을 넓히는 유일한 경로인
`AddPlugin<Self, P>`는 이 문서 자신이 **외부 플러그인**용이라고 선을 긋는다.
그런데 `base/architecture.md`의 확정된 결정 13번은 *"`require(quad)`가 돌려준
걸 바로 `Quad.Dispatch`처럼 씀"*을 **표준 사용법으로 확정**해뒀다.
실측(`InitDebug`와 완전히 같은 모양의 `InitDispatch`로 재현):
```lua
local quad = New()
quad.Dispatch.addHandler(function() end)
```
`luau-analyze``TypeError: Key 'Dispatch' not found in table 'Quad'`.
즉 M2가 `Dispatch.luau`를 만들고 `module:RunInit(InitDispatch)` 패턴(이 문서가
이미 예시로 보여준 그 패턴)으로 붙이면 **런타임엔 붙지만 타입엔 영원히 안
보인다.** `ROADMAP.md` M5의 quad-roblox 주입 경로도 같은 벽에 부딪힌다 —
quad-roblox는 `quad-types`의 좁은 `Quad`만 본다.
**확정(사용자, 2026-08-24): `quad-types``Quad`를 마일스톤마다 갱신한다.**
- **M2**가 `Dispatch: Dispatch` 필드와 그 타입 재수출을 여기 추가한다 —
`ROADMAP.md` M2 체크리스트에 **항목으로 명시**한다(지금까지 아무도 이
필요성을 항목화해두지 않았다).
- 이후 서브시스템도 같은 규칙을 따른다.
- **"가벼운 타입 계약"이라는 이 패키지의 존재 이유와 상충하지 않는다** —
타입만 재수출하므로 런타임 무게는 안 는다.
- 검토했다 기각된 둘: **quad-base 내부만 넓은 로컬 교차 타입**
(`New(): Quad & { Dispatch: Dispatch }`) — quad-roblox도 결국
`Dispatch.addHandler`를 부르므로 그 경로엔 별도 노출이 또 필요해진다.
**`(quad :: any).Dispatch` 캐스트** — 가장 싸지만
`base/typing-limits.md`가 시종 강조하는 명시 바인딩 원칙과 정면으로
배치되고 quad-base 자기 코드가 `--!strict`의 이득을 잃는다.
`Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`.
이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한
구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴

View file

@ -110,6 +110,33 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
— 아래 구현 디테일 참고,
**[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**,
바로 아래 캐비엇 참고).
- **⭐⭐ [재설계, 2026-08-24 6라운드 손 트레이싱 `H-7` 후속] `.Callbacks`
배열이 아니라 `{[callback|thread] = true}` **해시맵 셋**이다.**
`Effect(fn, someRef)`의 leaf가 죽어도 콜백을 뗄 방법이 없다는 갭(`H-7`)을
**해제 경로 추가**로 닫기로 하면서 자료구조도 같이 바뀌었다 — 배열이면
삭제가 선형 탐색이다. **사용자 제안**(2026-08-24): *"Effect 의 callback
들은 해시맵이 되는게 나아보이는데 `[callback/루틴]=true`"*.
- **해제가 `t[fn] = nil` 하나로 끝난다** — 이게 `H-7`이 요구하는 연산이다.
- **`type(v) == "thread"` 분기는 그대로 성립한다** — 값이 아니라 **키**의
타입을 보면 된다(`for k in pairs(self.Callbacks)`).
- **중복 등록은 dedup을 계약화한다**(사용자 확정) — 같은 콜백/thread를
여러 번 등록해도 한 번만 발화한다. 해제가 `nil` 하나로 끝나야 하므로
"몇 번 해제해야 떨어지나"라는 질문이 생기면 안 된다. 사용자가 실수로
중복 등록하는 상황도 이 쪽이 안전하다.
- **⭐ 순회 전에 스냅샷을 뜬다** — `pairs` 순회 중 **새 키 추가는 Lua에서
미정의**다(기존 키를 `nil`로 지우는 소진은 합법). 같은 문제가 State
구독자 집합에서 **실측으로 재현**됐고(`base/source-state-plan.md`의
`H-23` 문단 — 실행마다 결과가 달랐고 한 구독자가 통째로 누락되기도 함),
**둘 다 같은 처방으로 닫는다**: 배열로 복사한 뒤 그 배열을 돈다.
"이번 발화 중에 등록된 콜백은 다음 발화부터 참여한다"가 계약이다.
- **⭐ 이 변경으로 `#t` border 실측 항목이 폐기된다** — 아래
`R-11` 항목의 ⚠️ 실측 대상("구멍 있는 테이블에서 `#t`가 항상 첫 `nil`
자리인가"를 M0/M8 스파이크로 확인)은 **해시맵엔 border 개념이 없어서
질문 자체가 사라진다.** 같은 이유로 아래 "`table.insert`가 구멍을
되찾아 쓴다"(`R-11`)와 "왜 `None`이 아니라 `nil`인가" 논의도 이 자료구조
아래에서는 **무의미해진다**(둘 다 배열 전제) — 결론(소진은 `nil`)은
`t[k] = nil`로 그대로 이어지지만 근거는 "구멍/border가 아예 없다"로
바뀐다. 두 항목은 역사 기록으로만 읽을 것.
- **[재설계, 2026-08-18 구현 전 QA] 콜백/대기자는 별도 필드
`.Callbacks` 테이블에 담고, `.Value`는 그냥 평범한 hash 필드로 둔다.**
옛 설계(2026-08-09 열한 번째 세션 보강)는 **Ref 객체 자신이 곧
@ -123,6 +150,28 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
되므로 **hash 파트 충돌 자체가 안 생기고, `__index` 우회 기법을 쓸
이유가 사라진다.** 대가는 Ref 하나당 테이블 하나가 더 만들어지는 것뿐
(`.Callbacks`를 첫 등록 시점에 lazy로 만들지는 구현 재량).
- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-7`] 콜백 해제 경로 —
`:Callback(fn)`이 해제 핸들을 돌려준다.** 지금까지 `Ref`엔 등록만 있고
해제가 없어서, `Effect(fn, someRef)`의 leaf가 죽어도 클로저가 영원히
남았다(그 클로저가 `EffectHandle`을 강참조해 누수이고, 이후 `ref:Set`마다
**이미 죽은 leaf의 `fn`을 계속 실행**했다).
**[2026-08-24 보강, 사용자 지적] 해제 경로와 `canExecute` 게이팅은 택일이
아니라 둘 다 필요하다.** 여기 한때 *"`canExecute` 게이팅으로 덮으려던
대안은 채택하지 않았다"*고 적었는데, 해제만으로는 창이 남는다 —
`unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안
도는데도 `canExecute`가 거짓이다. **해제는 누수를, 게이팅은 발화를** 막는다.
`Ref` 자신은 여전히 `canExecute`를 모르고(그건 Observer 쪽 배관이다)
`Effect`가 거는 **콜백 본문**이 자기 핸들에 대해 확인한다 —
`base/effect-plan.md`가 소스.
- `:Callback(fn)`**여전히 `self`를 반환한다**(체이닝 관용구
`Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은
**`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고,
중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다.
- `EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`
`:Unsubscribe()`에서 `:Uncallback`한다 — State/Source dep의
`_observers`와 대칭(`base/effect-plan.md`).
- **`:Wait()`의 대기자는 이 표면의 대상이 아니다** — 발화 시 소진되므로
뗄 일이 없다.
- **`:Wait(thread?)``thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
@ -135,7 +184,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은
블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`
`nil`이면 yield, 있으면 yield 안 함.
- **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume
- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-53`] `:Set(value)`의 순서 —
`.Value`**콜백 순회 전에** 쓴다.** `.Value`*읽는* 관용구는 코퍼스에
여러 번 나오는데 `:Set`이 그걸 언제 쓰는지 보여주는 코드가 없었다
(`\.Value = ` grep 0건). 콜백은 값을 인자로 직접 받으므로 실무 영향은
낮지만, **콜백 A가 `.Value`를 읽는데 콜백 B가 아직 안 돈 시점에 무엇이
보이는가**가 사양에 없었다. 확정된 순서:
```lua
function Ref:Set(value)
self.Value = value -- (1) 먼저 확정
local snapshot = {} -- (2) [`H-23`] 순회 전 스냅샷
for k in pairs(self.Callbacks) do table.insert(snapshot, k) end
for _, k in ipairs(snapshot) do
if self.Callbacks[k] == nil then continue end -- 순회 중 해제됐으면 skip
if type(k) == "thread" then
self.Callbacks[k] = nil -- 대기자는 소진
coroutine.resume(k, self) -- 값이 아니라 **Ref 자신**(아래 참고)
else
k(value) -- 일반 콜백은 원래 값을 받고, 소진 안 함
end
end
return self
end
```
`.Value`가 먼저이므로 **모든 콜백이 새 값을 본다** — 순서에 따라 옛 값이
보이는 창이 없다.
- **⚠️ [역사 기록, 2026-08-24 6라운드 `H-7` 후속] 아래 "구현 디테일" 문단은
`.Callbacks`가 **배열**이던 시절 서술이다 — 지금 자료구조는 해시맵 셋이고
확정 의사코드는 위 `:Set` 블록이 소스.** 배열 전제인 부분(`for i, v in
self.Callbacks`, `[i] = nil` 소진, `table.insert`의 빈 슬롯 재사용,
구멍 있는 테이블의 `#t` border)은 전부 **무효**다. 다만 그 문단이 확정한
**의미론 셋은 그대로 유효**하고 지금 의사코드도 그걸 지킨다:
(1) 대기자와 콜백을 같은 자료구조에 담고 `type(v) == "thread"`로 분기,
(2) 대기자는 `coroutine.resume(v, self)`로 **Ref 자기 자신**을 넘기며 소진,
(3) 일반 콜백은 **원래 값**을 받고 소진 안 함.
- **(역사) 구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume
payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로
`:Set()`될 때, 같은 배열 하나를 `for i, v in self.Callbacks do ... end`
한 번만 순회하면서(**[2026-08-18]** 순회 대상은 Ref 객체 자신이 아니라
@ -289,6 +372,14 @@ RefLeafHandler.isHandlable(inst, k, v) =
-- HANDLER_PRIORITY_FALLBACK 가드(아래 "동적 경로 가드")가 죽은 코드가 된다.
function RefLeafHandler.process(inst, k, v, index)
-- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다.
-- 일반 `Ref`가 등록 대상이라는 건 `base/dispatch-core-plan.md`가 명시적으로
-- 못박아뒀는데(값이 없는 자리도 짝을 맞춰 `0`), 정작 이 핸들러엔 없었다 —
-- `Frame { Ref(myRef), Frame{} }`처럼 leaf가 앞에 오면 첫 recompute가
-- `sourceList[k]가 nil`로 죽는다. 아래 `Processed*` 둘과 같은 모양.
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local old = relate:GetStrong(inst, k)
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error —
@ -409,6 +500,33 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
부작용으로 동기적으로 발화**할 수 있음 — 이때 이벤트 핸들러가 아직 안
채워진 self-ref를 읽으면 터짐.
**⚠️ [조건 명시, 2026-08-24 6라운드 손 트레이싱 `H-42`] 그 "동기 발화"는
`Workspace.SignalBehavior`에 조건부다.** 코퍼스 90개+ 문서 어디에도 이 설정이
한 번도 등장하지 않은 채 무조건 사실처럼 서술돼 있었다.
- `Enum.SignalBehavior``Default`/`Immediate`/`Deferred`. **`Immediate`**에선
이벤트가 다른 이벤트를 트리거하면 두 번째 핸들러가 **즉시** 발화한다(중첩
실행) — 위 서술이 성립하는 경우다. **`Deferred`**에선 큐 뒤에 붙어 **다음
resumption point**(입력 처리, `RunService` 콜백, `task` 계열)에서 실행되므로
스크립트의 동기 실행 흐름을 끊지 않는다 — **그러면 이 레이스는 발생할 수
없고**(quad의 동기 마운트가 전부 끝난 뒤 콜백이 도므로 일반 `Ref`도 이미
채워져 있다) `PreRef`는 무해하게 불필요해질 뿐이다.
- 공식 문서 기준 **신규 템플릿 place는 이미 `Deferred`가 기본**이고, 레거시
`Default`는 현재 `Immediate`와 같지만 앞으로 `Deferred`로 바뀔 예정이다.
출처: https://create.roblox.com/docs/scripting/events/deferred ,
https://create.roblox.com/docs/reference/engine/classes/Workspace#SignalBehavior
- **왜 문구만 고치고 구조는 안 건드리나**: 크래시를 만드는 문제가 아니다.
진짜 이득은 **나중에 실측할 때** 나온다 — 언젠가 Studio에서 "이 레이스가
재현이 안 된다"는 결과가 나오면, 그게 quad 설계가 틀린 건지 place의
`SignalBehavior` 때문인지 구분할 수 있어야 한다. 실측 계획을 세울 때 place
설정을 같이 적게 만드는 것이 목적이다.
- **`PreRef`/`PostRef`/pre-pass 구조 자체는 유지한다**(사용자 확정,
2026-08-24) — 레거시 place와 명시적 `Immediate` 설정이 살아있는 한 필요하고,
불필요해질 때의 비용이 싸다.
- 참고: `base/dispatch-core-plan.md`의 *"프레임 경계는 여전히 안 낀다(yield
금지)"*는 **quad 자신의 코드가 yield 안 한다**는 얘기라 이 축과 다르다 —
반박이 아니다.
**해결**: 이 케이스만 별도 타입 `PreRef`로 분리.
- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Set()`/`:Callback()`/
`:Wait()` API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음.
@ -541,12 +659,18 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
```lua
ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급>
ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
function ProcessedPreRefHandler.process(inst, i, v)
-- [시그니처 정정, 2026-08-24 `H-20`] 계약은 `process(inst, k, v, index)`
-- 4-인자다. 이 핸들러는 재위임을 안 해서 `index`를 실제로 안 쓰지만
-- 표기는 계약대로 둔다(`NilHandler`/`NoneHandler`는 4-인자로 적혀 있어
-- 지금까지 표기가 갈려 있었다). 2번째 인자를 `i`가 아니라 `k`로 부르는
-- 것도 같은 이유 — 배열 위치(`k`)와 재귀 깊이(`index`)를 혼동한 전례가
-- 실제로 있다(`base/dispatch-core-plan.md`의 Handler 작성 체크리스트 6번).
function ProcessedPreRefHandler.process(inst, k, v, index)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가
-- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
-- (`base/dispatch-core-plan.md`의 해제 순서 계약)
Dispatch.setOffsetSource(inst, i, None)
Dispatch.setLength(inst, i, 0)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract, 이 자리는 fire가 끝나
-- 되돌릴 상태 자체가 없음
end
@ -819,10 +943,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
```lua
ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급>
ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef)
function ProcessedPostRefHandler.process(inst, i, v)
-- [시그니처 정정, 2026-08-24 `H-20`] 위 ProcessedPreRefHandler와 같은 이유로 4-인자
function ProcessedPostRefHandler.process(inst, k, v, index)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유)
Dispatch.setOffsetSource(inst, i, None)
Dispatch.setLength(inst, i, 0)
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음)
end
```

File diff suppressed because it is too large Load diff

View file

@ -554,7 +554,26 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
안 늘어남(노드 1개). 구현은 `:With(...)`가 이미 하는 "구독 목록 확장"
로직을 Compute 노드 생성 시점에 그대로 적용하는 것뿐 — 새 메커니즘
아님.
- **`Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은
- **⭐⭐ [부분 역전, 2026-08-24 반영 — 근거는 5라운드 `C-6`, 발견은 6라운드
손 트레이싱 `H-13`] `Effect(fn, ...deps)`는 이제 **허용**된다. `Observer`
기각으로 남는다.** 아래 문단은 그 역전 **이전** 서술이고, `base/effect-plan.md`
이미 2026-08-21에 뒤집어둔 것을 이 문서가 반영하지 않아 **두 base 문서가
정반대를 말하고 있었다.** 이 문서가 반응형 코어의 정본이라 구현자가
`Effect` 표면을 짜기 전에 반드시 읽는 자리이고, 그대로 두면 5라운드가 닫은
갭(**`Ref`는 `:With`로 못 합치므로 `Effect`의 의존성이 될 방법이 아예 없다**)이
되돌아온다.
- **역전 근거**(`effect-plan.md`): *"각 의존성에 구독을 따로 걸면 **합치는
노드 자체가 안 생긴다** — 감출 비용이 애초에 없다."* 즉 아래 문단의 전제
("의존성이 둘 이상이면 합칠 별도 노드가 필요하다")가 `Effect`에 대해서는
성립하지 않는다. `Effect`는 파생값을 안 만드는 leaf라 각 dep에 독립
구독을 걸면 그만이다.
- **`state:Observer(fn, ...)`는 기각을 유지한다. 다만 근거를 새로 쓴다**
(옛 근거는 `Effect`와 공유하던 것이라 지금은 비어 있다, 사용자 확정
2026-08-24): **`Observer`는 리시버 State 하나에 붙는 구독이고, 여럿을
엮는 건 `Effect`가 대신한다.** 즉 역할 분담이지 비용 은폐 회피가 아니다.
- **아래 "일반 원칙" 항목도 `:Compute` 한정으로 좁힌다** — 그 항목 자신의
정정 문단이 소스.
- **(역전 전 원문) `Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은
기각 — 여기선 진짜 새 노드가 생기기 때문.** Effect/Observer는 Compute와
달리 **자기 자신이 결과를 담는 State 노드가 아님**(파생값을 안 만드는
순수 leaf 소비자, 위 "독립 프리미티브 vs 파생 데이터" 분류에서도
@ -572,7 +591,13 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
- **일반 원칙으로 정리**: "trailing args sugar는 그게 정말 무료일 때만
붙인다 — 호출부가 이미 만들어야 하는 노드에 엣지만 얹는 경우(Compute)엔
sugar, 없던 노드를 새로 만들어야 하는 경우(Effect/Observer의 다중
의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다." `quadnomicon`
의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다."
**⚠️ [범위 축소, 2026-08-24 `H-13`] 이 원칙의 적용 범위는 `:Compute` 계열과
"정말 새 노드가 생기는" 자리로 좁힌다.** 괄호 안의 `Effect` 예시는 틀린
것으로 드러났다 — `Effect`의 다중 의존성은 각 dep에 독립 구독을 걸 뿐
**합치는 노드를 안 만들므로** 이 원칙의 대상이 아니다(위 역전 배너).
원칙 자체("숨겨지는 비용이 있는가")는 그대로 유효하고, 바뀐 건 *어디에
비용이 있는가*에 대한 사실 판단이다. `quadnomicon`
에세이 후보로 좋음(`research/documentation-content-map.md` 6번 항목
다음에 추가) — "왜 Compute만 여러 deps를 편하게 받고 Effect/Observer는
안 그런가"가 겉보기엔 비일관적으로 보이지만 실제로는 "숨겨지는 비용이
@ -1035,6 +1060,30 @@ override할 자리를 구조적으로 열어두는 것.)
선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의
`getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀
(`base/lifecycle-pattern.md` 참고).
- **⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-23` — 실측] 전파 루프는
구독자 집합을 **스냅샷으로 복사한 뒤** 돈다.** Lua/Luau에서 순회 중
**기존 키의 값 변경은 안전하지만 새 키 추가는 미정의**인데, **그 순회 도중
같은 테이블에 새 키가 추가되는 정상 경로가 있다.**
- **실측(로컬 `luau` 0.734)**: 구독자 8개짜리 집합을 순회하며 첫 콜백에서
새 구독자 1개를 등록했더니 `obs8 fired=2` / `obs4 fired=2` /
`obs1 fired=2, obs2 fired=0`처럼 **실행마다 결과가 달랐다**(어떤 실행은
깨끗했다). `pairs()`로 명시해도 같았고 **크래시는 안 났다** — 즉 터져서
금방 잡히는 종류가 아니라 **간헐적으로 한 Observer가 통째로 누락되는**
종류다.
- **가상의 오용이 아니라 문서가 권장하는 조합에서 나온다**:
`slot:List(store.items, function(...) return Row { Text = store.items:Compute(...) } end)`
`store.items:Set(...)` → 전파 루프 시작 → 그중 `_listObserver`
`reconcile``updateFn``Dispatch.drive``store.items:Compute(...)`
**같은 집합에 새 키를 삽입**한다. 재진입 자체는 이미 정상 경로로
인정돼 있다(`Dispatch.process`의 (A)/(B) 분기).
- **`Epoch` 규칙으로는 못 막는다** — dedup은 "같은 emit이 두 번 도착했을 때
접는" 것이라 **이중 발화는 접히지만 누락은 안 접힌다.**
- **확정된 계약**: 순회 전에 배열로 스냅샷을 뜨고 그 배열을 돈다.
**이번 파동 중에 붙은 구독자는 다음 파동부터 참여한다.** 스냅샷과 실제
발화 사이에 죽은 구독자는 기존 `canExecute` 게이트가 걸러준다(그래서
스냅샷이 stale해도 안전하다). 비용은 파동마다 배열 하나.
- **같은 처방이 `Ref.Callbacks`에도 적용된다**(`base/ref-plan.md`) — 그쪽도
같은 모양의 순회이고, 같은 날 해시맵 셋으로 바뀌면서 더 분명해졌다.
- **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면
내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속
능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자"
@ -1317,6 +1366,15 @@ ObserverEffectLeafHandler.isHandlable(inst, k, v) =
-- 매치돼버려 죽은 코드가 됨(2026-08-14 열두 번째 세션 수정)
function ObserverEffectLeafHandler.process(inst, k, v, index)
-- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져
-- 있었다. 이 자리는 물리 리프를 하나도 기여하지 않으므로 짝을 맞춰 `0`.
-- 없으면 `Frame { someObserver, Frame{} }`처럼 leaf가 앞에 오는 배치가
-- 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다
-- (`base/dispatch-core-plan.md`가 확정한, 값이 없는 자리도 짝을 맞춰 `0`
-- 등록한다는 규칙).
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local old = relate:GetStrong(inst, k)
if old ~= v then -- 이미 같은 값이 이 자리를 차지 중이면 재바인딩 skip
bindLifetime(inst, v) -- Effect는 내부적으로 자기 Observer까지 cascade(`base/effect-plan.md`)

View file

@ -256,9 +256,19 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
**따라서 "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가
필요한가"라는 질문도 성립 안 함으로 종결** — 두 가지 독립적인 이유로
이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 문단). (2) 설령
발생해도 State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적
`dispose()` 호출이 아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙
재사용)라 "같은 걸 두 번 해제"할 행위 자체가 존재하지 않음(GC는 멱등).
발생해도 State/Source 그래프 구독에 **명시적 `dispose()` 호출이 아예 없어서**
(`base/lifecycle-pattern.md`의 GC 위임 원칙 재사용) "같은 걸 두 번 해제"할
행위 자체가 존재하지 않음(GC는 멱등).
> **⚠️ [근거 정정, 2026-08-24 6라운드 손 트레이싱 `H-36`]** 위 (2)번은 원래
> *"그래프 구독이 **전부** weak-keyed GC-native"*라고 적혀 있었다. 그 명제는
> `base/source-state-plan.md`**스스로 미확정이라고 선언한 항목**(중간
> State가 살아남는가 — 구독 엣지의 방향성, 상류가 strong인지 weak인지)이라
> 근거로 쓸 수 없다. 사용자가 지목한 해법 방향("하류로 weak, 상류로 strong")
> 으로 닫히면 그래프는 더 이상 "전부 weak"가 아니게 된다.
> **결론(이중 해제 불가)은 안 뒤집힌다** — (1)번만으로도 성립하고, (2)번도
> "해제 호출 자체가 없다"로 다시 쓰면 방향성과 무관하게 참이다. 그 미해결이
> 닫힐 때 이 문단을 같이 훑을 것.
## Store가 담을 수 없는 값 — Modifier

View file

@ -155,9 +155,23 @@ local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} —
-- TagHandler 자신은 `.priority` 없음(직접 등록 안 됨) — 아래 "패키지 배치" 절의
-- `TagFallbackHandler = { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = TagHandler.isHandlable,
-- process = TagHandler.process }`가 실제로 등록되는 얇은 래퍼(2026-08-14 열두 번째 세션 정정)
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
-- [정정, 2026-08-24 6라운드 손 트레이싱 `H-52`] **`type(k) == "number"` 가드 추가.**
-- 주석은 "array-part 전용"이라 말하면서 실제 판정은 값만 봤다. `RefLeafHandler`
-- 2026-08-18에 정확히 같은 버그를 고친 전례가 있다 — 빠지면 named 자리로 흘러온
-- 값을 잡으려는 `HANDLER_PRIORITY_FALLBACK` 가드가 죽은 코드가 된다.
TagHandler.isHandlable(inst, k, v) = (type(k) == "number" and isTag(v))
function TagHandler.process(inst, k, v, index)
-- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다.
-- `Tag`는 물리 리프를 하나도 기여하지 않으므로 짝을 맞춰 `0`. 없으면
-- `Frame { Tag("card"), TextLabel { … } }`처럼 Tag를 자식보다 앞에 두는
-- **아주 흔한 배치**가 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다
-- (`base/dispatch-core-plan.md`의 등록 책임 절). 그룹 `Attribute`와 함께
-- "Length/Offset 비참여 카테고리"로 재정의하는 갈래도 있었으나 `bk.N`
-- 의미가 바뀌어 파급이 커서 기각(사용자 확정 2026-08-24).
Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst)
local added = {}
for name in v:Names() do
local holders = tagNameMap:GetStrong(inst, name)

View file

@ -466,6 +466,14 @@ tween:Mapped(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)
- **타입이 안전하게 성립한다**`Tween<T>`는 immutable raw 값이고 `Value` 외의
필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value``U`로 바꾼
`Tween<U>`를 만드는 건 타입 레벨에서 깨끗하다.
- **⚠️ [단서, 2026-08-24 6라운드 손 트레이싱 `H-24` — 실측] 단 선언 방식이
갈린다.** 이 시그니처는 `base/typing-limits.md` 1번이 지적한 재귀 제네릭
누수(`Foo<T>` 안에서 `-> Foo<U>`)와 **글자 그대로 같은 모양**이라,
인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히
통과**시킨다(`Tween<string>`의 `.Value``number`에 넣어도 안 잡힘).
**구현 시 `typeof(named function)` 스타일(③)로 선언할 것** — 그러면 정상적으로
잡힌다. 위 "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는
뜻은 아니다.
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고
`table.clone``Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의
clone 체이닝과 같은 계열).

View file

@ -241,8 +241,33 @@ self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T
| `state:Compute(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 무주석이어도 안전(다운스트림) |
| `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 |
| `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 |
| `Effect(fn, state)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
| `Effect(fn, ...deps)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) |
| `tween:Mapped(fn)` | 인라인 제네릭 메소드 | — | ❌ **조용히 통과**(아래 `H-24`) |
| `tween:Mapped(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 안전 |
**⭐ [2026-08-24 추가, 6라운드 손 트레이싱 `H-24` — 실측] `Tween<T>:Mapped`
이 표에서 빠져 있었다.** 확정된 시그니처 `tween:Mapped(fn: (T) -> U): Tween<U>`
위 1번이 *"이것만"* 문제라고 못박은 모양(`Foo<T>` 안에서 `-> Foo<U>`)과 **글자
그대로 같은데**, 이 문서도 `base/tween-plan.md`도 그 사실을 몰랐다(양쪽 grep 0건).
실측:
```lua
--!strict
export type Tween<T> = { Value: T, Mapped: <U>(self: Tween<T>, fn: (T) -> U) -> Tween<U> }
local t = (nil :: any) :: Tween<number>
local mapped = t:Mapped(function(x: number) return tostring(x) end)
local wrong: number = mapped.Value -- Tween<string>.Value는 string → 에러여야 정상
```
`luau-analyze`**진단 0건**(조용히 통과). 같은 검사를 ③(`typeof(named function)`
선언)으로 바꾸면 `TypeError: Expected this to be 'number', but got 'string'`으로
**정상적으로 잡힌다** — 즉 **기존 완화책이 그대로 통하는데 아무도 적용을
지시하지 않고 있었다.** 새 설계 결정은 필요 없다.
(§6의 `type function`을 거친 값과는 다른 문제다 — `Mapped``type function`
안 거치는 순수 제네릭 메소드다. 같은 각도로 최근 확정 표면을 훑어봤고
`state:Gate(setup)`/`EpochMap`/`Effect`는 **전부 이 패턴이 아니라 무관**했다 —
셋 다 제네릭 self를 다른 타입 인자로 반환하지 않는다.)
`state:With(...)`/`state:Apply(factory)`도 원리상 ③으로 같은 이득을
받을 것으로 예상되나(둘 다 `Compute`와 같은 "재귀 자기 반환" 모양),

View file

@ -9,7 +9,8 @@
> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
> `01`이 낡아 `done/``rewrite-required/` 이동(검증 대상인 순서 계약
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을
> 없는 실측 항목(중간 State GC — **[2026-08-24]** 같이 나열돼 있던 `R-11`
> `table.insert` 구멍 재사용은 6라운드 `H-7`로 **전제가 사라져 폐기**됐다)을
> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
@ -141,7 +142,7 @@
| 파일 | 확인된 것 |
|---|---|
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`**해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
@ -156,7 +157,7 @@
|---|---|
| `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 |
| `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 |
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) |
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로). **[2026-08-24 `H-54`] 단 스파이크 자신의 주석이 실제 결과와 어긋난다** — *"이건 당연히 통과해야 함"*이라 적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(`AttributeKey<T>(name)`이 문맥에서 `T`를 못 추론해 `unknown`으로 남음). 오히려 "왜 진짜 테스트 대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적 결과라 **이 총론은 그대로 유효**하고, 근거 라인만 다르다 — 재작성 시 참고 |
| `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef<T>`/`PostRef<T>` 둘 다 `Ref<T>`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) |
| `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 |
| `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType<Input>`이 정확히 `{ty: Source<string>, count: Source<number>}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 |
@ -205,6 +206,6 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
| 검증할 것 | 왜 | 출처 |
|---|---|---|
| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` |
| ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7``Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` |
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 |
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |

View file

@ -0,0 +1,375 @@
# 6라운드 손 트레이싱 — 처리 결과
**상태**: **[2026-08-24] 전량 처리·반영 완료.** 사용자와 대화형으로 하나씩
결정했고(`H-1`~`H-54`), 그 결정을 각 `base/` 문서에 **전부 반영했다**.
이 문서는 **결정과 근거의 기록**이고, 지금 유효한 설계는 항상 `base/`가 소스다.
원 발견 보고는 `pre-implementation-handtrace-round6.md`.
**반영된 문서**: `slot-plan.md` / `dispatch-core-plan.md` / `source-state-plan.md` /
`effect-plan.md` / `ref-plan.md` / `gate-plan.md` / `blocker-plan.md` /
`debounce-throttle-plan.md` / `tag-plan.md` / `attribute-plan.md` /
`modifier-plan.md` / `onchange-plan.md` / `tween-plan.md` / `typing-limits.md` /
`fallback-plan.md` / `quad-types-plan.md` / `store-plan.md` /
`module-lifecycle-plan.md` / `architecture.md`, 그리고 루트 `ROADMAP.md`
`.claude/todos.md` / `luau-test/STATUS.md`.
## A. 처리 완료 (결정됨)
### `H-39` — 말단 핸들러 4종의 `setLength`/`setOffsetSource` 미등록
**결정: 넷 다 똑같이 등록**(갈래 1 + 2-(a)). `TagHandler`/
`AttributeGroupHandler`/`RefLeafHandler`/`ObserverEffectLeafHandler`의
`process` 맨 앞에서 `setOffsetSource(inst,k,None)``setLength(inst,k,0)`.
`AttributeGroupHandler`도 예외로 두지 않는다 — Tag/Attribute를 "Length/Offset
비참여 카테고리"로 재정의하면 `bk.N`의 의미가 바뀌어 파급이 크다.
### `H-25``New(): Quad`가 닫힌 타입
**결정: `quad-types``Quad`를 마일스톤마다 갱신**(갈래 2). M2가
`Dispatch` 필드와 그 타입 재수출을 `quad-types`에 추가하는 걸 `ROADMAP.md`
M2 체크리스트 항목으로 명시한다. quad-roblox(M5)도 같은 경로로 본다.
### `H-1``keyIndex`가 사이클 도중 stale
**결정: 사용자 역제안 채택 — 역방향 인덱스 맵을 raw 층에 둔다.**
- `:List``k → realElem`만 들고, 배열 인덱스를 직접 들지 않는다.
- **`slot._elemIndex`** 신설(`realElem → index`), `_elements`와 같은 수명.
자리를 밀고 당기는 모든 연산(`spliceArraysUp`/`Down`, `rawMove`,
`rawSwap`, `rawReplace`, `rawAdd`)이 같이 갱신한다. detach된 요소는
`_elements` 밖이므로 맵에서도 빠진다.
- **raw\*의 index 시그니처는 유지**(5라운드 결정 그대로). 대신
`indexOfRaw(self, element)`가 선형 탐색이 아니라 이 맵 조회가 되고,
"폴백이 아니라 기본 경로"로 승격된다.
- 근거: 층 분리가 오히려 깨끗해지고(raw\*가 `:List` 클로저를 안 봄),
시프트는 이미 O(n)이라 맵 갱신이 점근 비용을 안 올리며, 조회가 O(1)이
된다. `claimOwnerAt`이 같은 요소의 이중 배치를 이미 error로 막으므로
키 유일성이 보장된다.
- **부수 효과**: `:List``keyIndex`가 인덱스 맵에서 **단순 키 집합**으로
내려앉는다(소멸 루프의 "직전 사이클 키" 용도만 남음) → `H-16`이 같이
사라진다.
### `H-2``pos`(리프 카운터)를 배열 인덱스로 겸용
**결정: 카운터를 분리하고, `updateFn``index``getOffsetAt`으로 구한다.**
사용자 원 디자인 초안대로 — activation이 순차이므로 그 시점 length는
확정돼 있다. 이를 성립시키기 위해 초기 population 경로를 같이 고친다:
- **`_mounted`는 "물리 인스턴스가 있는가"만 가리킨다.** `rawAdd`
얼리리턴을 **`native*` 호출만 가리는 분기**로 좁히고, 부기
(`spliceArraysUp`/`setOffsetSource`/`setLength`/중첩이면 실체화)는
마운트 전에도 수행한다.
- **`slot._physicalTarget` 신설** — `materializeSlotTree` 머리에서 저장.
`setLength`의 앵커/`bk.observers` 앵커가 마운트 전에도 필요하기 때문.
`_mountedInst`는 지금대로 "마운트됨"만 뜻한다(미실체화/실체화/마운트 3상태).
- **`_listed``materializeSlotTree`의 자식 실체화 루프를 건너뛴다** —
`activateList`가 이미 전부 등록하므로 이중 등록이 된다. `:List`와 CRUD가
상호배타라 정확하다.
- **`blocker:On()``activateList` 앞으로 옮긴다** — 5라운드 `AS-5`
근거("그 안에선 게이팅할 recompute 자체가 안 일어난다")가 사라지므로.
population 중 아이템마다 recompute가 도는 O(n²)를 막는다.
`_baseObserver` 생성은 여전히 `blocker:On()` 뒤.
### `H-5``spliceArraysUp``bk.N`을 먼저 올려 여는 창
**결정: `spliceArraysUp``lengthList[index]`에 자리표시자를 채운다.**
`sourceList``None`으로 채워지는 것과 대칭. 동기 재진입(`ChildAdded`)이
그 창에서 `sum += nil`로 터지던 것이 닫힌다.
### `H-6``unmountSlotTree`의 미정의 `physicalTarget` + offset 어긋남
**결정: `physicalTarget``slot._mountedInst`를 로컬로 먼저 뽑아 쓴다**
(지우는 대입보다 위에서 읽는 순서 유지). 곁가지는 **역순 순회로 변경**
뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. 백엔드 계약을
좁히지 않아도 닫힌다.
### `H-7``Effect``Ref` 의존성을 뗄 방법이 없음
**결정: `Ref`에 콜백 해제 경로를 추가**(갈래 a).
### (파생) `Ref.Callbacks`를 해시맵 셋으로
**결정: `{[callback/thread] = true}`로 변경**(사용자 제안). 해제가 O(1)이
되어 `H-7`이 요구하는 연산과 맞고, `type(v) == "thread"` 분기는 키 타입
검사로 그대로 성립한다.
- **같은 콜백/thread의 중복 등록은 dedup을 계약화**(여러 번 등록해도 한 번
발화). 해제가 `t[fn] = nil` 하나로 끝나야 하기 때문.
- **`ref-plan.md`의 ⚠️ 실측 대상(구멍 있는 테이블에서 `#t` border가 항상
`nil`인가 — M0/M8 스파이크)이 폐기된다** — 해시맵엔 border 개념이 없다.
- `table.insert` 재사용 근거(`R-11`)와 "`None`이 아니라 `nil`" 논의도 이
자료구조 변경에 맞춰 다시 쓴다.
### `H-23` — 전파 도중 새 구독자가 붙으면 누락/이중 발화 (실측)
**결정: 순회 전 스냅샷**(갈래 1). State 구독자 집합과 `Ref.Callbacks`
**둘 다** 배열로 복사한 뒤 그 배열을 순회한다. "이번 파동 중에 붙은
구독자는 다음 파동부터 참여"를 계약으로 명시. 스냅샷 사이에 죽은 구독자는
기존 `canExecute` 게이트가 걸러준다.
### `H-11``Effect`의 leaf 사망 cleanup을 부르는 배선이 없음
**결정(1번 소항목): `EffectHandle` 쪽이 자기 `bindLifetime` 직후에
`Destroying`을 건다.** `bindLifetime`이 값 타입을 가리지 않는다는 기존
원칙을 유지한다. 2·3번 소항목(=`unbindLifetime`이 cleanup을 부르는가 /
cleanup을 어디 보관하는가)은 아직 미확정.
### `H-12``rawRemove`/`rawUnmount`/`rawDetach`에 마운트 전 분기 부재
**결정: `native*` 호출(및 `_mountedInst`를 쓰는 줄)만 `_mounted`로 가드**해
`rawAdd`와 대칭을 맞춘다. `H-2`의 "부기는 마운트 전에도 한다"와 같은 규칙을
네 함수가 공유한다.
### `H-13``Effect(fn, ...deps)` 역전이 정본 문서에 미반영
**결정: `Observer`는 기각 유지하되 근거를 새로 쓴다.** `source-state-plan.md`가 승격해둔 일반 원칙(없던 노드를 새로
만들어야 하면 sugar를 안 붙인다)은 **`:Compute` 한정**으로
좁히고, `Effect`의 역전(`C-6`)을 그 문서에 반영한다. `Observer` 기각의 새 근거는
"Observer는 리시버 State 하나에 붙는 구독이고, 여럿을 엮는 건 `Effect`가 대신한다".
### `H-14``Effect``fn` 시그니처
**결정: `Effect(fn: (self: Effect) -> (() -> ())?, ...deps)`.**
- `fn`**`self`(Effect 핸들) 하나만 받는다.** `...deps`는 **의존성 선언일 뿐
`fn`에 안 넘어간다** — dep 값은 사용자가 클로저로 직접 읽는다.
- 사용자 논거: *"Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게
맞아보이고, Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼
그냥 넣을 수 있게 하자는거였을 뿐임."*
- 그 따름정리로 **`Ref` dep의 `.Value` vs State dep의 `:Get()` 비대칭 문제가
사라진다**(아무것도 안 넘기므로). 문서의 *"trailing deps를 lazy 위치 인자로
콜백에 넘긴다"*와 옛 `fn(state)` 표기는 **둘 다 삭제**.
### `H-17``Dispatch.drive`의 Blocker 범위
**결정: "`drive` 전체"로 넓혀 적고 계약화.** `F-4-1`의 단일 일반화 `for`에선
배열 파트 종료 시점이 관측되지 않고 `postRefList` 소비는 애초에 해시 파트보다
뒤이므로, 실제 범위를 문장에 맞춘다. 추가로 **`PostRef` 콜백은 게이트가 켜진
채로 실행되며, 그 안에서 일어난 Length 변화는 직후의 명시적 `recompute`
번으로 정리된다**를 계약으로 명시(지금은 우연히 맞고 있을 뿐).
### `H-18` + `H-45` — attribute 이름을 그룹 사이에서 옮길 때 emit 순서 의존
**결정: UB로 못박는다.** 사용자 논거: *"state<attribute> 로 그 경로가 열리는데
어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이 없음. 외부에서
먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임. 애초에 싱크이기도
하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한 예외들이 생기고
오버 엔지니어링으로 보임."* `H-45`(단건 `AttributeKey` ↔ 그룹)도 같은 결정으로
닫힌다.
### `H-19``recompute`가 두 번 도는 것
**결정: `setLength`에 일임하고 `rawAdd`/`rawReplace`의 명시 `recompute` 호출을
삭제.** "자리의 길이가 바뀌면 `setLength`가 책임지고 `recompute`를 태운다"로
소스를 하나로 만든다 → `H-3``invalidAfter` 당김도 `setLength` 한 자리에만
두면 된다.
### `H-21``unwrapElement`가 Instance에서 크래시
**결정: `isSlot` 가드**(갈래 1). `if isSlot(el) then return el._wrapped or el end
return el`. 래퍼는 항상 Slot이고 `isSlot`은 Brand의 weak-key 조회라
Instance/userdata/number 전부에 안전하다.
### `H-22` — 기본 identity `updateFn``KeyGone`을 되돌려 항상 error
**결정: `identityUpdateFn``KeyGone`을 흡수**(갈래 1) —
`if item == KeyGone then return nil end`. 사용자가 직접 쓴 identity에도 같은
처리가 필요하다는 건 문서로 안내한다.
### `H-24``Tween<T>:Mapped`의 재귀 제네릭 타입 누수
**결정: 단순 정정.** `typing-limits.md`의 영향 범위에 `Tween<T>:Mapped` 행을
추가하고, `tween-plan.md``Mapped` 절에 "인라인 제네릭 메소드가 아니라
`typeof(named function)`으로 선언할 것" 각주. 기존 완화책이 그대로 통한다.
### `H-26` — 부분 생성 후 예외로 생긴 Instance가 영구히 안 죽음
**결정: 둘로 나눈다.**
1. **기본 경로(예외가 밖으로 나가 아무것도 안 그려지는 경우)는 서술 정정으로
끝낸다.** 같이 **`dispatch-core-plan.md`에서 잔여 부기가 인스턴스 GC로
정리된다고 적은 문장을 고친다** — gcconn 불멸성과 양립하지 않는 **틀린 안전망
주장**이다.
2. **`Fallback`/`Traceback` 중 생성된 부분 트리는 백로그.** 사용자 판단:
*"의도적으로 error 를 사용하고자 하는 경우 항상 컴포넌트들이 쌓이거든. 이건
후행에서 더 다뤄보도록 백로깅해줘. fallback/traceback 자체가 슈거라서, 그
때 가서 생각해도 될듯."*
### `H-29` — 정의 없는 `raw*` 다섯
**결정: 작성 + `collectLeaves(slot)` 헬퍼 신설.** 재귀 리프 수집 헬퍼를 두고
`Move`/`Swap`/`Extract`/`Splice`가 공유한다(`native*`의 "빠지는 요소는 반드시
elements 배열로" 계약을 그대로 지킴). 작성 시 같이 명시할 것: 함께 치환되는
배열(`lengthList`/`sourceList`/`bk.observers`/`_elemIndex`), `bk.N`은 안 변함,
`recompute``setLength`에 일임(`H-19`).
### `H-32`/`H-33`/`H-49` — `Debounce`/`Throttle`과 `Gate`의 관계
**결정: 정책 합성 구조를 확정.**
- **`Gate` 노드에 `Flush`/`Cancel` 표면을 두지 않는다.** 확정된
`state:Gate(setup)`(State 하나만 반환)이 그대로 유지된다.
- **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.**
`state:Block(b)`는 그 위의 얇은 래퍼가 된다.
- **`Debounce`/`Throttle`은 emit을 아예 안 쥔다.** 자기 `Blocker`를 사적으로
갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는 Blocker에 위임한다:
```lua
state:Gate(function(emit)
local b = Blocker()
local pass = b:Policy(emit)
return function() -- 상류 emit 도착 (동기)
...타이머 리셋 / b:On() / b:Off() 시점 판단만...
pass() -- 그 시점 상태로 Blocker가 판정
end
end)
```
동기 실행이라 같은 호출 안에서 정책이 바꾼 Blocker 상태를 `pass()`가 본다.
- **Blocker는 `Debounce` 설정당 하나가 아니라 적용 핸들당 하나**다 — `Debounce{}`
커링 결과는 여러 곳에 적용될 수 있으므로 Apply 시점에 생성된다.
- **`pending`은 Blocker의 `HasBlockedEmit`으로 흡수**한다(중복 상태를 안 만든다).
`Trailing=false``OffWithoutEmit()`, `Flush``Off()`로 매핑되어 `H-32`
새는 경로가 구조적으로 사라진다.
- 합성 순서(누가 상류인가)는 손으로 중첩해 표현한다 — **`state:Gate(p1, p2, ...)`
가변인자 슈가는 두지 않는다.**
- **기각된 대안(기록)**: "블로커를 바깥에 중첩"(`blocker:Policy(debounceOnEmit)`)은
unblock 시 흘러나온 emit이 디바운스 창을 새로 시작시켜 창이 안 끝난다.
### `H-38` — reconcile의 예외 원자성
**결정: 키 집합을 증분적으로 갱신**(마지막 일괄 교체 폐지). `settle` 직후에
키를 넣고 소멸 루프에서 뺀다. 계약은 "reconcile은 원자적이지 않지만 **중단된
지점까지는 정합하다**". `H-31`(duplicate key)의 선행 검증 패스와 함께 그 실패
모양을 닫는다.
### `H-39`~`H-42`, `H-46`, `H-52` (4차)
- **`H-40`** — 빠진 가드 넷을 전부 의사코드에 반영. 타입 검증은 **화이트리스트로
뒤집되 base가 `T`를 아는 방식이 아니라 백엔드 주입으로** 한다: **`isInst`
백엔드 계약(주입 op)에 추가**하고 quad-roblox가 `typeof(v) == "Instance"`
채운다. 판정 순서는 `isSlot` → 구조 처리 / `isState` → 래퍼 Slot으로 풀어
재귀 / 그 외 → `isInst(v)`가 거짓이면 error. **관문은 `wrapElement` 하나**
(공개 CRUD와 `:List``settle`이 둘 다 통과하므로 State가 나중에 이상한 값이
되는 경우도 같은 자리에서 걸린다). 사용자 논거: 브랜드 기반 판정은 불가 —
*"이제 brand 는 각각 따로 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는
의미"*.
- **파생: `Splice`의 `T | {T}` 기각은 유지하되 근거를 정정한다.** *"근거 정정.
근데 실질적으로 저 splice 는 T 가 slot/state 일 수도 있어서, isSlot/state 도
봐야함. 당연하게도 `Splice(..., comp())` 패턴은 흔할 수 있어서 그래. 그리고
여전히 splice 는 소수의 요소를 다루는게 많고, 테이블 생성 비용을 지불할
의미가 없다는 근거는 여전해."*
- **`H-42`** — **문서만 정정**. "동기 발화"를 `SignalBehavior.Immediate` 조건부로
명시(Deferred에선 이 레이스가 없고 `PreRef`는 무해하게 불필요). 그 뒤의 큰
질문(구조 존속)은 **유지**로 결론 — Immediate place가 살아있는 한 필요하고
불필요해질 때의 비용이 싸다.
- **`H-46`** — **top-level `Slot.luau` 신설**(다른 값 타입과 대칭,
`slot-plan.md`가 이미 적어둔 파일명과도 일치). `Dispatch/Slot.luau`
핸들러/부기만.
- **`H-52`** — **대칭적으로 가드 추가**(`type(k)=="number"` + 동적 경로 가드),
`RefLeafHandler`가 2026-08-18에 받은 수정과 같은 모양.
### 일괄 단순 정정 (사용자 확인: "전부 그대로 반영")
`H-3`(캐시 무효화 3규칙을 `setLength`/splice/`_baseObserver`에 실제 배치) ·
`H-4`(`bk` 스펙에 `offsetCache`/`invalidAfter` 추가, `0`/`{}` 초기화 명시) ·
`H-8`(`_observers` 배열화 + cascade/Subscribe/Unsubscribe 순회) ·
`H-9`(`withheld` 스왑도 weak-key로) · `H-10`(`sum` 주석 정정) ·
`H-20`(`process(inst, k, v, index)` 표기 통일) · `H-27`(`OnChange`의 `v == nil`
얼리리턴) · `H-28`+`H-43`(`dispose`의 소유권 가드를 `isSlot` 분기 밖으로) ·
`H-30`+`H-31`(선행 일괄 검증 패스) · `H-34`+`H-44`(`native*` 계약 보강 —
Roblox 백엔드는 `nativeMove`/`nativeSwap`을 덮어써야 함, ROADMAP M6 줄 갱신) ·
`H-35`(`ProcessedModifierHandler` 의사코드 + 색인 두 곳) · `H-36` · `H-37` ·
`H-41`(`groupClaimKeys` 배선) · `H-47`~`H-51` · `H-53` · `H-54`.
## B. 답할 게 없는 항목
- **`H-15`** — 2차 패스에서 이미 철회(오탐).
- **`H-16`** — `H-1` 해법의 부수로 소멸(`keyIndex`가 인덱스 맵이 아니게 됨).
- **`H-2`의 크래시 주장 정정** — 3차 패스의 실측(`table.insert(t, 0, x)`는
Luau에서 안 터짐)대로 "크래시"가 아니라 "조용한 영구 고아"로 본문을 고친다.
결론과 처방은 그대로.
## C. 백로그로 넘긴 것
- **`H-26`의 2번** — `Fallback`/`Traceback` 중 생성된 부분 트리의 회수.
그 둘이 슈가라 구현 시점에 다룬다.
## D. 반영 후 `/code-review high` (2026-08-24) — 7건, 전부 유효
반영을 커밋하기 전에 사용자가 `/code-review high`를 돌렸고 **7건이 나왔으며
전부 유효했다.** 그중 셋(1·2·3)은 **이번 반영이 새로 만든 회귀**다 — 감사자가
보는 축(코퍼스 정합성)으로는 안 보이고 diff 자체를 읽어야 보이는 종류였다.
1. **`rawAdd`가 미실체화 가드를 통째로 잃었다**(높음). `H-2` 재작성이
`if not self._mounted then return index end`를 지우면서 **3상태 중 첫
경계에 아무것도 안 뒀다.** `Slot { frameA }` 생성자가 `:Add`를 부르는데
그건 `materializeSlotTree`보다 훨씬 먼저라 `slot.Offset``nil`이고,
`setLength``gatedRecompute``getOffsetAt``ownerKey.Offset:Get()`에서
즉시 죽는다. 중첩이면 `bindLifetime(nil, …)`까지 간다.
**`self._physicalTarget == nil`이면 `_elements`/`_elemIndex`만 갱신하고
리턴**하도록 복원. `rawReplace`의 미마운트 분기에도 같은 가드를 넣었다.
같이 정리된 것: `_elemIndex` 갱신을 `reindexFrom(self, from)` 헬퍼로 떼어
**실체화 여부와 무관하게 항상** 돌게 했다(`spliceArrays*`는 부기만 담당).
2. **`rawRemove`가 마운트 전 창에서 요소를 안 죽인다**(높음). `H-12`
`nativeRemove``_mounted`로 가렸는데 **`nativeRemove`가 곧 파괴**였다
(백엔드 융합). `else` 분기가 비어 있어 요소가 `_elements`에서 빠지고
아무도 안 죽인다 — gcconn 때문에 GC도 안 되므로 **영구 누수**다.
`else nativeDispose(element)` 추가(`rawReplace`가 이미 하던 대로).
3. **`_listed` 분기가 포탈 재마운트를 깬다**(중간). 재마운트에선
`activateList``_listActivated` 멱등 가드에 걸려 앵커만 옮기고 리턴하므로,
자식 루프까지 건너뛰면 보존된 `_elements` 안의 중첩 Slot이 **다시 실체화되지
않는다**(`_physicalTarget`이 `nil`인 채 `_mounted`만 켜지고 `_baseObserver`
죽은 옛 target에 매달린다). → 조건을
**`slot._listed and not slot._listActivated`**로 좁히고, `else` 가지에서
재마운트 시 `activateList`(앵커만)를 부른 뒤 루프를 돌게 했다.
4. **`H-11`의 두 결정이 서로 모순**(중간). "`bindLifetime`은 값 타입을 안
가린다"를 지키기로 해놓고 "`unbindLifetime`이 `Destroying`을 끊는다"를
같이 정했는데, 후자는 정확히 그 분기를 요구한다. 게다가 *"`EffectHandle`
쪽이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지 않는다**
(핸들은 남이 자기를 bind하는 걸 관측 못 한다). `Effect` 바인드 경로가
둘이라(leaf 핸들러 / `activateList``_detachCleanup` 직접 바인드) 핸들러
층에 둬도 안 덮인다.
→ **재결정(사용자, 2026-08-24): `bindLifetime`/`unbindLifetime`이
`isEffect`를 보고 직접 처리한다.** *"Destroying 자체가 엔진이 아는 요소이기
때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기
때문에, bind 상 옵져버 목록을 가져와 자신이 재귀하고, bindLifetime 이
처리하는게 나아보입니다."* 근거로 들었던 "게이트는 값 타입을 안 가린다"는
**인용이 틀렸다** — 그 절은 `canBound` 판정이 두 진입점에서 같다는 얘기지
부수 배선 얘기가 아니고, 실제로 그 함수는 이미 `_observers`로 cascade한다.
의사코드는 `base/lifecycle-pattern.md`에 반영.
- **⭐ 같은 자리에서 사용자가 추가 지적**: `Effect`**`Ref` 콜백도
`canExecute`를 거쳐야 한다.** 해제 경로만으로는 창이 남는다 —
`unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안
도는데도 `canExecute`가 거짓이다. **해제는 누수를, 게이팅은 발화를** 막는다.
`Effect`가 거는 `Ref` 콜백은 본문 맨 앞에서 `canExecute(handle)`
확인하고 거짓이면 리턴한다. 이로써 두 dep 경로가 같은 게이트를 공유한다.
(`H-7` 처리 때 "그 대안은 채택하지 않았다"고 적었던 서술을 정정했다 —
택일이 아니라 둘 다 필요하다.)
5. **`Block`/`Gate` 동치 표기가 호출 불가**(중간). `state:Gate(b:Policy)`
문법 오류이고 `state:Gate(b.Policy)`는 언바운드 메소드라 `emit``self`
자리에 들어가 게이트가 영영 안 열린다.
`state:Gate(function(emit) return b:Policy(emit) end)`로 두 문서 정정.
6. **`settle`의 교체 분기가 새 자리로 안 옮긴다**(낮음). `rawReplace`는 자리를
유지하는데 `slotPos`를 무시해서, 값 교체와 리오더가 같은 사이클에 겹치면 그
요소만 옛 자리에 남는다. → 교체 후 `idx ~= slotPos``rawMove`.
7. **`prevKeys[key] = true``settle` 뒤에 있어 `H-38` 계약이 약해진다**(낮음).
`settle`이 커밋 후 던지면 그 키가 집합에 안 들어가 다음 사이클 소멸 루프가
못 물어 영구 고아가 된다 — `H-38`이 고치려던 바로 그 모양. → `settle` **앞**으로.
**교훈(기록)**: `conventions.md`가 *"`/code-review`는 감사자를 대체하지 않는다"*
라고 적어둔 그대로였다. 이번엔 감사자를 돌리기 **전에** code-review가 먼저
돌았는데, 잡힌 7건 중 셋이 "이번 diff가 만든 새 결함"이라 코퍼스 정합성
각도로는 애초에 안 보이는 것들이었다.
## E. 반영 후 `quad-doc-auditor` 감사 루프 (2026-08-24) — 9라운드, 34건
`/code-review high` 뒤에 감사 루프를 돌렸다. `conventions.md`의 절차대로
**한 턴에 하나씩**(병렬 금지) 돌리고 **라운드마다 각도를 바꿨으며**, 새 발견이
**0건인 라운드가 나올 때까지** 반복했다. 라운드별 발견: **5 → 7 → 2 → 2 → 3 →
9 → 2 → 4 → 0**.
| 라운드 | 각도 | 발견 |
|---|---|---|
| 1 | `base/` 문서들끼리 정합성(옛 주장 잔존) | 5 |
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS/archive/reference) | 7 |
| 3 | **이번에 새로 쓴 서술 자체**의 내부 모순·인용 검증·정의/사용 일치 | 2 |
| 4 | 이번에 **안 바뀐** 문서가 조용히 틀려졌는가 | 2 |
| 5 | **어휘 추적** — 뜻이 바뀐 낱말을 옛 뜻으로 쓰는 산문 | 3 |
| 6 | `ROADMAP.md`의 **미완료 체크박스**를 구현자 눈으로 | 9 |
| 7 | 6라운드 픽스 검증 + `agents`/`tools`/followup 사각 | 2 |
| 8 | **완결성** — "N개 중 일부만 고친 자리" 세기 | 4 |
| 9 | 수렴 확인 + 전 코퍼스 스윕 | **0** |
**각도를 바꾼 게 실제로 작동했다.** 가장 많이 잡은 6라운드(9건)는 문서
정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은
라운드였고, 그때 `ROADMAP.md`의 미완료 항목들이 대거 stale인 게 드러났다 —
M6엔 폐기된 `pos` 공식이 "확정"으로 남아 있었고, M2엔 접두합 캐시 무효화
계약이 통째로 없었으며, M8은 `bindLifetime`이 *"둘만 한다"*고 적어 `H-11`
Effect 분기와 직접 모순이었다. 말단 핸들러 4종의 부기 의무(`H-39`)는 어느
체크박스에도 없었다.
**반복해서 드러난 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만
고쳤다".** 1라운드는 *배너를 달아놓고 그 배너가 부정하는 위쪽 문장을 안 고친*
것(핸드오버 체크리스트 2번), 2라운드는 *`base/` 19개를 바꾸고 `.claude/README.md`
한 줄도 안 고친* 것(체크리스트 6번), 7·8라운드는 *그 README를 고칠 때 11개 행
중 6개만 고치고 다섯을 빠뜨린* 것이었다. 8라운드를 아예 **완결성 축**(새 이름
하나당 나타나야 할 자리 넷 — 정의 문서/소스 트리/ROADMAP 체크박스/README 행 —
을 열거해 세기)으로 잡은 게 그래서였고, 실제로 4건이 더 나왔다.
**같이 채운 절차적 공백 둘**: `conventions.md`가 요구하는
`session/2026-08-24-01-handtrace-round6-resolution.md` 원문과
`session-summary.md` 항목을 남겼다(설계 결정이 대량으로 오간 세션인데 기록이
없었다). 그리고 이 감사에서 파생된 새 결정이 셋 있어 `base/`에 함께 반영했다 —
**주입 op `onDestroying(inst, fn)` 신설**(`_bindDestroying` 의사코드를 쓰다
드러남, base는 `Instance`를 모르므로), **`EffectHandle`의 필드 다섯**
(`_destroyConn`/`_refDeps`/`_refCallbacks` 등), **`quad-types``Quad` 갱신을
M3/M6/M7/M8/M10에도 항목화**(확정이 "마일스톤마다"였는데 M2에만 있었다).

View file

@ -1,9 +1,11 @@
# 구현 전 손 트레이싱 **6라운드** — 최근 확정분 전수 추적 + 2~4차 전역 패스
**상태**: **[2026-08-22 작성, 2026-08-23 2·3차 패스, 2026-08-24 4차 패스 추가] 사용자 회신 대기.**
아무것도 고치지 않았다 — `base/`는 한 줄도 안 건드린 상태이고, 아래는 전부
**발견 보고**다. 판단이 필요한 항목이 섞여 있어서(특히 `H-1`) 임의로
반영하지 않았다.
**상태**: **[2026-08-24] 회신·반영 완료 — 이 문서는 이제 발견 기록이다.**
작성 시점(2026-08-22~24)엔 아무것도 안 고친 발견 보고였으나, 같은 날 사용자와
대화형으로 `H-1`~`H-54` 전부를 처리하고 `base/`에 반영했다.
**결정과 근거는 `pre-implementation-handtrace-round6-followup.md`가 소스**이고,
지금 유효한 설계는 항상 `base/`가 소스다 — 아래 본문은 **발견 당시의 서술**
이므로 그대로 믿지 말 것(특히 각 항목의 "갈래"는 선택 전 목록이다).
**⭐⭐ [2026-08-23] 3차 패스가 맨 아래 붙어 있다(`H-21`~`H-38`)** — 1·2차가
한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성,

View file

@ -239,16 +239,22 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**:
`ref-plan.md` "동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러"
절 바로 아래 "PreRef는 '취소'라는 개념이 없다" 항목 참고.
7. **왜 `Compute(fn, ...)`는 여러 의존성을 편하게 받고 `Effect`/`Observer`는
안 받는가** (2026-08-11 세션 원자료, `source-state-plan.md` "`:Compute(fn,
...)` — 추가 의존성을 trailing args로 직접 받는 sugar" 절) — 겉보기엔
비일관적인 API 표면(하나는 React `useMemo`식 trailing deps sugar를 받고,
다른 둘은 명시적 `:With` 호출을 강제)이 실은 "sugar가 새 노드 생성 비용을
감추는가"라는 단일 원칙에서 갈라져 나온다는 게 소재 — `Compute`는 어차피
자기 결과를 담을 노드를 만들어야 해서 추가 구독을 얹는 게 공짜지만,
`Effect`/`Observer`는 자기 자신이 State 노드가 아니라 다중 의존성 병합에
진짜 새 노드가 필요해서 그 비용을 코드에 그대로 드러내는 쪽(`:With`
명시)을 택했다는 비교.
7. **왜 `Compute`/`Effect`는 여러 의존성을 편하게 받고 `Observer`만 안
받는가** (2026-08-11 세션 원자료 + 2026-08-21 `C-6` 역전 + 2026-08-24
`H-13` 근거 재작성, `source-state-plan.md` "`:Compute(fn, ...)` — 추가
의존성을 trailing args로 직접 받는 sugar" 절) — 겉보기엔 비일관적인 API
표면이 실은 "sugar가 새 노드 생성 비용을 감추는가"라는 단일 원칙에서
갈라져 나온다는 게 소재. `Compute`는 어차피 자기 결과를 담을 노드를
만들어야 해서 추가 구독을 얹는 게 공짜다.
**⚠️ [2026-08-24 정정] 이 항목은 원래 `Effect`도 "안 받는" 쪽으로 묶어
서술했는데 그 전제가 뒤집혔다** — `Effect(fn, ...deps)`는 **각 dep에 구독을
따로 걸어 합치는 노드 자체를 안 만들므로** 감출 비용이 애초에 없다(`C-6`).
그래서 **기각으로 남은 건 `Observer` 하나**이고, 그 근거도 비용 은폐가
아니라 **역할 분담**으로 새로 쓰였다("Observer는 리시버 State 하나에 붙는
구독이고, 여럿을 엮는 건 Effect가 대신한다").
**에세이의 진짜 소재는 그 뒤집힘 자체다** — 같은 원칙을 유지한 채
*"어디에 비용이 있는가"*라는 사실 판단만 바뀌어 결론이 갈린 사례이고,
`source-state-plan.md`의 그 절이 이 항목을 새 소재로 지목해뒀다.
**publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion
반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도

View file

@ -1789,3 +1789,53 @@ Luau에선 **배열**인데 실제 게이트 배치는 `{[Epoch]: true}` **집
"M3 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다.
이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다"
경고만** 달았다.
## 2026-08-24 — 6라운드 손 트레이싱 전량 처리·반영, 그리고 세 번의 재검토
원문: `session/2026-08-24-01-handtrace-round6-resolution.md`.
결정과 근거의 소스는 `qa-request/pre-implementation-handtrace-round6-followup.md`.
발견 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한 순서로**
사용자에게 물어(사용자 요청: *"같이 하나하나 처리해나가보자. 질문 모드로 계속
물어보며"*) 전량 결정하고 `base/` 24개 문서에 반영했다. **M2/M3를 막던 것이
전부 닫혔다** — 말단 핸들러 4종의 `setLength`/`setOffsetSource` 미등록(`H-39`),
`New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가 타입에러인 것(`H-25`),
`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 좌표계 결함 둘.
**구조가 바뀐 것 넷**: `slot._elemIndex`(역방향 인덱스 맵) 신설로 `keyIndex`
단순 키 집합으로 강등 / `_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고
`slot._physicalTarget` 신설(부기는 실체화 시점부터 항상) / `Ref.Callbacks`
해시맵 셋 + `:Uncallback` / `blocker:Policy(emit)` 노출로 `Debounce`/`Throttle`이
자기 Blocker를 조종하는 정책이 됨. 요소 타입 검증은 블랙리스트에서 **주입 술어
`isInst` 기반 화이트리스트**로 뒤집혔다.
**사용자가 에이전트의 갈래를 뒤집은 자리가 여럿**이고 그게 결과를 크게 바꿨다 —
`H-1`(제 세 갈래가 전부 차선, 역방향 맵을 raw 층에 두는 역제안), `H-2`(부기와
물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이 성립 불가임을 지적,
`isInst` 주입 제안), `H-33`(제 중첩 합성안이 디바운스 창을 안 끝나게 만든다는
지적). `Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 **사용자가
먼저 발견**했다.
**반영 후 세 번의 재검토가 19건을 더 잡았고, 그 실패 패턴이 이 세션의 교훈이다.**
`/code-review high` 7건 중 **셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 생성자가 크래시하던 것, `native*`
`_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐 영구 누수를 만든 것, "`:List`와
CRUD는 상호배타"라며 승인받은 분기가 **재마운트 경로를 안 봐서** 포탈을 깬 것.
셋 다 코퍼스 정합성 각도로는 구조적으로 안 보이는 종류라
`conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가 실측으로 재확인됐다.
`quad-doc-auditor` 감사 루프는 **9라운드에서 새 발견 0건으로 수렴**했고 총 34건을
고쳤다(라운드별 5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절이 소스).
**라운드마다 각도를 바꾼 게 실제로 작동했다** — 가장 많이 잡은 6라운드(9건)는
문서 정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은
라운드였고, `ROADMAP.md`의 미완료 항목이 대거 stale인 게 그때 드러났다
(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화 계약이 통째로 부재,
`bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).
**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). **셋 다 핸드오버 체크리스트가 명시적으로 경고하는
항목**(2번·6번)이라, 규율이 없어서가 아니라 **지켰는지 스스로 확인하지
않아서** 생긴 실패다 — 8라운드를 아예 완결성 축("새 이름 하나당 나타나야 할
자리 넷을 열거해 세기")으로 잡은 게 그래서고 거기서 4건이 더 나왔다.
감사에서 파생된 새 결정 셋(주입 op `onDestroying` 신설, `EffectHandle`의 필드
다섯, `quad-types``Quad` 갱신을 M3/M6/M7/M8/M10에도 항목화)도 같이 반영했다.

View file

@ -0,0 +1,152 @@
# 2026-08-24 — 6라운드 손 트레이싱 전량 처리·반영, 그리고 세 번의 재검토
**한 줄**: 발견 보고 `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정하고
`base/` 24개 문서에 반영했다. 그 뒤 `/code-review high`가 7건(그중 셋은 이번
반영이 만든 회귀), 감사 1라운드가 5건, 2라운드가 7건을 더 잡았다.
**결정과 근거의 소스는 `qa-request/pre-implementation-handtrace-round6-followup.md`**
— 여기서 반복하지 않는다. 이 문서는 **어떻게 진행됐고 무엇이 잘못됐는가**의 기록이다.
## 진행 방식 — 사용자 요청
*"`pre-implementation-handtrace-round6.md` 에 대해서 같이 하나하나 처리해나가보자.
질문 모드로 계속 물어보며, 처리할 방법들을 찾거나 택하거나 해보자. 전부 사람이
직접 읽기엔 양이 많아서 그래. 다만 네 질문과 원 파일은 같이 볼테니, 어디에 원문이
있는지만 알려줘"*
54건을 4·5라운드 같은 문항지로 만들지 않고, **갈래 선택이 필요한 것만 추려
급한 순서로** 물었다(M2/M3를 막는 것 먼저). 단순 정정은 마지막에 한 번에 묶어
확인받았다("전부 그대로 반영"). 답변마다 원문 줄 번호를 같이 줬다.
## 사용자가 제 선택지를 뒤집은 자리들 — 이번 세션의 핵심
에이전트가 낸 갈래가 **틀렸거나 부족했던** 경우가 여럿이고, 그게 결과를 크게
바꿨다. 기록해둘 가치가 있는 것만:
### `H-1` — 제 세 갈래가 전부 차선이었다
`:List``keyIndex`가 사이클 도중 stale이라는 발견에 (1) 라이브 인덱스 맵
(2) `indexOfRaw`를 기본 경로로 (3) reconcile 2패스를 냈는데, 사용자가 **역제안**:
> *"raw* 가 층위를 알아야할 이유를 모르겠는 상태. 그냥 k->realElem 을 list 에선
> 저장하고, raw 는 realElem -> index 를 indexOf 로 하거나, 보통 읽기가 더
> 빈번하므로 realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서
> 이걸 같이 업데이트해주는 편이 나아보이기도. quad-base 의 현 모양에서 더 확장
> 될 땐, index 를 알아야하게 되는 경우가 많아질것이라, 선제 처리로 해결하는게
> 맞아보이는데, 어떻게 생각하는지?"*
제 1번 안은 `raw*``:List`의 클로저를 알아야 해서 층이 섞이는 게 단점이었는데,
**맵을 `_elements`와 같은 층에 두면 그 단점 자체가 없어진다**는 게 요지였다.
채택. 부수로 `keyIndex`가 단순 키 집합으로 강등되며 `H-16`도 같이 사라졌다.
### `H-2` — "offsetAt으로 구하라"가 초기 경로에서 안 맞았고, 그걸 되물었다
사용자 답:
> *"offsetAt 으로 구함 -> 기본적으로 activation 자체는 순차로 일어나므로 length
> 자체는 확정되는게 맞을것임."*
확인해보니 **최초 population에서만 안 맞았다**`materializeSlotTree`
`_mounted = false`인 채 `activateList`를 부르고 `rawAdd`가 부기까지 얼리리턴해서
`bk.lengthList`가 비어 있었다. 그걸 보고했더니 사용자가 되물었다:
> *"1. 안이 맞긴 한데, length 자체는 설정하지 않아? offset 을 넘겨주는건
> updateFn 기준으로 부기이지, 물리 마운트와는 무관해서 말이야. … 부기 확정에서
> length 를 확정해도 되는거 아님?"*
맞는 지적이었다. **`_mounted`가 "물리 인스턴스 유무"만 뜻하도록 좁히고 부기는
실체화 시점부터 항상** 하는 것으로 정리됐고, `slot._physicalTarget`이 신설됐다.
### `H-40` — 제가 낸 "브랜드로 판정" 안이 성립 불가였다
> *"브랜드를 두는건 못 해, 이제 brand 는 각각 따로 생성되어서 있는지 없는지
> 보는건 결국 전부 봐야한다는 의미일껄? 난 3. 을 추천해. isInst 같은걸 백엔드
> 계약에 넣고, quad-roblox 가 채워주는게 맞아보이는데 어떻게 생각해?"*
2026-08-21의 `Brand` 재작성(인스턴스 브랜드)을 제가 못 따라간 것. 화이트리스트로
뒤집되 **base는 `T`를 모른 채 주입 술어만 부르는** 형태로 닫혔다.
### `H-33`/`H-49` — 제 중첩 합성안이 창을 안 끝나게 만들었다
`Gate` 정책 합성에서 제가 `blocker:Policy(debounceOnEmit)`(블로커가 바깥) 형태를
제안했는데:
> *"맞는 모양 같긴 해. 근데 저러면 unblock 될 때 다시 타이머 셋팅됨. 타이머
> 끝난건데, 다시 셋팅되잖아. … 사실 debounce 는 emit 핸들 조차 필요 없는거지.
> 그냥 blocker 를 자신이 가지니까, 껐다 켰다로 emit/block 을 하고(내부 구현을
> 위임). On/Off 할 시기만 본인이 결정한다는 생각이였는데, 이 구조가 불가한거였음?"*
가능했고 더 나았다. 제 트레이스는 "디바운스도 진짜 `emit`을 쥔다"를 전제했는데,
**디바운스가 emit을 아예 안 쥐면** 순차 호출이 성립한다.
### 사용자가 먼저 발견한 것 둘
- **`Ref.Callbacks`를 해시맵으로** — `H-7`을 "해제 경로 추가"로 정하자마자
*"그렇다면 Effect 의 callback 들은 해시맵이 되는게 나아보이는데 [callback/루틴]=true
그렇지 않아?"* 로 자료구조까지 바꿨다. 부수로 `ref-plan.md``#t` border 실측
항목이 폐기됐다.
- **`Ref` 콜백의 `canExecute`** — `H-11` 재결정 중에 *"그런데, Effect 또한
canExecute 를 거쳐서 Ref 의 Callback 이 실제 Effect 의 Callback 을 실행할 지
결정해야하는데, 그것도 되어있는지 봐줘요"*. 확인해보니 없었고, 오히려 제가
`H-7` 처리 때 "그 대안은 채택하지 않았다"고 적어놨었다. **해제는 누수를,
게이팅은 발화를** 막는 별개 역할이라 둘 다 필요했다.
## 반영 후 세 번의 재검토에서 나온 것 — 실패 기록
### `/code-review high` — 7건, 그중 **셋이 이번 반영이 만든 회귀**
`conventions.md`가 *"`/code-review`는 감사자를 대체하지 않는다"*고 적어둔 그대로였다.
셋 다 **코퍼스 정합성 각도로는 구조적으로 안 보이는** 종류였다:
1. **`rawAdd`가 미실체화 가드를 통째로 잃음** — 상태가 셋이 됐다고 산문에 쓰고
코드엔 `_mounted` 경계만 남겼다. `Slot { frameA }` 생성자가 즉시 크래시.
2. **`rawRemove`가 마운트 전 창에서 아무도 안 죽임** — `nativeRemove`가 곧
파괴였는데 `_mounted`로 가리기만 하고 `else`를 비워뒀다. 영구 누수.
3. **`_listed` 분기가 포탈 재마운트를 깸** — 제가 "`:List`와 CRUD가
상호배타이니 안전"이라 말하고 승인받았는데 **재마운트 경로를 안 봤다.**
`activateList`가 멱등 가드로 즉시 리턴하는 경우를 놓친 것.
그리고 `H-11`의 두 결정이 **서로 모순**이라는 지적(4번)이 나와 재결정했다 —
*"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지
않았다.** 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 직접 처리하는 것으로
바뀌었다:
> *"Destroying 자체가 엔진이 아는 요소이기 때문에, 엔진이 처리하는 곳에 두긴
> 해야합니다. 옵져버는 바로 생성되기 때문에, bind 상 옵져버 목록을 가져와 자신이
> 재귀하고, bindLifetime 이 처리하는게 나아보입니다."*
제가 반대 근거로 인용했던 *"게이트는 값 타입을 안 가린다"*는 **인용 자체가
틀렸다** — `source-state-plan.md`의 그 절은 `canBound` 판정이 두 진입점에서
같다는 얘기지 부수 배선 얘기가 아니었다.
### 감사 루프 — 9라운드, 34건 (라운드별 5→7→2→2→3→9→2→4→0)
라운드별 각도와 발견 목록은 **followup의 E절이 소스**. 여기엔 교훈만 적는다.
**2라운드**: **`.claude/README.md`를 한 줄도 안 고쳤다.** `base/` 19개 파일
1300줄을 바꿔놓고 색인을 안 건드렸으니 체크리스트 6번을 통째로 빠뜨린 것.
**6라운드가 9건으로 가장 많이 잡았고, 그게 각도 전환의 증거다.** 앞선 다섯
라운드는 전부 "문서가 서로 맞는가"를 물었는데, 6라운드만 **"이 체크박스로
코드를 짜면 무엇이 나오는가"**를 물었다. 그러자 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다 — 폐기된 `pos` 공식이 "확정"으로 남아 있고, 접두합
캐시 무효화 계약이 통째로 없고, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`
직접 모순이었다.
**반복된 실패 패턴은 하나 — "N개 중 일부만 고쳤다".** 1라운드(배너를 달고 그
배너가 부정하는 문장을 안 고침), 2라운드(README를 통째로 안 고침),
7·8라운드(그 README를 고칠 때 11행 중 6행만). 8라운드를 아예 **완결성 축**으로
잡은 게 그래서고 4건이 더 나왔다. **세 번 다 `conventions.md`의 핸드오버
체크리스트가 명시적으로 경고하는 항목이었다**(2번·6번) — 규율이 없어서가
아니라 규율을 지켰는지 스스로 확인하지 않아서 생긴 실패다.
**감사에서 파생된 새 결정 셋**도 `base/`에 반영했다 — 주입 op
`onDestroying(inst, fn)` 신설(`_bindDestroying` 의사코드를 쓰다 드러났다),
`EffectHandle`의 필드 다섯, `quad-types``Quad` 갱신을 M3/M6/M7/M8/M10에도
항목화(확정은 "마일스톤마다"였는데 M2에만 있었다).
## 남은 것
- **백로그 1건**: `Fallback`/`Traceback` 중 생성된 부분 트리 회수(`H-26`).
그 둘이 슈가라 구현 시점에 같이 다룬다.
- **M2 착수를 막는 설계 항목은 없다.** 남은 건 `question.md` 2번(M2↔M3 양방향
의존, 마일스톤 순서)뿐이다.

View file

@ -52,60 +52,42 @@
M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도
`ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히
`luau-test/STATUS.md`).
그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`
생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`).
그 외 남은 것은 판단이 아니라 구현 시 정할 것 하나 — 스파이크 `05`
재작성(`luau-test/STATUS.md`).
**[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날
`ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다.
**[2026-08-24 해소, 6라운드 `H-49`]** 여기 `Gate`의 "생명주기·재진입 계약"을
같이 나열했었는데 **두 항목 다 부정확했다** — 재진입은 이미
`[2026-08-21 정리 — 열린 항목 아님]`으로 닫혀 있었고, 생명주기는 "판단이
아니라 구현 시 정할 것"이 아니라 그 항목이 사는 절 제목이 정확히
"사용자 판단 필요"였다. 그 안의 *"`Gate` 자체에 `Flush`/`Cancel` 같은
표면을 둘지"*가 `H-33`이 걸린 바로 그 자리였고, **2026-08-24에
`blocker:Policy(emit)` 노출 + `Debounce`/`Throttle`이 자기 Blocker를
조종하는 구조로 확정되며 닫혔다**(`base/gate-plan.md` 5번).
**⭐⭐ [2026-08-22 신설] 6라운드 — 손 트레이싱, 사용자 회신 대기.**
사용자 요청으로 최근 확정 5개 영역(`Effect(fn, ...deps)` / `Gate`·`Blocker` /
State 전파(`rawInvalid`·emit 지연) / Slot의 `native*`·offset·length·mount /
`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려봤고, **의사코드 그대로
구현하면 크래시하거나 조용히 어긋나는 것들이 나왔다.** 문항지가 아니라
발견 보고이고 **`base/`는 아직 한 줄도 안 고쳤다** — 소스는
`qa-request/pre-implementation-handtrace-round6.md`(발견 수·심각도·개별
항목은 여기서 세지 않는다). **M2/M3 구현에 직접 걸리므로 다음 세션의 첫
작업으로 볼 것.**
**⭐⭐ 6라운드(손 트레이싱) — [2026-08-24] 회신·반영 완료.**
4개 패스에 걸친 발견 보고(`H-1`~`H-54`)를 사용자와 대화형으로 하나씩
처리하고 `base/`에 전량 반영했다. 발견 원문은
`qa-request/pre-implementation-handtrace-round6.md`, **결정과 근거는
`-followup.md`가 소스**(개수·심각도·개별 항목은 여기서 세지 않는다).
**⭐ [2026-08-23] 같은 파일에 2차 패스가 이어붙었다** — 사용자 요청으로
1차가 안 본 영역(디스패치 코어 전체 / 라이프타임 유틸 / `Ref`·`Tag`·
`Attribute`·UI 숏핸드 핸들러 / Slot의 `raw*` 계층)까지 넓혀 다시 돌린
것. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 갈래 선택이 필요한 항목이
1차보다 늘었고(어느 것인지는 그 문서의 두 "회신 방법" 절이 소스),
그중 하나는 **`Effect`의 leaf 사망 cleanup을 발화시키는 배선이 어느
의사코드에도 없다**는 것이라 `Effect`/`OnDestroyed`/`slot._detached`
정리가 통째로 걸린다 — M3 `Effect` 구현 전에 반드시 닫을 것.
**M2/M3에 직접 걸리던 것들이 전부 닫혔다** — 말단 핸들러 4종의
`setLength`/`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라
`quad.Dispatch`가 타입에러인 것(`H-25`, M2 체크리스트에 항목 신설),
`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계
결함 둘(`H-1`/`H-2`).
**⭐⭐ [2026-08-23] 같은 파일에 3차 패스도 이어붙었다 — 발견 번호는
`H-21`~`H-38`.** 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어,
Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트,
타입 계약과 **실제 커밋된 M1 코드**)와 문서 경계를 가로지르는 통합
시나리오를 돌렸다. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 1·2차와
달리 **추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`로 직접 재현**했고,
그 부수로 **기존 `H-2`의 크래시 주장이 틀렸다는 것도 드러났다**(결론은
유효하지만 결과가 크래시가 아니라 조용한 영구 고아 — 그 문서 3차 패스
머리의 정정 절이 소스). **M2 착수 전에 정해야 하는 것이 하나 늘었다**
`New(): Quad`가 닫힌 타입이라 M2가 붙일 `quad.Dispatch` 접근이 그대로는
타입에러라는 것(`H-25`, `luau-analyze`로 재현). 개별 항목·심각도·갈래는
여기서 세지 않는다 — 그 문서의 세 "회신 방법" 절이 소스.
**구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스) 신설로
`:List``keyIndex`가 단순 키 집합으로 강등, (2) `_mounted`가 "물리
인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`이 신설되어 부기는
실체화 시점부터 항상 수행, (3) `Ref.Callbacks`가 해시맵 셋 + 해제 경로
(`:Uncallback`), (4) `blocker:Policy(emit)` 노출로 `Debounce`/`Throttle`이
자기 Blocker를 조종하는 정책이 됨. 요소 타입 검증도 블랙리스트에서
**`isInst` 주입 술어 기반 화이트리스트**로 뒤집혔다(`H-40`).
**⭐⭐ [2026-08-24] 4차 패스도 이어붙었다 — 발견 번호는 `H-39`~`H-54`.**
이번엔 문서 단위가 아니라 **축을 바꿔서** 훑었다(핸들러 레지스트리 전수 /
`ref-plan.md`·`attribute-plan.md` 심층 / **`luau-test` 스파이크 실제 재실행** /
프리미티브 조합 매트릭스 / `reference`·`archive`·로드맵 M3~M9 / **엔진·언어
사실 주장 전수 검증**). 여기서도 **`base/`는 한 줄도 안 고쳤다.**
**⭐ M2에 직접 걸리는 게 하나 나왔다** — 배열 자리를 차지하는 말단 핸들러
4종(`TagHandler`/`AttributeGroupHandler`/`RefLeafHandler`/
`ObserverEffectLeafHandler`)이 `setLength`/`setOffsetSource`를 **아예 등록하지
않아** `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 마운트에
`recompute`의 명시적 error로 죽는다(`H-39`, **세 축에서 독립 발견**
전수 grep으로 재확인). 부수로 **`H-21`의 전제가 공식 문서로 확인**됐고,
반대로 **`PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부**임이
드러났다(`H-42` — 신규 템플릿 place는 이미 `Deferred`가 기본).
**스파이크 쪽은 깨끗하다**`done/` 16개 전원이 `STATUS.md` 주장과 실행
결과가 일치했고 GC 스파이크도 반복 실행에서 안 흔들렸으며 설계 드리프트도
0건이었다. 개별 항목·심각도·갈래는 여기서 세지 않는다 — 그 문서의 네
"회신 방법" 절이 소스.
**백로그로 넘긴 것 하나**`Fallback`/`Traceback` 중 생성된 부분 트리의
회수(`H-26`, `base/fallback-plan.md`의 ⚠️ 절). 그 둘이 슈가라 구현 시점에
같이 다룬다.
**4라운드 — [2026-08-21] 종결.** 문항지는
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은

View file

@ -171,6 +171,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
폴더 — 실제 소스는 M5)
- [x] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수:
소스 트리 구조 확정" 절 그대로)
- [x] **[2026-08-24 소급 등재, 6라운드 `H-47`/`H-48`] `quad-base/src/Debug/init.luau`**
`InitDebug(module)``module.debug = false`를 심는다. **이미 커밋돼
있었는데 이 목록에도 `architecture.md` 소스 트리에도 없었다**(`ROADMAP.md`
전체에 "Debug"가 0건이었다). `ROADMAP.md`가 진행 상황의 소스인데 M1
체크박스만 보면 이 모듈의 존재도 완료 여부도 알 수 없던 상태.
같이 확인된 것: **`Quad.debug`의 스코프는 "인스턴스별"로 이미 확정돼
있다**(이 파일이 인스턴스 필드를 심고 `quad-types``Quad`
`debug: boolean`을 필드로 갖는다) — `base/module-lifecycle-plan.md`
"미정"이라 적어둔 걸 `H-48`이 닫았다
- [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
`quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
@ -233,6 +242,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
(`luau-test/STATUS.md`)
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외
없이 자기 배열 자리의 `setOffsetSource(inst,k,None)``setLength(inst,k,0)`
등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이
빠져 있었으니 각 마일스톤에서 확인할 것: `TagHandler`(M10) /
`AttributeGroupHandler`(M10) / `RefLeafHandler`(M8) /
`ObserverEffectLeafHandler`(M3). 안 하면 `bk.N`이 다른 자리 등록으로
커질 때 그 구멍이 범위 안에 끼어 `recompute`가 **명시적 error로 죽는다**
(`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치).
**배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.**
소스는 `base/dispatch-core-plan.md`의 등록 책임 절
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types``Quad`
타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌
레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면
`module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔
되는데 `luau-analyze`에선 타입에러**다(실측 재현). `base/architecture.md`
확정된 결정 13번이 그 접근을 표준 사용법으로 못박아뒀다.
상세는 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절.
**⚠️ 이건 M2 한 번으로 끝나는 일이 아니다** — 그 문서가 *"마일스톤마다
갱신한다 … 이후 서브시스템도 같은 규칙을 따른다"*로 확정했으므로,
**서브시스템을 붙이는 모든 마일스톤이 같은 항목을 진다**:
M3(`Source`/`State`/`Store`) · M6(`Slot`) · M7(`Modifier`) ·
M8(`Ref`) · M10(`Tag`/`Attribute`). 빠뜨리면 그 마일스톤 완료 후
`quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선
타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.**
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
`process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable`
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
@ -352,6 +385,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`setOffsetSource``None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지
"참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답),
숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull.
**⭐ [2026-08-24 6라운드 `H-3`/`H-4`/`H-19`] `getOffsetAt`의 접두합 캐시
계약을 반드시 같이 구현할 것** — 이게 빠지면 형제 Slot이 커져도 뒤 형제의
`Offset`이 **영원히 고정**된다(`sum`은 매번 새로 더하므로 위로는 맞고
옆으로만 틀려 알아채기 특히 어렵다):
(a) `getBookkeeping``bk.offsetCache = {}`, `bk.invalidAfter = 0`으로
초기화(`nil` 시작이면 첫 `setOffsetSource``nil` 비교에서 죽는다),
(b) **캐시를 앞으로 당기는 자리 셋**`setLength` 본문과 그 자리 length가
State일 때 다는 Observer 콜백(`math.min(invalidAfter, i)`),
`spliceArraysUp`/`spliceArraysDown`(같은 식), `slot._baseObserver` 콜백
(베이스가 바뀐 경우라 `0`). 셋 다 `recompute`보다 **먼저**.
(c) **`recompute` 호출은 `setLength`의 단독 책임**이다 — `rawAdd`/
`rawReplace`의 명시 호출은 삭제됐고, 자리가 없어지는 경로
(`rawRemove`/`rawUnmount`/`rawDetach`)만 예외로 직접 부른다.
`recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서
시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다.
**이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→
@ -456,9 +502,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M3
스파이크에서 확인. `Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시
유지(의도적 비대칭, 같은 절 참고)
- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(`fn(self,
스파이크에서 확인. **[2026-08-24 정정, 6라운드 `H-13`]** 여기 원래
*"`Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 유지"*라고 적혀
있었는데 **`Effect``C-6`에서 이미 역전됐다** — 각 dep에 구독을 따로
걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건
`Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State
하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다")
- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** —
**`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과
거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) ·
**`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`).
`fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
**`...deps``fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서
**`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는
`base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-39`]** `ObserverEffectLeafHandler.process`가 자기 배열
자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 —
빠져 있었다(위 M2의 그 항목)
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Source`/`State`/
`Store` 필드 추가(위 M2 항목의 "마일스톤마다" 규칙)
- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24
`H-14`] 이 항목은 `:Compute` 한정이다** — `Effect``fn`엔 deps가 안
넘어간다) — (`fn(self,
previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터
리스트 맨 끝이어야 하는 것과 같은 이유로 `previous?`가 deps 팩
**앞**에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째
@ -559,6 +631,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만
> 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게
> 실익). 상세는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절.
>
> **⚠️ [2026-08-24 정정, 6라운드 손 트레이싱 `H-34`/`H-44`] 위 "최소 구현 부담은
> 셋" 서술을 좁힌다 — Roblox 백엔드는 `nativeMove`/`nativeSwap`도 반드시
> 덮어써야 한다.** 조합 폴백(`nativeMove` = `nativeExtract` + `nativeInsert`)은
> 여기선 "느린 정답"이 아니라 **관측 가능한 동작 차이**를 만든다:
> `Move`/`Swap`을 공개 CRUD에 추가한 근거 자체가 *"`Extract`+`Add`는 실제
> Parent 조작이 두 번(detach+reattach) 일어남"*을 피하려는 것이었는데, 폴백은
> 정확히 그 두 번을 되돌려 `AncestryChanged` 재발화·깜빡임·재바인딩 비용을
> 다시 만든다. offset을 무시하는 백엔드에서 순서 이동은 애초에 물리 조작이
> 아니므로 **no-op으로 덮어쓰면 된다.**
> 그리고 **주입 op이 둘 더 늘었다**(6라운드) — **`isInst`**(`H-40`): 요소 타입
> 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, 조합 폴백이 불가능해
> **미주입이면 에러**다(quad-roblox 구현은 `typeof(v) == "Instance"` 한 줄).
> **`onDestroying(inst, fn)`**(`H-11`): `Effect`의 leaf 사망 cleanup을 발화시키는
> 훅으로, `bindLifetime``isEffect`일 때 부른다(quad-roblox 구현은
> `inst.Destroying:Connect(fn)`). 이것도 조합으로 못 만들므로 미주입이면 에러다.
> **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
@ -638,7 +726,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List``prev`는 전부
**언래핑된 원래 값**이다). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
타입 제약 확정** — **[2026-08-24 6라운드 `H-40`로 전면 정정] 판정은
블랙리스트가 아니라 화이트리스트다**: `isSlot``isState`(래퍼 Slot으로
풀어 재귀) → **주입 술어 `isInst`**, 셋 중 어디에도 안 걸리면 error.
관문은 `wrapElement` 하나(공개 CRUD와 `:List``settle`이 둘 다 지난다).
아래 나열은 이제 **그 화이트리스트의 따름정리**다(에러 메시지는 계속
구분해 낸다) — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
@ -724,14 +817,45 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
nested-Slot 결과를 반환하는 아이템 다음 형제의 `index`가 그 결과의
물리 개수만큼 건너뛰어야 함 — 안 그러면 멀티루트 아이템 다음 형제의
LayoutOrder가 겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
**⚠️ [2026-08-24 6라운드 `H-2`] 그 의도를 구현하던 옛 공식
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)`)은
폐기됐다** — 두 좌표계(리프 개수 / `_elements` 자리)를 한 변수에 겹쳐 써서
`table.insert(t, 0, x)`로 조용한 영구 고아를 만들었고, `.Length`를 읽는
시점이 **항상 0**(마운트 전이라 `recompute`가 아직 안 돎)이라 애초에
아무것도 반영하지 못했다. 확정된 해법은 배열 자리를 `slotPos`로 따로 세고
`updateFn``index`**`Dispatch.getOffsetAt`에서 뽑는** 것 — 같은 문서의
`reconcile` 의사코드가 소스.
### 짜야 할 것
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-46`] top-level
`quad-base/Slot.luau` — 값 타입 본체가 들어가는 파일.** 생성자, 공개
CRUD 11종, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`,
`attachSlot` 3형제(`materializeSlotTree`/`mountSlotTree`),
`elementOwner`/`claimOwner`/`releaseOwner`, `reindexFrom`,
`collectLeaves`, `dispose`, `Detach`/`KeyGone`. 아래 불릿들이 전부 이
파일의 내용이다. **`Dispatch/Slot.luau`(맨 아래)는 핸들러/부기만** —
다른 값 타입(`Modifier`/`Tag`/`Tween`/`Ref`/`Effect`)이 전부 top-level
파일을 갖는 것과 같은 대칭이고, `base/slot-plan.md``attachSlot`
블록이 이미 머리에 이 파일명을 적어뒀다
- [ ] **⭐ [2026-08-24, 6라운드] 이 마일스톤에서 새로 생긴 필드·헬퍼**
(구현 항목으로 드러나야 놓치지 않는다):
**`slot._elemIndex`**(물리 요소→`_elements` 인덱스 역방향 맵,
`indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**) ·
**`reindexFrom(self, from)`**(`_elements`를 시프트하는 **모든** 자리가
부른다 — 실체화 여부와 무관하게 항상) ·
**`slot._physicalTarget`**(실체화 시점부터의 생명주기 앵커,
`_mountedInst`는 "마운트됨"만 뜻하게 좁혀졌다) ·
**`collectLeaves(slot)`**(중첩 Slot의 물리 리프 평탄화 —
`Move`/`Swap`/`Extract`/`Splice`가 `native*`에 넘길 `elements` 배열을
만드는 데 필요, 없어서 그 넷이 미작성이었다) ·
`:List`**`prevKeys`**(옛 `keyIndex`의 강등판, 단순 키 집합).
전부 `base/slot-plan.md`가 소스
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Slot` 필드 추가
(위 M2 항목의 "마일스톤마다" 규칙)
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 —
@ -804,6 +928,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`
한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건
`Effect``_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
**⚠️ [2026-08-24 `H-50`] 단 그 마지막 절반은 아직 "될 예정"이지 "된다"가
아니었다** — `Effect`의 leaf 사망 cleanup을 실제로 발화시키는 배선이
코퍼스 어디에도 없었다(6라운드 `H-11`). 같은 날 확정으로
**`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 `Destroying`
걸고/끊는 것**으로 닫혔고(`base/effect-plan.md` +
`base/lifecycle-pattern.md`의 그 의사코드), 이 문장은 그 배선이 구현된
뒤에야 참이 된다 — M3 `Effect` 구현이 M6의 이 항목을 **선행**한다는 뜻이다.
(**[같은 날 재결정]** 처음엔 *"`EffectHandle`이 자기 `bindLifetime` 직후에
건다"*였으나 `/code-review high`가 **그 호출부가 실재하지 않는다**는 걸
지적했다 — 핸들은 남이 자기를 bind하는 걸 관측할 수 없고, `Effect`
바인드되는 경로가 둘이라 호출부 쪽에 두면 반드시 한쪽이 샌다.)
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
`state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저,
@ -828,7 +963,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
안 구독한 상태라 무의미한 연산이 됨, `updateFn`만 이 갈래를 정확히
알아 낭비를 피할 수 있음(반환값 두 개는 서로 독립, `result``nil`이어도
`userdata`는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는
`mounted`가 아니라 직전 사이클 `keyIndex` 전체를 순회해야 함
`mounted`가 아니라 직전 사이클의 키 집합 `prevKeys` 전체를 순회해야 함
(**[2026-08-24 `H-1`]** 옛 이름은 `keyIndex`였고 인덱스 맵이었으나,
역방향 맵 `slot._elemIndex`가 생기며 **단순 키 집합으로 강등**됐다)
(`userdata`만 살아있는 채로 key가 완전히 사라지는 케이스 커버).
`userdata = userdata or {}` lazy-init 패턴이 Luau 제네릭에서 잘
좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 허용,
@ -896,9 +1033,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
10번, 2026-08-10 세션, `base/tween-plan.md`)
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-35`]
`quad-base/Dispatch/Modifier.luau``ProcessedModifierHandler`.**
`flatten`이 배열 자리를 `ProcessedModifier` 센티널로 소진하고, 이 전담
nop 핸들러가 정상 `Dispatch.process` 경로에서 캐치해
`setOffsetSource(None)`/`setLength(0)`만 등록한다(`Pre`/`PostRef`의
`Processed*` 핸들러와 완전히 같은 모양). **`Modifier`가 하나라도 든
`Frame{...}` 호출은 전부 이 핸들러를 거치는데** 색인 두 곳에서 통째로
빠져 있어 구현자가 존재 자체를 놓칠 수 있던 자리다. 의사코드는
`base/modifier-plan.md`의 flatten 절이 소스
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Modifier` 관련
표면이 노출돼야 하면 같이 갱신(위 M2 항목의 "마일스톤마다" 규칙)
## M8 — Ref
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가
(위 M2 항목의 "마일스톤마다" 규칙)
- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/
`:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단,
@ -960,14 +1110,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
패턴. pre-pass가 소진시킨 자리가 Length/Offset에 "0 기여"를 등록할
책임을 지는 자리 — `base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절,
`base/dispatch-core-plan.md` "Length/Offset" 절
- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면
`coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째
세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌
`nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면
`v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`
`thread``nil`이면
- [ ] Ref 콜백/대기자 실행 루프 — **[2026-08-24 6라운드 `H-7`로 재작성]**
`.Callbacks`**`{[callback|thread] = true}` 해시맵 셋**이다(배열 아님).
`:Set(value)`**`.Value`를 먼저 쓰고**, **순회 전 스냅샷을 뜬 뒤**
(`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방)
키 타입으로 분기: `type(k) == "thread"``Callbacks[k] = nil`로 소진 후
`coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)`
호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 신설된
**`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄).
의사코드는 `base/ref-plan.md`가 소스.
**⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, `[i] = nil` 소진, `#t` border
실측)는 전부 폐기됐다** — 해시맵엔 구멍도 border도 없다.
`:Wait(thread?)`는 그대로 — `thread``nil`이면
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`
반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
- [ ] **[2026-08-24 신설, 6라운드 `H-7`]** `Ref:Uncallback(fn)` — 해제 경로.
`Effect`가 자기 `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔
바로 그 클로저를 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/
`unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는
M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현
@ -977,7 +1136,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
함수들은 `InstData`에서 찾아 쓰기만 함. `bindLifetime`
`gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value,
"gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게)
둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림. **[2026-08-14
둘을 하고, `unbindLifetime(value)`은 그걸 되돌림.
**⭐ [2026-08-24 6라운드 `H-11`] 그리고 `isEffect(value)`면 분기가 하나 더
붙는다** — `handle._observers` 전부로 cascade하고
`value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다(전자는
주입 op `onDestroying`으로 `Destroying`을 연결하고 `Ref` dep 콜백을
(재)등록, 후자는 그 대칭). 여기 원래 *"둘만 하고"*라고 적혀 있었는데
이 분기와 직접 모순이었다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다
(그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`
`base/effect-plan.md`가 소스. **[2026-08-14
열한 번째 세션] `canBound(value)`/`canExecute(value)`는 비공개
헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는
`.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/
@ -1016,6 +1184,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> `archive/tag-attribute-load-time-registration-reversed.md`.
- [ ] **[2026-08-24 `H-39`]** `TagHandler`/`AttributeGroupHandler`가 자기 배열
자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 —
**둘 다 0건이었다**(위 M2의 그 항목). 같이 **`type(k) == "number"` 가드**도
추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이 둘은 못 받았다)
- [ ] **[2026-08-24 `H-41`]** `AttributeGroupHandler.process``groupClaimKeys`
위치 claim 배선 — 5라운드 `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고
의사코드에 안 들어가 있었다. **`nameClaims`보다 먼저** 해야 절반만 기록되는
중간 상태가 안 생긴다
- [ ] **[2026-08-24 `H-27`]** `OnChangeHandler.process``v == nil` 얼리리턴 —
없으면 `None`으로 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로
심는** 동작이 된다
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Tag`/`Attribute`
필드 추가(위 M2 항목의 "마일스톤마다" 규칙)
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
- [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler,
`GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인