quad/.claude/qa-request/pre-implementation-qa-round4-followup.md
qwreey 9642cf8f18
qa: QA 4라운드 followup — 회신 처리 결과 + 재질문 정리
사용자 회신을 (a) 바로 반영 (b) 설명 부족으로 재질문 (c) 사용자 판단 필요
(d) 조사로 닫힘으로 갈라 followup 문서로 정리했다. A절(반영 완료)은 앞
커밋에서 이미 base/에 들어갔고, 이 커밋은 그 기록과 남은 항목이다.

가장 큰 두 미결:

- C-1 KeyGone 센티널 — 키 소멸 시 updateFn을 한 번 더 불러 처분을 묻는
  사용자 제안. SL-45의 "파괴도 반환도 안 되고 참조만 끊기는" 상태를 정확히
  메우지만, 반환값 의미/userdata 수명/소멸 루프 순회 대상/index 인자 넷이
  안 정해지면 구현이 못 나감.
- C-2 "밀려난 prev는 dispose" vs state<Frame> 의미론 충돌 — 회신의 SL-43과
  SL-51이 서로 반대를 가리켰는데, 따져보니 둘 다 맞고 갈리는 축이 "누가 그
  요소를 만들었는가"였다. :List의 updateFn이 만든 건 reconcile이 지워야 하고
  (만든 쪽이 자기 손으로 못 지움), Slot:Add(state) sugar로 온 건 사용자
  소유라 죽이면 안 됨. 지금 설계엔 후자를 표현할 방법이 없어 선택지 셋을
  올리고 per-installation 소유권 옵션을 추천.

부수로 사용자가 문서에 아예 없던 갭 둘을 찾아냄 — Attribute 생성자 자신의
이름 겹침 정책(앞 커밋에서 "뒤가 이김"으로 명시)과, flatten이 소진한
Modifier 자리 처리(C-3, ProcessedModifier 센티널 vs flatten 압축).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-20 00:57:19 +09:00

43 KiB
Raw Blame History

구현 전 QA 4라운드 followup — 회신 처리 결과 + 재질문

상태: [2026-08-20] 1차 처리 완료, 아래 B/C절 회신 대기.

입력: pre-implementation-qa-round4-response.md(사용자 회신 원문 — 그 파일이 소스이고 여기서 전문을 반복하지 않음). 이 문서는 그 회신을 (a) 바로 반영한 것, (b) 설명이 부족해 다시 풀어 쓴 것, (c) 사용자 판단이 더 필요한 것, (d) 조사해서 답이 나온 것으로 갈라 정리한 것이다.

같이 일어난 일 — 업스트림 스캐폴딩 병합. 문항지를 쓰는 동안 업스트림에 M0 스파이크 검증 + M1 스캐폴딩이 12커밋 올라와 있었고, 이 세션에서 pull 후 문항지 커밋을 그 위로 rebase했다. 그래서 문항지 일부가 이미 stale하다 — 어느 문항이 그런지는 아래 E절.


A. 반영 완료 — 바로 고친 것

전부 이번 세션에 base/(+HUMAN_TODO.md/question.md/todos.md)에 반영했고 doc-check.py ERROR 0을 유지했다. 회신에서 판단이 명확했던 것만 손댔고, 조금이라도 애매하면 아래 B/C로 뺐다.

항목 무엇을 고쳤나 대상
LP-1 Connected는 "계산된 속성"이 아니라 그냥 RBXScriptConnection의 네이티브 필드. 실제 판정은 "gcconn 없음" / "있는데 Connected==false" 두 상태뿐이고, quad가 참조를 nil로 끊는 자리는 없음. rbvm에서 가져오는 건 gcconn 관용구뿐이고 __index 계산 속성 구현은 안 가져옴 lifecycle-pattern.md
LP-2 "예상보다 적을 수 있다"가 아니라 지금은 정확히 Effect 하나뿐 lifecycle-pattern.md
LP-4 커스텀 Destroy-time 처리의 정상 경로는 [Event "Destroying"] 직접 바인드가 아니라 Effect(+슈가 OnDestroyed) lifecycle-pattern.md
D-56 bindLifetime의 첫 인자가 물리 Instance가 아닐 수 있다(Slot-in-Slot의 ownerKey)를 백엔드 요구사항으로 신설. gcconn 트릭이 안 통하는 이유와, isBoundAlive에 세 번째 분기가 필요하다는 것까지 명시 lifecycle-pattern.md
D-8/ML-9 Dispatch.listHandlers()Quad.debug와 무관하게 항상 호출 가능(목록을 반환만 하고 스스로 출력 안 함). 게이팅 대상은 "라이브러리가 스스로 콘솔에 쓰는" 동작뿐 dispatch-core-plan.md, module-lifecycle-plan.md
D-60/SL-75 Slot.Offset은 마운트 전에도 nil이 아니라 0, 언마운트해도 nil로 되돌리지 않음. nil로 갈아치우면 그 Source를 이미 구독 중인 다운스트림이 영구히 끊겨 포탈이 깨짐 slot-plan.md, dispatch-core-plan.md
SL-74 SetAndDisposesource:SetAndDispose(value) 콜론 메서드로 확정(:Set과 한 세트). Apply 오버라이딩은 SourceState 단방향 때문에 타입이 안 성립해서 애초에 불가 → state:Apply 시그니처 영향 없음, 열린 항목에서 제거 slot-plan.md, question.md, todos.md
SL-4 예시를 slot:Add(Frame { Ref = myRef })MyComponent { Ref = myRef } 로. Frame은 코퍼스에서 인스턴스 리터럴을 가리키는 이름이라 정반대 오해를 부름 slot-plan.md
SL-72 조기 해제의 사용자 경로는 unbindLifetime아님(그건 Handler 작성자용 내부 배관) — State<Observer?>nil을 emit하는 게 정상 경로 slot-plan.md
E-10 dedup 대칭 확인 완료relate로 이전 값을 들고 old ~= v / nextValue ~= v 두 분기 에서만 bind/unbind(+내부 Observer cascade)가 일어나므로 성립. 남은 건 구현 시 회귀 확인뿐 effect-plan.md
E-11 leaf 바인딩된 EffectHandle:Unsubscribe()아예 안 먹는 것으로 Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩 게이트상 성립할 수 없는 문장이라 삭제 effect-plan.md
M-5 None만이 명시적 unsettermod:X(nil)은 "그 필드가 없는 새 Modifier", mod:X(None)은 "None으로 채워진 Modifier". 차이가 Overridden/Peek에서 관측된다는 것까지 예시로 modifier-plan.md
M-9 "Getter는 안 만든다"와 :Peek의 관계 명시(전자는 필드별 setter 짝 getter, :Peek은 키를 받는 범용 접근자라 모순 아님) modifier-plan.md
BK-9 HasBlocked는 "영원히 안 만듦"이 아니라 백로그 — 신설된다면 이름은 HasBlockedState blocker-plan.md
R-11 등록은 선형 탐색 함수가 아니라 그냥 table.insert(구멍을 알아서 되찾아 씀). 단 "#t가 항상 첫 nil 자리"는 Lua 명세상 보장이 아니므로 실측 스파이크 항목 추가 ref-plan.md
AT-11 위치별 claim 레지스트리 이름 groupClaimKeys로 확정(키 설계는 여전히 미정) attribute-plan.md, question.md
AT-20 생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 원리적으로 불가능 — 이름이 같아도 값이 바뀌었을 수 있고, 값을 비교하려면 :Get()이 필요한데 그건 State 계약 위반 attribute-plan.md
Attribute 생성자 Attribute(a, b, ...) 자신의 이름 겹침 정책이 어디에도 없었음Merged(error)/Overridden(뒤가 이김)만 적혀 있었다. 생성자는 뒤가 이김으로 명시 attribute-plan.md
TW-16 initValue가 "에이전트 범위 밖"인데 HUMAN_TODO.md에 항목이 없던 것 — 10번으로 신설 HUMAN_TODO.md
UI-5 해소process 위임을 하는 이상 gcconn/gchold 없으면 옵저버 바인딩부터 실패하므로 "조용히 미아"가 아니라 즉시 드러나는 전제 조건. ensureManagedChild가 일반 인스턴스 생성과 같은 경로를 타야 한다는 계약으로 승격 ui-shorthand-plan.md
UI-8 mapTweenValue 로컬 헬퍼를 Tween<T>:Map(fn) 공개 메소드로 승격 tween-plan.md, ui-shorthand-plan.md
UI-11 자식 파괴 시 retractFrom(child, prop, 1)"정석"이 아님 — 엔진이 Tween을 알아서 정리하고 PropertyHandler의 retractor는 애초에 no-op이라 두 겹으로 무의미 ui-shorthand-plan.md
LH 백로그 진짜 componentDidMount(조상 체인까지 이어진 뒤)는 백로그로 신설 — 자기 위를 알아야 해서 drive 한 콜스택 안에서 표현 불가, 사용 사례가 나오면 재검토 lifecycle-hooks-plan.md
D-3/D-10/LH-8 설명이 어렵다는 지적을 받아 코드/다이어그램으로 풀어 씀(아래 B절에 같은 설명 재수록) 각 문서

부수로 확인한 것 — TW-12는 이미 맞게 적혀 있었음. 문항이 CanAnimate를 "단순 boolean"으로 축약한 게 문제였고, tween-plan.md는 처음부터 CanAnimate: State<boolean> | boolean | nil로 적어두고 resolve:Get()한다 — 문서 정정 불필요, 문항만 부정확했다.


B. 재질문 — 설명이 부족했던 것 (풀어 쓴 답 + 재확인)

전부 base 문서에도 같은 설명을 반영해뒀다. 아래 설명이 맞는지만 확인해주면 되고, 어긋나면 그 지점을 알려주면 문서까지 같이 고친다.

B-1. D-3 — "깊은 인덱스"가 뭔지, 그리고 철거 방향

질문: "방향이 그게 맞나? … 실제 process 에선 retract 가 인덱스 1 부터 5, 6, ... 순으로 작동함. 그런데 달라질 때 5, 4, 3, 2 ... 순이 되는건 아니지? '깊은 인덱스' 라는게 무슨 의미?"

: "깊다" = 인덱스 숫자가 크다는 뜻이다(트리 깊이가 아니라 같은 (inst,k) 체인 안에서의 재귀 깊이). 그리고 "5,4,3,2 순이 되는 게 맞다."

(inst, k) 체인 — State<State<Tag>> 예시
  index 1 : StoreBind   ← 바깥 State 구독.   "얕음"
  index 2 : StoreBind   ← 안쪽 State 구독(1이 재귀로 만든 것)
  index 3 : TagHandler  ← 최종 Tag를 실제 반영. "깊음"
  • 설치(Dispatch.process)는 1 → 2 → 3 — 사용자가 짚은 그대로. 각 레벨이 값을 한 겹 벗겨 index + 1로 재귀하므로 인덱스가 커지는 방향.
  • 철거(Dispatch.retractFrom)는 그 반대인 3 → 2 → 1 — 의사코드가 for i = #list, index, -1꼬리부터 역순으로 돈다.
  • 왜 반대여야 하나: index 2가 index 3을 만들어낸 주체다. 만든 쪽을 먼저 지우면 만들어진 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것(LIFO)과 같은 이유. 그래서 각 retractor는 자기가 만든 하위 인덱스를 쫓아갈 필요가 없다(자기 차례엔 아래가 이미 비어 있음).
  • 주의: 이건 (inst,k) 체인 안의 이야기다. 서로 다른 키는 완전히 별개 배열이고 서로의 순서와 무관하다.

이 이해가 맞나?

B-2. D-10 — Luau 일반화 for는 이미 지원하는데 뭘 검증한 건가

질문: "루아우의 일반화된 반복 for 이 이를 그냥 지원함. 어떻게 실제로 이해했는지 확인 필요"

: 맞다 — Luau가 배열 파트를 먼저 훑는다는 걸 의심한 적은 없다. 문항이 "M0에서 순회 동작을 검증한다"고 적어 마치 언어 동작을 의심하는 것처럼 읽힌 게 문제였다. 실제 구분은 이렇다:

  • 언어가 주는 것: 일반화 for가 배열 → 해시 순으로 돈다. 2026-08-07에 사용자가 REPL로 직접 확인한 관찰.
  • 그런데도 드라이버가 두 패스를 명시하는 이유는 순서를 못 믿어서가 아니라 (1) 이식성 — 다른 백엔드가 props를 Lua 테이블이 아닌 자료구조로 표현하면 그 근거 자체가 없어짐, (2) 어차피 숫자 키/문자열 키를 다른 의미로 처리해야 해서 구분 비용이 이미 들고, 순서 고정이 거의 공짜.
  • 스파이크 01이 검증한 것: 언어 동작이 아니라 "우리가 짠 두 패스 드라이버가 계약대로 도는가". 그 스파이크 주석도 "사용자가 이미 REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 … 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다.
  • [업스트림 반영] 이미 통과했다luau-test/done/01-two-pass-array-hash-order.luau. 문항의 "M0에서 검증할 항목이다"는 stale이라 base 문서에서 닫았다.
  • nil-hole로 배열 파트가 통째로 해시 취급이 되는 문제는 이것과 별개이고 02/06 스파이크가 담당한다.

이 구분이 맞나?

B-3. D-32 — "재위임 핸들러는 (A) 분기에서도 반드시 다시 재위임"이 무슨 말인가

질문: "무슨말인지 확인 필요"

: **"조건부로만 재위임하는 핸들러를 새로 만들면 고아 체인이 생긴다"**는 경고다. 지금 존재하는 핸들러엔 위반 사례가 없어서 추상적으로만 적혀 있었다.

가상의 위반 예시로 설명하면:

-- ⚠️ 이런 핸들러를 새로 만들면 위험하다는 뜻
function MaybeWrapHandler.process(inst, k, v, index)
    if v.enabled then
        Dispatch.process(inst, k, v.inner, index + 1)  -- 재위임함
    end
    -- enabled가 false면 아무것도 안 함 ← 여기가 문제
    return function() end
end
  • 1차 사이클에 v.enabled == true → index 2에 하위 체인이 설치됨.
  • 2차 사이클에 같은 핸들러로 v.enabled == false가 오면 (A) 분기를 탄다 (핸들러가 같으므로 retractFrom이 안 불림 — (A)는 아래를 안 건드리는 게 핵심).
  • 그런데 이번엔 재위임을 안 했으므로 index 2에 옛 하위 체인이 그대로 남는다. 아무도 그걸 지우지 않고, 옛 값에 대한 구독/부작용이 계속 산다.
  • 해법: 재위임을 건너뛰는 그 자리에서 Dispatch.retractFrom(inst, k, index + 1)을 직접 불러 아래를 비운다.

StoreBind/NoneHandler항상 재위임하므로 이 함정에 안 걸린다 — 그래서 "지금 위반 사례는 없다"고 적혀 있는 것이다.

이 설명이 의도한 내용이 맞나? (맞다면 base 문서에도 이 예시를 넣겠다 — 지금은 규칙만 있고 예시가 없어서 읽고 이해가 안 되는 게 정상이다.)

B-4. BR-7 — duck-typing 기각 근거를 더

질문: "더 설명좀 필요"

: Brand 대신 type(x) == "table" and x.Compute ~= nil 같은 모양으로 판별하지 않는 이유가 두 가지다.

  1. false positiveModifier 필드나 :Peek가 돌려주는 T사용자가 넣은 임의의 값이다. 사용자가 우연히 Compute라는 필드를 가진 테이블을 넣으면 quad가 그걸 State로 오인한다. 브랜드는 quad가 만든 값에만 찍히므로 이 오인이 원천적으로 없다.
  2. 인덱싱 자체가 터질 수 있음 — 일부 Roblox userdata는 정의되지 않은 키를 인덱싱하는 것만으로 에러를 던진다(x.Compute를 읽는 순간 throw). 그래서 duck-typing을 하려면 판별 코드를 전부 pcall로 감싸야 하고, 그건 "판별은 부작용 없이 빠르게"라는 isHandlable 계약(base/dispatch-core-plan.md)과 정면으로 부딪힌다. 최악의 경우엔 엔진 자체가 죽는 상황도 있다.

즉 (1)은 정확성 문제, (2)는 안전성+비용 문제라 서로 독립된 두 근거다. weak-key 레지스트리 조회는 포인터 해싱 한 번이라 pcall도, 오인도 없다.

이 두 근거로 충분한가? 빠진 게 있나?

B-5. SL-5_mounted 트리거 시점을 더 풀어서

질문: "더 풀어 서술해주길 바람. 판단 보류"

: 요지는 **"_mounted를 언제 true로 세우는가"**이고, 후보가 둘 있었다.

-- 후보 A (채택): Dispatch가 이 Slot을 실제로 처리하기 시작한 시점
function SlotHandler.process(inst, k, slotValue, index)
    ...
    attachSlot(slotValue, inst, inst, k)   -- 이 안에서 _mounted = true
end

-- 후보 B (기각): 물리 Parent 대입이 끝난 시점
element.Parent = physicalTarget
slot._mounted = true   -- ← 여기서 세우는 모양
  • 왜 A인가: quad의 다른 "마운트됐다" 판정이 전부 dispatch-process 시점 기준이다 — PreRef가 소진되는 시점, Ref 콜백이 fire되는 시점, claimOwnerAt이 소유권을 잡는 시점이 전부 그렇다. 여기 하나만 "물리 Parent 대입 이후"로 두면 같은 사이클 안에서 어떤 판정은 이미 마운트됨, 어떤 판정은 아직 아님인 구간이 생긴다.
  • 오탐 걱정이 없는 이유: "컴포넌트가 Slot을 prop으로 받아 저장만 하고 실제 트리에 안 놓는" 경로에서는 Dispatch.process가 애초에 안 불린다 — A 기준으로도 _mounted가 안 켜진다.
  • ⚠️ 단, 이 서술은 그 뒤에 한 번 더 정밀해졌다(SL-55) — 지금 attachSlot_mounted = true함수 맨 위가 아니라 activateList에 둔다. activateList가 도는 동안 _mountedfalse여야 reconcile의 rawAdd가 "아직 마운트 전"(= _elements에만 넣고 끝) 경로를 타기 때문. 즉 "process가 불린 순간"이 아니라 "process가 불려 들어간 attachSlot 안에서, activateList가 끝난 직후"가 정확한 시점이다. 원칙(dispatch-process 시점 기준, 물리 Parent 기준 아님)은 그대로.

이 정리가 맞나? 특히 마지막 ⚠️(정확한 시점이 activateList 직후)까지 포함해서 확인 부탁.

B-6. SL-59 — 재귀적 Clear() 금지가 어떤 상황인가

질문: "실 상황에 대한 설명 더 필요함. 이 글만 보아서는 어떤 상황인지 정확히 판단 어려워보임. 판단 보류."

: 중첩 Slot을 파괴할 때 "각 요소를 Remove로 하나씩 지우는" 순진한 구현을 하면 O(n²)가 된다는 얘기다.

outer(position 3에 마운트됨)
  └ inner Slot (요소 500개)

outer가 파괴될 때 두 가지 방식:

  • 순진한 방식(금지)inner:Clear()가 요소마다 rawRemove를 부른다. rawRemove 하나당 (a) spliceArraysDown으로 뒤 배열을 한 칸씩 당기고 (b) recompute(inner, bk)로 전체 순회를 돈다. 500개면 당기기 500번 × 평균 250칸 + recompute 500번 × 500칸. 어차피 통째로 죽을 서브트리인데 그 안에서 위치 계산을 500번 다시 하는 셈.
  • 채택한 방식destroySlotTree부기를 전혀 안 건드리고 요소를 훑으며 파괴만 한다(spliceArraysDown도, recompute도 안 부름). 어차피 이 Slot 자체가 사라지므로 그 안의 위치 계산은 의미가 없다. 바깥에서 딱 한 번outer가 차지하던 position 3에 대해 setOffsetSource(None)setLength(0)을 부르고, 그때 recompute가 한 번 돈다.

즉 "재귀적 Clear() 금지"는 API를 금지한다는 게 아니라, 파괴 경로가 CRUD를 재사용하면 안 된다는 구현 지침이다.

이 이해가 맞나?

B-7. SL-63recompute가 owner의 Length를 직접 Set하는 게 이상하다

질문: "(b) 가 뭔가 이상함. 오너의 length 를 직접 설정하지는 않을것임. 자신 length 를 변경하면, 자동으로 observer 에 등록된것으로 인해 length 가 업데이트 되는 방식일텐데 … 최종 리컴퓨팅 결과가 length 가 되는거 아니였음?"

: 사용자 이해가 맞고, 문항의 표현이 나빴다. "owner"라는 말이 두 가지로 읽혀서 생긴 혼동이다.

local function recompute(ownerKey, bk)
    ...
    if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
        ownerKey.Length:Set(sum)
    end
end
  • 여기서 ownerKey부모가 아니라, 지금 자식들의 합을 구하고 있는 그 Slot 자신이다. 즉 recompute(innerSlot, bk)innerSlot.Length를 세운다 — "최종 리컴퓨팅 결과가 자기 Length가 된다"는 사용자 이해 그대로.
  • 부모는 이 값을 직접 안 받는다 — 부모는 attachSlot에서 Dispatch.setLength(부모ownerKey, position, innerSlot.Length)로 그 State 객체 자체를 등록해뒀고, 값이 바뀌면 Observer로 통지받아 자기 recompute를 돈다.
  • 그래서 실제 흐름은 innerSlot.Length:Set(sum) → 부모의 observer 발화 → recompute(부모, ...) → 부모의 뒤 형제 offset 갱신, 이고 "부모 length를 직접 설정"하는 코드는 어디에도 없다.

문항(그리고 base 문서의 "(b) owner가 Slot이면 그 .Length")이 ownerKey를 그냥 "owner"로 줄여 써서 부모로 읽히게 만든 게 원인이다.

이 정리가 맞나? 맞다면 base 문서의 그 표현도 "그 recompute의 ownerKey 자신"으로 명확히 고치겠다(아직 안 고쳤음 — 확인 후 반영 예정).

B-8. SL-76recomputenil을 관대하게 넘기는 게 왜 필요한가

질문: "애초에 해제에서 nil이 관측 될 일이 없다고 생각하는데, 그게 아니라면 다시 더 자세히 말을 해주길 바람."

: 지금 설계에서 도달 가능한 경로를 저도 못 찾았다. 그 방어 조항은 2026-08-13(bk.N 정의가 확정되기 전, Blocker 게이팅이 생기기 전)에 들어간 것이고, 그 뒤 두 변경으로 전제가 바뀌었다:

  • bk.N이 "그때그때 실제 개수"로 확정돼(2026-08-18), lengthList/sourceList가 아직 안 채워진 위치를 recompute가 읽을 일 자체가 없어짐.
  • 배치 등록 중엔 Blocker 게이팅으로 recompute가 아예 안 돎.
  • 해제는 setOffsetSource(None)setLength(0)이라 None이지 nil이 아님.
  • spliceArraysDown은 배열을 압축하므로 중간에 구멍을 안 남김.

→ 그래서 선택지 셋 중 하나를 골라야 한다(사용자 판단 필요, C절 C-6으로 옮김). 지금은 문서를 안 고치고 그대로 뒀다.

B-9. SL-78 — nested Slot의 Length만큼 건너뛰는 얘기가 뭘 섞고 있나

질문: "애초에 slot 내의 index 와, length 로 구해진 offset 은 다른 개념인데, 너무 섞어 말하는것 아닌지 … Length 업데이트는 상위 slot 이 observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것임?"

: 섞어 말한 게 맞다. 두 얘기를 분리하면 이렇다.

  1. 형제 Slot 사이의 offset — 사용자 말대로 자동이고 이 캐비엇과 무관하다. nested Slot의 Length가 바뀌면 부모가 observe해서 recompute가 돌고 뒤 형제 offset이 갱신된다. 여기엔 문제가 없다.
  2. 같은 :List 안에서 updateFn에 넘어가는 index — 이쪽이 캐비엇의 대상이다. reconcilepos를 훑으며 각 아이템의 index를 계산하는데, 어떤 아이템이 nested Slot(Length=3)을 반환하면 다음 아이템의 index는 1이 아니라 3만큼 건너뛴다. 그런데 index는 그 사이클의 raw 스냅샷이라, :List가 다시 reconcile되지 않은 채 그 nested Slot의 Length가 3→5로 바뀌면 뒤 아이템들의 index는 3 기준으로 멈춰 있다.

즉 캐비엇은 offset이 아니라 index에 대한 것이고, "형제 Slot 갱신"이 아니라 **"한 :List 안 아이템들끼리"**의 얘기다. 그리고 이건 이미 확정된 "index는 State가 아니라 raw number"의 당연한 연장이라 새 문제가 아니다 — 실시간 정확성이 필요하면 updateFnresult.Length를 직접 구독해야 한다.

이 분리가 맞나? 맞다면 base 문서의 그 캐비엇 문단도 "offset이 아니라 index 얘기"임이 드러나게 고치겠다.

B-10. LH-8 — "자기 아래 vs 자기 위"를 그림으로

질문: "사람이 너무 이해하기 어려운 표현임 … 풀어 보여줘야함."

: Frame { Frame { TextLabel {}, OnRendered(fn) } }에서 안쪽 Framefn이 불릴 때:

바깥 Frame { ... }          ← ⑤ 이 호출은 아직 시작도 안 함
  └ 안쪽 Frame { ... }      ← ④ 이 drive가 지금 끝나는 중
      ├ TextLabel {}        ← ① 이미 완성돼 안쪽 Frame에 붙어 있음
      └ OnRendered(fn)      ← ③ fn이 여기서 불림
      (프로퍼티/이벤트)       ← ② 이미 전부 세팅됨
  • ①② = "자기 아래" — 자식(과 그 서브트리 전체) + 자기 프로퍼티/이벤트. fn이 불릴 때 전부 끝나 있다.
  • ⑤ = "자기 위" — 안쪽 Frame이 바깥 Frame의 자식이 되는 일. Lua 표현식 평가 순서상 안쪽 Frame{...} 호출이 끝나야 바깥 Frame의 props 테이블이 완성되므로, fn이 불릴 때 바깥 Frame존재하지도 않는다.
  • 그래서 "화면에 올라간 뒤"가 아니다 — 화면에 올라가려면 루트까지 이어져야 하는데 그건 ⑤ 이후 일이다.
  • 원래 뭐가 헷갈렸나: 경계를 "이 인스턴스의 프로퍼티만이냐(a) / 자식 서브트리까지냐(b)"로 놓았는데, 배열 파트 루프가 각 자식을 동기적으로 끝내고 넘어가므로 ①이 공짜로 따라온다 — (a)/(b)는 갈리는 지점이 아니었고 실제로 갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다.

이 그림이 맞나? (base 문서에도 이 그림을 넣어뒀다.)


C. 사용자 판단 필요 — 임의로 처리하지 않은 것

전부 아직 문서를 안 고쳤다. 판단이 갈리거나 파급이 커서 임의 결정이 위험한 것들이다.

C-1. SL-40/SL-43/SL-45KeyGone 센티널 신설

사용자 제안: 키가 데이터에서 사라지면 updateFnKeyGone으로 한 번 더 불러 처분을 묻고(T | KeyGone), userdata를 지울지도 사용자가 정하게 위임. 이걸로 SL-45(Detach 홀드 중 키 소멸)가 닫힌다.

방향은 좋아 보인다SL-45가 "파괴도 반환도 안 되고 참조만 끊긴다"는 어정쩡한 상태였던 근본 원인이 "키가 사라지는 순간엔 updateFn에게 물어볼 방법이 없다"였고, KeyGone이 정확히 그 구멍을 메운다. 다만 정하지 않으면 구현이 못 나가는 세부가 넷 있다:

  1. updateFnKeyGone을 받았을 때 반환값의 의미는? 제안: nil → 파괴(기본), Detach → 언마운트만(사용자가 ud로 홀드), prev 그대로 반환 → ? (키가 없는데 계속 마운트해두는 건 모순으로 보임 — error가 맞나, 아니면 파괴로 취급하나?)
  2. userdata 엔트리는 언제 지워지나? "유저가 결정"이면 updateFn(result, nil)을 반환할 때만 지운다는 뜻인데, 그러면 사용자가 ud를 계속 반환하는 한 userdata[key] 엔트리가 영원히 남는다(키는 이미 사라졌으므로 다시 물어볼 기회도 없음). 의도된 것인가, 아니면 "한 번 물어본 뒤엔 무조건 지운다"인가?
  3. 다음 사이클의 소멸 루프가 무엇을 순회하나? 지금은 직전 keyIndex를 순회하는데, ud만 남은 키는 keyIndex에 없다. userdata 키까지 합집합으로 순회해야 하나(그러면 매 사이클 그 키를 계속 KeyGone으로 다시 물어보게 됨), 아니면 한 번 물어본 키는 목록에서 빼나?
  4. index/offset 인자는 뭘 넘기나? 사라진 키엔 위치가 없다. nil? 마지막으로 알던 값? (updateFn 시그니처가 index: number로 확정돼 있어서 nil을 넘기면 타입이 바뀐다.)

추가로 이름/배치: Detach와 같은 급의 sentinel이므로 패키지 최상위 export가 일관적이다(None/Detach 선례). 이름은 KeyGone이 의미는 명확한데, 코퍼스가 "이 자리에서 무엇을 하라"는 지시형(None=세팅 안 함, Detach=떼되 죽이지 마라)을 쓰는 것과 달리 KeyGone은 상태 서술형이라 결이 조금 다르다 — 그게 오히려 맞을 수도 있다(이건 지시가 아니라 통지이므로). 그대로 갈지 확인 부탁.

C-2. SL-43 vs SL-51 — "밀려난 prev는 dispose"와 state<Frame> 의미론이 충돌한다

이번 회신에서 나온 것 중 파급이 가장 크다. 두 답변이 서로 반대 방향을 가리킨다:

  • SL-43 답변: "'updateFn이 새 값을 반환하면 밀려난 prev는 언마운트만' 는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접 destroy 를 호출 못함 … 그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄."
  • SL-51 답변: "state -> slot {frame} 형태가 될 때 이전 state 에서 변경으로 다른게 와도, slot 이 이전 frame 을 destroy 해버리면 안 됨."

둘 다 맞는데 서로 다른 경우다 — 갈리는 축은 누가 그 요소를 만들었는가:

경로 요소를 만든 주체 밀려난 prev의 올바른 처분
:List(data, updateFn)updateFnFrame{...}을 만들어 반환 updateFn dispose — 만든 쪽이 자기 손으로 못 지우니(reconcile 중이라 dispose가 거부됨) reconcile이 대신 지워야 함
Slot:Add(state) 라 sugar(:Single + identity updateFn) 사용자(state에 담아 넘김) 언마운트만state<Frame>가 이전 값을 안 죽인다는 확정 의미론 그대로

지금 설계는 후자를 표현할 방법이 없다. Detach는 "이 자리를 비우되 죽이지 마라"라서 교체와 같이 못 쓴다(새 값을 반환하는 순간 result ~= nil 경로로 가고, 그 경로가 dispose가 됨).

선택지 셋:

  • (a) :List/:Single에 소유권 옵션을 둔다 — 예: Slot:Single(state, updateFn?, opts?)opts.Owned = false. 설치 시점에 한 번 정하고, Slot:Add(state) sugar가 Owned = false로 설치한다. 장점: 갈리는 축(누가 만들었나)이 실제로 설치 단위 속성이라 의미가 정확하고, 매 사이클 반환값에 부담을 안 준다. 단점: 옵션 파라미터가 하나 늘고, "한 :List 안에서 어떤 아이템은 내가 만들고 어떤 건 사용자 것"인 혼합은 표현 못 함(그런 사례가 있는지는 모르겠음).
  • (b) Detach를 호출 가능하게 만들어 Detach(newValue) — "교체하되 옛 것은 떼기만" 을 반환값 프로토콜 안에서 표현. 장점: 국소적이고 per-cycle로 정확. 단점: 2026-08-19에 확정한 Detach 배치 결정과 부딪힌다 — 그때 "sentinel 상수 하나 때문에 callable-table+메타테이블 구조를 들이는 건 과함"이라고 판단해 최상위 순수 sentinel로 뒀다.
  • (c) 새 sentinel 하나 더 — 예: 반환값을 (newValue, ud, DetachPrev)처럼 세 번째 슬롯으로 신호. 단점: 반환 프로토콜이 복잡해짐.

추천: (a). 갈리는 기준이 per-cycle이 아니라 per-installation이라는 게 분석의 핵심이고, (b)는 방금 내린 결정을 뒤집는 비용이 있다.

같이 확인 부탁: SL-51에서 "list 슈거에서 Detach 가 사용중인지 확인이 필요해보임" 이라 하셨는데 — 지금 :Single의 기본 identity updateFnDetach를 전혀 안 쓴다(그냥 item을 반환). 그래서 위 충돌이 실재한다.

C-3. M-2 — flatten이 소진한 Modifier 자리에 구멍이 남는다 ( 실제 갭)

사용자 제안: "이것도 이 이후 ProcessedModifier / ProcessedModifierHandler 를 만들면 될듯. Post/Pre Ref 와 유사히 처리 가능하다고 생각함."

조사 결과 — 이건 문서의 실제 갭이 맞다. modifier-plan.md는 flatten이 "isModifier(v)가 참인 항목만 필드를 뽑아 merge하고 나머지는 안 건드린다"고만 적어두고, 뽑아낸 그 자리를 어떻게 하는지를 한 번도 안 적었다. 그냥 지우면 배열에 구멍이 생기고, 그건 PreRef pre-pass가 ProcessedPreRef로 소진하는 바로 그 이유(sparse 테이블이 되면 배열 파트 전체가 순서 보장을 잃음)에 정확히 걸린다.

선택지 둘:

  • (a) ProcessedModifier 센티널 + 전담 nop HandlerPre/PostRef와 완전히 대칭. 그 Handler가 setOffsetSource(None)/setLength(0)을 등록하니 Length/Offset 계약도 특수 취급 없이 만족된다. 일관성 최고.
  • (b) flatten이 결과 배열을 압축(compact) — flatten은 이미 flatten(nonFlatten) -> flattened라는 새 테이블을 만드는 순수 변환이라, Modifier 자리를 아예 안 넣고 뒤를 당겨 담으면 구멍 자체가 안 생긴다. 센티널도 Handler도 불필요. 단, 이러면 배열 위치가 원본과 달라진다flattened만 보는 drive 입장에선 무해해 보이지만, 확인이 필요하다.

추천: (b) — flatten이 어차피 새 테이블을 만드는 이상 가장 부품이 적다. Pre/PostRef가 센티널을 써야 했던 이유는 pre-pass가 flattened를 제자리에서 소진하고 그 뒤 두 패스가 같은 테이블을 다시 돌기 때문인데, flatten은 그 앞 단계라 그 제약이 없다. 다만 사용자가 (a)를 제안하신 만큼 어느 쪽인지 정해주면 그대로 반영하겠다.

C-4. destroySlotTree가 자식 소유권을 명시적으로 반납해야 하는가

사용자 의문: "Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 동작일 수 있어보임."

분석 — 사용자 쪽이 더 맞아 보인다. 명시적 releaseOwner가 들어간 원래 근거(2026-08-13 감사)는 *"elementOwner가 weak라 GC 타이밍에 따라 '이미 마운트돼 있음' error가 비결정적으로 터진다"*였는데, 다시 보면:

  • 그 error가 나는 상황 자체가 버그다 — 파괴된 요소를 다른 곳에 다시 넣으려는 코드이기 때문. 즉 "비결정적으로 실패"의 반대는 "성공"이 아니라 **"항상 실패"**이고, 그게 더 나은 동작이다.
  • 명시적 반납은 오히려 그 버그를 통과시킨다 — 파괴된 요소를 재마운트하는 코드가 조용히 성공하고, 그 다음에 죽은 Instance를 다루다 엉뚱한 데서 터진다.
  • 비결정성만 없애면 된다 — 반납을 안 하면 GC 전엔 error, GC 후엔 통과라 여전히 비결정적이다. 이걸 결정적으로 만들려면 오히려 파괴된 요소에 "파괴됨" 표식을 남기는 쪽이 맞는데, 그건 새 부기다.

선택지:

  • (a) 명시적 releaseOwner 제거 — 단순해지고, "파괴된 걸 재사용하면 (대개) 막힌다"는 동작이 남는다. 비결정성은 그대로.
  • (b) 유지 — 지금 문서 그대로.
  • (c) 반납 대신 "파괴됨" 마킹 — 결정적으로 항상 error. 부기가 하나 는다.

추천: (a), 다만 "비결정적"이라는 성질이 남는 걸 문서에 명시. (c)는 conventions.md의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 기준으로 지금은 과해 보인다.

C-5. SL-58 — 배치 밖 재마운트의 낭비가 실제로 있나 (+ 개선안)

사용자 질문: "offset 이 전부 같아 set 안 일어나고 가벼운거로 아는데, 아님?"

분석 — 뒤에 형제가 없으면 맞고, 있으면 아니다.

steady state에서 position kstate<Slot>이 교체될 때:

  1. attachSlotsetOffsetSource(ownerKey, k, ...)1..k-1 합이라 안 바뀜(사용자 말대로 여기까진 무해).
  2. Dispatch.setLength(ownerKey, k, slot.Length)slot.Length는 아직 flush 전이라 0. Observer "등록 즉시 1회 실행" → 부모 blocker가 꺼져 있으니 recompute(부모)즉시 돈다.
  3. recomputelengthList[k] = 0으로 계산하므로 k+1..N 위치의 offset이 전부 줄어든 값으로 :Set된다 → 그 아래 LayoutOrder 캐스케이드.
  4. flush가 끝나고 slot.Length:Set(최종) → 다시 recompute → offset 원복.

k 뒤에 형제가 하나도 없으면 낭비가 정말 없고(offset이 바뀔 대상이 없음), 뒤에 형제가 있으면 그 형제들의 offset이 두 번 Set되고 다운스트림도 두 번 돈다. 값은 결국 맞으므로 크래시나 영구 오류는 아니다.

개선안(제안): attachSlot에서 Dispatch.setLength를 flush 루프 로 옮기면 이 왕복이 사라진다 — 그 시점엔 slot.Length가 이미 최종 값이라 부모 recompute가 한 번만 돈다. 배치 안에서는 어차피 부모 blocker가 켜져 있어 차이가 없고, 배치 밖에서만 이득이다.

  • 확인 필요한 것: setOffsetSourcesetLength 순서 계약은 유지된다 (둘 다 flush 앞뒤로 갈릴 뿐 상대 순서는 그대로). bk.NsetLength가 올리는 규칙도 한 position 안에서는 영향이 없어 보인다.
  • 판단 부탁: 이건 확정된 의사코드의 순서를 바꾸는 것이라 임의로 안 고쳤다. 옮길지, 지금처럼 두고 "한 프레임 낭비 허용"으로 남길지.

C-6. SL-76recomputenil 관대 처리를 어떻게 할지

B-8의 조사 결과, 지금 설계에서 도달 경로를 못 찾았다. 선택지:

  • (a) 유지 + 근거 갱신 — "지금은 도달 경로가 확인되지 않지만 전이 구간 방어로 남긴다"로 문구만 정직하게 고침.
  • (b) 삭제None만 처리하고 nilbk.N 계약 위반이므로 자연히 터지게 둠.
  • (c) error로 승격nil이 관측되면 부기가 깨졌다는 신호이므로 조용히 skip하지 말고 즉시 error(코퍼스의 "매치 실패는 즉시 error", releaseOwner 불일치 error와 같은 톤).

추천: (c) — 관대한 skip은 "위치 하나가 조용히 순서 계산에서 빠지는" 디버깅 어려운 오작동이 되고, 지금은 그게 정상 경로로 생기지 않는다는 게 분석 결과이므로 error가 더 안전하다.

C-7. "Length로 먼저 밀어내고 그 공간에 넣는다"가 다른 CRUD/:List에도 통하는가

사용자 제기: "만약 밀어내고 당기지 않은 상태에서 그 공간에 넣는다 하면, 밀어내는걸 구현해야하는 백엔드에서 골치아파짐. 지금 어떤 상황인지 확인해볼것"

조사 결과 — rawAdd만 순서가 명시돼 있고 나머지는 안 적혀 있다.

  • rawAdd(문서화됨): self.Length:Set(newCount)(→ 뒤 형제 offset 갱신이 여기서 동기적으로 끝남) → element.Parent = target. 즉 "밀어낸 뒤 넣는다".
  • rawRemove/rawUnmount(문서화 안 됨): 의사코드는 unbindLifetimereleaseOwner → 파괴/언마운트 → spliceArraysDownrecompute 순서다. 즉 "빼고 나서 당긴다". rawAdd의 거울상이라 일관돼 보이지만 명시적으로 계약화돼 있지는 않다.
  • Splice(문서화 안 됨): "shift+recompute 1회로 묶는다"만 있고 물리 detach/attach와 부기의 선후가 안 적혀 있다. 제거분과 삽입분이 겹치는 구간이라 특히 애매하다.
  • :ListrawMove(문서화 안 됨): "Parent를 안 건드린다"만 확정돼 있어 물리 이동이 없으니 이 문제에서 빠지는 것으로 보인다.

판단 부탁: 이걸 일반 계약으로 승격할지 — 예컨대 "부기(Length/offset) 갱신이 물리 트리 조작보다 항상 먼저 끝난다"dispatch-core-plan.mdslot-plan.md에 한 줄로 못박고 모든 raw*가 따르게 할지. 그러면 백엔드 작성자가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있다. 승격에 찬성하시면 문구를 써서 반영하겠다.

C-8. SL-48 — Slot-in-Slot에서도 Destroy 후 정리가 성립하나

사용자 메모: "더 나아가 slot in slot 에서도 유효한가 생각해보아야함. 아마 그런것으로 알고있음. 피지컬 홀더랑 오너가 다르거든."

분석 — 성립하지만, D-56이 해결돼야 성립한다. 중첩에서 둘이 갈린다:

  • physicalTarget(물리 Instance) — attachSlot이 자식 요소를 실제로 Parent하는 대상. 최상위든 중첩이든 같은 물리 부모다.
  • ownerKey(부기 소유자) — 최상위는 inst, 중첩은 부모 Slot 자신.

Destroy 시나리오를 따라가면:

  1. physicalTarget이 Destroy → gcconn 끊김 → 그 instbindLifetime된 것들은 canExecute가 거짓이 되어 발화가 멈춘다.
  2. 중첩 Slot의 setLength Observer는 bindLifetime(ownerKey=부모Slot, observer)로 묶여 있다 — inst가 아니다. 그래서 1번만으론 안 끊기고, 부모 Slot이 unreachable해질 때 같이 죽어야 한다.
  3. 부모 Slot은 gchold(physicalTarget의)에 강참조로 매달려 있으므로, physicalTarget이 GC되면 부모 Slot도 참조가 끊긴다 → 그 아래 Relate(부모 Slot) 부기도 weak-keyed라 같이 사라진다.

결론: 체인이 성립한다. 다만 2번이 성립하려면 bindLifetime이 Slot을 첫 인자로 받는 걸 실제로 처리해야 한다 — 그게 A절에서 신설한 D-56 요구사항이다. 즉 SL-48은 "이미 맞다"가 아니라 D-56이 구현되면 맞다.

이 분석이 맞나? 특히 3번(physicalTarget → 부모 Slot → 중첩 부기의 연쇄 GC)이 실제로 그렇게 도는지.

C-9. AT-13retractor를 부르는 주체

사용자 확인 요청: "정확히는 같은 핸들러 재프로세스는 retractor 를 process 에서 굴리고 자기 작업을 함. 따라서 process → calls retractor(v) → process new one 이 맞는걸로 보이는데 … 실제 구현은 저렇게 알고 있는게 맞음?"

답: 맞다. 의사코드 그대로다:

function Dispatch.process(inst, k, v, index)   -- ← 오케스트레이터
    ...
    if slot ~= nil and slot.handler == h then
        slot.retractor(v)                       -- ① 여기서 굴림
        slot.retractor = NOOP
        local retractor = h.process(inst, k, v, index)   -- ② 자기 작업
        ...

Dispatch.process(오케스트레이터)가 ①과 ②를 둘 다 부른다. 문항의 "retractor(v)process"는 그 둘의 순서만 적고 누가 부르는지를 생략한 축약이었고, 사용자 이해가 정확하다. handler.process(개별 핸들러)는 자기가 retractor를 부르지 않는다 — 그건 오케스트레이터의 일이다.

→ 문서엔 이미 이렇게 적혀 있어 정정 불필요. 확인만.


D. 문항지 자체가 stale해진 것 (업스트림 병합 결과)

문항지는 pullbase/를 기준으로 썼다. 업스트림 12커밋이 들어오면서 아래가 이미 달라졌다 — 문항이 틀린 게 아니라 시점이 지났다.

문항 무엇이 달라졌나
ML-5 사용자가 회신에서 직접 짚음("새로운 커밋에서 이것이 달라짐"). 파일마다 Relate()+INITED 센티널을 두는 설계가 RunInit 하나로 통합됐다 — 함수 자기 자신을 릴레이션 키로 쓰므로 센티널이 불필요. quad-base/src/init.luau에 실제 구현 + test/smoke.init.luau로 검증까지 끝. 문항 ML-5는 폐기.
ML-8 위 변경에 맞춰 RunInit(함수 identity) vs _initializedBy(문자열 마커) 구분으로 재서술됨. 결론은 그대로(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효.
ST-3 "M0에서 실측 확인할 것"이 실측 완료됨 — luau-test/done/21-type-store-undeclared-key-rejected.luau가 미선언 키 접근 2건이 정확히 TypeError로 거부됨을 확인. ⚠️ 열린 항목이 아니게 됨.
D-10 위 B-2 — 스파이크 01이 이미 통과.
A-2 패키징이 wally → pesde로 바뀌었고 mise/selene가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 결론 자체는 유지).
전반 M0/M1완료됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다.

신규 문서 2개는 문항이 아예 없다base/project-setup-plan.md(pesde/mise/ selene/Rojo/darklua 경계)와 base/quad-types-plan.md(AddPlugin<Self,P>/ CheckedQuad<T, Pattern>/type-version-check). architecture.mdtyping-limits.md도 이 라운드에 내용이 늘었다. 원하시면 이 넷만 대상으로 5라운드 문항지를 따로 만들겠다 — 지금 4라운드 회신 처리가 먼저라 착수하지 않았다.


E. 회신 방법

  • B절(재질문) — 각 항목의 "이 이해가 맞나?"에만 답해주면 된다. 맞으면 그대로 두고, 어긋나면 그 지점만 알려주면 base까지 같이 고친다.
  • C절(판단 필요) — 선택지에 번호를 달아뒀으니 고르거나, 더 나은 안을 주면 된다. C-1(KeyGone)과 C-2(dispose vs state<Frame> 충돌)가 가장 파급이 크고 나머지를 막고 있다.
  • D절 — 5라운드를 만들지만 알려주면 된다.