사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `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
154 KiB
구현 전 손 트레이싱 6라운드 — 최근 확정분 전수 추적 + 2~4차 전역 패스
상태: [2026-08-24] 회신·반영 완료 — 이 문서는 이제 발견 기록이다.
작성 시점(2026-08-2224)엔 아무것도 안 고친 발견 보고였으나, 같은 날 사용자와
대화형으로 H-1H-54 전부를 처리하고 base/에 반영했다.
결정과 근거는 pre-implementation-handtrace-round6-followup.md가 소스이고,
지금 유효한 설계는 항상 base/가 소스다 — 아래 본문은 발견 당시의 서술
이므로 그대로 믿지 말 것(특히 각 항목의 "갈래"는 선택 전 목록이다).
⭐⭐ [2026-08-23] 3차 패스가 맨 아래 붙어 있다(H-21~H-38) — 1·2차가
한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성,
이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된
M1 코드)와 문서 경계를 가로지르는 통합 시나리오를 돌린 결과다. 이번엔
추론으로 끝내지 않고 로컬 luau 0.734 / luau-analyze로 직접 재현했고,
그 과정에서 기존 H-2의 크래시 주장이 틀렸다는 것도 드러났다(3차 패스
머리의 "먼저" 절이 소스 — 결론은 유효하지만 결과가 크래시가 아니라 조용한
영구 고아다).
⭐⭐ [2026-08-24] 4차 패스도 붙어 있다(H-39~H-54) — 이번엔 문서 단위가
아니라 축을 바꿔서 훑었다(핸들러 레지스트리 전수 / 두 대형 핸들러 문서
심층 / 스파이크 실제 재실행 / 프리미티브 조합 매트릭스 / reference·
archive·로드맵 M3~M9 / 엔진·언어 사실 주장 전수 검증). 가장 무거운
H-39는 세 축에서 독립적으로 같은 결론에 도달했고, 문서를 하나씩 읽는
방식으로는 구조적으로 안 보이는 종류였다. 부수로 H-21의 전제가 공식
문서로 확인됐고, 반대로 PreRef의 존재 근거가 엔진 설정에 조건부라는
게 드러났다(H-42).
⭐ [2026-08-23] 2차 패스를 이어붙였다 — 사용자 요청("지금 나온거 이외에
더 문제될만한게 있는지 조사해봐줘. … 시스템 전체 계획을 봐도 좋고")으로
1차가 안 본 영역까지 확장해서 다시 돌렸다. 1차는 사용자가 지목한 5개
영역이 범위였고, 2차는 dispatch-core-plan.md/source-state-plan.md/
lifecycle-pattern.md/ref-plan.md/tag-plan.md/attribute-plan.md/
ui-shorthand-plan.md/brand-plan.md/gate-plan.md/blocker-plan.md/
state-epoch-plan.md와 slot-plan.md의 raw* 계층을 의사코드 단위로
훑었다. 발견은 아래 "2차 패스" 절(H-11~H-20)이고, 1차와 같은 규칙으로
아무것도 반영하지 않았다.
왜 이 라운드가 있는가: 사용자 요청 — "Effect 의 다인자 Ref 허용 변경분, Gate/Blocker 의 새 형태, State 의 전파모델(invalid 의 새 전략, emit 지연과 전파의 새 전략), Slot 의 새로운 native와 offset/length/mount 전략, 새로운 Brand와 Epoch/EpochMap에 대해 손 트레이싱을 시도해봐요. 뭔가 문제가 되는 것들이 나오면 알려주세요."
성격이 4·5라운드와 다르다. 4·5라운드는 "예/아니오로 답할 문항지"였는데,
이번엔 2·3라운드와 같은 손 트레이싱이다 — 확정된 의사코드를 실제 값으로
한 줄씩 돌려보고 어긋나는 지점만 적는다. 그래서 문항 번호가 아니라 발견
번호(H-n)를 쓴다.
추적 대상 5개 영역(사용자가 지목한 그대로):
Effect(fn, ...deps) / Gate·Blocker / State 전파(rawInvalid·emit 지연) /
Slot의 native*·offset·length·mount / Brand·Epoch·EpochMap.
읽는 순서: 🔴 셋(H-1~H-3)이 실제로 크래시하거나 조용히 어긋나는
것이고 나머지는 그보다 가볍다. 마지막 "이상 없다고 확인한 것" 절도 같이
볼 것 — 다시 트레이싱할 필요가 없는 자리를 적어뒀다.
| 번호 | 심각도 | 한 줄 | 주 대상 |
|---|---|---|---|
H-1 |
🔴 | :List의 keyIndex가 사이클 도중엔 stale인데 live 배열 인덱스로 쓰임 |
base/slot-plan.md |
H-2 |
🔴 | pos(리프 개수)를 _elements 배열 인덱스로 겸용 — 중첩 Slot 반환 시 즉시 크래시([2026-08-23 재검증] 트레이스 수치 정정) |
base/slot-plan.md |
H-3 |
🔴 | getOffsetAt의 접두합 캐시를 아무도 무효화하지 않음 |
base/dispatch-core-plan.md |
H-4 |
🟡 | bk.invalidAfter/bk.offsetCache가 부기 스펙에 없음 + 초기값 nil 비교 |
base/dispatch-core-plan.md |
H-5 |
🟡 | spliceArraysUp이 bk.N을 먼저 올려, 금지하기로 한 창을 연다 |
base/slot-plan.md |
H-6 |
🟡 | unmountSlotTree가 정의되지 않은 physicalTarget을 참조 |
base/slot-plan.md |
H-7 |
🟡 | Effect의 Ref 의존성은 떼어낼 방법이 없음(해제 API·게이팅 부재) |
base/effect-plan.md, base/ref-plan.md |
H-8 |
🟡 | EffectHandle._observer가 아직 단수 — N-deps 확정과 불일치 |
base/effect-plan.md |
H-9 |
🟢 | 게이트 withheld가 첫 flush 스왑에서 weak를 잃음 |
base/gate-plan.md |
H-10 |
🟢 | recompute의 sum 시작값 주석이 stale — 그대로 구현하면 Length가 틀림 |
base/dispatch-core-plan.md |
🔴 H-1 — :List reconcile의 keyIndex가 사이클 도중엔 stale인데 live _elements 인덱스로 쓰인다
어디: base/slot-plan.md의 settle(keyIndex[key]를 rawDetach/
releaseElement/rawMove/rawReplace의 index 인자로 넘기는 네 자리)과
reconcile의 소멸 루프.
무엇이 어긋나나: keyIndex = newKeyIndex 교체는 reconcile의 끝에서
한 번만 일어난다. 그런데 사이클 도중의 rawAdd/rawRemove는
table.insert/spliceArraysDown으로 배열을 시프트하므로, 그 뒤에 처리되는
키들의 keyIndex 값은 전부 한 칸씩(또는 그 이상) 어긋난다. raw* 인자
규약 절이 "그 값은 이미 keyIndex가 들고 있다(그 키가 지금 차지한 압축
위치 = _elements 인덱스)" 라고 단정한 게 정확히 여기서 깨진다.
트레이스 A — 앞에 하나 끼우기(가장 흔한 케이스)
data = [a, b] → _elements = {A, B}, keyIndex = {ka=1, kb=2}.
다음 사이클 data = [x, a, b]:
| 스텝 | 키 | 판정 | 결과 |
|---|---|---|---|
| 1 | kx |
새 키 → rawAdd(self, X, 1) |
_elements = {X, A, B} |
| 2 | ka |
result == prev, keyIndex[ka]=1 ≠ pos=2 → rawMove(self, 1, 2) |
인덱스 1은 이제 X → {A, X, B} |
| 3 | kb |
keyIndex[kb]=2 ≠ pos=3 → rawMove(self, 2, 3) |
인덱스 2는 X → {A, B, X} |
최종이 x, a, b여야 하는데 a, b, x가 된다. rawMove가 Parent를 안
건드리는 경로라 물리 순서는 recompute가 만드는 offset을 통해 어긋난다 —
에러가 안 나고 조용히 틀린다.
트레이스 B — 전체 삭제(크래시)
data = [a, b, c] → data = []. 소멸 루프가 pairs(keyIndex)를 도는데
해시 순회라 순서가 임의다. kb가 먼저 나오면:
settle(kb, nil, false, 0)→releaseElement(self, 2, B, false)→rawRemove(self, 2)→spliceArraysDown→_elements = {A, C}- 이어서
kc→releaseElement(self, 3, C, false)→rawRemove(self, 3)→local element = self._elements[3]=nil→releaseOwner(nil, self)또는nativeRemove(..., { nil })에서 터진다.
즉 필터도 중첩도 없는 평범한 "리스트 비우기"가 순회 순서에 따라 크래시한다.
왜 지금까지 안 잡혔나: 2·3라운드 트레이싱은 RC-1/RC-3/RC-4처럼
마운트 순서 쪽을 봤고, 5라운드의 raw* index 통일은 "settle이 index를
어디서 구하나"만 물었지 그 값이 언제 유효한가는 안 물었다.
⭐ 판단이 필요하다 — 세 갈래 중 어느 쪽으로 갈지:
- 라이브 인덱스 맵 유지 — 모든
raw*가 자리 수를 바꿀 때keyIndex(또는 전용 맵)를 같이 갱신. 정확하지만raw*가:List의 클로저 상태를 알아야 해서 층이 섞인다. settle에 element를 넘기고indexOfRaw를 기본 경로로 — 5라운드가 "폴백이지 기본 경로가 아니다"라고 못박은 걸 되돌리는 것. O(n)이 붙지만 층 분리는 유지된다.- reconcile을 2패스로 — 제거/detach를 전부 먼저 처리해 배열을 압축한 뒤, 삽입/이동을 하는 forward pass. 시프트가 한 방향으로만 일어나 인덱스 추론이 가능해진다.
어느 쪽이든 raw*를 전부 index 기준으로 통일한 5라운드 결정의 전제를
다시 봐야 한다.
🔴 H-2 — pos(리프 개수)를 _elements 배열 인덱스로 겸용한다
어디: base/slot-plan.md의 reconcile —
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
로 전진시킨 pos를 그대로 settle(key, result, detach, pos)에 넘기고,
settle은 그걸 rawAdd(self, element, pos) / rawMove(..., pos) /
newKeyIndex[key] = pos로 쓴다.
무엇이 어긋나나: pos는 물리 리프 개수 기준(중첩 Slot 하나가
.Length만큼 전진)인데, _elements는 중첩 Slot 하나당 한 칸이다. 두
좌표계가 한 변수에 겹쳐 있다.
⚠️ [2026-08-23 재검증에서 정정] 원래 여기 적었던 트레이스는 숫자가
틀렸었다 — 그런데 실제 동작은 그것보다 더 나쁘다. 옛 트레이스는
*"updateFn이 Length 3짜리 중첩 Slot을 반환"*을 전제했는데, 그 상황은
일어날 수 없다:
updateFn이 반환하는 중첩 Slot은 정의상 아직 어디에도 마운트 안 된 것이다 — 이미 마운트돼 있으면rawAdd의claimOwner가 즉시 error다 (base/slot-plan.md의 "요소 소유권" 절).- 그런데
Length를 쓰는 주체는recompute하나뿐이고(5라운드C-2확정),recompute는materializeSlotTree안에서야 처음 돈다. 즉 마운트 전 Slot의.Length는 **항상0**이다(같은 문서가 *"옛 코드는inner.Length == 0인 미완성 스냅샷을 보여줬다"*고 확인해준다).
그래서 isSlot(result) 분기는 언제나 + 0이다. 두 가지가 동시에 깨진다.
(a) _elements 인덱스로서 — 첫 사이클에 바로 터진다
빈 :List, 첫 아이템의 updateFn이 중첩 Slot S를 반환(멀티루트 컴포넌트
결과 — 문서가 명시적으로 지원하는 형태):
pos = 0,candidateIndex = 1result = S,isSlot→pos = 1 - 1 + S.Length:Get()=0 + 0=0settle(k1, S, false, 0)→rawAdd(self, S, 0)→table.insert(self._elements, 0, S)→ Luau는pos가1..#t+1밖이면position out of bounds를 던진다 → 그 자리에서 터진다.
앞에 이미 생존자가 있는 변형에선 터지지 않는 대신 직전 생존자와 똑같은
인덱스를 받아 조용히 잘못된 자리에 끼워 넣고, newKeyIndex[key] = pos도
그 값으로 기록되어 다음 사이클의 H-1/H-16 경로와 합쳐진다.
(b) updateFn의 index로서 — 원래 목적 자체가 첫 사이클에 성립 안 한다
pos가 .Length만큼 전진하도록 만든 이유는 "다음 형제의 index가 이
아이템이 실제로 차지하는 물리적 개수를 반영해야 함"(base/slot-plan.md의
":List의 index도 nested-Slot 결과의" 절)인데, 새로 만들어진 중첩 Slot은
그 시점 Length가 0이라 아무것도 반영하지 못한다. 다음 사이클부터는
그 Slot이 마운트돼 있어 진짜 Length가 나오므로, 같은 데이터에 대해
첫 사이클과 이후 사이클의 index가 달라진다 — updateFn이 그 값으로
LayoutOrder를 계산하는 확정 관용구(같은 문서의 "왜 LayoutOrder를 Slot이
대신 안 해주는가" 절 예시)가 첫 프레임에 틀린 값을 받는다는 뜻이다.
주의: base/slot-plan.md의 ":List의 index도 nested-Slot 결과의"
절이 의도한 것(updateFn에 넘기는 index가 물리 개수를 반영해야 함)
자체는 옳다. 깨진 건 둘이다 — (1) 같은 카운터를 _elements 배열 인덱스로도
재사용한 것, (2) 그 물리 개수를 **아직 마운트 안 된 Slot의 .Length**에서
읽는 것. (1)은 리프 카운터와 슬롯 카운터를 분리하면 닫힌다(pos는
리프용, _elements 위치는 별도 slotPos를 매 생존 아이템마다 +1).
(2)는 더 근본적이다 — 마운트 전 Slot의 기여도를 미리 알 방법이 지금
설계엔 없다(Length는 recompute만 쓰고 그건 마운트 시점에 돈다).
"이 자리를 몇 칸으로 세는가"를 마운트 전에 확정할 수 있게 하든, 첫
사이클의 index가 잠정값임을 계약으로 인정하든 정해야 한다.
🔴 H-3 — getOffsetAt의 접두합 캐시가 어디에서도 무효화되지 않는다
어디: base/dispatch-core-plan.md의 "Length/Offset — 여러 Slot이 형제로
섞일 때 순서 보장" 절, Dispatch.getOffsetAt 의사코드와 그 아래 무효화 표.
무엇이 어긋나나: 표는 무효화 규칙(invalidAfter = math.min(invalidAfter, i),
베이스 변경이면 0)을 정확히 정의해뒀는데, 코퍼스 전체에서
bk.invalidAfter에 실제로 대입하는 코드는 getOffsetAt 안의 = 1/= at
둘뿐이다(전수 grep으로 확인). Dispatch.setLength, gatedRecompute,
slot._baseObserver의 콜백, spliceArraysUp/spliceArraysDown 어느
의사코드도 캐시를 당기지 않는다. 즉 규칙이 산문으로만 존재하고 코드
경로가 없다.
트레이스 A — 형제 길이 변경
Frame { SlotA(Length 2), SlotB(Length 3) }:
- 등록 후
bk.offsetCache = {0, 2},bk.invalidAfter = 2,SlotB.Offset = 2 SlotA에 요소가 하나 늘어Length2 → 3, emit →bk.observers[1]→gatedRecompute→recompute(Frame, bk)recompute의i = 2:Dispatch.getOffsetAt(Frame, 2)→at(2) <= invalidAfter(2)→ 캐시된2를 반환(정답은 3)offset:Get() ~= abs가드에 걸려Set이 안 일어남 →SlotB.Offset이 2에 고정된 채SlotA가 0..2를 점유
sum(→ ownerKey.Length)은 lengthList에서 매번 새로 더하므로 위로는
맞고 옆으로만 틀린다 — 알아채기 특히 어려운 형태다.
트레이스 B — 베이스 변경 / 포탈 재마운트
slot._baseObserver도recompute만 부르고 캐시를0으로 안 당긴다 → 앞 형제가 커져 내 베이스가 밀려도 자식 offset 전체가 옛 베이스 기준.- 재마운트는 더 나쁘다:
bk는Relate(slot)위에 있어 언마운트를 넘어 살아남으므로invalidAfter > 0이고offsetCache[1]엔 옛 베이스가 들어 있다.materializeSlotTree의setOffsetSource즉시 계산이slot.Offset에 새 값을 넣어도, 그 시점_baseObserver는 아직unbindLifetime상태(재앵커 전)라canExecute가 거짓이어서 발화조차 안 한다.
필요한 것: 표의 세 규칙을 실제 호출부에 배치. 최소한 (a) setLength
본문과 그 Observer 콜백, (b) spliceArraysUp/Down, (c) _baseObserver
콜백 — 셋 다 recompute 전에 invalidAfter를 당겨야 한다.
🟡 H-4 — bk.invalidAfter/bk.offsetCache가 부기 스펙에 없고, 초기값이 nil이면 비교에서 터진다
base/dispatch-core-plan.md의 "저장 위치" 문단은 lengthList/sourceList/
observers/N만 열거한다 — offsetCache/invalidAfter가 빠져 있다.
그리고 getOffsetAt은 if bk.invalidAfter == 0 then으로 시작하는데,
bk.N이 nil로 시작하는 규칙(그래서 recompute가 bk.N or 0으로 방어)과
같은 lazy 생성이면 invalidAfter도 nil이다 → nil == 0은 거짓 → 다음 줄
at <= bk.invalidAfter에서 attempt to compare number with nil. 첫
position의 setOffsetSource가 바로 이 경로다(그 함수가 getOffsetAt을
부르므로 fresh bk에서 반드시 밟는다).
getBookkeeping이 invalidAfter = 0, offsetCache = {}로 초기화한다는 것을
"저장 위치" 문단에 명시하면 닫힌다.
🟡 H-5 — spliceArraysUp이 bk.N을 먼저 올려, 금지하기로 한 창을 연다
base/dispatch-core-plan.md의 bk.N 수명주기 문단은 이렇게 못박아뒀다 —
setOffsetSource는 bk.N을 건드리지 않는다, 그래야
"lengthList[i]가 아직 안 채워진 채로 bk.N만 먼저 커지는 창이 안 생긴다".
그런데 base/slot-plan.md의 rawAdd는:
spliceArraysUp(self, index) -- 주석: "_elements 외 배열들을 한 칸 밀고 bk.N 증가"
Dispatch.setOffsetSource(self, index, None)
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
Dispatch.setLength(self, index, 1, self._mountedInst)
bk.N은 첫 줄에서 이미 올라가고 lengthList[index]는 마지막 줄에서야
채워지므로 그 창이 실재하며, 그 안에 물리 op이 들어 있다. Roblox의
Parent 대입은 ChildAdded/DescendantAdded를 동기 발화시키므로, 그
핸들러가 같은 owner에 손대면 recompute가 lengthList[index] == nil을 읽어
sum += nil로 터진다(sourceList[index]는 None으로 채워져 있어 그쪽
error 가드엔 안 걸린다).
이건 yield가 아니라 동기 재진입이라 "체인 도중 yield 금지" 불변식으로는
안 덮인다. 셋 중 하나가 필요하다 — (a) bk.N 규칙 문단을 "splice도 올린다"로
정정하고 그 창을 UB로 명시, (b) spliceArraysUp이 lengthList[index]에
자리표시자를 채우게 함, (c) nativeInsert를 setLength 뒤로 옮김(단
"자기 자리 먼저 / 뒤를 미는 것 나중" 계약과 충돌하므로 그쪽을 다시 봐야 함).
🟡 H-6 — unmountSlotTree가 정의되지 않은 physicalTarget을 참조한다
base/slot-plan.md의 "파괴 — 재귀적 Clear() 금지, flat teardown" 절:
local function unmountSlotTree(slot) -- 인자는 slot 하나뿐
...
nativeExtract(physicalTarget, Dispatch.getOffsetAt(slot, i), { element })
physicalTarget이 어디에도 안 묶여 있다. slot._mountedInst여야 하고,
같은 함수가 아래에서 slot._mountedInst = nil로 지우므로 읽는 순서도
같이 지켜야 한다(루프가 그 대입보다 위라 지금 배치로는 문제없지만, 로컬로
먼저 뽑아두는 게 안전).
곁가지(같은 절): 이 루프는 요소를 하나씩 빼면서 매번
Dispatch.getOffsetAt(slot, i)(=부기 offset)을 넘기는데, 앞을 뺄 때마다 뒤가
물리적으로 당겨지므로 두 번째부터는 실제 물리 위치와 다른 값이 넘어간다.
native* 계약이 *"빠지는 요소는 반드시 elements 배열로 넘긴다"*로 확정돼
있어 Roblox 백엔드는 무해하지만, offset을 신뢰하는 백엔드(DOM에서
childNodes[offset]으로 찾는 최적화 등)에선 어긋난다. nativeExtract의
offset이 제거 경로에선 의미가 없다는 걸 계약으로 못박든지, 역순
순회(뒤에서부터)로 바꾸든지 정해야 한다.
🟡 H-7 — Effect의 Ref 의존성은 떼어낼 방법이 없다
base/effect-plan.md의 "Effect(fn, ...deps) — 여러 의존성을 직접 받는다"
절이 Ref를 의존성으로 허용하고, Ref면 :Callback으로 구독한다고
확정했다. 그런데 base/ref-plan.md는 콜백을 이렇게 확정해뒀다 —
type(v) == "thread"인 대기자만 발화 후 [i] = nil로 소진하고, 일반 콜백
함수는 "소진 안 함, 계속 유지". 그리고 콜백 해제 API가 없다
(:Uncallback 류 없음), canExecute 게이팅도 안 걸린다(그건 Observer
쪽 배관이다).
트레이스: Effect(fn, someRef)를 children 배열 leaf로 놓는다. 그 leaf가
죽는다 →
- Observer 쪽은
bindLifetimecascade가 끊겨canExecute가 거짓이 된다. ✅ Ref쪽은ref.Callbacks에 클로저가 영구히 남는다. 그 클로저가EffectHandle을 강참조하므로 → 누수, 그리고 이후ref:Set마다 이미 죽은 leaf에 대해 직전 cleanup +fn을 계속 실행한다.
같은 절이 *"leaf dedup/cascade가 전부를 덮어야 한다"*고 요구하는데, Ref
쪽은 그걸 만족시킬 수단 자체가 아직 없다. 필요한 건 둘 중 하나 —
(a) Ref에 콜백 해제 경로를 추가, 또는 (b) Effect가 거는 Ref 콜백이
자기 핸들의 canExecute를 먼저 확인하고(거짓이면 자기 자신을 Callbacks에서
nil로 소진) 넘어가게 하는 관용구를 계약으로 못박기.
🟡 H-8 — EffectHandle._observer가 아직 단수다
base/effect-plan.md의 "보강 — EffectHandle의 내부 Observer 바인딩 세부"
문단 전체가 단수 전제로 쓰여 있다 — handle._observer = observer,
bindLifetime(inst, handle._observer) cascade, :Subscribe()도
handle._observer.
N-deps 확정(같은 문서 아래 절)과 정면으로 어긋난다. 그대로 구현하면 2번째
이후 dep의 Observer에는 canExecute 판정 근거가 아예 안 실려 그 Observer의
재실행이 통째로 죽는다 — 그 문단 자신이 옛날에 경고한 바로 그 실패 모드다.
필드를 handle._observers(배열)로 바꾸고 cascade/Subscribe/Unsubscribe를
전부 순회로 고치면 된다(새 결정 없음, 단순 반영 누락).
🟢 H-9 — 게이트 withheld가 첫 flush 스왑에서 weak를 잃는다
base/gate-plan.md 4번이 withheld를 weak key로 확정했는데, flush
의사코드는 self._withheld = {}로 평범한 테이블을 만든다
(OffWithoutEmit의 "새 테이블로 스왑"도 같다). 첫 flush 이후로는 그 게이트가
죽은 Epoch를 붙잡을 수 있다. setmetatable({}, {__mode = "k"})로 만드는
헬퍼 하나면 닫힌다.
🟢 H-10 — recompute의 sum 시작값 주석이 stale하다
base/dispatch-core-plan.md의 recompute 의사코드 위 주석은 여전히
*"0이 아니라 이 owner의 베이스에서 시작한다"*인데, 바로 아래 코드는
local sum = 0이고 지금은 그게 맞다 — 접두합이 getOffsetAt으로
빠지면서 sum은 Length 전용이 됐고, 같은 함수 끝의 주석이
*"Length엔 base를 안 더한다"*로 이미 그렇게 말한다. 주석을 믿고 구현하면
Length에 베이스가 더해져 상위 전체의 길이 합이 틀어진다. 주석 한 줄
정정.
이상 없다고 확인한 것 (다시 트레이싱하지 말 것)
Epoch/EpochMap판정 규칙 —base/state-epoch-plan.md§1의 다이아몬드(A→B→D,A→C→D)를 실제 값으로 돌렸다. 규칙 1/2/3 +valueEpochMap:Refresh()가 섞인 값 관측과 이중 재계산을 둘 다 막는다. 전파 도중 하류가D:Get()을 부르면C가Refresh로 스스로 낡음을 알아채 재계산하고, 뒤늦게 도착한C쪽 emit은 규칙 3으로 접힌다.- 게이트가 붙들고 있는 동안 하류가 앞당겨 읽는 경로 — 재계산이 끝나며
valueEpochMap을 전부 갱신해두므로, 나중에 게이트가 푸는 통지는 규칙 2 (통지만)로 떨어져 같은 값을 다시 계산하지 않는다.GateNode가emitEpochMap을 수신이 아니라 flush 시점에:Sync(batch)로 갱신하는 예외도 다이아몬드에서 정책이 한 번 더 도는 것 말고 부작용이 없다. blocker:OffWithoutEmit()후emitEpochMap이 뒤에 남는 것 — 그Epoch의 다음 진짜 emit이 규칙 1로 걸려 자가 치유된다. 별도 조치 불필요가 맞다.Effect의EpochMapdedup +_installing순서 —A → b,A → c,Effect(fn, b, c)에서A:Set()한 번에fn이 정확히 한 번 돈다. 억제 플래그가Update보다 먼저라 설치 발화의from = nil이 맵을 오염시키지도 않는다.Ref콜백의 발화 계약 —:Callback은 등록 즉시 1회 불리고 ([2026-08-23 재검증 시 표현 정정] "이미 채워져 있으면"이 아니라 미설정이어도 그 상태 그대로 무조건 1회 —base/ref-plan.md의 "Ref 일반화" 절), 함수 콜백은 소진되지 않는다. 그래서Effect의 "최소 1회 실행"과 "Ref는Set될 때마다 발화"가 둘 다 성립하고,Effect의 설치 구간 억제 플래그가Ref쪽 첫 발화까지 같이 눌러야 한다는 것도 이 계약에서 바로 나온다(떼어내는 쪽만H-7).materializeSlotTree→mountSlotTree분해 — depth 2 트리 ({plainA, SlotInner{p1, p2}, plainB})로 부기와 물리를 양쪽 다 돌렸고, 등록 순서(setOffsetSource→ 실체화 →setLength)와 물리 삽입 위치 (acc)가 정확히 맞는다. 중첩 Slot을 런타임에Add하는 경로도 추적했고nativeInsert가 뒤 형제를 미는 결과가 부기 offset과 일치한다.- 단건
rawAdd의 순서가 배치 경로와 반대인 것 — plain 분기는nativeInsert→setLength, 중첩 Slot 분기는attachSlot(부기 먼저) → 물리다.native*가 미는 주체라는 계약 아래에선 둘 다 맞다(모순 아님). activateList를blocker:On()밖에서 부르는 것 —rawAdd의_mounted == false얼리리턴이 실제로 그걸 안전하게 만든다(게이팅할recompute자체가 안 일어남).Brand인스턴스 브랜드 + 다중 태깅 —Source를SourceBrand와EpochBrand에 동시 등록하는 것과, 포함 관계를 predicate 합성으로 남기는 것 사이에 충돌이 없다.isEpoch분기(노드 생성 시딩의:Syncvs:TrackFrom)도 이 표면으로 정확히 표현된다.
1차 패스 회신 방법
H-1만 선택이 필요하고(위 세 갈래), 나머지는 "맞다/아니다"만 알려주면
그대로 반영하겠다. H-5·H-6은 어느 쪽으로 정정할지도 같이 정해야 한다.
반영은 이 파일이 아니라 각 base/ 문서에 하고, 처리 결과는 4·5라운드와 같은
방식으로 -followup.md에 쌓는다.
⬇️ 아래에 2026-08-23 2차 패스(H-11~H-20)가 이어진다 — 회신 방법은
그 절 끝에 따로 적어뒀다.
2차 패스 (2026-08-23) — 1차가 안 본 영역까지 확장, 발견 9건 + 철회 1건
범위가 왜 늘었나: 1차는 사용자가 지목한 5개 영역만 봤다. 2차는 같은
방식(의사코드를 실제 값으로 돌려보기)을 디스패치 코어 전체 / 라이프타임
유틸 / Ref·Tag·Attribute·UI 숏핸드 핸들러 / Slot의 raw* 계층까지 넓혔다.
그래서 발견의 성격도 조금 다르다 — 1차가 "최근 확정분 안의 어긋남"이었다면,
2차엔 오래 전에 확정됐지만 배선이 통째로 빠진 것(H-11)과 두 base
문서가 서로 반대를 말하는 것(H-13)이 섞여 있다.
읽는 순서: H-11이 가장 무겁다 — 기능 하나가 아니라 Effect라는
프리미티브 전체가 안 도는 문제이고, slot._detached 정리와 OnDestroyed가
거기 딸려 있다. 그다음이 H-12(정상 사용 경로 크래시),
H-13(문서 간 정면 모순).
⭐ [2026-08-23] 커밋 전에 H-1~H-20 전수를 base/ 원문과 대조했다.
결과는 셋이다:
H-15는 오탐이라 철회했다 —base/ui-shorthand-plan.md에 이미 예외 판정(UI-11)이 있었는데 그걸 못 보고 일반 규칙만 인용했다. 번호는 비우지 않고 철회 기록으로 남긴다.H-2는 결론은 맞지만 트레이스 수치가 틀려서 다시 썼다 — 마운트 전 중첩 Slot의.Length는 **항상0**이라(그 값을 쓰는 주체가recompute하나뿐이고 그건 마운트 시점에 돈다) 원래 적은+3이 나올 수 없었다. 실제 동작은 원래 적은 것보다 더 나쁘다(그 항목 참고).- 나머지 18건은 원문 대조로 전부 유효함을 확인했다. 특히
H-3은bk.invalidAfter에 대입하는 코드가 코퍼스 전체에서getOffsetAt안의 두 줄뿐임을 grep으로,H-11은Destroying이 산문에만 등장하고 어떤 의사코드에도 연결되지 않음을 grep으로 다시 확인했다.
| 번호 | 심각도 | 한 줄 | 주 대상 |
|---|---|---|---|
H-11 |
🔴 | Effect의 leaf 사망 cleanup을 발화시키는 배선이 어느 의사코드에도 없다 |
base/lifecycle-pattern.md, base/effect-plan.md |
H-12 |
🔴 | rawRemove/rawUnmount/rawDetach에만 "아직 마운트 전" 분기가 없음 — 마운트 전 CRUD가 크래시 |
base/slot-plan.md |
H-13 |
🔴 | Effect(fn, ...deps) 역전이 source-state-plan.md에 반영 안 됨 — 거긴 아직 "기각"이 일반 원칙으로 서 있음 |
base/source-state-plan.md, base/effect-plan.md |
H-14 |
🟡 | Effect의 fn 시그니처가 확정 안 됨(self/previous 자리, Ref dep 읽는 법) |
base/effect-plan.md |
H-15 |
✅ 철회 | (UICornerHandler가 자식 체인을 안 걷어냄 — 오탐이었다, 아래 참고) |
base/ui-shorthand-plan.md |
H-16 |
🟡 | newKeyIndex[key] = pos가 마운트 안 된 키에도 배열 인덱스를 기록(0 포함) |
base/slot-plan.md |
H-17 |
🟡 | Dispatch.drive의 Blocker 범위가 F-4-1(단일 일반화 for)와 안 맞음 + PostRef가 blocker ON 상태에서 발화 |
base/dispatch-core-plan.md |
H-18 |
🟡 | attribute 이름을 그룹 A→B로 옮기는 게 emit 순서에 따라 error | base/attribute-plan.md |
H-19 |
🟢 | rawAdd/rawReplace의 plain 분기가 recompute를 두 번 돌림 |
base/slot-plan.md |
H-20 |
🟢 | Processed*RefHandler의 process 시그니처가 핸들러 계약과 다름 |
base/ref-plan.md |
🔴 H-11 — Effect의 leaf 사망 cleanup을 실제로 발화시키는 코드가 어디에도 없다
어디: base/lifecycle-pattern.md의 "(1)" 코드 블록(bindLifetime/
unbindLifetime 실 구현 스케치), base/source-state-plan.md의
"Observer/Effect Leaf dedup" 절(ObserverEffectLeafHandler.process),
base/effect-plan.md의 "보강" 문단.
무엇이 어긋나나: 세 문서가 Destroying 훅을 전제로만 쓰고, 아무도
그걸 연결하지 않는다.
base/lifecycle-pattern.md의 "2. Instance 파괴는" 절은 *"인스턴스 라이프사이클 훅 지점은Destroying하나로 통일"*이라 못박고, 같은 절의LP-2구체화가Effect가 그 훅을 쓰는 유일한 소비자라고 확정했다.base/effect-plan.md는 비용까지 적어뒀다 — "비용은 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 비쌈)".- 그런데 leaf가 실제로 붙는 유일한 경로는
ObserverEffectLeafHandler.process의bindLifetime(inst, v)한 줄이고,bindLifetime의 실 구현 스케치는gchold[value] = true+BindData에 gcconn/gchold 복사가 전부다.Destroying도, cleanup 저장도, 그걸 부를 주체도 없다.
트레이스
local e = Effect(function()
local conn = RunService.Heartbeat:Connect(tick)
return function() conn:Disconnect() end -- 이 cleanup
end)
Frame { e }
-- ... 나중에 frame:Destroy()
Dispatch.drive→ObserverEffectLeafHandler.process(inst, 1, e, ...)→bindLifetime(inst, e)→gchold[e] = true,BindData:SetWeak(e, "gcconn", …).inst:Destroy()→ 엔진이 gcconn을 끊음 →isBoundAlive(e)가 거짓이 됨.- 끝.
canExecute가 거짓이 되어 *"앞으로 발화하지 마라"*는 성립하지만, cleanup을 부르는 코드는 어디에도 없다.conn은 영원히 연결된 채 남는다.
파급이 국소적이지 않다 — 이 한 줄이 빠지면 같이 죽는 것들:
slot._detachCleanup(base/slot-plan.md의activateList). 이건Detach로 홀드된 요소를 파괴하는 유일한 경로이고, 같은 문서가 *"quad는 자기가 만든 Instance마다 gcconn을 걸고 그 클로저가inst를 캡처하므로 …Parent = nil인 detach 노드는 아무도 안 들고 있어도 자기 시그널 커넥션이 자기를 살려 영원히 남는다. GC 폴백이 아예 없으므로 명시적 정리 경로가 필수"*라고 적어뒀다. 그 필수 경로가Effect하나에 얹혀 있는데 그게 안 돈다.OnDestroyed(base/lifecycle-hooks-plan.md) —Effect위의 순수 슈가라 통째로 무동작.base/lifecycle-pattern.md가 *"커스텀 Destroy-time 처리가 필요하면Effect를 쓰면 되는 구조"*라며[Event "Destroying"]직접 바인드를 비권장으로 돌린 안내(LP-4)도 근거를 잃는다.
왜 지금까지 안 잡혔나: 이 배선은 bindLifetime이 정식 명명되기(2026-08-09)
전에 결정된 것이고(Effect 절은 2026-08-07), 이후 bindLifetime의
실 구현 스케치가 세 번 재작성되는 동안(canExecute 시그니처 역전, (0)
gcconn 선생성 전환, canBound 재도입) 매번 Observer 기준으로만 쓰였다.
Effect만 필요한 추가 요구가 그 사이 조용히 빠진 것으로 보인다.
정해야 하는 것(전부 열려 있음):
- 누가
Destroying을 거는가 —bindLifetime이isEffect(value)일 때 같이 걸지,EffectHandle쪽이 자기bindLifetime직후에 걸지. 전자면bindLifetime이 값 타입을 가리게 되는데, 그건 "게이트는 값 타입을 안 가린다"(base/source-state-plan.md의 "bindLifetime이 이 게이트의 두 번째" 절)와 결이 다르다. unbindLifetime은 cleanup을 부르는가 — 안 부르는 게 맞아 보인다.destroySlotTree가_detachCleanup을unbindLifetime하며 달아둔 주석 ("이미 손으로 비웠으니 Effect는 할 일 없음")이 그 전제 위에 서 있고,base/effect-plan.md의E-11(leaf 바인딩엔:Unsubscribe()가 아예 안 먹는다)도 같은 방향이다. 다만 그러면unbindLifetime은Destroying커넥션을 끊기만 하고 cleanup은 영영 안 불린다는 것도 계약으로 명시해야 한다(지금은 어느 쪽도 안 적혀 있다).- cleanup을 어디 보관하는가 —
handle필드(예:handle._cleanup)로 둘지,Destroying클로저의 upvalue로만 둘지.Rerun(재실행 시 직전 cleanup 호출)이 이미 그 값을 필요로 하므로 필드 쪽이 자연스러워 보인다.
🔴 H-12 — rawRemove/rawUnmount/rawDetach에만 "아직 마운트 전" 분기가 없다
어디: base/slot-plan.md의 raw* 의사코드 블록.
무엇이 어긋나나: 같은 블록 안에서 rawAdd와 rawReplace는
if not self._mounted then … return end 얼리리턴을 갖고 있는데, 자리를
없애는 3형제(rawRemove/rawUnmount/rawDetach)엔 그 분기가 없다.
셋 다 self._mountedInst(마운트 전엔 nil)와 Dispatch.getOffsetAt,
recompute를 무조건 부른다.
트레이스 — 완전히 평범한 사용
local s = Slot()
s:Add(Frame{}) -- rawAdd: _mounted == false → _elements에만 넣고 return
s:Remove(1) -- rawRemove: 얼리리턴 없음
rawRemove(self, 1):
local bk = getBookkeeping(self)—rawAdd가 부기를 하나도 안 남겼으므로lengthList/sourceList가 비어 있다.Dispatch.getOffsetAt(self, 1)— freshbk라bk.invalidAfter가nil, 즉H-4가 지적한attempt to compare number with nil경로를 그대로 밟는다. (H-4를 고쳐invalidAfter = 0으로 초기화하면 여기선0이 반환된다.)nativeRemove(self._mountedInst, 0, { element })— 첫 인자가nil. 백엔드가Destroy를 부르든 뭘 하든 그 자리에서 터진다.recompute(self, bk)—bk.N이nil이라bk.N or 0방어에 걸려 no-op.
rawUnmount/rawDetach도 nativeExtract(self._mountedInst, …)로 정확히 같다.
중첩 Slot 요소면 unmountSlotTree(element)로 빠지는데 그것도 H-6
(physicalTarget 미정의)에 걸린다.
이게 정상 사용인 근거: Slot은 *"inst/i를 모르는 독립 값(어디
마운트될지 자기가 결정 안 함)"*이고, rawAdd의 _mounted == false 분기가
존재하는 이유 자체가 "마운트 전에 요소를 채워두는 것"을 정상 경로로 지원하기
위해서다. 그 상태에서 하나를 도로 빼는 것도 당연히 정상이다. CRUD 표의
에러 조건 목록에도 "마운트 전 Remove 금지" 같은 건 없다.
필요한 것: 셋에 rawAdd/rawReplace와 같은 얼리리턴을 넣고 —
table.remove(self._elements, index) + releaseOwner(+rawRemove면 파괴)만
하고 물리/부기 조작 없이 반환 — 그 안에서 파괴 대상 처리(nativeDispose vs
destroySlotTree)는 rawReplace의 not self._mounted 분기가 이미 쓰는
모양("트리 밖이라 물리 op이 필요 없다")을 그대로 재사용하면 된다.
🔴 H-13 — Effect(fn, ...deps) 역전이 source-state-plan.md엔 반영 안 됐고, 거기선 아직 일반 원칙으로 서 있다
어디: base/source-state-plan.md의 ":Compute(fn, ...) — 추가 의존성을
trailing args로 직접 받는 sugar" 절 vs base/effect-plan.md의
"Effect(fn, ...deps)" 절.
무엇이 어긋나나: 두 base 문서가 정반대를 말한다.
source-state-plan.md: "Effect(fn, ...)/state:Observer(fn, ...)류 trailing-args 확장은 기각 — 여기선 진짜 새 노드가 생기기 때문." 그리고 바로 아래에서 이걸 일반 원칙으로 승격해뒀다 — "trailing args sugar는 그게 정말 무료일 때만 붙인다 … 없던 노드를 새로 만들어야 하는 경우(Effect/Observer의 다중 의존성 병합)엔 sugar 없이:With를 명시적으로 남긴다." 심지어quadnomicon에세이 소재로까지 등록돼 있다.effect-plan.md: 같은 결정을 5라운드C-6에서 역전했다(🔄 [역전됨, 2026-08-21 …]). 근거도 정면으로 부딪힌다 — "각 의존성에 구독을 따로 걸면 합치는 노드 자체가 안 생긴다 — 감출 비용이 애초에 없다."
왜 위험한가: source-state-plan.md는 반응형 코어의 정본이라 구현자가
Effect 표면을 짜기 전에 반드시 읽는 문서다. 지금 상태로는 그 문서만 보고
Effect(fn, state?)(단일 dep)로 구현하는 게 완전히 합리적이고, 그러면
5라운드가 닫은 갭(Ref는 :With로 못 합치므로 Effect의 의존성이 될
방법이 아예 없다)이 그대로 되돌아온다. H-8(_observer 단수)과 합쳐지면
"두 문서가 모두 단수를 말하고 한 문서만 복수를 말하는" 상태라, 단수 쪽으로
수렴할 가능성이 오히려 높다.
같이 정해야 하는 것 — Observer는 정말 그대로 기각인가: effect-plan.md
C-6은 Effect만 역전했다. 결과적으로 state:Observer(fn, ...)는 여전히
기각인데, 그 기각의 근거가 Effect와 공유되던 것이라 지금은 논거가
비어 있다. 실제로는 "Observer는 리시버 State 하나에 붙는 구독이고, 여럿을
엮는 건 Effect가 대신한다"가 새 근거로 맞아 보이지만, 그건 어디에도 안
적혀 있다.
🟡 H-14 — Effect의 fn 시그니처가 확정 안 됐다
base/effect-plan.md는 *"인자 모양은 :Compute(fn, ...deps)의 선례
그대로 — trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*고만 적었다.
그런데 그 선례의 확정 시그니처는 **fn(self, previous?, ...deps)**이고
(base/source-state-plan.md), Effect엔 둘 다 없다:
self가 없다 —Effect는 자유 함수라 리시버 State가 없다.previous가 없다 — 파생값을 안 만드는 leaf라 캐시 슬롯 자체가 없다. 대신fn의 반환값이 cleanup이라 의미가 정반대다.- 같은 문서 위쪽엔 아직 옛 단수 시절 표기 **
fn(state)**가 남아 있다.
그래서 Effect(fn, a, b)의 fn이 fn(a, b)인지 fn(nil, a, b)인지
문서만으로 안 정해진다. 단수 시절 fn(state)와 하위 호환되려면 fn(...deps)
쪽이지만, 그러면 "선례 그대로"라는 문장이 틀린 서술이 된다.
곁가지 — Ref dep은 lazy 핸들이 아니다. State/Source dep은 dep:Get()로
읽는 lazy 핸들인데 Ref는 즉시 값 박스라 dep.Value다(:Get()이 없다).
Effect(fn, someState, someRef)의 fn은 인자마다 읽는 법이 다른데, 그
구분을 사용자가 위치로 기억해야 하는지 isRef로 분기해야 하는지가 안 적혀
있다. :Compute의 "모든 인자가 lazy State 핸들"이라는 단일 규칙(그리고
:Get() 누락이 반복 실수라 별도 절까지 있는 그 규칙)이 Effect에서는
성립하지 않는다는 걸 명시할 필요가 있어 보인다.
✅ H-15 — 철회됨(오탐). UICornerHandler가 자식 체인을 안 걷어내는 건 이미 판정된 사항
[2026-08-23 커밋 전 재검증에서 철회] 초안은 *"destroyManagedChild가
Dispatch.retractFrom(child, "CornerRadius", 1)을 안 부르는데,
base/dispatch-core-plan.md가 '실행 중인 Tween/구독처럼 즉시 끊어야 하는 게
있으면 명시적 정리가 필요'라고 요구한 바로 그 케이스"*라고 적었다.
그 일반 규칙의 예외로 이미 사용자 판정이 나 있었다 —
base/ui-shorthand-plan.md의 "자식을 파괴할 때" 절(4라운드 UI-11)이
*"Dispatch.retractFrom(child, prop, 1)을 "정석"으로 요구하지 않는다 —
실익이 없다"*로 확정해뒀고, 근거 셋이 전부 유효하다:
- Roblox 엔진이 Destroy 시점에 그 인스턴스에 걸린 Tween을 알아서 정리한다.
PropertyHandler가 반환하는 retractor는 애초에 몸체가 no-op이라 불러봐야 하는 일이 없다(base/tween-plan.md).chains는child에 대해 weak-keyed라 자식을 버리면 결국 GC된다.
직접 재확인한 나머지 한 갈래도 안전하다 — v가 State<number>여서 그
체인에 StoreBind Observer가 얹히는 경우, 그 Observer는
bindLifetime(child, observer)로 자식에게 묶이므로 자식이 Destroy되면
gcconn이 끊겨 canExecute가 거짓이 되고 gchold째로 GC된다. UI-11의
*"명시적 정리가 필요한 자원이 이 자리엔 없다"*가 맞다.
남는 것은 표기 하나뿐(🟢) — 그 의사코드 retractor의 인자 이름이 아직
hint다. base/dispatch-core-plan.md가 "코퍼스 전반에서 이 인자를
hintValue라고 부르는데 이는 타입이 보장되지 않던 옛 모델에서 온 이름"
이라고 이미 짚어둔 잔재이고, 이 문서만 남아 있는 것으로 보인다.
교훈(다음 라운드용): 일반 규칙(dispatch-core-plan.md)만 보고 "이
호출부가 규칙을 안 지킨다"고 판정하기 전에, 그 호출부 문서에 예외 판정이
있는지를 반드시 먼저 확인할 것. 이번 2차 패스에서 유일하게 이 확인을
빠뜨린 항목이다.
🟡 H-16 — newKeyIndex[key] = pos가 마운트 안 된 키에도 배열 인덱스를 기록한다
어디: base/slot-plan.md의 reconcile 정상 루프 끝
(userdata[key] = ud 바로 다음 줄).
newKeyIndex[key] = pos는 생존 여부와 무관하게 모든 키에 실행된다.
그런데 pos는 result ~= nil일 때만 전진하므로:
| 키 | updateFn 반환 |
pos |
newKeyIndex |
|---|---|---|---|
a |
새 값 | 1 | 1 |
b |
nil(필터 탈락) |
1 | 1 ← a와 같은 인덱스 |
c |
Detach |
1 | 1 ← 또 같음 |
d |
새 값 | 2 | 2 |
그리고 첫 아이템부터 탈락하면 newKeyIndex[key] = 0 이다 — Lua 배열에
존재할 수 없는 인덱스다.
지금은 대부분 안 터진다 — 그 키들이 다음 사이클에 밟는 분기들(재마운트,
wasDetached 파괴, 신규 rawAdd)이 마침 keyIndex를 안 쓰고 pos를 쓰기
때문이다. 하지만 그건 우연이고, H-1을 어느 갈래로 고치든(특히 (1)번
"라이브 인덱스 맵 유지") 이 값들이 실제 인덱스로 취급되는 순간 바로
_elements[0]/엉뚱한 요소를 만지게 된다.
근본 원인은 keyIndex가 두 역할을 겸하는 것이다:
- "직전 사이클에 데이터에 있던 키 전체 집합" — 소멸 루프가
pairs(keyIndex)를 도는 이유이고, 문서가 *"pairs(mounted)로는 필터 탈락 상태로 사라진 키가 안 잡혀userdata가 샌다"*고 명시적으로 요구한 성질이다. - "그 키가 차지한
_elements인덱스" —raw*인자 규약이 의존하는 성질.
마운트 안 된 키는 (1)엔 있어야 하고 (2)는 정의상 없다. 두 관심사를 한
테이블에 담은 게 defect의 뿌리다 — H-1을 손볼 때 같이 쪼개야 한다
(예: seenKeys 집합 + keyIndex는 마운트된 키만).
🟡 H-17 — Dispatch.drive의 Blocker 범위가 F-4-1과 안 맞고, PostRef가 blocker ON 상태에서 발화한다
어디: base/dispatch-core-plan.md의 Dispatch.drive 설명
("배열 파트 순회 전체를 inst 전용 Blocker로 감싼다")과 같은 문서의
F-4-1 정정("실제 구현은 일반화 for 한 번").
무엇이 어긋나나: 확정 문장은 *"순회 시작 전에 … :On()하고, 배열 파트
순회가 (pre-pass/post-pass 포함) 전부 끝나면 :OffWithoutEmit() 한 뒤
recompute(inst, bk)를 명시적으로 1회"*다. 두 패스 구현에선 이게 자연스럽게
표현되지만, F-4-1이 확정한 단일 일반화 for + type(k) == "number"
분기에선 "배열 파트가 끝나는 시점"이 루프 밖에서 관측되지 않는다 — 첫
비숫자 키를 만나는 순간이거나 루프 종료이고, 둘 중 어느 쪽인지는 그 인스턴스의
props에 해시 키가 있느냐에 달렸다.
게다가 괄호 안의 *"post-pass 포함"*이 그 문장 자체와 부딪힌다 —
postRefList 소비는 해시 파트보다 뒤다(base/ref-plan.md의 PostRef
절: "두 패스가 끝난 뒤"). 그러니 실제로 감쌀 수 있는 범위는 배열 파트가
아니라 drive 전체다.
그래서 무엇이 문제인가 — 범위가 넓어지는 것 자체는 무해해 보이지만
(해시 파트는 setLength를 안 부른다), PostRef 콜백이 blocker가 켜진
채로 실행된다는 사실이 어디에도 안 적혀 있다. 그 콜백은 사용자 코드이고,
base/ref-plan.md가 드는 대표 용례부터가 "이 시점 이후의 동적 변경을
구분하는 플래그를 세우는 것"이라 그 자리에서 slot:Add(...)류를 부르는
게 자연스럽다. 그러면:
- 그 Slot 자신의 blocker는 이미 꺼져 있어 자기
recompute는 정상적으로 돈다. - 그 결과 바뀐
slot.Length가 emit되어 부모inst의bk.observers[i]→gatedRecompute로 올라오는데, 여기서 부모 blocker가 아직 ON이라 조용히 스킵된다. - 정합성은 그 직후의 명시적
recompute(inst, bk)한 번에 전적으로 의존하게 된다. 지금은 우연히 맞지만, 그건 계약으로 적힌 적이 없다.
정할 것: (a) blocker 범위를 문장 그대로 "배열 파트"로 좁힐지(그러면
단일 루프 안에서 전이를 감지하는 구현을 명시해야 하고, post-pass는 밖으로
나간다), 아니면 (b) 실제대로 "drive 전체"로 넓혀 적고 PostRef 콜백은
게이트 안에서 돈다는 걸 계약으로 못박을지.
🟡 H-18 — attribute 이름을 그룹 A에서 그룹 B로 옮기면 emit 순서에 따라 error가 난다
어디: base/attribute-plan.md의 "이름 소유권" 절, "해제 → 재클레임
순서는 Dispatch가 보장함" 항목.
그 항목의 논증은 이렇다 — "같은 핸들러 재프로세스는 slot.retractor(v) →
h.process(...) 순서이고, 핸들러가 바뀌는 경우는 retractFrom → process
순서 — 어느 경로든 옛 claim 반납이 새 claim보다 먼저라 자기 자신과
충돌하는 일이 없음." 마지막 다섯 글자가 정확하다: 이 보장은 한
(inst,k) 체인 안에서만 성립한다.
트레이스 — 두 그룹이 이름을 주고받는 경우
Frame {
groupA, -- k=1, State<Attribute>. 지금 {"Hp"}를 잡고 있음
groupB, -- k=2, State<Attribute>. 지금 {}
}
-- 런타임에: A는 "Hp"를 놓고, B가 "Hp"를 가져간다
두 자리는 완전히 별개의 체인이고, 각자 자기 StoreBind의 Observer가
독립적으로 깨어난다. groupB 쪽 emit이 먼저 처리되면:
(inst,2)재프로세스 →AttributeGroupHandler.process→Dispatch.process(inst, groupKey(vB,"Hp"), …, 1)→AttributeKeyHandler.processnameClaims:GetStrong(inst, "Hp")가 아직groupA의 전용 키를 가리킴 →cur ~= nil and cur ~= k→error("attribute "Hp" is already bound by another owner")
groupA 쪽 emit이 먼저면 정상 동작한다. 즉 같은 코드가 emit 순서에 따라
성공하거나 크래시한다. 사용자가 Blocker로 두 소스를 묶어도 두 체인의
전파 순서는 여전히 미정이라 통제 수단이 없다.
곁가지 — 이건 "부분 실패"로도 나쁘다: 위 2번에서 error가 날 때
groupB의 process는 이미 그 앞 이름 몇 개를 Dispatch.process로
위임해둔 뒤일 수 있다. 같은 문서가 *"에러=패닉 상태, 그 이후 정합성은
관리 대상 아님"*으로 이미 선을 그어뒀으니 계약 위반은 아니지만, 정상적인
리팩터링 의도가 그 패닉 경로로 들어간다는 게 문제다.
선택지:
- UB/에러로 못박기 — "attribute 이름을 두 그룹 사이에서 옮기려면 한 프레임 안에서 하지 말고, 놓는 쪽을 먼저 커밋하라"를 문서 계약으로. 가장 싸지만 사용자가 순서를 통제할 수단이 지금 없다.
- claim 반납을 늦추기(2단계) — 재프로세스 사이클 동안은 claim을 "예약 해제" 상태로 두고 사이클 끝에 sweep. Dispatch에 사이클 개념을 들여야 해서 비싸다.
- claim 충돌 시 즉시 error 대신 "이전 소유자가 아직 살아 있는가"를
확인 — 옛 소유자의 체인이 이미 죽었으면 조용히 인수. 판정 수단이
지금 없다(그래서
groupClaimKeys가 따로 생긴 것).
🟢 H-19 — rawAdd/rawReplace의 plain 분기가 recompute를 두 번 돌린다
base/slot-plan.md의 rawAdd plain 분기:
Dispatch.setOffsetSource(self, index, None)
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
Dispatch.setLength(self, index, 1, self._mountedInst)
recompute(self, bk)
Dispatch.setLength는 len이 상수여도 마지막에 gatedRecompute()를 부르고
(base/dispatch-core-plan.md의 setLength 구현 — "상수 길이도 같은 게이트를
통과"), 런타임 단건 경로에선 그 owner의 Blocker가 이미 꺼져 있으므로
("런타임에 이미 마운트된 Slot에 한 번에 하나씩 :Add()하는 흔한 패턴은 이
게이팅이 필요 없다") 그게 곧바로 recompute(self, bk)다. 바로 다음 줄이
또 recompute(self, bk)다. rawReplace의 마지막 두 줄도 같은 모양이다.
크래시는 아니다 — recompute는 offset:Get() ~= abs 가드가 있어 두
번째 호출은 대체로 아무것도 안 쓴다. 다만 (a) O(N) 순회가 두 번 돌고,
(b) 더 중요하게는 "recompute를 누가 부르는가"의 소스가 두 곳이 되어
H-3(캐시 무효화)을 고칠 때 어느 자리에 invalidAfter를 당길지가 애매해진다.
setLength가 게이트를 태우는 것으로 통일하고 명시 호출을 지우든, 반대로
명시 호출만 남기고 상수 경로에선 gatedRecompute를 안 태우든 하나로 정할 것.
🟢 H-20 — ProcessedPreRefHandler/ProcessedPostRefHandler의 process 시그니처가 핸들러 계약과 다르다
base/ref-plan.md의 두 의사코드가 전부 이 모양이다:
function ProcessedPreRefHandler.process(inst, i, v)
확정된 계약은 process(inst, key, value, index)(4-인자)다. 두 가지가 어긋난다:
- 4번째 인자
index가 빠졌다. 이 두 핸들러는 재위임을 안 하므로 실제로 안 쓰지만, 시그니처는 계약대로 두는 게 맞다(NilHandler/NoneHandler는 4-인자로 적혀 있어 지금은 표기가 갈려 있다). - 2번째 인자를
k가 아니라i로 부른다. 본문에서 실제로 배열 위치로 쓰고 있으니 동작은 맞지만, 이건base/dispatch-core-plan.md의 "Handler 작성 체크리스트" 6번이 경고하는 바로 그 혼동이다 — "배열 파트의 위치(k)와 이index는 완전히 다른 것 —AttributeGroupHandler가 배열 위치를index라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음." 같은 실수의 거울상(위치를i로 부르고 계약상index자리를 비움)이라, 표기만(inst, k, v, index)로 통일하면 닫힌다.
2차 패스에서 확인만 하고 문제 없었던 것 (다시 트레이싱하지 말 것)
TagHandler의 위치 기준 참조 카운트 —Frame { Tag("a"), Tag("a","b") },Tag(A)→Tag(B)같은 위치 교체, 같은Tag객체를 두 위치에서 재사용, spurious 재발행(v == nextValue얼리리턴)까지 실제 값으로 돌렸다.holders[k]기준이라 전부 정확하고,added/removed가 실제로 바뀐 이름에만 모인다. (유일한 잔여는holders가 빈 테이블이 된 뒤에도tagNameMap엔트리가 남는 것인데,inst에 대해 weak라 누수가 아니고 다음process가next(holders) == nil을 보고 정상적으로addTag한다.)AttributeGroupHandler의 retractor가Dispatch.retractFrom을 부르는 것 — "클로저 안에서retractFrom금지"는 **같은(inst,k)**에 대한 것이고 (base/dispatch-core-plan.md체크리스트 5번), 여기선 자기가 위임했던 다른 키들이라 명시적으로 허용된 경우다. 게다가AttributeKeyHandler의 retractor가 엔진 부작용이 0(claim 반납만)이라 생존 이름을 일단 철거했다가 다시 등록해도a→nil→b깜빡임이 없다. 계약대로 맞다.Dispatch.process의 (A)/(B) 분기와#list불변식 —drive가 항상index=1로 진입하고, (B) 분기가h.process전에 점유 마커를 박고, 래핑 핸들러가index+1로만 재귀하므로 배열에 구멍이 뚫리는 경로가 없다.State<State<T>>도 인덱스가 겹치지 않는다.Brand의 다중 태깅과isState/isRef합성 —Source가SourceBrand+EpochBrand에 동시 등록되는 것과,isRef = isPreRef or isPostRef or RefBrand:is,isState = isSource or StateBrand:is가 서로 안 부딪힌다.RefLeafHandler.isHandlable의not isPreRef and not isPostRef배제와도 일관된다.materializeSlotTree→mountSlotTree순서와_baseObserver생성 위치 —_baseObserver를blocker:On()뒤에 만드는 것이 실제로 필요하고 (등록 즉시 1회 실행이 빈 부기를 훑는 걸 막음),activateList를On()앞에서 부르는 것도rawAdd의_mounted == false얼리리턴 덕에 안전하다. 두 배치가 서로 반대인 게 의도된 것이 맞다.Epoch리비전의bit32.bnot(-rev)랩 —luau로 실제 값을 확인했고 (0 → 4294967295,1 → 0,2 → 1), 규칙이==/~=만 쓰므로 감소·랩이 문제되지 않는다.2^32충돌 조건이 "정확히 한 바퀴 동안 한 번도 안 건드려진 항목"이라는 문서 서술도 맞다.unmountSlotTree/destroySlotTree의 자원 해제 대칭 — 언마운트는 앵커만 풀고 핸들·_listActivated·_detached·Offsetidentity를 보존, 파괴는 핸들까지nil.activateList의 멱등 가드가 재마운트에서 앵커만 옮기는 것과 정확히 맞물린다. (단 그 재마운트 경로가bindLifetime을 다시 부르는 게 안전한 건 언마운트가 먼저unbindLifetime했기 때문이고,canBound게이트가 그걸 전제한다 — 구현 시 이 짝이 깨지면 바로 error가 난다는 걸 인지할 것.)
2차 패스 회신 방법
1차와 같다 — "맞다/아니다"만 주면 그대로 반영하겠다. 다만 아래 넷은 갈래 선택이 필요하다:
H-11—Destroying을 누가 거는가(1/2/3번 소항목). 이게 가장 급하다: M3의Effect구현이 여기서 시작하고,slot._detached정리와OnDestroyed가 전부 딸려 있다.H-13—source-state-plan.md의 기각 서술과 그 "일반 원칙"을 어떻게 고칠지(원칙 자체를:Compute한정으로 좁힐지,Observer의 새 근거를 같이 적을지).H-17— blocker 범위를 좁힐지(a) 넓혀 적을지(b).H-18— 세 선택지 중 하나.
나머지(H-12/H-14/H-16/H-19/H-20)는 확인만 해주면 바로
반영 가능하다. H-15는 철회했으므로 답할 게 없다(그 자리에 남은 🟢
표기 하나만 같이 봐주면 된다). 반영은 이 파일이 아니라 각 base/ 문서에 하고, 처리 결과는
1차와 묶어 -followup.md에 쌓는다.
⬇️ 아래에 2026-08-23 3차 패스(H-21~H-38)가 이어진다 — 회신 방법은
그 절 끝에 따로 적어뒀고, 맨 앞에 H-2 실측 정정이 있다.
⬇️ 그 뒤에 2026-08-24 4차 패스(H-39~H-54)가 이어진다 — 회신 방법은
그 절 끝에 따로 있다.
3차 패스 (2026-08-23) — 1·2차가 안 본 문서 전체 + 실측 검증, 발견 18건 + H-2 정정
범위가 왜 또 늘었나: 사용자 요청("지금 나온거 이외에 더 문제될만한게
있는지 조사해봐줘 … 시스템 전체 계획을 봐도 좋고 … 그러고 나서 모든 요소들이
진짜 실존하는 문제인지 사실확인을 해줘"). 1차는 최근 확정 5개 영역, 2차는
디스패치 코어·라이프타임·핸들러·raw* 계층이었다. 3차는 그 둘이 한 번도
안 연 문서를 전부 연다 — store-plan.md/source-state-plan.md(:Compute/
Observer/GC 불변식) / modifier-plan.md/component-composition-plan.md/
bind-system-plan.md / event-plan.md/onchange-plan.md/relate-plan.md/
module-lifecycle-plan.md/fallback-plan.md/lifecycle-hooks-plan.md /
tween-plan.md/debounce-throttle-plan.md / typing-limits.md/
quad-types-plan.md/project-setup-plan.md와 실제 커밋된 M1 코드, 그리고
slot-plan.md의 공개 CRUD·:Single·래핑·dispose 계층. 여기에 문서 경계를
가로지르는 통합 시나리오(실제 앱 한 화면 / 중첩 멀티루트 / 파괴 순서 / 에러
경로)를 따로 돌렸다.
1·2차와 다른 점 — 이번엔 추론으로 끝내지 않고 실제로 돌렸다. 로컬
luau 0.734 / luau-analyze로 (a) table.insert의 범위 밖 동작, (b) 테이블
순회 중 키 추가의 실제 결과, (c) Tween<T>:Mapped의 타입 누수, (d)
quad.Dispatch 접근의 타입 에러를 직접 재현했다. 그 결과 기존
H-2의 트레이스가 틀렸다는 것도 드러났다(아래 바로 다음 절).
여기서도 base/는 한 줄도 안 고쳤다.
⚠️ 먼저 — H-2의 크래시 주장이 틀렸다 (실측 정정)
H-2는 *"table.insert(self._elements, 0, S) → Luau는 pos가 1..#t+1
밖이면 position out of bounds를 던진다 → 그 자리에서 터진다"*고 적었다.
Luau에서 안 터진다. 로컬 luau 0.734로 확인(모든 fflag를 켠 상태도 동일):
| 호출 | 결과 |
|---|---|
table.insert({}, 0, "x") |
에러 없음 → t[0] = "x", #t == 0 |
table.insert({"a","b"}, 0, "x") |
에러 없음 → t[0] = "x", t[1]/t[2] 그대로, #t == 2 |
table.insert({"a","b"}, 5, "x") |
에러 없음 → t[5] = "x", #t == 2 |
table.insert({"a","b"}, -1, "x") |
에러 없음 |
그래서 실제 결과는 크래시가 아니라 "조용한 영구 고아"다 — H-2가 원래
*"실제 동작은 더 나쁘다"*고 쓴 것보다도 한 단계 더 나쁘다:
rawAdd가claimOwner(S, self)를 먼저 통과시킨다 →elementOwner에 기록되어S는 이제 영원히 다른 곳에 마운트할 수 없다(다시 넣으려 하면 "이미 마운트돼 있음" error).table.insert(self._elements, 0, S)→_elements[0] = S,#_elements는 안 변한다.materializeSlotTree/mountSlotTree/destroySlotTree/unmountSlotTree가 전부ipairs(slot._elements)로 도는데,ipairs는 인덱스1부터 시작한다 →_elements[0]은 어떤 walk도 닿지 않는다. 실체화도, 마운트도, 파괴도 영원히 안 된다.S가 이미 자식 Instance를 갖고 있다면 그 Instance들엔 gcconn이 걸려 있어 (base/lifecycle-pattern.md) 아무도 참조 안 해도 GC되지 않는다 — 서브트리 통째로 영구 누수.newKeyIndex[key] = 0으로 기록되므로 다음 사이클엔rawMove(self, 0, 1)같은 호출로 이어진다(그 함수는 정의 자체가 없다 —H-29).
H-2의 결론(두 좌표계를 한 변수에 겹쳤다 / 마운트 전 Slot의 .Length는
항상 0이다)은 그대로 유효하다. 틀린 건 "터진다"는 마지막 한 걸음뿐이고,
고칠 방향(pos를 리프 카운터와 슬롯 카운터로 분리)도 안 바뀐다. 다만
"터지니까 금방 발견된다"는 안심이 사라졌다 — 이건 테스트를 통과하고
프로덕션에서 조용히 메모리를 먹는 종류의 결함이다.
| 번호 | 심각도 | 한 줄 | 주 대상 |
|---|---|---|---|
H-21 |
🔴 | unwrapElement가 Instance 요소에서 즉시 크래시 — :List 두 번째 사이클에 죽음 |
base/slot-plan.md |
H-22 |
🔴 | 기본 identity updateFn이 KeyGone을 그대로 반환해 항상 error |
base/slot-plan.md |
H-23 |
🔴 | 전파 도중 새 구독자가 붙으면 구독자 하나가 누락되고 다른 하나가 두 번 발화(실측) | base/source-state-plan.md |
H-24 |
🔴 | Tween<T>:Mapped가 재귀 제네릭 타입 누수 — typing-limits.md가 이걸 모름(실측) |
base/tween-plan.md, base/typing-limits.md |
H-25 |
🔴 | New(): Quad가 닫힌 5-필드 타입이라 M2가 붙일 quad.Dispatch가 타입에러(실측) |
base/quad-types-plan.md, base/module-lifecycle-plan.md |
H-26 |
🟡 | 부분 생성 후 예외가 나면 이미 만든 Instance가 영구히 회수 안 됨 + 그걸 커버한다던 서술이 모순 | base/fallback-plan.md, base/dispatch-core-plan.md |
H-27 |
🟡 | OnChangeHandler에 v == nil 얼리리턴이 없음 — None으로 끄면 폭탄 Connection이 심김 |
base/onchange-plan.md |
H-28 |
🟡 | dispose의 소유권 가드가 Slot 분기에만 있음 — Instance는 그냥 파괴됨 |
base/slot-plan.md |
H-29 |
🟡 | rawMove/rawSwap/rawExtract/rawSplice/rawClear가 정의 자체가 없음 |
base/slot-plan.md |
H-30 |
🟡 | Splice의 "반쪽 상태 방지" 약속을 지키는 코드 경로가 없음 |
base/slot-plan.md |
H-31 |
🟡 | duplicate key 가드가 이미 절반 뮤테이트한 뒤에 터짐 | base/slot-plan.md |
H-32 |
🟡 | Debounce{Trailing=false} + MaxTime에서 pending이 새어 Flush가 영구 no-op |
base/debounce-throttle-plan.md |
H-33 |
🟡 | debounce 7절 의사코드가 확정된 state:Gate(setup) API로는 성립 불가 |
base/debounce-throttle-plan.md, base/gate-plan.md |
H-34 |
🟢 | native* 조합 폴백이 Move/Swap을 만든 이유를 그대로 되돌림 |
base/slot-plan.md |
H-35 |
🟢 | ProcessedModifierHandler가 의사코드도 없고 색인 두 곳에서 빠짐 |
base/modifier-plan.md |
H-36 |
🟢 | store-plan.md가 미해결 항목(상류 strong 여부)을 확정처럼 근거로 인용 |
base/store-plan.md |
H-37 |
🟢 | Slot:List 의사코드에 _crudUsed 역방향 가드가 빠짐 |
base/slot-plan.md |
H-38 |
🟢 | :List reconcile의 예외 원자성 계약이 어디에도 없음 |
base/slot-plan.md |
🔴 H-21 — unwrapElement가 Instance 요소에서 즉시 크래시한다
어디: base/slot-plan.md의 "래핑/언래핑은 Slot 전체에 걸린 연산이다" 절.
확정된 의사코드:
local function unwrapElement(el)
if el == nil then return nil end
return el._wrapped or el -- 래퍼면 원래 값, 아니면 자기 자신
end
무엇이 어긋나나: el은 물리 요소다 — 같은 절이 *"_elements에는 항상
물리 요소(래퍼일 수 있음)가 들어간다"*고 못박았고, quad-roblox에서 그건 사실상
**Roblox Instance**다(base/slot-plan.md의 "요소 타입 제약" 절: "quad-roblox엔
사실상 T = Instance 하나뿐"). Roblox Instance는 없는 멤버를 인덱싱하면
nil을 주는 게 아니라 에러를 던진다 — _wrapped is not a valid member of Frame. 즉 el._wrapped 한 줄이 가장 흔한 요소 타입에서 항상 죽는다.
트레이스:
local items = Source({ {id=1}, {id=2} })
slot:List(items, function(item, index, offset, prev, ud)
if prev then return prev end
return Frame { Name = "row"..item.id }
end, function(item) return item.id end)
- 첫 사이클:
mounted[1],mounted[2]가 아직 없으므로unwrapElement(nil)→ 얼리리턴nil. 정상 통과, 두 Frame이 마운트된다. items:Set({ {id=1}, {id=2} })— 아주 평범한 갱신.- reconcile 첫 아이템:
local prev = unwrapElement(mounted[1] or ...)→mounted[1]은 Frame Instance →Frame._wrapped→ error.
:List가 raw Instance를 다루는 모든 두 번째 사이클이 여기서 죽는다.
같은 이유로 settle의 result == unwrapElement(prev) 비교, 소멸 루프의
prev 계산, 그리고 공개 Get/IndexOf/Extract/ExtractAll/Splice의
반환 경로가 전부 같은 줄에서 죽는다.
반대 증거 확인: 코퍼스 전체에서 _wrapped를 grep했다 — 정의 두 줄
(slot-plan.md 2616/2622)과 5라운드 followup의 요약, 그리고 세션 원문뿐이다.
가드가 붙은 변형은 어디에도 없다. isSlot은 Brand의 weak-key 집합
조회라(base/brand-plan.md) Instance/userdata에 대해 안전하다.
참고 — base의 일반성 관점에서도 틀렸다: Slot<T>의 T는 base 레벨에서
"뭐든 될 수 있는" 제네릭이라고 같은 문서의 Splice 항목이 명시적으로
논증한다("다른 백엔드면 테이블일 수도, userdata일 수도"). T가 number/boolean이면
Luau에서도 attempt to index number로 죽는다.
갈래: 단순 정정에 가깝지만 어느 쪽인지는 정해야 한다 —
isSlot가드:if isSlot(el) then return el._wrapped or el end return el— 래퍼는 항상 Slot이므로 정확하고 O(1)(브랜드 조회) 유지.- 필드 대신 weak side table:
wrappedOf[sub] = v—T가 우연히_wrapped필드를 가진 테이블인 백엔드까지 커버되지만 조회 한 단계가 는다.
🔴 H-22 — 기본 identity updateFn이 KeyGone을 그대로 반환해 항상 error를 낸다
어디: base/slot-plan.md의 :Single 절(identityUpdateFn)과 "키가
데이터에서 사라질 때 처분을 묻는다" 절.
무엇이 어긋나나: 확정된 기본 updateFn은
local function identityUpdateFn(item) return item end
이고, 소멸 루프는 updateFn(KeyGone, 0, offset, prev, ud)를 부른 뒤
**nil/None/Detach가 아닌 반환은 전부 error**로 못박았다(5라운드 DE-9).
identity는 받은 KeyGone을 그대로 돌려주므로 그 error에 100% 걸린다.
트레이스:
local s = Source(myFrame)
slot:Add(s) -- 확정 sugar: wrapElement → sub:Single(s, nil, { Owned = false })
...
s:Set(nil) -- State<T?>가 nil이 되는, 문서가 명시적으로 지원한다는 경우
:Single의data = state:Compute(...)가{}(빈 배열)을 낸다._listObserver→reconcile({})→ 메인 루프가 0회 돈다.- 소멸 루프: 직전 사이클의
keyIndex에 고정 키true가 있다 →identityUpdateFn(KeyGone)→KeyGone반환. if result == None then result = nil end—KeyGone은None이 아니다.local detach = (result == Detach)—Detach도 아니다.if result ~= nil then error("Slot:List — KeyGone에는 …") end→ 터진다.
문서 내부 모순이기도 하다: 같은 문서의 "래핑/언래핑" 절이
"State<T?>(nilable)도 특별 취급 없이 그냥 됨 — :Single이 이미
if v:Get() == nil then {} else {v:Get()}로 nil을 '빈 리스트'로 흡수하므로"
라고 명시적으로 보장하고 있는데, 실제로는 그 흡수가 곧바로 소멸 루프를
깨운다.
영향 범위: Slot:Add(state) sugar 전부, Slot():Single(state)의 updateFn
생략 전부, 그리고 quad 내부의 wrapElement(=:List가 State를 반환받는
모든 자리). 즉 반응형 raw 요소 기능 전체다.
반대 증거 확인: KeyGone/identityUpdateFn을 코퍼스 전체에서 grep했다 —
identity를 예외 처리하는 서술도, :Single이 소멸 루프를 우회한다는 서술도
없다. :Single은 keyFn = function() return true end로 키가 하나뿐이라
"데이터가 비면 그 키가 사라진다"가 정확히 매 nil-전이마다 일어난다.
갈래:
identityUpdateFn이KeyGone을 흡수:if item == KeyGone then return nil end— 기본 동작이 "사라지면 파괴"가 되어Owned표와도 맞는다(Owned = false면releaseElement가 언마운트만 하므로 사용자 값은 안 죽는다).:Single층에서 흡수::Single이 감싸는 래퍼updateFn이KeyGone을 가로챈다 —:Single은 애초에 키가 하나뿐이라 "키 소멸"에 사용자 판단을 물을 이유가 없다는 논거.- 소멸 루프가
item == KeyGone을 반환한 경우만 nil로 강등 — 가장 관대하지만DE-9의 fail-fast 톤과 어긋난다.
🔴 H-23 — 전파 도중 새 구독자가 붙으면 구독자 하나가 누락되고 다른 하나가 두 번 발화한다 (실측)
어디: base/source-state-plan.md의 "state:Observer(fn) — 값을 안
실어주는 구독" 절("외부에 weak table({[observer] = true}, __mode = "k")로
인덱싱")과 "동적 경로 가드 — k 무관 매치" 절("이 게이팅이 일어나는 자리는
State의 전파 루프다 — State는 구독자를 weak로 담고, 발화 시 각 구독자마다
canExecute(observer)를 확인해 거짓이면 그 구독자만 건너뜀").
무엇이 어긋나나: 전파 루프가 구독자 weak table을 순회하는데, 그 순회 도중 같은 테이블에 새 키가 추가되는 정상 경로가 있다. Lua/Luau에서 순회 중 기존 키의 값 변경은 안전하지만 새 키 추가는 미정의다. 코퍼스 어디에도 "순회 전에 스냅샷을 뜬다"는 서술이 없다.
실측(로컬 luau 0.734) — 구독자 8개짜리 weak 집합을 순회하면서 첫 콜백에서
새 구독자 1개를 등록:
run 1: obs8 fired=2 (obs8이 두 번)
run 2: obs4 fired=2
run 3: obs1 fired=2, obs2 fired=0 (obs1은 두 번, obs2는 아예 안 불림)
pairs()로 명시해도 동일했고, 실행마다 결과가 달랐다(어떤 실행은 깨끗했다).
크래시는 안 났다 — 즉 "터져서 금방 잡히는" 종류가 아니라 간헐적으로 한
Observer가 통째로 누락되는 종류다.
quad에서 실제로 어떻게 닿는가 (가상의 오용이 아니라 문서가 권장하는 조합):
slot:List(store.items, function(item, index, offset, prev, ud)
return Row { Text = store.items:Compute(function(s) return #s:Get() end) }
end)
store.items:Set(...)→ 전파 루프가 구독자 집합을 순회 시작.- 그중 하나가
:List의_listObserver→reconcile→updateFn→Row{...}생성 →Dispatch.drive→store.items:Compute(...)가 같은store.items의 구독자 집합에 새 키를 삽입. - 그 순간 순회 중인 테이블이 리해시되어 위 실측 그대로 누락/이중 발화가 난다.
반대 증거 확인: base/*.md 전체에서 스냅샷/복사/table.clone으로 순회를
방어한다는 서술을 찾지 못했다. base/dispatch-core-plan.md의 Handler 작성
체크리스트는 chains 배열과 핸들러 재진입만 다루고 State 구독자 테이블
순회는 다루지 않는다. 반대로 재진입 자체는 이미 정상 경로로 인정돼
있다 — Dispatch.process의 (A)/(B) 분기가 "h.process가 내부에서 재귀하는
것이 정상 경로"라고 전제하므로, 발화 중 새 구독이 생기는 것은 예외가 아니라
기본이다.
갈래:
- 순회 전 스냅샷(배열로 복사한 뒤 그 배열을 돈다) — 이번 파동에 새로
붙은 구독자는 다음 파동부터 참여한다고 계약을 못박는다. 비용은 파동마다
배열 하나.
canExecute게이트가 이미 있으므로 스냅샷 사이에 죽은 구독자는 그 게이트가 걸러준다. - 지연 큐 — 순회 중 등록을 큐에 모았다가 끝나고 병합. (1)보다 무겁고, "이번 파동에도 참여해야 하는가"라는 질문에 답해야 한다.
- 배열 + 인덱스 순회로 자료구조 자체를 바꾸기 — 구독자 집합을 hash가 아니라 배열로 들면 추가는 항상 끝이라 순회가 안 깨진다. 대신 제거가 O(n) 이거나 tombstone이 필요하고, weak 의미론을 배열로 유지해야 한다.
⚠️ 이건 Epoch 규칙으로 못 막는다 — 1차 패스가 확인한 dedup 규칙 1/2/3은
"같은 emit이 두 번 도착했을 때 접는" 것이라 이중 발화는 접히지만 누락은
안 접힌다. 누락된 Observer는 아무 신호도 못 받는다.
🔴 H-24 — Tween<T>:Mapped가 재귀 제네릭 타입 누수에 걸리는데 typing-limits.md가 이걸 모른다 (실측)
어디: base/tween-plan.md의 "Tween<T>:Mapped(fn) — 값만 갈아끼운 새
Tween을 반환" 절 vs base/typing-limits.md의 "1. ⭐ 재귀 제네릭이 다른
타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐" 절.
무엇이 어긋나나: 확정된 시그니처
tween:Mapped(fn: (T) -> U): Tween<U>는 typing-limits.md 1번이 "이것만"
문제라고 못박은 모양(Foo<T> 안에서 -> Foo<U>)과 글자 그대로 같다.
그런데 그 문서의 "영향 범위" 절에는 Compute/With/Apply/Effect/Observer만
있고 Tween/Mapped는 한 번도 언급되지 않는다(전수 grep 0건). tween-plan.md
쪽도 이 한계를 모른다(같은 grep 0건).
실측:
--!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건(조용히 통과). 같은 검사를 typing-limits.md 1번③
(typeof(named function) 선언)으로 바꾸면 →
TypeError: Expected this to be 'number', but got 'string'으로 정상적으로
잡힌다. 즉 기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않았다.
반대 증거 확인: typing-limits.md §6(type function을 거친 값)과 다른
문제인지 확인했다 — Mapped는 type function을 안 거치는 순수 제네릭 메소드라
무관하다. quad-types-plan.md의 AddPlugin 실측 사례와도 별개다.
갈래: 단순 정정 — typing-limits.md의 "영향 범위" 절에 Tween<T>:Mapped
행을 추가하고, tween-plan.md의 Mapped 절에 "인라인 제네릭 메소드가 아니라
typeof(named function)로 선언할 것" 각주를 단다. 새 설계 결정 불필요.
⭐ 함께 볼 것: 같은 종류의 누수가 다른 최근 확정 표면에도 있는지 한 번
훑는 게 맞다. 이번에 state:Gate(setup)/EpochMap/Effect는 확인했고
전부 이 패턴이 아니라 무관했다(그 셋은 제네릭 self를 다른 타입 인자로
반환하지 않는다).
🔴 H-25 — New(): Quad가 닫힌 타입이라, M2가 Dispatch를 붙이는 순간 확정된 quad.Dispatch 접근이 타입에러가 된다 (실측)
어디: base/quad-types-plan.md의 "Quad 타입 — 확정된 표면" 절(실제 반영
파일 quad-types/src/init.luau) vs base/module-lifecycle-plan.md의
"New()의 내부 구성 — InitXxx 팩토리 체이닝" 절 vs base/architecture.md
확정된 결정 13번.
무엇이 어긋나나: 실제 커밋된 quad-types/src/init.luau의 Quad는
Version/debug/New/RunInit/AddPlugin 5개 필드짜리 닫힌 레코드이고,
quad-base/src/init.luau의 New(): Quad가 그 별칭을 그대로 반환 타입으로
쓴다. RunInit은 (self: Quad, initFn: (Quad) -> any) -> ()로 반환값이
없어 타입을 넓히지 못한다 — AddPlugin<Self, P>만이 타입을 넓히는 경로인데
quad-types-plan.md 자신이 그건 외부 플러그인용이라고 선을 긋는다.
그런데 architecture.md 13번은 *"require(quad)가 돌려준 걸 바로
Quad.Dispatch처럼 씀"*을 표준 사용법으로 확정해뒀다.
실측 — 실제 Quad 정의와 InitDebug와 완전히 같은 모양의 InitDispatch로
재현:
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 M2 체크리스트의 "Dispatch.addHandler(handler) …
quad-roblox가 팩토리 뮤테이션 시점에 호출"(M5)도 같은 벽에 부딪힌다 —
quad-roblox는 quad-types의 좁은 Quad만 본다.
반대 증거 확인: Quad & X 형태의 타입 확장 언급은 AddPlugin 항목 하나뿐이고,
같은 문서가 *"모든 백엔드/플러그인 패키지가 이 패턴을 따를 필요는 없다"*고
범위를 좁힌다. ROADMAP.md M2 체크리스트에 "quad-types의 Quad를 갱신"
류 항목은 없다 — 아무도 이 필요성을 항목화해두지 않았다.
갈래(판단 필요):
- quad-base 내부만 넓은 로컬 교차 타입 —
New(): Quad & { Dispatch: Dispatch, ... }로 서브시스템마다 누적.module-lifecycle-plan.md의 원래 그림 그대로. 단 quad-roblox도 결국Dispatch.addHandler를 부르므로 그 경로엔 별도 노출이 필요하다. quad-types의Quad를 마일스톤마다 갱신 — M2가Dispatch필드(와 그 타입 재수출)를 추가하는 걸 체크리스트 항목으로 명시. "가벼운 타입 계약"이라는 존재 이유와 상충하는지부터 봐야 한다(타입만 재수출하면 런타임 무게는 안 는다는 게 1차 판단이지만 아직 미검증).- 내부 접근을 전부
(quad :: any).Dispatch로 캐스트 — 가장 싸지만typing-limits.md가 시종 강조하는 "명시 바인딩으로 다운스트림 안전성 확보"와 정면으로 배치되고, quad-base 자기 코드가--!strict의 이득을 잃는다.
⚠️ 이건 M2 착수 전에 정해야 한다 — 첫 서브시스템을 붙이는 방식 자체가 여기서 갈린다.
🟡 H-26 — 부분 생성 후 예외가 나면 이미 만든 Instance가 영구히 회수되지 않는다
어디: base/fallback-plan.md(Fallback/Traceback의 격리 범위)와
base/lifecycle-pattern.md의 "(0) gcconn/gchold는" 절.
두 에이전트가 서로 독립적으로 같은 결론에 도달한 항목이다.
무엇이 어긋나나: quad는 *"자기가 만든 Instance마다 생성 즉시 gcconn을
걸고 그 클로저가 inst를 캡처"*하므로 "참조를 놓는 것만으로는 회수되지 않고
반드시 Destroy로 회수된다"(같은 절). 이 모델은 **"만든 Instance는 언젠가
반드시 Destroy된다"**를 전제한다. 그런데 컴포넌트가 자기 리터럴을 만드는
도중 예외를 던지면, 그때까지 완성된 형제/자손은 트리에 붙지도, Destroy되지도
않은 채 예외에 실려 스코프를 빠져나간다. Fallback은 pcall(base, ...)
하나라 그 존재를 알 방법이 없다.
트레이스:
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이 걸린다. Broken{}이 던진다 — 바깥Frame(...)호출은 인자 테이블조차 완성 못 해 호출되지 않는다.Fallback이pcall경계에서 잡아 대체 UI를 만든다 → 겉보기엔 격리 성공.child A는 (a) 어떤 지역 변수에도 안 남고, (b)Parent가 끝내 세팅된 적 없고, (c)Fallback이 존재를 모른다. 그를 살려두는 건 자기 gcconn↔gchold 순환뿐이고 그걸 끊는 유일한 수단(Destroy)을 부를 주체가 없다 → 참조 0개인데 세션 끝까지 안 죽는다.- 실패 지점 앞에 중첩 서브트리가 있으면 그 전체가 대상이다.
⭐ 그리고 이건 다른 문서의 안전망 서술과 정면으로 부딪힌다.
base/dispatch-core-plan.md가 AttributeGroupHandler의 부분 실패를 두고
*"nameClaims/tagNameMap이 inst에 대해 weak라 그 인스턴스가 GC되면 잔여
부기도 같이 사라지는 것으로 충분히 커버됨"*이라고 적어뒀는데, 그 전제(실패한
inst가 결국 GC된다)가 gcconn 불멸성과 양립하지 않는다. 아무도 Destroy를
안 부르면 inst가 안 죽고, 따라서 잔여 부기도 안 사라진다. 즉 이 문제는
Fallback 전용이 아니라 "부분 실패 후 아무도 Destroy를 안 부르는 모든
경로"의 일반적 위험이고, Fallback/Traceback은 그 경로를 계속 살려두는
것을 존재 이유로 삼는 대표 사용처다.
반대 증거 확인: 두 에이전트가 각각 "고아/orphan/leak/반쪽/Instance.new"
계열로 전수 grep했고, 회수 경로를 언급하는 문장을 못 찾았다. 인접 사례로
materializeSlotTree의 예외 시 Blocker가 켜진 채 남는 갭이 사용자 판단으로
이미 인정돼 있지만("마운트 도중 예외는 quad가 복구를 보장하지 않는 상태"),
그건 마운트 경로에 한정된 국소적 결과(Length 영구 stale)이고, 이쪽은
Fallback이라는 사용자에게 노출된 복구 API가 "예외 후에도 앱이 계속 정상
동작한다"를 약속하는 자리라 층위가 다르다.
갈래(판단 필요):
Fallback/Traceback이 실패 시 이번 호출에서 생성된 Instance를 정리 —New/Dispatch.drive가 "이번 construction에서 만든 것" 목록을 쌓고pcall실패 시 역순Destroy. 정상 경로에도 부기 비용이 붙는다.- gcconn 자기순환 자체를 재고(한 번도 Parent 안 된 채 참조가 끊기면 GC
가능하게) —
lifecycle-pattern.md(0)의 핵심 트레이드오프를 건드리므로 재설계 규모가 크다. 두 에이전트 모두 비권장. - UB로 명문화 —
Fallback/Traceback문서에 "실패 이전에 생성된 부분 트리는 회수되지 않는다"를 캐비엇으로 못박고, 컴포넌트 저작자에게 "생성 전에 검증부터"를 권고. 비용 0이지만 장수 세션에서 실제 누수로 누적된다. 이 갈래를 고르더라도base/dispatch-core-plan.md에서 잔여 부기가 인스턴스 GC로 정리된다고 적은 문장은 같이 고쳐야 한다 — 그건 캐비엇이 아니라 틀린 안전망 주장이다.
🟡 H-27 — OnChangeHandler에 v == nil 얼리리턴이 없다
어디: base/onchange-plan.md의 "확정" 절 안 process 항목 vs
base/event-plan.md의 "이벤트도 store-bind 가능" 절.
무엇이 어긋나나: onchange-plan.md는 process를
*"inst:GetPropertyChangedSignal(name):Connect(function() v(inst[name]) end) 후
그 Connection을 :Disconnect()하는 클로저를 반환. 일반 Handlers/Event.luau와
같은 결"*로 확정했다. 그런데 event-plan.md가 확정한 그 "같은 결"의 핵심은
(k=이벤트키, v=nil)을 받으면 기존 Connection 해제만 하고 새로 Connect하지
않는다는 것인데, OnChange 쪽 의사코드엔 그 얼리리턴이 없다.
트레이스: Frame { [OnChange "Position"] = someState }, someState가
fn1을 들고 있다가 None으로 바뀌는 경우:
- StoreBind가 언랩 → index 2에서
OnChangeHandler가fn1로 Connect. 정상. someState:Set(None)→ 재-dispatch → index 2에서NoneHandler가 매치 → (B) 분기 →retractFrom이 옛 Connection을 정상 Disconnect →NoneHandler.process가Dispatch.process(inst, k, nil, 3)로 재귀.- index 3에서
OnChangeHandler가 다시 매치된다 — 매치가 키 기반이라v == nil도 잡고,NilHandler는type(k) == "number"전용이라 여기 안 걸린다. - 의사코드 그대로면
Connect(function() nil(inst.Position) end)가 실제로 실행된다. 이 시점엔 아무 일도 안 일어난다. - 나중에
inst.Position이 실제로 바뀌면(Tween이든 다른 코드든) 그 콜백이 발화 →attempt to call a nil value.
즉 "콜백을 끈다"는 동작이 실제로는 "나중에 터질 Connection을 새로 심는" 동작이 된다.
반대 증거 확인: OnChangeHandler가 언급되는 모든 파일을 확인했다
(onchange-plan.md/attribute-plan.md/lifecycle-hooks-plan.md/
dispatch-core-plan.md/architecture.md) — 별도 의사코드도 nil 가드 언급도
없다. dispatch-core-plan.md는 같은 종류의 방어를 PropertyHandler에는
명시적으로 요구하고 있어("v == nil이면 셋을 건너뛰는 방어"), OnChange만
빠진 모양이다.
갈래: 단순 정정 — process에 if v == nil then return function() end end
(또는 Connect를 건너뛰고 no-op retractor 반환)을 명시. 문서가 이미 스스로
"event-plan.md와 같은 결"이라 결론 내린 것을 의사코드에 반영만 하면 된다.
🟡 H-28 — dispose의 소유권 가드가 Slot 분기에만 있다
어디: base/slot-plan.md의 "quad가 관리 중인 값을 안전하게 지우는 유일한
경로" 절.
확정된 의사코드:
function dispose(value)
if isSlot(value) then
-- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴
...
else
nativeDispose(value) -- 아래 주입 op
end
end
무엇이 어긋나나: 같은 절이 산문으로는 *"대상이 아직 어느 트리에 의해
살아있길 요구되고 있으면 파괴를 거부하고 즉시 error"*라고 대상 구분 없이
선언하고, 그게 성립하는 근거로 *"elementOwner가 element → owner를 들고
있음"*을 든다. elementOwner는 Slot이든 plain 마운트 가능 값이든 전부를
커버한다(같은 문서의 elementOwner 정의 주석이 명시). 그런데 의사코드는
가드를 isSlot 분기 안에만 뒀고, **가장 흔한 대상인 "Slot에 마운트된
Instance"**는 else로 새어 그대로 파괴된다 — dispose가 막으려던 바로 그
UB(_elements/lengthList/sourceList/elementOwner가 어긋남)가 그대로
일어난다.
트레이스: local f = Frame{}; slot:Add(f); dispose(f) → isSlot(f)가
거짓 → nativeDispose(f) → f:Destroy(). slot._elements엔 죽은 Instance가
남고 elementOwner엔 클레임이 남는다. 이후 그 자리의 recompute/rawRemove가
죽은 대상을 만난다. 정상 경로였다면 *"아직 요구 중이므로 error"*가 나야 했다.
갈래: 단순 정정 — 가드를 분기 밖으로 끌어올린다(elementOwner 조회는
값 종류와 무관하므로 그대로 재사용 가능).
🟡 H-29 — rawMove/rawSwap/rawExtract/rawSplice/rawClear가 정의 자체가 없다
어디: base/slot-plan.md 전체. 의사코드가 존재하는 raw*는
rawUnmount/rawDetach/rawAdd/rawReplace/rawRemove 다섯뿐이다
(전수 grep). 나머지 다섯은 이름만 있다.
왜 그냥 "미작성"이 아닌가: rawMove는 reconcile이 직접 부르는 둘 중
하나다 — 같은 문서가 *"reconcile이 직접 호출하는 건 rawAdd/rawMove와,
처분 헬퍼 …"*라고 못박았고, settle의 리오더 분기가 실제로
rawMove(self, keyIndex[key], pos)를 부른다. 즉 :List의 핵심 경로에
정의 없는 함수가 있다. 그리고 그 의무는 자명하지 않다:
- 어떤 배열이 함께 치환되는가 —
bk의 배열들은 position 인덱스다.lengthList[i](그 요소의 기여도)와bk.observers[i](그 요소의LengthState를 보는 Observer)는 요소를 따라가야 한다.sourceList[i]도 마찬가지다 —materializeSlotTree가Dispatch.setOffsetSource(ownerKey, position, offsetSource)에 넘기는 값이 **그 중첩 Slot 자신의slot.Offset**이라 position이 아니라 요소에 귀속된다. 셋 다 같은 순열로 움직여야 하는데 어디에도 안 적혀 있다. bk.N은 안 변한다 — 자리 수가 그대로이므로.spliceArraysUp/Down과 다른 점인데 명시가 없다.recompute를 부르는가 — 순서가 바뀌면 offset이 전부 바뀌므로 불러야 한다.- ⭐
nativeMove(target, fromOffset, elements, toOffset)의elements를 중첩 Slot에서 어떻게 만드는가 —native*계층은 "빠지는 요소는 반드시elements배열로 넘긴다"(Roblox는 offset으로 역조회를 못 하므로)를 계약으로 못박았는데,_elements[index]가 Slot이면 평탄화된 리프 목록이 필요하다. 코퍼스에 그런 헬퍼가 없다 — 재귀 리프 walk는mountSlotTree하나뿐이고 그건 수집이 아니라 삽입이다.rawRemove/rawUnmount는 Slot 요소를destroySlotTree/unmountSlotTree로 빠져나가 이 문제를 피했지만,Move엔 그런 우회가 없다.
rawSwap도 같은 문제를 두 구간에 대해 갖는다. rawSplice는 여기에 더해
H-30의 사전 검증까지 져야 한다.
참고: 같은 문서에 *"raw* 내부 호출 규약은 공개 API와 다를 수 있음(구현
세부, M6에서 확정)"*이라는 옛 문장이 남아 있는데, 5라운드가 index 기준으로
통일하며 그 갈래는 이미 닫혔다 — 이 문장은 지금 "정의를 안 적어도 되는
이유"처럼 읽히지만 실제로는 stale이다.
갈래: 판단이라기보다 작성이다. 다만 4번(리프 평탄화)은 새 헬퍼가
필요하다는 뜻이라 그 자체가 결정이다 — collectLeaves(slot) 같은 걸 둘지,
아니면 Move/Swap이 중첩 Slot 요소에 대해선 다른 경로(unmount +
attach)를 타게 할지.
🟡 H-30 — Splice의 "반쪽 상태 방지" 약속을 지키는 코드 경로가 없다
어디: base/slot-plan.md의 "CRUD API 확정" 절, Splice의 에러 조건 항목.
확정된 문장: "newElements 각각에 Add와 동일한 검증(이미 마운트/타입
제약) 적용, 검증은 실제 mutate 전에 전부 먼저 통과해야 함(일부만 적용된 채
중간에 에러나는 반쪽 상태 방지)".
무엇이 어긋나나: "이미 마운트됨" 판정을 실제로 하는 코드는 rawAdd 안의
claimOwner 하나뿐이고, 그건 요소를 삽입하기 직전에 요소마다 돈다. 사전
일괄 검증을 하는 자리가 어디에도 없다(그리고 rawSplice 자체가 미정의 —
H-29).
트레이스: local a, b = Frame{}, Frame{}; other:Add(c) 상태에서
slot:Splice(1, 0, a, b, c):
a삽입 —claimOwner(a, slot)통과,_elements/물리 갱신.b삽입 — 통과.c—claimOwner가other의 클레임을 보고 error. 결과:a/b는 이미 들어가 있고c만 실패한 반쪽 상태. 문서가 명시적으로 방지하겠다고 한 바로 그 상태다.
갈래: 단순 정정 — Splice(와 Add/Replace/Extract의 newElement)가
rawSplice 호출 전에 elementOwner 조회 + 타입 제약을 전량 선행 검증하도록
의사코드에 명시. H-31과 같은 처방이다.
🟡 H-31 — duplicate key 가드가 이미 절반 뮤테이트한 뒤에 터진다
어디: base/slot-plan.md의 reconcile 의사코드.
무엇이 어긋나나: 중복 키 검사는 메인 루프 안에서 item마다 돈다:
for i, item in ipairs(items) do
local key = keyFn(item, i)
if seen[key] then error("Slot:List — duplicate key: " .. tostring(key)) end
...
settle(key, result, detach, pos)
N번째 item에서 중복이 발견될 때 1..N-1번째의 settle(=rawAdd/rawReplace/
rawMove/물리 조작)은 이미 커밋됐고, keyIndex = newKeyIndex 교체는 루프
뒤라 일어나지 않는다.
트레이스: data = [{id=1},{id=2},{id=2}](사용자 데이터에 흔한 중복 id):
key=1→rawAdd커밋.key=2→rawAdd커밋.key=2재등장 → error → reconcile 전체가 unwind.- 이제
_elements엔 두 요소가 들어 있는데keyIndex는 옛 사이클 것 (첫 사이클이면 빈 테이블). 다음에data가 갱신되면 reconcile이 어긋난 인덱스로 돌고,H-1과 합쳐지면nil인덱싱까지 간다.
왜 "에러=패닉이니 신경 안 씀"으로 못 넘기나: 중복 키는 사용자 데이터에서
가장 흔한 실수 중 하나라 도달 빈도가 다른 패닉 경로와 다르다. 그리고
:List는 앱 수명 내내 반복되는 경로라, 한 번 어긋난 부기가 이후 모든
사이클을 오염시킨다.
갈래: 단순 정정 — 키 수집/중복 검사를 선행 패스로 분리(H-30과
같은 처방). keyFn을 한 번 더 도는 O(n) 비용이 붙지만, :List는 이미 O(n)
단일 패스라 상수배다.
🟡 H-32 — Debounce{Trailing=false} + MaxTime에서 pending이 새어 Flush가 영구 no-op이 된다
어디: base/debounce-throttle-plan.md의 "7. 의사코드" 절.
무엇이 어긋나나: 창이 열려 있는 동안 오는 신호는 trailing과 무관하게
pending = true를 세우는데, pending을 false로 되돌리는 자리는
전부 if pending and trailing then 안에 있다(onWindowEnd, MaxTime
콜백, _flush 셋 다). 그래서 Trailing = false 구성에서 한 버스트에 신호가
둘 이상 오면 pending이 영구히 참으로 남는다. 그런데
Leading = true, Trailing = false는 문서가 *"버스트 시작에 한 번만"*이라며
직접 드는 정상 사용례이고, 5-2절의 유효성 검사는 *"둘 다 false"*만 막는다.
트레이스 — Debounce{Time=1, Leading=true, Trailing=false, MaxTime=3},
신호 t = 0, 0.3, 0.6:
| 시각 | 무슨 일 | 상태 |
|---|---|---|
| 0 | idle → leading → passThrough(), openWindow() |
pending=false, 창 마감 1.0 |
| 0.3 | 창 안 → pending = true, reset → 창 재개 |
pending=true, 마감 1.3, cap 무장(마감 3.3) |
| 0.6 | 창 안 → pending=true(변화 없음), 창 재개 |
마감 1.6 |
| 1.6 | onWindowEnd → if pending and trailing → 스킵 |
pending이 true로 남는다 |
| 3.3 | cap 콜백 → if pending and trailing → 스킵 |
타이머 하나를 통째로 낭비 |
이후 이 게이트에서는 (a) MaxTime이 매번 재무장되며 영원히 아무 효과가
없고, (b) opts.Handle로 받은 :Flush()가 if pending and trailing에
막혀 영구 no-op이다 — 사용자가 명시적으로 커밋을 요청해도 반응이 없다.
:Cancel()만이 pending = false를 무조건 하므로 유일한 탈출구다.
반대 증거 확인: MaxTime이 Trailing에 의존한다거나 그 조합을 금지한다는
서술을 base/debounce-throttle-plan.md/base/gate-plan.md/base/blocker-plan.md와
4·5라운드 문항지 전체에서 찾지 못했다.
갈래:
else-분기의pending = true를if trailing then pending = true end로 좁힌다(가장 단순 —Trailing=false면 애초에 그 부기를 안 함).MaxTime+Trailing=false조합을 5-2절 수준의 명시적 error로 막는다.MaxTime을Trailing과 독립적으로 재정의(예: 강제 재-leading) — 의미론 변경이라 범위가 크다.
🟡 H-33 — debounce 7절 의사코드가 확정된 state:Gate(setup) API로는 성립하지 않는다
어디: base/debounce-throttle-plan.md의 "7. 의사코드" 절 vs
base/gate-plan.md.
무엇이 어긋나나: 7절은 local gate = Gate(self)로 탑레벨 생성자를
부르고 반환 객체에 .onUpstreamSignal/._flush/._cancel을 사후 대입한다.
확정된 형태는 정반대다 — gate-plan.md가 state:Gate(setup)
(setup: (emit) -> (onUpstreamEmit))로 못박으면서 *"탑레벨 Gate(...)
생성자는 안 만든다"*고 명시했고, 호출자는 state:Gate(setup)가 돌려주는
State 하나만 받을 뿐 노드 객체에 접근할 수 없다. 실제로 확정된 자매
구현인 Blocker는 그 제약을 지켜서, 제어 표면(onunblock 핸들)을
정책 클로저 안에서 외부 blocker 객체에 등록하는 형태로 우회한다.
즉 7절을 그대로 옮기면 (a) Gate라는 호출 가능한 값이 없어 attempt to call a nil value, (b) 이름을 self:Gate(...)로 고쳐도 setup이 핸들러를 반환해야
하는 프로토콜과 안 맞고, (c) Handle:Set({Flush = gate._flush, ...})는 노드
참조를 못 받으므로 표현 자체가 불가능하다.
반대 증거 확인: 7절 머리말에 *"Gate 노드의 내부 훅(onUpstreamSignal,
commit)은 아직 이름도 계약도 확정되지 않은 가칭"*이라는 캐비엇이 실제로
있다 — 그래서 이걸 🔴가 아니라 🟡로 낮춘다. 다만 그 캐비엇은 "이름/계약이
가칭"이라 말할 뿐, 코드의 골격(탑레벨 생성자 + 필드 대입)이 확정 모델에서
아예 성립 불가능하다는 건 알려주지 않는다. 반대로 gate-plan.md 쪽은
gate:passThrough()/onWindowEnd를 *"이미 같은 것을 …로 부르고 있다"*며
정착된 이름처럼 인용해, 이 절이 여전히 미해결이라는 신호를 오히려 흐린다.
갈래: 단순 정정 — Blocker의 실제 패턴대로 makeGate의 __call을
self:Gate(policy)로 바꾸고, policy(emit)이 pending/window/cap을
지역으로 들고 onUpstreamEmit을 반환하며, Handle/instances 등록도
policy 본문에서 emit을 캡처한 클로저로 다시 쓴다.
우선순위 주의: Debounce/Throttle은 백로그라 급하지 않다 — 다만 H-32를
고칠 때 같은 코드를 손대므로 한 번에 처리하는 게 싸다.
🟢 H-34 — native* 조합 폴백이 Move/Swap을 만든 이유를 그대로 되돌린다
어디: base/slot-plan.md의 "물리 조작은 주입 op다" 절의 조합 폴백 항목 vs
"원시 최소화 원칙 정정" 절.
무엇이 어긋나나: 폴백은 *"nativeMove = nativeExtract + nativeInsert"*로
정의되고 *"백엔드는 이득 있는 것만 덮어쓴다"*고 적혀 있다 — 즉 안 덮어써도
맞다는 톤이다. 그런데 Move/Swap이 공개 CRUD에 추가된 근거가 정확히
*"Extract+Add는 실제 Parent 조작이 두 번(detach+reattach) 일어남 — Roblox에서
AncestryChanged 발화, 잠재적 깜빡임, 불필요한 재바인딩 비용"*을 피하려는
것이었다. Roblox 백엔드가 nativeMove/nativeSwap을 덮어쓰지 않으면
그 비용이 그대로 돌아온다 — 조합 폴백은 이 경우 "느린 정답"이 아니라
관측 가능한 동작 차이(ChildAdded 재발화, 레이아웃 리플로우)를 만든다.
정확히 짚어둘 것 — quad 자신의 배관은 안 깨진다. quad의 생명주기 관측은
Destroying 하나로 통일돼 있고 AncestryChanged를 안 쓴다(base/lifecycle-pattern.md).
영향은 사용자 코드/렌더 쪽이다. 그래서 🟢다.
갈래: 단순 보강 — "Roblox 백엔드는 nativeMove/nativeSwap을 no-op으로
덮어써야 한다(offset을 무시하는 백엔드에서 순서 이동은 물리 조작이 아니다)"를
그 절에 명시. nativeInsert를 흡수하지 않은 이유를 적어둔 것과 같은 결이다.
🟢 H-35 — ProcessedModifierHandler가 의사코드도 없고 색인 두 곳에서 빠져 있다
어디: base/modifier-plan.md의 "flatten의 정확한 형태" 절.
무엇이 어긋나나: flatten이 배열 자리를 ProcessedModifier 센티널로
소진하고 *"전담 nop Handler ProcessedModifierHandler가 정상 Dispatch.process
경로에서 캐치해 setOffsetSource(None)/setLength(0)을 등록한다"*고 한 줄로
확정했는데:
- 의사코드가 없다. 같은 문서가 *"
Pre/PostRef와 완전히 같은 방식"*이라며 지목한ProcessedPreRefHandler/ProcessedPostRefHandler는base/ref-plan.md에 실제process골격이 있다(그 골격 자체엔H-20이 지적한 시그니처 문제가 있지만, 최소한 "이렇게 짜라"가 있다). base/dispatch-core-plan.md의 Length/Offset 등록 책임 열거에 없다. 그 절은 plain 요소 / Slot / store-bind /NilHandler/ProcessedPreRefHandler/ProcessedPostRefHandler를 하나씩 짚는데ProcessedModifier만 빠져 있다(전수 grep 확인).base/architecture.md의Dispatch/파일트리에도 없다 —Modifier.luau항목은 flatten/체이닝만 적고 자기가 만드는 센티널의 핸들러를 언급하지 않는다.
Modifier가 하나라도 든 Frame{...} 호출은 전부 이 핸들러를 거치므로,
modifier-plan.md를 안 읽고 두 색인 문서만 보고 구현하는 사람은 이 핸들러의
존재 자체를 놓친다.
심각도를 🟢로 두는 이유: 이건 "그대로 구현하면 깨진다"가 아니라 스펙
공백이다 — modifier-plan.md를 정확히 읽으면 산문으로 재구성할 수 있다.
갈래: 단순 보강 — (1) modifier-plan.md에 의사코드 추가, (2)
dispatch-core-plan.md의 열거에 항목 추가, (3) architecture.md 파일트리에
소속 파일 명시.
🟢 H-36 — store-plan.md가 미해결 항목을 확정처럼 근거로 인용한다
어디: base/store-plan.md("State/Source 그래프 구독이 전부 weak-keyed
GC-native") vs base/source-state-plan.md의 "⚠️ 미해결 — 중간 State가
살아남는가(구독 엣지의 방향성)" 절.
무엇이 어긋나나: store-plan.md는 "그래프 구독이 전부 weak-keyed"라는
명제를 기정사실로 써서 다른 결론(이중 해제 걱정 불필요)을 내리는데, 그 명제
자체가 source-state-plan.md가 스스로 미확정이라고 선언한 바로 그
항목(상류 방향이 strong인지 weak인지)이다. 사용자가 지목한 해법 방향
("하류로 weak, 상류로 strong")으로 확정되면 그래프는 더 이상 "전부 weak"가
아니게 되어 인용된 전제가 깨진다. .claude/todos.md도 이 항목을 M3 착수 전
필요로 열어두고 있다.
결론 자체는 안 뒤집힌다 — 그 문단의 결론(이중 해제 불가)은 "Store가 Store를 안 담기로 확정"이라는 별개 이유만으로도 성립한다. 문제는 근거로 쓰인 명제뿐이다.
갈래: 새 결정이 아니라 기존 미해결이 닫힐 때 같이 처리할 표기 문제다 — 그 결론이 "상류 strong"이면 이 문장을 그에 맞게 정정하고, "전부 weak + 별도 앵커"면 그 앵커를 반영한다. 닫을 때 이 문장을 같이 훑을 것을 남겨둔다.
🟢 H-37 — Slot:List 의사코드에 _crudUsed 역방향 가드가 빠져 있다
어디: base/slot-plan.md의 "CRUD API 확정" 절(":List/:Single(내부적으로
:List 호출)이 설치 시 assert(not self._crudUsed, ...)를 추가로 확인") vs
같은 문서의 Slot:List 의사코드(assert(not self._listed, ...) 하나만 있음).
산문이 확정한 대칭 가드가 코드에 안 옮겨졌다. 그 가드가 막으려던 것
(Slot():Add(x); slot:List(...)가 조용히 통과해 reconcile이 x의 존재를 모른
채 도는 것)은 지금도 유효한 위험이다.
갈래: 단순 정정.
🟢 H-38 — :List reconcile의 예외 원자성 계약이 어디에도 없다
어디: base/slot-plan.md의 reconcile 의사코드.
updateFn이 사이클 중간에 던지면 앞선 settle들이 이미 물리/부기를 커밋한
채 unwind되고 keyIndex = newKeyIndex는 실행되지 않는다 — H-31(duplicate
key)과 정확히 같은 실패 모양인데, 이쪽은 원인이 사용자 updateFn이라
훨씬 흔하다. 그런데 "reconcile이 예외에 대해 원자적인가 / 부분 커밋을
허용하는가"를 명시한 문장이 코퍼스에 없다.
materializeSlotTree의 예외 갭은 이미 인정돼 있지만 그건 마운트 경로에
한정된 서술이고, :List의 steady-state reconcile은 언급하지 않는다.
갈래:
H-1/H-31해법에 흡수 —H-1을 "라이브 인덱스 맵"으로 고치면 부분 커밋이 그 자체로 일관되어 문제가 거의 사라진다.- 계약만 명문화("reconcile은 원자적이지 않다, 예외 시 부분 커밋을 예상하라").
updateFn호출을pcall로 감싸 그 키만 스킵 —:List레벨 국소 격리. 비용/필요성 모두 불확실.
3차 패스에서 확인만 하고 문제 없었던 것 (다시 트레이싱하지 말 것)
Modifier / 컴포넌트 합성 / 바인드
- 같은
Modifier객체를 두 Frame(또는:List의 여러 행)에 재사용 —flatten은 Modifier를 읽기만 하고 뮤테이션 대상은 항상 그 호출부의input배열이다. 필드 저장소가table.clone기반 immutable 체이닝이라 공유가 안전하다. - 인라인 필드가 modifier를 이기는 규칙 —
Frame { BackgroundColor3 = None, M }를 reverse 순회 +input[key] ~= nil로 손으로 돌렸고 확정 서술과 일치. - 컴포넌트 경계
props.Modifier or None관용구 — 미전달 시None→NoneHandler(재귀) →NilHandler(setLength(0)/setOffsetSource(None))로 정확히 떨어진다. - 멀티루트(Slot 반환) 컴포넌트에 modifier/
Ref를 붙이려는 시도 — 크래시가 아니라 "저작자가 안 받으면 조용히 버려짐"으로, 문서가 의도한 동작 그대로. bind-system-plan.md의 "확정된 것" — 핸들러 3종 계약/Signal 미채택/ Ref 도입 배경/quad2-try 죽은 접근 목록 전부 최신 문서와 대조해 불일치 없음.
Store / State / Source
:Compute/:With의 lazy 핸들 체인 3단(A→B→C) —previous스코핑,valueEpochMap/emitEpochMap시딩(:Syncvs:TrackFrom)까지 정상.- 같은 상류를 두 번 참조(
a:With(b, b),:Compute(fn, b, b)) —EpochMap이 테이블 identity 키라 idempotent. - 재진입 — Observer 콜백에서 상류를 다시
Set— 다이아몬드로 확장해 돌렸고, 중첩Set이 먼저 훑은 가지에 outer가 뒤늦게 도착하면 규칙 3이 중복 배달을 삼킨다. glitch/누락 없음. (단H-23은 이것과 별개다 — 거긴Set재호출이 아니라 새 구독자 등록이다.) Storeeager 생성의 순회 중 값 교체 —for k, v in sources do sources[k] = Source(v) end는 기존 키의 값만 바꾸므로 안전(H-23과 성격이 다르다).
이벤트 / 라이프사이클 / 모듈
- 이벤트 재디스패치 전 사이클(
fn1 → fn2 → None → fn3) — (A)/(B) 분기,NoneHandler/NilHandler재귀,chains인덱스 배정이 이중 Connect/유실 없이 맞물린다.EventHandler는v==nil을 명시적으로 다뤄 안전하다(빠진 건OnChange뿐 —H-27). Relate상호 강참조 순환 — 이 영역의 어떤 문서도 두Relate가 서로의 키를 상대 값으로 강하게 잡는 패턴을 새로 만들지 않는다. 실측 스파이크 (luau-test의 상호 순환 케이스)도 이미 통과.RunInit멱등성 — 실행 전에 표시하는 순서 덕에 상호 재귀 초기화에서도 무한루프가 안 난다.OnDestroyed(fn) = Effect(function() return fn end)의 zero-dep 시그니처 — 계약과 정확히 맞음(그 cleanup을 발화시키는 배선이 없는 건H-11, 파생일 뿐).- 최상위
Destroy()의 원자성 — Roblox 재귀 파괴는 Lua가 관측 가능한 지점에서 인터리브되지 않으므로 "파괴 도중 emit이 절반만 죽은 대상을 만나는" 경로가 없다.Blocker로 지연된 emit이Destroy뒤에 풀려도canExecute가 걸러낸다.
Tween / 시간 게이트
Tween<T>는 State/Epoch 그래프에 아예 안 얹힌다 — 값-레벨 raw 데이터일 뿐이고 실제 보간은 엔진TweenService가 전담한다. 매 프레임Set이 도는 코드 경로가 없어 dedup/에포크와 섞일 지점 자체가 없다.- 대상 인스턴스 사망 시 tween 정리 —
H-11의 파생이 아니다. 정리할 임의 자원이 없고(부기는Relate슬롯뿐), 실행 중이던 엔진 Tween은 엔진이 자동으로 멈춘다(이미 사용자 판정으로 확인된 사실). Throttle{Time=1}기본(Trailing=true) 워크스루 — 문서의 자체 트레이스와 재계산 결과가 일치._flush/_cancel의setTimeout/clearTimeout짝도 더블프리/댕글링 없음(H-32는Trailing=false에서만).
타입 / M1 코드 / 셋업
- 실제
pesde install재실행 — 워크스페이스 4멤버 심볼릭 링크 구조가base/project-setup-plan.md서술 그대로 재현되고, 재설치 후에도git status클린(lockfile 5개 전부 커밋돼 있음). - 문서가 서술한 symlink 문제 재현 —
luau-analyze quad-base/src/init.luau가 실제로Unknown require: unsupported path로 깨지고, 스모크도 런타임에서 같은 이유로 깨진다(문서 주장과 일치). - symlink를 실파일로 치환한 복사본으로 M1 전체 검증 —
quad-base/src/init.luau가luau-analyze0 진단, 스모크 3종(RunInit멱등성 /AddPluginmutate+identity 보존+체이닝 누적 /Relate격리) 전부 PASS. RelateImpl/Relate()실제 구현이base/relate-plan.md와 1:1 일치 — weak-key 버킷,StrongMap/WeakMaplazy 생성, 공유 메타테이블 재사용까지.Gate/Effect/EpochMap은 재귀 제네릭 한계와 무관 — 셋 다 "Foo<T>안에서-> Foo<U>" 패턴이 아니다(H-24와 대조).luau-test/STATUS.md의 재작성 대기 지침 — 2026-08-22의 마일스톤 이동 (EpochMap/GateNode/Blocker가 M2로)은 "어디서 구현하는가"만 바꿨고 "무엇을 검증하는가"는 안 바꿔서 스파이크 지침엔 영향 없음.
3차 패스 회신 방법
1·2차와 같다 — "맞다/아니다"만 주면 그대로 반영하겠다. 다만 아래는 갈래 선택이 필요하다:
H-25(Quad타입 확장 경로) — 세 갈래 중 하나. 이게 가장 급하다: M2가 첫 서브시스템(Dispatch)을 붙이는 방식 자체가 여기서 갈리고, M5의 quad-roblox 주입 경로까지 딸려 있다.H-23(구독자 집합 순회) — 세 갈래 중 하나. 실측으로 재현되는 간헐적 누락이라 M3State구현에 직접 걸린다.H-26(부분 생성 후 예외) — 세 갈래 중 하나. 어느 쪽을 고르든base/dispatch-core-plan.md에서 잔여 부기가 인스턴스 GC로 정리된다고 적은 문장은 틀린 안전망 주장이라 같이 고쳐야 한다.H-21/H-22— 각각 두세 갈래이지만 전부 좁은 선택이라 "1번으로" 정도면 충분하다.H-29— 미작성 함수를 쓰는 일이라 대부분 작업이지만, 리프 평탄화 헬퍼를 새로 둘지는 결정이다.H-32/H-33— 백로그(Debounce/Throttle)라 급하진 않다. 둘이 같은 코드를 손대므로 한 번에 처리하는 게 싸다.
나머지(H-27/H-28/H-30/H-31/H-34~H-38)는 확인만 해주면 바로 반영
가능하다. H-2의 실측 정정도 같이 확인해주면 그 항목을 고쳐 쓰겠다.
반영은 이 파일이 아니라 각 base/ 문서에 하고, 처리 결과는 1·2차와 묶어
-followup.md에 쌓는다.
4차 패스 (2026-08-24) — 축을 바꿔서 훑기, 발견 16건
이번엔 "문서를 하나씩"이 아니라 축을 바꿨다. 13차는 문서 단위로 훑었고
그 층은 상당히 걷혔다. 그래서 4차는 문서를 가로지르는 축 여섯 개로 다시
잡았다 — (G) 모든 핸들러를 한 표에 모아 서로 부딪히는지, (H) 두 대형 핸들러
문서(M9·ref-plan.md/attribute-plan.md)의 심층, (I) luau-test 스파이크를
실제로 다시 실행, (J) 프리미티브 × 프리미티브 조합, (K) reference/·
archive/·로드맵 M3question.md, (L) 코퍼스가 근거로 삼는 엔진/언어
사실 주장을 전수 검증.
이번에도 실측했고, 인터넷으로 엔진 사실을 교차검증했다. 로컬 luau
0.734 / luau-analyze에 더해 create.roblox.com 공식 문서를 대조했다.
그 결과 H-21의 전제가 공식 문서로 확인됐고(Roblox Instance는 없는
멤버 인덱싱 시 에러), 반대로 PreRef의 존재 근거가 엔진 설정에 조건부라는
게 드러났다(H-42).
⭐ 가장 무거운 발견(H-39)은 세 에이전트가 서로 다른 축에서 독립적으로
같은 결론에 도달했다 — 핸들러 레지스트리 전수(G), ref-plan.md 심층(H),
조합 매트릭스(J). 문서를 하나씩 읽는 방식으로는 구조적으로 안 보이는
종류였다: 등록 의무는 dispatch-core-plan.md에만 있고 각 핸들러 문서는
자기 로직만 서술하므로, 교차 대조 없이는 빠진 걸 알 수 없다.
여기서도 base/는 한 줄도 안 고쳤다.
| 번호 | 심각도 | 한 줄 | 주 대상 |
|---|---|---|---|
H-39 |
🔴 | 배열 자리를 차지하는 말단 핸들러 4종이 setLength/setOffsetSource를 아예 등록 안 함 — 첫 마운트에 recompute가 명시적 error로 죽음 |
base/tag-plan.md, base/attribute-plan.md, base/ref-plan.md, base/source-state-plan.md |
H-40 |
🔴 | 공개 Slot:Add 의사코드가 CRUD 절이 확정한 가드를 하나도 안 함(H-37은 이 문제의 반쪽이었다) |
base/slot-plan.md |
H-41 |
🔴 | groupClaimKeys(같은 그룹 객체 이중 배치 방지) 확정이 실제 의사코드에 배선 안 됨 |
base/attribute-plan.md |
H-42 |
🟡 | PreRef의 존재 근거 자체가 Workspace.SignalBehavior에 조건부인데 코퍼스가 이 설정을 모름 |
base/ref-plan.md |
H-43 |
🟡 | dispose가 Slot도 Instance도 아닌 값을 그대로 백엔드로 흘림(H-28의 짝) |
base/slot-plan.md |
H-44 |
🟡 | ROADMAP.md M6의 Slot 백엔드 줄이 native* 확정 이전 모델 + 존재하지 않는 엔진 API |
ROADMAP.md |
H-45 |
🟡 | H-18 파생 — 단건 AttributeKey ↔ 그룹 사이의 이름 이전도 emit 순서 의존 |
base/attribute-plan.md |
H-46 |
🟢 | top-level Slot.luau가 소스 트리에 없는데 slot-plan.md는 그 파일명을 박아둠 |
base/architecture.md |
H-47 |
🟢 | 소스 트리가 이미 커밋된 quad-base/src/Debug/를 빠뜨림 |
base/architecture.md |
H-48 |
🟢 | Quad.debug 스코프가 문서상 "미정"인데 M1 코드가 이미 인스턴스별로 정함 |
base/module-lifecycle-plan.md |
H-49 |
🟢 | todos.md가 Gate의 이미 닫힌 항목(재진입)을 남은 것처럼 나열 + 남은 하나를 오분류 |
.claude/todos.md |
H-50 |
🟢 | ROADMAP.md M6이 Effect의 _detached 정리를 사실처럼 서술(H-11 파생) |
ROADMAP.md |
H-51 |
🟢 | todos.md가 마일스톤 재편을 아직 열린 것처럼 적은 문장이 2026-08-22 재편으로 이미 닫힘 |
.claude/todos.md |
H-52 |
🟢 | TagHandler/AttributeGroupHandler의 isHandlable에 type(k)=="number" 가드가 없음(RefLeafHandler는 같은 버그로 이미 한 번 고쳐졌다) |
base/tag-plan.md, base/attribute-plan.md |
H-53 |
🟢 | Ref:Set()이 .Value를 언제 쓰는지 의사코드가 없음 |
base/ref-plan.md |
H-54 |
🟢 | 스파이크 12의 자기 주석이 실제 결과와 어긋남(총론은 유효) |
.claude/luau-test/ |
🔴 H-39 — 배열 자리를 차지하는 말단 핸들러 4종이 setLength/setOffsetSource를 아예 등록하지 않는다
어디: base/dispatch-core-plan.md의 "Length/Offset — 여러 Slot이 형제로
섞일 때 순서 보장" 절이 세운 계약 vs base/tag-plan.md의 TagHandler.process /
base/attribute-plan.md의 AttributeGroupHandler.process /
base/ref-plan.md의 RefLeafHandler.process /
base/source-state-plan.md의 ObserverEffectLeafHandler.process.
확신도: 확실. 세 에이전트가 서로 다른 축에서 독립 발견했고, 메인 세션이 전수 grep으로 재확인했다.
계약 쪽 원문:
- "둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB"
- "호출 책임은 … 그 위치의 체인을 실제로 끝내는 말단 Handler"
- "아래 '짝을 맞춰
0' 규칙은 값이 정말 없는 자리(Ref/nil)에만 해당한다 … 대상은 일반Ref뿐 아니라 그 배열 위치의 값 자체가None인 모든 경우" — 즉 이 문서가 일반Ref를 등록 대상으로 명시적으로 지목한다. recompute의사코드:if offset == nil then error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") end(2026-08-20C-6으로 "관대한 skip"에서 error로 승격된 자리다).
검증(메인 세션 재확인): setLength|setOffsetSource 전수 grep —
tag-plan.md: 0건attribute-plan.md: 0건source-state-plan.md: 1건(무관한 산문,ObserverEffectLeafHandler와 별개)ref-plan.md:ProcessedPreRefHandler/ProcessedPostRefHandler두 블록에만 있음 —RefLeafHandler.process본문엔 없음
대조군은 전부 정확히 등록한다: NilHandler, ProcessedPreRefHandler,
ProcessedPostRefHandler, materializeSlotTree.
무엇이 어긋나나: bk.N은 setLength 안에서 bk.N = math.max(bk.N or 0, i)로
다른 위치가 등록될 때 커진다. 그래서 "등록을 건너뛴 위치"가 bk.N 범위
안에 끼면 recompute가 그 자리에서 sourceList[i] == nil을 만난다.
트레이스 — Frame { Tag("card"), TextLabel { Text = "Hi" } }
(Tag를 자식보다 앞에 두는, 아주 흔한 배치):
| 스텝 | 무슨 일 | 부기 상태 |
|---|---|---|
| 1 | Dispatch.drive가 배치 Blocker를 켜고 배열 파트 순회 |
— |
| 2 | i=1: TagFallbackHandler 매치 → addTag(inst, {"card"})만 하고 리턴 |
sourceList[1]/lengthList[1] 둘 다 nil, bk.N도 아직 없음 |
| 3 | i=2: plain 요소 경로가 setOffsetSource(inst,2,None) → setLength(inst,2,1) |
bk.N = math.max(nil or 0, 2) = 2 |
| 4 | 배치 종료 → blocker:OffWithoutEmit() → recompute(inst, bk) |
— |
| 5 | for i = 1, 2의 i=1: bk.sourceList[1]이 nil |
error("Dispatch.recompute: sourceList[1]가 nil …") |
같은 트레이스가 Frame { Ref(myRef), Frame{} }, Frame { someObserver, Frame{} },
Frame { Attribute(store), Slot() }에서 그대로 재현된다. 해당 항목이 배열
맨 끝이면 bk.N이 거기까지 안 커져서 안 터진다 — 즉 저작 순서에 따라
"가끔 되고 가끔 터지는" 형태로 드러난다.
반대 증거 확인: (1) Dispatch.process/Dispatch.drive 의사코드에
기본값 채우기가 있는지 직접 읽음 — 없다. chains(handler/retractor 부기)와
lengthList/sourceList/bk는 완전히 별개 자료구조다. (2) "이
핸들러들은 Length/Offset 대상이 아니다"라는 면제 규정을 코퍼스 전체에서
grep — 없다. (3) 오히려 dispatch-core-plan.md에서 말단 핸들러의 inst 부작용을 정리한
표가 NilHandler 행엔 *"없음(setLength/setOffsetSource 부기만)"*이라
적어두고 바로 아래 RefLeafHandler 행엔 Ref:Set 하나만 적어놨다 — 그
표 자체가 이미 비대칭을 드러내고 있었다. (4) H-3/H-4(캐시 무효화·
스펙 부재)와 다른 문제다 — 그쪽은 등록은 하되 캐시가 어긋나는 것이고,
이쪽은 값 자체가 없다. (5) H-35(ProcessedModifierHandler 의사코드
부재)와도 다르다 — 그쪽은 핸들러가 아예 안 적힌 것이고, 이쪽은 적혀
있는데 등록을 안 한다.
갈래: 대부분은 단순 정정이지만 한 자리는 판단이 필요하다.
- 네 핸들러의
process본문에setOffsetSource(inst,k,None)+setLength(inst,k,0)을 추가(순서는 계약대로 offsetSource 먼저).Ref/Observer/Effect는 이걸로 끝이고, 문서가 이미 "일반Ref도 대상"이라 못박아뒀으므로 새 결정이 아니다. AttributeGroupHandler만 층위 질문이 남는다 — 그룹 핸들러는 자기 체인에선 말단이지만 실제로는 다른 키로 위임하는 성격이라, "이 배열 위치가 몇 개를 기여하는지 답하는 게 왜 Attribute 핸들러의 책임인가"가 깔끔하지 않다. (a) 그냥 다른 핸들러와 똑같이process맨 앞에서 등록, (b) Tag/Attribute를 "Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하고recompute가 건너뛰게 — (b)는bk.N의미를 바꾸므로 파급이 크다. (a)를 권한다.
🔴 H-40 — 공개 Slot:Add 의사코드가 CRUD 절이 확정한 가드를 하나도 하지 않는다
어디: base/slot-plan.md의 "CRUD API 확정" 절(계약)과 같은 문서의
Slot:Add 의사코드(구현).
의사코드 전문(이게 전부다):
function Slot:Add(element, index)
return rawAdd(self, wrapElement(element), index)
end
같은 문서가 확정한 것 — 네 가지가 전부 빠졌다:
self._listed가드 — "공개 CRUD 중 실제로 mutate하는 것은 …self._listed(:List가 설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은raw*에".self._crudUsed = true— "모든 mutate CRUD(Slot(initial)이 호출하는:Add포함)가self._crudUsed = true를 세팅". 이게 없으면Slot():Add(x); slot:List(...)같은 코드가 조용히 통과한다(그 가드가 막으려던 바로 그 gap).- 요소 타입 검증 — "
element가nil/None이거나 핸들러 계층 값 (Ref/PreRef/PostRef/Observer/Effect/Modifier)이면 에러". - index 범위 검증 — "
index가 범위 밖(1..현재 개수+1)이면 에러 — clamp 안 함".
검증: slot-plan.md 전체에서 error( 호출을 전수 확인 — 소유권
(claimOwner/releaseOwner), duplicate key, KeyGone 관련뿐이고
요소 타입/index 범위 검증 error는 0건이다.
⭐ H-37은 이 문제의 반쪽이었다. 3차 패스가 "Slot:List 의사코드에
_crudUsed 가드가 빠졌다"고 보고했는데, 이제 보면 그 대칭 가드의 양쪽이
둘 다 어느 의사코드에도 없다 — :List 쪽은 assert(not self._crudUsed)가
없고, Add 쪽은 _crudUsed = true 세팅 자체가 없다. 즉 그 가드는 완전히
배선되지 않았다.
트레이스 (a) — 타입 검증 부재로 조용히 통과하는 값:
local t = Tween { Value = someFrame }
slot:Add(t)
wrapElement(t)—isState(t)가 거짓(Tween은TweenBrand) →t그대로.rawAdd(self, t, nil)—claimOwner(t, self)는 타입을 안 본다 →table.insert(self._elements, t).isSlot(t)도 거짓 → plain 분기 →nativeInsert(..., { t })+setLength(self, index, 1). 물리적으로 아무것도 안 붙었는데Length만 유령 +1이 되고, 나중 파괴 경로에서nativeDispose(t)(=t:Destroy())가Destroy메소드 없는 테이블에서 죽는다.
Tween은 애초에 금지 목록에도 없다 — "요소 타입 제약"의 블랙리스트는 Ref/PreRef/PostRef/Observer/Effect/Modifier뿐이다. 블랙리스트 방식이라 목록에 없는 값 타입은 전부 샌다.
트레이스 (b) — 상호 배타 가드가 안 걸림:
Slot():Add(frameA) 후 slot:List(data, fn) → Slot:List의
assert(not self._listed)는 통과(아직 :List 없음), _crudUsed assert는
애초에 코드에 없음 → 설치 성공 → reconcile이 frameA의 존재를 모른 채
mounted/keyIndex가 빈 상태로 시작 → Length 이중 계산·인덱스 꼬임.
문서가 *"이 상태로는 … gap이 있었음"*이라며 막았다고 선언한 바로 그 경로다.
갈래: 단순 정정 — 확정 산문이 이미 정확한 알고리즘을 서술해뒀으므로
의사코드에 옮기기만 하면 된다. 하나만 판단이 필요하다: Tween(그리고
앞으로 생길 다른 값 타입)을 블랙리스트에 추가할지, 아니면 화이트리스트로
뒤집을지. base는 T가 뭔지 모른다는 게 이 문서의 일관된 입장이라
(Splice의 T | {T} 기각 논거) 화이트리스트는 어려워 보이는데, 그러면
블랙리스트는 영원히 새는 채로 남는다.
🔴 H-41 — groupClaimKeys 확정이 AttributeGroupHandler.process 의사코드에 배선되지 않았다
어디: base/attribute-plan.md의 "이름 소유권 — 그룹 전용 키 + 이름 claim"
절(확정 서술)과 "메커니즘 — 그룹 전용 키로 단일 키 경로에 위임" 절(실제 코드).
확정 서술: "✅ [해소, 2026-08-21 구현 전 QA 5라운드 AT-1] 키는
(inst, groupValue) → k로 확정 … 판정: process 시점에
groupClaimKeys[inst][groupValue]를 보고, 비어 있으면 k를 기록하고 통과,
이미 다른 k가 있으면 error. retract에서 자기 k일 때만 지운다 …
nameClaims와의 순서: 위치 claim이 먼저다".
실제 의사코드:
function AttributeGroupHandler.process(inst, k, v, index)
local keys = {}
for name, source in pairs(v:NameMap()) do
local key = groupKey(v, name)
Dispatch.process(inst, key, source, 1)
keys[key] = true
end
return function()
for key in pairs(keys) do Dispatch.retractFrom(inst, key, 1) end
end
end
groupClaimKeys 읽기도 쓰기도 없다(전수 grep — 그 이름은 "이름 소유권"
절의 서술에만 3회 등장한다). 이 코드 블록은 2026-08-13 확정본이고
AT-1 결정보다 시기적으로 앞선다 — 결정이 나면서 이 블록을 안 고친 것.
트레이스 — 문서 자신이 예로 드는 Frame { a, a }(같은 그룹 값 객체):
k=1: 체크가 없으니 곧장groupKey(a, name)으로 위임.k=2: 역시 체크가 없으니 아무 저지 없이 같은groupKey(a, name)으로 또 위임 →nameClaims는cur == k라 통과(문서가 "이름 소유권" 절에서 이미 지적한 우회).- 두 위치가
(inst, 같은 groupKey)체인 하나를 공유하게 된다. k=1이 retract되면 그 클로저가retractFrom(inst, key, 1)을 부르는데 그건k=2가 아직 쓰고 있는 체인이라k=2의 바인딩까지 통째로 철거된다 —AT-11이 원래 지적했던 크래시가 그대로 재현.
반대 증거 확인: groupKey(v, name) 자체가 막아주지 않는지 확인했는데,
그건 "그룹 값 객체별·이름별 메모이즈"라 같은 객체면 의도적으로 같은 키를
반환한다(1회성 재프로세스 dedup에 필요한 설계). 즉 이 함수가 이중 배치
방지를 겸할 수 없고, 그래서 별도로 groupClaimKeys를 두기로 한 것이다.
갈래: 단순 정정 — "판정" 문단이 이미 정확한 알고리즘을 서술해뒀으므로
process 맨 앞과 retractor에 그대로 옮기면 된다.
🟡 H-42 — PreRef의 존재 근거가 Workspace.SignalBehavior에 조건부인데, 코퍼스가 이 설정을 모른다
어디: base/ref-plan.md의 PreRef 신설 근거 문단.
근거 원문: "Roblox 이벤트 중 일부(ChildAdded/DescendantAdded/Changed류)는
유저 인터랙션을 기다리지 않고 setup 도중 프로퍼티 대입/Parent 세팅 자체의
부작용으로 동기적으로 발화할 수 있음 — 이때 이벤트 핸들러가 아직 안 채워진
self-ref를 읽으면 터짐." PreRef가 pre-pass로 호이스팅되는 이유 전체가 이
동기성 전제 위에 서 있다.
검증(공식 문서): Workspace.SignalBehavior(Enum.SignalBehavior:
Default/Immediate/Deferred)가 이 동기성을 결정한다.
- Immediate: 이벤트가 다른 이벤트를 트리거하면 두 번째 핸들러가 즉시 발화(중첩 실행).
- Deferred: 큐 뒤에 붙어 다음 resumption point(입력 처리,
RunService콜백,task계열)에서 실행 — 스크립트의 동기 실행 흐름을 끊지 않는다. - "template places are directly set to Deferred by default" — 오늘 Studio에서 새로 만드는 템플릿 place는 이미 Deferred다.
Default(레거시 place)는 현재 Immediate와 같지만 앞으로 Deferred로 바뀔 예정이라고 공식 문서가 명시한다.- 출처: https://create.roblox.com/docs/scripting/events/deferred , https://create.roblox.com/docs/reference/engine/classes/Workspace#SignalBehavior
무엇이 어긋나나: Deferred에서는 PreRef가 막으려는 레이스가 발생할 수
없다 — Parent 대입이 ChildAdded를 발화 "대상"으로 만들어도 그 콜백은
quad의 동기 마운트가 전부 끝난 뒤에 실행되므로, 그 시점엔 일반 Ref도
이미 채워져 있다. 즉 코퍼스 90개+ 문서 어디에도 SignalBehavior/Deferred가
단 한 번도 등장하지 않는데(전수 grep 0건), 조건부 사실이 무조건 사실처럼
서술돼 있다.
왜 그냥 넘기면 안 되나: 크래시를 만들진 않는다 — Deferred에서 PreRef는
무해하게 불필요해질 뿐이다. 문제는 나중에 실측할 때다. 언젠가 Studio에서
"이 레이스가 재현이 안 된다"는 결과가 나오면, 그게 quad 설계가 틀린 건지
place의 SignalBehavior 때문인지 아무도 구분할 수 없다 — 근거 문장이
전제를 안 적어놨기 때문. 그리고 quad는 PreRef/PostRef라는 두 프리미티브와
pre-pass 전체를 이 전제 위에 세웠다.
반대 증거 확인: dispatch-core-plan.md의 *"프레임 경계는 여전히 안
낀다(yield 금지)"*는 quad 자신의 코드가 yield 안 한다는 얘기이고,
Roblox가 그 이벤트 콜백을 큐잉하는지와는 다른 축이다 — 반박이 아니다.
갈래:
- 문서만 정정(권장) — "동기 발화"를 "
SignalBehavior.Immediate에서 동기 발화(Deferred에선 이 레이스가 없고PreRef는 무해하게 불필요)"로 조건을 명시. 코드 변경 없음. 실측 계획을 세울 때 place 설정을 같이 적게 만드는 효과가 진짜 이득이다. - 런타임 감지·경고 —
Workspace.SignalBehavior를 읽어 진단 도구에서 안내(읽기 가능 여부 미확인). 오버엔지니어링 소지. - 아무것도 안 함 — 실질 피해가 없다고 보고 방치.
⚠️ 판단이 필요한 진짜 질문은 그 다음이다 — Deferred가 표준이 되어가는
방향이라면, PreRef/PostRef/pre-pass라는 구조 자체를 계속 유지할
가치가 있는가? 이건 문구 정정과 다른 층위의 결정이라 여기서 답하지 않는다.
🟡 H-43 — dispose가 Slot도 Instance도 아닌 값을 그대로 백엔드로 흘린다
어디: base/slot-plan.md의 "quad가 관리 중인 값을 안전하게 지우는 유일한
경로" 절. H-28(가드가 Slot 분기에만 있음)의 짝이다 — 그쪽은 "Instance인데
아직 필요한데도 파괴된다"이고, 이쪽은 "Instance가 아닌 것도 그대로 파괴로
간다"이다.
무엇이 어긋나나: 시그니처는 dispose(value: Slot | Instance): ()이고
산문은 *"범위 — Slot + 엔진 객체(Instance)만, Observer/Effect는 명시적으로
제외"*라고 하는데, 의사코드는 if isSlot(value) then ... else nativeDispose(value) end
하나뿐이라 isSlot이 거짓이면 뭐든 else로 샌다. "제외"가 타입 체크가
아니라 산문상 규약일 뿐이다.
트레이스: local r = Ref(); dispose(r) → isSlot(r) 거짓 →
nativeDispose(r) = r:Destroy(). Ref는 .Value/:Set/:Callback/:Wait만
갖는 값 박스라 Destroy가 없다 → attempt to call a nil value (method 'Destroy'). quad 레벨의 명확한 에러("dispose는 Slot|Instance만 받습니다")
대신 백엔드 깊은 곳에서 알 수 없는 실패가 난다 — 이 유틸의 존재 이유가
정확히 "명확한 에러로 잡아주는 것"인데 그 반대가 된다.
갈래: 단순 정정 — else 진입 전에 백엔드가 제공하는 판별(isInstance 류)로
확인하고 아니면 error("dispose는 Slot | Instance만 받습니다, got " .. typeof(value)).
H-28(소유권 가드를 분기 밖으로)과 한 번에 같이 고치는 게 맞다.
🟡 H-44 — ROADMAP.md M6의 Slot 백엔드 줄이 native* 확정 이전 모델이다
원문: "base Dispatch/Slot.luau(추상 재조정, mount/unmount/reposition
3훅) + quad-roblox Handlers/Slot.luau(실제 Parent 조작 + reposition —
SetSiblingIndex 또는 LayoutOrder 기반이면 no-op, 구현 선택)"
무엇이 어긋나나: 2026-08-21에 확정된 건 3훅이 아니라 native* 6개
(nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap/
nativeDispose)이고, 인자는 0-based 절대 offset + 대상 요소 배열이며,
미주입 시 조합 폴백이다. base/architecture.md의 EngineOps.luau 줄이
**"이 줄이 주입 op 전체 목록의 단일 소스다"**라고 스스로 선언해뒀다.
M6 체크박스를 그대로 따르면 이미 폐기된 인터페이스를 구현하게 된다.
부수 — SetSiblingIndex는 Roblox에 없는 API로 보인다:
create.roblox.com/devforum 검색에서 찾지 못했고, Roblox가 문서화한 자식
순서 수단은 LayoutOrder(+table.sort)와 "Parent가 설정된 순서"뿐이다
(https://create.roblox.com/docs/reference/engine/classes/GuiObject ,
https://create.roblox.com/docs/reference/engine/classes/Instance#GetChildren —
후자는 "The returned array is not sorted in any particular order").
부재 증명은 아니지만, 그 줄이 native* 확정 이전에 엔진 API를 추정으로
적어둔 자리라는 정황이 강해진다.
갈래: 단순 정정 — M6 줄을 native* 계약으로 다시 쓰고, 백엔드 쪽 서술은
architecture.md의 EngineOps.luau 줄을 가리키게 한다(개수를 다시 세지 말 것).
H-34(조합 폴백이 Move/Swap 이유를 되돌림)와 같이 처리하는 게 싸다.
🟡 H-45 — H-18 파생: 단건 AttributeKey ↔ 그룹 사이의 이름 이전도 emit 순서에 의존한다
어디: base/attribute-plan.md의 "이름 소유권 — 그룹 전용 키 + 이름 claim" 절.
무엇이 어긋나나: H-18은 "그룹 A가 이름을 놓고 그룹 B가 가져가는" 조합만
봤다. 같은 메커니즘이 직접 쓰기 ↔ 그룹 조합에도 그대로 적용된다.
그 문서는 *"직접 리터럴 쓰기는 … 한 Modifier 안에 같은 해시 키가 중복될 수
없어 이 경로 자체의 소유자는 항상 유일하고, 그룹이 이미 그 이름을 잡고 있으면
claim이 즉시 error"*라며 소유자 유일성만 논증하는데, 유일성은 소유권
이전 시의 순서 문제를 없애주지 않는다.
트레이스:
Frame {
[AttributeKey<<number>> "Hp"] = someStore:Compute(fn), -- 지금 "Hp" claim 보유
groupB, -- State<Attribute>, {} → {"Hp"}
}
someStore가 None으로 물러나는 emit과 groupB가 "Hp"를 흡수하는 emit이
서로 다른 두 (inst,k) 체인이라 도착 순서가 미정이다. groupB 쪽이
먼저 처리되면 nameClaims:GetStrong(inst,"Hp")가 아직 직접 쓰기 키를 가리켜
cur ~= nil and cur ~= k → error. 반대 순서면 정상.
갈래: H-18과 같은 결정으로 닫힌다 — 새 선택지가 필요한 게 아니라,
그 결정의 적용 범위를 "그룹↔그룹"에서 **"이름을 가진 임의의 두 소유자 조합"**으로
넓히기만 하면 된다.
🟢 H-46 ~ H-49 — 소스 트리·문서 층의 어긋남 (메인 세션 발견)
H-46 top-level Slot.luau가 소스 트리에 없다
base/architecture.md의 소스 트리에서 quad-base/src의 값 타입은 전부 top-level
파일을 갖는다(Modifier/Tag/Attribute/AttributeKey/Tween/Ref/Effect).
Slot만 없다 — 트리엔 Dispatch/Slot.luau와 quad-roblox Handlers/Slot.luau뿐이다.
그런데 slot-plan.md(3106줄)가 정의하는 것(생성자, 공개 CRUD 11종,
:List/:Single, raw* 세트, wrapElement/unwrapElement, attachSlot
3형제, elementOwner/claimOwner/releaseOwner, dispose, Detach/KeyGone)의
집이 어디인지 정해져 있지 않다. ⭐ 그냥 누락이 아니라 문서 간 불일치다 —
slot-plan.md의 attachSlot 의사코드 블록이 머리에 **-- quad-base, Slot.luau**라고
파일명을 직접 적어놨다. 대조: Brand.luau/탑레벨 None.luau가 트리에 없는 건
ROADMAP.md가 이미 알고 항목화해뒀는데(정상), Slot은 아무도 모른다.
갈래: (a) top-level Slot.luau 신설(다른 값 타입과 대칭), (b) Dispatch/Slot.luau
하나에 값 타입+핸들러를 같이 둔다고 명시(배치 규칙이 Slot만 갈림).
H-47 소스 트리가 이미 커밋된 Debug/를 빠뜨렸다
quad-base/src/Debug/init.luau는 M1에서 이미 커밋됐고(InitDebug(module),
module.debug = false) base/project-setup-plan.md가 그 경로를 여러 번 인용한다.
그런데 트리엔 없다. 즉 그 트리는 세 방향으로 어긋나 있다 — 실재하는데 없는 것
(Debug/), 확정됐는데 없지만 로드맵이 아는 것(Brand.luau/탑레벨 None.luau),
확정됐는데 없고 아무도 모르는 것(Slot.luau, H-46).
H-48 Quad.debug 스코프가 문서상 "미정"인데 M1이 이미 정했다
base/module-lifecycle-plan.md: "다중 인스턴스화 시 인스턴스별인지 전역인지는
미정." 그런데 커밋된 Debug/init.luau가 module.debug = false로 인스턴스
필드를 심고, quad-types/src/init.luau의 Quad 타입도 debug: boolean을
필드로 갖는다 → 사실상 인스턴스별로 확정돼 있다. 결함은 아니지만, 다음
세션이 "아직 정할 게 남았다"고 읽고 다시 논의하거나 전역으로 바꾸려 들 수 있다.
H-49 todos.md가 Gate의 잔여 항목을 잘못 나열·분류한다
base/gate-plan.md의 "아직 안 정한 것 (사용자 판단 필요)" 절에서 [해소]/
[확정] 태그가 안 붙은 항목은 5번(생명주기) 하나뿐이고, 6번(재진입)은
**[2026-08-21 정리 — 열린 항목 아님]**으로 명시적으로 닫혔다. 그런데
todos.md는 *"판단이 아니라 구현 시 정할 것들 — Gate의 생명주기·재진입
계약"*이라 적는다. (a) 재진입은 이미 닫혔는데 남은 것처럼 나열하고,
(b) 생명주기를 "판단이 아니"라고 분류하는데 그 항목이 사는 절 제목이 정확히
**"사용자 판단 필요"**다. 그리고 5번은 실제로 판단이 필요해 보인다 — 그 안의
*"Gate 자체에 Flush/Cancel 같은 표면을 둘지"*가 H-33이 걸린 바로
그 자리다(debounce 7절 의사코드가 gate._flush/gate._cancel을 전제).
🟢 H-50 ~ H-54 — 나머지
H-50 ROADMAP.md M6이 Effect의 _detached 정리를 사실처럼 서술한다 (H-11 파생)
"owner가 죽으면 activateList가 건 Effect가 _detached를 전부 정리한다" —
H-11이 확인한 대로 그 cleanup을 발화시키는 배선이 코퍼스 어디에도 없다.
ROADMAP은 구현 순서를 신뢰하고 읽는 문서라 이 문장은 안 되는 일을 "된다"로
오도한다. H-11 처리 시 같이 정정할 것.
H-51 todos.md가 마일스톤 재편을 아직 열린 것처럼 적은 문장이 이미 닫혔다
그 문장이 가리키는 구체적 문제("M2가 M3 소속 Blocker.luau에 의존")는
2026-08-22 재편으로 Blocker.luau/EpochMap.luau/GateNode가 M2로
옮겨지며 사라졌다. 지금 실제로 열려 있는 더 큰 문제(M2↔M3 양방향 의존,
question.md 2번)는 바로 위 문단이 이미 짚고 있어 실질 혼동 위험은 낮다.
H-52 TagHandler/AttributeGroupHandler에 type(k)=="number" 가드가 없다
TagHandler.isHandlable(inst,k,v) = isTag(v)(주석엔 "array-part 전용"),
AttributeGroupHandler도 k가 array-part라고 산문으로만 서술한다.
이 코퍼스는 같은 버그를 이미 한 번 고친 전례가 있다 — RefLeafHandler의
주석: "[2026-08-18 구현 전 QA] type(k) == "number" 체크가 빠져 있었음 —
leaf 바인딩은 배열 전용이고 … 빠지면 named 자리로 흘러온 Ref를 잡으려는
HANDLER_PRIORITY_FALLBACK 가드가 죽은 코드가 된다." Tag/AttributeGroup은
그 수정을 못 받았고, 게다가 Ref/PreRef/PostRef/Observer/Effect가 가진
"동적 경로 가드"(k 무관 매치 + 명시적 error)도 없어 안전망이 이중으로 없다.
다만 지금 타입 시스템에서 Tag/Attribute 값이 실제로 해시 자리로 흘러들
경로를 확정하지 못해 🟢로 둔다. 갈래: (a) 대칭적으로 가드+동적 경로 가드
추가, (b) 그 경로가 없으니 불필요하다고 판단하고 그대로 둠.
H-53 Ref:Set()이 .Value를 언제 쓰는지 의사코드가 없다
.Value를 읽는 관용구는 여러 번 나오지만 :Set(value)가 self.Value = value를
콜백 순회 전/후 어디에 두는지 보여주는 코드가 없다(\.Value = grep 0건).
콜백은 값을 인자로 직접 받으므로 실무 영향은 낮지만, 콜백 A가 .Value를
읽는데 콜백 B가 아직 안 돈 시점에 무엇이 보이는지가 사양에 없다.
갈래: 단순 정정(콜백 순회 전에 쓰는 것으로 자연스럽게 채우면 됨).
H-54 스파이크 12의 자기 주석이 실제 결과와 어긋난다
12-type-attribute-generic-key-narrowing.luau가 *"이건 당연히 통과해야 함"*이라
적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(AttributeKey<T>(name)이
문맥에서 T를 못 추론해 unknown으로 남음). 이건 오히려 "왜 진짜 테스트
대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적
결과라 STATUS.md의 총론(❌지만 설계 영향 없음)은 그대로 유효하다 —
근거 라인만 다르다. 재작성 시 참고.
4차 패스에서 확인만 하고 문제 없었던 것 (다시 하지 말 것)
⭐ luau-test 스파이크 전수 재실행 — 전원 일치, 설계 드리프트 없음
done/의 16개를 실제로 다시 돌렸다(luau/luau-analyze).
- 16개 전원이
STATUS.md/README.md의 주장과 실행 결과가 일치했다. - GC 스파이크(
07연쇄 GC,18두-Relate상호 순환)는 각 3회 반복 실행에서 완전히 동일한 수치가 나왔다(07: 5/5→0/0, 18: true/true→false/false) —lifecycle-pattern.md의 GC-native 전제를 흔드는 신호 없음. - 설계 드리프트 0건 —
done/16개를 최근 확정분 키워드(Epoch/EpochMap/Brand재작성,Effect(fn,...deps),state:Gate,native*,Detach/_detached/KeyGone/Owned,attachSlot분해)로 전수 grep → 매칭 0건. 전부 그 재설계 이전부터 있던 기반 메커니즘만 검증하고 있어 오염이 없다.Brand를 직접 구현해 쓰던22는 이미rewrite-required/에 있다. 23이 원위치에서Unknown require: unsupported path로 깨지는 건 3차 패스가 이미 기록한 pesde symlink 문제이고, symlink 해제 복사본에서는 정상 통과한다.
⭐ 엔진/언어 사실 주장 검증 표 (코퍼스가 근거로 삼는 것들)
| 주장 | 결과 | 근거 |
|---|---|---|
Roblox Instance는 없는 멤버 인덱싱 시 에러(nil 아님) |
참 | 공식 문서 + devforum 다수 — H-21의 전제가 확인됨 |
GetChildren() 순서 미보장, 자식은 순서 없는 집합 |
참 | "not sorted in any particular order" — native* 전체의 전제 |
LayoutOrder/ZIndex는 물리 parent 순서와 분리 |
참 | 위와 정합 |
ClassName은 안 바뀌어 그 시그널은 발화 안 함 |
참 | audit/gcconn-trick-verification.md 기존 실측(대조만) |
연결이 살아있으면 캡처값이 GC 안 됨 / Destroy() 시 Connected 즉시 false |
참 | 같은 실측(대조만) — canExecute의 유일한 하드 의존 |
Destroy()는 재귀적으로 자식까지 파괴 + 커넥션 해제 |
참 | 공식 문서 |
Parent=nil 후 Destroy()가 그냥 Destroy()보다 비싸다 |
부분적으로 참 | Destroy()가 그 일을 어차피 다 하는 건 확인 — 실제 비용 차 벤치마크 근거는 없음(논리적 추론) |
| Luau에 ephemeron 테이블 없음 | 참 | luau.org/compatibility, 코퍼스 인용 URL/내용 일치 |
bit32.bnot(-rev) 랩 |
참 | 로컬 luau 실행, 코퍼스 예시값과 정확히 일치 |
ipairs는 첫 nil에서 멈춤 |
참 | 로컬 실행 |
table.insert(t,0,x)가 에러 |
거짓 | 3차 패스 정정을 재확인(범위 밖 큰 index·음수도 에러 없음) |
TweenBase:Cancel()은 프로퍼티를 안 되돌림 / 진행 중 목표 교체 API 없음 |
참 | 공식 문서 + devforum — tween-plan.md의 override 정책 근거 유효 |
ChildAdded류가 setup 중 동기 발화 |
조건부(Immediate 한정) | H-42 |
| Instance userdata가 나중에 다시 얻으면 같은 객체인가 | 미검증 | audit/가 이미 "미확인"으로 정확히 표시 |
| weak-key 테이블 + Instance 키 동작 | 미검증 | 같음(Studio 필요) |
table.insert의 구멍 재사용 일반성 / Relate lazy 성능 / CollectionService 왕복 |
미검증 | 전부 문서가 이미 "실측 대기"로 표시 — 중복 실측 안 함 |
핸들러 레지스트리 — H-39/H-52 외엔 깨끗
- 매치 중복 없음 —
NoneHandler/NilHandler/StoreBind/RefLeafHandler/ObserverEffectLeafHandler/ProcessedPreRefHandler/ProcessedPostRefHandler를 전부 짝지어 대조했고, 브랜드/센티널이 상호 배타라 동률 우선순위여도 모호성이 없다. HANDLER_PRIORITY_FALLBACK끼리도 충돌 없음 — Tag/AttributeKey/AttributeGroup/ PreRef·PostRef·Effect·Observer 동적 경로 가드가 전부 다른 브랜드/조건.- 매치 공백 없음 —
k(number/string/AttributeKey) ×v(nil/None/Ref/Observer/ Effect/Tag/Attribute/State/Slot/Instance/Modifier) 조합이 전부 어느 핸들러 하나 또는 명시적 매치 실패 error로 수렴한다(ProcessedModifier만 예외 — 이미H-35). - retractor 계약 위반 없음 — 이번에 읽은 모든 핸들러가 함수를 반환한다.
SlotHandler의 Length/Offset 등록은 정상 —attachSlot→materializeSlotTree가 자기 owner 위치와 자식 위치를 전부 정확히 등록한다 (H-39의 대조군).
조합 매트릭스 — 다음은 안전
Blocker/Gate×Slot.Length—slot.Length:Block(b)은 새 노드를 반환할 뿐 원본을 변형하지 않고,Dispatch.setLength가 쓰는len은 항상 프레임워크가 만든 원본/상수라 사용자의 게이트가 그 경로에 끼어들 수 없다.:Get()이 게이트와 무관하다는 계약이 이걸 한 번 더 보강한다.Tween× Slot 요소 위치 —PropertyHandler는 해시 파트 키만 매치하고 배열 자리는 다른 체인을 타므로 조합 자체가 성립하지 않는다(단 검증 부재는H-40).Ref×:List— 같은 Ref를 여러 행에 재사용하면bindLifetime의 범용canBound가드가 의도대로 즉시 error를 낸다.Detach재마운트는claimOwner의fromDetached예외로 이미 처리되고, Ref 바인딩은 비파괴 Detach로 안 끊긴다.Modifier(State 필드) ×:List의 각 행 — 각 행이 독립 Instance라 공유 상태가 없다.State<Slot>3중 중첩 —materializeSlotTree의isSlot재귀가 깊이 무관하게 일반화돼 있어 depth-2 검증이 귀납적으로 적용된다(단 3단을 손으로 전부 펼치진 않았으므로 "불확실"에 가깝다 — 필요하면 다음 세션이 재확인).- 단건
Attribute(named) × Slot — 배열 파트가 아니라 Length/Offset 시스템과 애초에 무관(H-39의 대상이 아님).
reference/ · archive/ · 로드맵
reference/epoch-brand-composition.md/slot-attach-decomposition.md— 둘 다 상단 배너로 "근거 기록, 정본은base/"를 명시하고, 본문의 옛 서술도 의도된 역사 기록임을base/대조로 확인.slot-attach-decomposition.md가 열어둔 "prepare만 하고 mount 안 한 중간 상태"는base/slot-plan.md가 이미 인용하며 열린 채로 주석 처리 — 추적 누락 아님.archive/의 최근 역전 문서 7종 전부 살아있는base/가 정확히 반영·포인팅. 특히 두 dedup 역전 문서는 서로 다른 역전임을 상호 명시해 혼동을 자체 차단.question.md↔todos.md↔HUMAN_TODO.md↔ROADMAP.md의 번호·상태 상호 참조는 정확히 맞물린다(H-49/H-51의 두 문장만 예외).ROADMAP.mdM2 체크박스(Brand/EpochMap/Gate+GateNode/Blocker/None)는 최근 확정 문서와 정합. M6의attachSlot분해가 체크박스가 아닌 것은 "확정된 것 — 코드 아님" 관례에 따른 정상.
그 밖에
Effect의EpochMapdedup이 실제로 성립한다 —state-epoch-plan.md가 *"뒤로 emit (받은from을 그대로 넘긴다)"*로 확정해뒀으므로,A→b,A→c,Effect(fn,b,c)에서 두 내부 Observer가 **같은from(=A)**을 받아 두 번째가 규칙대로 접힌다.Update가 처음 보는Epoch엔true를 주므로 첫 파동도 정상. (3차 패스가 "확인만" 했던 것을 이번에from전파 규칙까지 따라가 닫았다.)Blocker의 "중첩마다 별도 인스턴스" 규칙이 실제 코드 경로와 맞는다 —materializeSlotTree가getBlocker(slot)로 그 Slot 자신의 키로 만들고, 중첩 자식은 재귀에서 자기 키로 또 만든다. 부모 Blocker 재사용 경로가 없다.architecture.md의 Store/State/Source 온톨로지 요약이 2026-08-21Epoch갱신까지 반영돼 있어source-state-plan.md/state-epoch-plan.md와 어긋나지 않는다.
4차 패스 회신 방법
1~3차와 같다 — "맞다/아니다"만 주면 그대로 반영하겠다. 갈래 선택이 필요한 것:
H-39— 네 핸들러에 등록을 추가하는 건 확정 계약대로라 판단이 필요 없는데,AttributeGroupHandler만 층위 질문이 남는다(그냥 똑같이 등록 / Tag·Attribute를 Length/Offset 비참여 카테고리로 재정의). 이게 이번 패스에서 가장 급하다 — M2 디스패치 코어가 그대로 이 계약 위에 선다.H-40— 빠진 가드 넷을 다 넣는 건 정정이지만,Tween같은 값 타입을 블랙리스트에 계속 더할지 화이트리스트로 뒤집을지는 결정이다(base가T를 모른다는 기존 입장과 충돌).H-42— 세 갈래 중 하나. 그리고 그 뒤의 진짜 질문(Deferred가 표준이 되어가는데PreRef/PostRef/pre-pass 구조를 유지할 가치가 있는가)에 대한 방향도 같이 주면 좋겠다.H-52— 가드를 대칭적으로 추가할지, 경로가 없으니 둘지.H-46— top-levelSlot.luau를 만들지,Dispatch/Slot.luau에 합칠지.
나머지(H-41/H-43/H-44/H-45/H-473차와 묶어
H-51/H-53/H-54)는 확인만
해주면 바로 반영 가능하다. 묶어서 고치면 싼 것: H-28+H-43(둘 다
dispose), H-34+H-44(둘 다 native* 계약), H-18+H-45(같은 결정),
H-33+H-49(둘 다 Gate의 Flush/Cancel 표면), H-11+H-50.
반영은 이 파일이 아니라 각 base/ 문서에 하고, 처리 결과는 1-followup.md에 쌓는다.