quad/.claude/session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md
qwreey 56ba2b3f37
docs(audit): 스파이크 상태판 신설 + 코퍼스 전반 모순·stale 감사 정정
## luau-test/STATUS.md 신설 (사용자 요청)

"사람이 보기엔 뭐를 바로 봐야하는지 눈에 안 띄어서" — 파일별 상태를
🔴 사람 결정 필요 / 🟠 스파이크 깨짐 /  미실행 /  통과로 분류하고
봐야 할 것을 위에 배치. 역할 분담도 명시: STATUS.md가 상태의 소스,
README.md는 각 파일의 검증 의도·배경, audit/는 실행 결과 상세.

## 코퍼스 전반 감사 (에이전트 둘, 보고받은 것 전부 직접 사실 확인 후 수정)

가장 위험: ROADMAP.md가 최우선 게이트(question.md 0-Z, 하강 diff 재설계)를
전혀 안 짚고 있었음. base 4개 문서엔 ⚠️ 배너를 달아뒀는데 정작 구현 순서의
소스인 ROADMAP엔 없어 M2/M4/M10 담당자가 옛 모델로 구현할 위험이 실재.
→ 세 마일스톤 전부에 배너/포인터 추가.

두 번째: slot-plan.md 앞부분이 뒤집힌 결정을 여전히 "확정"으로 자칭.
"폐기, 옮기지 않음, portal은 오버엔지니어링"과 "열린 질문" 절에 역전 표시가
없어 앞에서부터 읽는 구현자가 구 모델로 짤 위험 → 🔄 역전 배너.
더 심각하게 reconcile 의사코드가 여전히 rawRemove(파괴)를 부르고 있었음
(같은 문서가 "[반영 완료]"라 태그해둔 것과 정면 모순) → rawUnmount 신설.
filter/toggle 근거 절도 "제거=파괴" 전제라 캐비엇 추가(결론은 유효).

그 외:
- ROADMAP M11 Tween이 이미 확정된 넷을 미결로 둠(override 정책/옵션 값
  모양/Animate 시그니처/initValue) + research/tween-plan.md 죽은 링크 4곳
- bind-system-plan.md:328, architecture.md:213의 "4종 계약" 잔여
  (isHandlable(k,v) 구식 시그니처 포함)
- store-semantics.md의 "State는 가칭" stale — 2026-08-12에 최종 확정됨
- luau-test/README.md의 04/19 판정 기준이 재작성된 파일을 못 따라감.
  특히 19 C섹션 기준이 정상 동작(nested error)을 실패로 오판하게 돼 있었음
- documentation-content-map.md의 4종 계약 / Attribute<T> 옛 이름
- modifier-plan.md의 "정정이 원문에 소급 안 됨" 패턴
- attribute-plan.md "이름 소유권"이 0-Z 미결인데 "최종"이라 적힌 것 캐비엇
- relate-plan.md의 kSlotMap 역할 서술 부정확, 상호참조 방향 오류 2곳

판단해서 안 고친 것: effect-plan.md의 "cleanup" 14곳 — Handler retract
(내부 배관)와 달리 Effect(fn)에서 사용자가 작성한 fn이 반환하는 React식
콜백이라 다른 층위. 매 감사마다 재지적되므로 lifecycle-pattern.md에
"확인 완료, 의도된 별개 개념"으로 못박음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:48:32 +09:00

36 KiB

2026-08-13 여섯 번째 세션 — c33ae04 커밋 전체 감사, 인덱스 재설계 의사코드의 실제 버그 4건 + 전파 누락 정리

사용자 요청: "c33ae04 커밋이 정확한지 봐줘. 많은게 바뀌여서 정확한지 검토해야함. 너가 직접 봐야할듯. 전체 문서에 잘못된 부분이 있는지, 로직이 이상한게 있는지. 더 나은 로직이 있는지 전체 감사 처리를 해줘" — 서브에이전트 위임이 아니라 메인 컨텍스트에서 직접 읽으라는 명시적 지시라 그렇게 진행.

총평

재설계 방향 자체는 옳음 — 인덱스 기반 재추적, State<State<T>> UB 해소, retractUnder/retractSelfAndUnder 통합, 체크포인트 패턴 철회, Relate 대량 정리 전부 근거가 맞고 archive/checkpoint-handler-pattern-reversed.md도 정확함. 문제는 그 결정에 맞춰 새로 쓴 의사코드들이 손 트레이싱을 안 거쳤다는 것 — 네 번째 세션이 "합성 시나리오를 pseudocode에 손으로 대입해 버그 찾기" 라운드였는데, 정작 그 결과로 다섯 번째 세션이 새로 쓴 코드에는 같은 방법을 안 돌린 채 커밋됨.

발견된 실제 버그 4건

1. Dispatch.process가 하위 위임 retractor를 통째로 유실 (치명)

bind-system-plan.mdchains 의사코드가 chains:SetStrong(inst,k,list)h.process 에 두고 있었고, list 확보도 chains:GetStrong(...) or {} 였음. h.process가 내부에서 재귀 Dispatch.process(...,index+1)을 부르는 게 정상 경로(StoreBind/NoneHandler)인데, 최초 마운트 시점엔 chains에 아직 아무것도 없어 재귀 호출이 or {}로 자기만의 새 테이블을 만들어 저장한 뒤 바깥이 그걸 덮어씀.

Frame { Text = state } 트레이싱:

단계 chains
process(idx1) StoreBind 진입, list={} (미저장) 없음
ㄴ observer 즉시 1회 → process(idx2) Property {[2]=noop} (별도 테이블)
StoreBind 반환, list[1]=r1, SetStrong(list) {[1]=r1} — 앞의 것 유실

증상: 이후 첫 재발행에서 retractFrom(inst,k,2,...)#list==1이라 아무것도 안 부름. Property(no-op)면 무해하지만 인덱스 2가 Slot이면 이전 서브트리가 파괴 안 된 채 새 서브트리가 마운트(자식 중복), Ref면 이전 Ref가 stale하게 남음. 즉 이 재설계의 핵심인 다단 체인 정리가 최초 마운트 경로에서 통째로 깨져 있었음.

수정: SetStrongh.process 위로 hoist. 추가로 h.process 호출 전에 no-op 점유 마커를 박도록 함 — 재귀 중 list에 구멍이 생기면 #list가 Lua에서 미정의이고(hole 있는 테이블), 같은 index 재진입 버그도 가드에 안 걸리기 때문.

2. Attribute 그룹의 소유권 충돌 감지가 실제로는 절대 안 걸림 (치명)

attribute-plan.md "이름 소유권" 절은 "점유 체크가 소유권 충돌 감지를 그대로 대신함"을 이번 재설계의 핵심 근거로 선언하는데, 정작 "메커니즘" 절 코드가

Dispatch.retractFrom(inst, key, 1, source)  -- 인덱스 1을 무조건 비움
Dispatch.process(inst, key, source, 1)      -- → 점유 error가 날 수가 없음

이라 그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음 (그룹 B가 그룹 A의 바인딩을 파괴하고 이김, 나중에 A의 클로저가 돌면 B의 바인딩을 대신 철거). 이 절이 없앴다고 선언한 바로 그 문제.

(그룹↔직접 리터럴 쓰기 방향은 정상 작동했음 — 배열파트가 먼저라 그룹이 점유하고, 해시파트 직접 쓰기는 retractFrom 없이 process만 부르므로.)

수정: retractFromprocess에서 빼고 반환 클로저가 자기가 등록한 이름 전부를 철거하도록 이동. 생존 이름도 매 사이클 철거→재등록되는 비용이 생기지만(구독 해제+재구독, 같은 값 SetAttribute 1회) 이 문서가 이미 "값 비교는 안 함"을 확정해둬서 결이 같음. TaghintValue == v 조기 반환을 여기 넣으면 안 됨 — 클로저가 아무것도 안 걷어낸 채 다음 process가 같은 인덱스를 잡으려 들어 자기 자신에게 점유 error를 냄.

3. SlotHandler가 claim 실패에도 파괴적 클로저를 반환 (치명)

claimOwnercurrent == ownerKey → return false 분기가 두 개의 서로 다른 상황을 구분 못 함:

  • 같은 (inst,k)의 spurious 재발행 (정상, no-op이어야)
  • 같은 inst의 다른 위치 (Frame { slot, slot }) — error여야 하는데 false

후자에서 attach는 건너뛰면서 파괴적 클로저는 그대로 반환해서, 철거 시 destroySlotTree 두 번 + unbindLifetime 짝 어긋남 + releaseOwner가 (이번 세션에 새로 넣은 엄격 버전이라) error로 터짐. 구 설계는 kSlotMap에 안 적힌 자리의 retract가 자연히 no-op이라 우연히 막혀 있었고, kSlotMap 제거가 그 방어를 같이 걷어낸 회귀.

감사 중 오답 하나 — 기록해둠: 처음엔 "claim 실패 시 no-op 클로저를 반환"으로 고치려 했는데 이건 더 나쁨 — retractFrom은 클로저가 early-return하든 말든 체인에서 항상 소비하므로, spurious 사이클에서 no-op을 심으면 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐. 원래의 단일 파괴적 클로저 구조가 맞고, 고칠 곳은 claimOwner뿐이었음.

4. Ref retractor가 자기 dedup을 스스로 무력화

if hintValue ~= v then v:Set(nil) end
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end  -- 무조건

spurious 재발행에서 Set(nil)은 건너뛰지만 relate를 지워서, 이어지는 processold ~= v가 항상 참 → v:Set(inst) 재실행 → 콜백 헛 재통지. 바로 아래 산문이 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. 사용자가 이 건은 즉시 확인해줌("4. 는 확인했어요 맞습니다"). 수정: relate 정리를 if hintValue ~= v 블록 안으로.

사용자 제기 — Slot-in-Slot / state<Slot> 소유권

"slot in slot 을 생각해보면 a=Slot {}; Slot{a,a} 도 UB 여야할텐데, Slot{state} 또한 가능합니다. ... Slot in slot 도 안전해야하는데, 저는 후자가 맞는듯 합니다."

조사 결과 후자가 맞고 실제로 성립하지만, 추가 수정 셋이 필요했음:

  • N1: rawAddclaimOwner의 반환값을 아예 안 봄 → Slot { a, a }가 조용히 통과해 _elements={a,a}, attachSlot이 두 번 불려 부모 lengthList에 같은 Slot이 두 번 계산되고 slot.Offset도 두 번째가 덮어써 첫 Source가 고아가 됨. → nested엔 "재클레임"이라는 개념이 애초에 없음(reconcile은 항상 rawRemoverawAdd 순서, rawMove/rawSwap은 클레임 미접촉)이라 엄격 error가 맞음. top-level만 claimOwnerAt(element,inst,k)으로 위치까지 봐서 spurious를 구분.
  • N2 (위치 키잉이 안전한 이유, 사용자 확인): "바깥 slot 안에서의 인덱스는 여전할거예요. 오직, flatten 되어진 상태에서의 위치만 달라질 뿐이고 ... offset/index 구현이 그걸 증명해요" — 물리 배치 변동은 전부 offset이 흡수하도록 설계돼 있고, top-level의 k는 props 배열 리터럴 위치라 더욱 고정. nested는 Move/Swap/Splice가 인덱스를 밀지만 위 결론대로 위치를 안 쓰므로 무관.
  • N3: rawRemove 의사코드에 releaseOwner가 아예 없었음(산문 쪽은 있다고 명시 — 코드/산문 불일치).
  • N4: destroySlotTree가 자식 releaseOwner를 안 하고 _mounted/ _mountedInst도 안 되돌림 → elementOwner 값이 weak라 "언젠간 사라지지만 언제인지가 GC 타이밍에 달려서" 그 전에 같은 element를 재사용하면 "이미 마운트됨" error가 비결정적으로 터짐.

state<Slot> 자체는 구조가 outer._elements[i] = sub(래퍼 Slot, 영구 고정) → sub:Single(state):List reconcile이 안쪽만 교체이고, reconcile이 rawRemove(prev) 다음 rawAdd(result) 순서라 소유권이 release→claim으로 정확히 갈림. 즉 "래퍼가 불변이라 괜찮다"가 아니라 nested CRUD의 release→claim 규율 자체가 안전성을 만듦 — 그래서 손으로 중첩한 Slot{Slot{Slot}}도 같은 규칙 하나로 동작. 이 결론을 slot-plan.md에 표와 함께 별도 절로 기록.

그 외 수정

  • AttributeGroupHandler.process(inst, index, v) — 코퍼스에서 유일하게 계약 (process(inst,k,v,index) 4-인자)과 안 맞던 시그니처. 배열 위치를 하필 index로 불러 새 index 파라미터와 충돌하기까지 했음.
  • Dispatch.drive의 진입 인덱스(1)가 어디에도 안 적혀 있었음.
  • retractor 안에서 같은 키retractFrom을 부르는 것도 process처럼 금지(진행 중인 루프가 #list를 이미 캡처)임을 명문화 — 원래는 "다른 키에 대해서는 문제없음"이라는 괄호로만 암시됐음.
  • 깊은 체인에서 hint 유실: retractFromvi == index에만 넘기므로 State<State<Tag>>의 바깥 재발행에선 TagHandler가 nil 힌트를 받아 RemoveTag→AddTag 깜빡임. 구조상 불가피(바깥은 안쪽 값을 모름)해서 tag-plan.md의 깜빡임 방지 주장을 "직속 위임 1단계 한정"으로 범위 축소.
  • 미문서화 접근자 Tag:Names()/Attribute:NameMap()을 각 API 절에 추가.

전파 누락 정리 (커밋이 안 건드린 것들)

  • ROADMAP.md가 전혀 갱신 안 됨 — CLAUDE.md가 "구현 순서의 소스"라고 못박은 문서인데 M2 Dispatch/init.luau 항목이 아예 2026-08-08 이전 모델 ("이전 담당자와 다르면 그 retract")이었고, chains도 핸들러 배열, retractUnder도 그대로. M2/M4/M7 전부 새 모델로 갱신 + 위 버그 1을 다시 내지 않도록 "구현 시 반드시 지킬 것" 체크리스트 추가.
  • base 안에서 계약 개수가 모순store-semantics.md는 이번에 3종으로 고쳤는데 module-lifecycle-plan.md/component-composition-plan.md는 4종, ui-shorthand-plan.md/onchange-plan.md/tween-plan.md/ lifecycle-pattern.md는 별도 retract 필드 전제. 전부 갱신 (onchange-plan.md는 단순 이름이 아니라 Connection Disconnect 로직이 반환 클로저로 이동해야 하는 실질 변경이었음).
  • luau-test/04설계와 정반대를 검증 중이었음 — 파일명부터 retractUnder이고, 다섯 번째 세션이 없앤 "중복 핸들러 즉시 error" 가드를 테스트하는데 luau-test/README.md는 "[2026-08-13 재작성] 신규 가드가 즉시 error하는지"라고 최신인 척 적어놨음. 04-dispatch-chain- retractFrom.luau로 전면 재작성 — 인덱스 기반 3단 체인, 깊은 쪽부터 정리, hint 전달 범위 + 버그 1을 재현하는 음성 대조군(SetStrongprocess 뒤로 옮기면 체인 깊이가 1로 무너지는지)을 포함. 옛 04의 no-op retract 스텁이 사각지대였던 전례를 반복하지 않도록 이번엔 retractor가 실제로 구독을 끊음.
  • luau-test/19 B/C 섹션이 폐기된 설계(rawNew+owners, 3분기 claimOwner)를 검증 중 — 파일 헤더에 무엇을 어떻게 다시 써야 하는지 배너로 명시(재작성 자체는 미착수).
  • slot-plan.md의 GC 주의 문단/relate-plan.mdkSlotMap/slotOwner를 현재 설계처럼 서술하던 것을 역사 표시로 정정(둘 다 지금은 없음 — 일반 규칙은 계속 유효).
  • .claude/README.mdSlotHandler.retract 언급 제거 + 세 행에 이번 감사 결과 반영.

남은 것

  • luau-test/19 B/C 섹션 재작성(헤더에 지시만 남김).
  • 스파이크 실측 자체는 여전히 미실행(이 환경에 luau 바이너리 없음) — 새로 쓴 04의 음성 대조군이 실제로 깊이 1로 무너지는지 확인하면 버그 1의 재현·수정이 실측으로 닫힘.

후속 라운드 (같은 세션, 사용자 추가 질문)

State<Slot?>의 사라짐/재등장, 그리고 포탈

사용자 질문: stateSlot: State<Slot?>Slot { stateSlot }에 넣고 지웠다 나타나게 하면 문제 없는가. 그리고 Get()으로 뽑아두고 Set()으로 갈아끼운 뒤 뽑은 걸 다른 데 넣을 수 있는가 — 되면 포탈이 해결됨.

  • 소유권 bookkeeping은 정상(이번 감사 수정 이후) — reconcile이 rawRemove(→releaseOwner) / rawAdd(→claimOwner)로 깨끗이 갈림.
  • 그러나 Slot 자체가 파괴됨 — reconcile이 쓰는 게 rawRemove(제거 + 파괴)라 nil이 되는 순간 destroySlotTree가 자식 Instance를 :Destroy()함. 같은 Slot 객체를 nilslotA로 왕복시키면 두 번째 등장부터 껍데기만 재마운트됨. 기존 "폐기, 옮기지 않음" 정책의 직접 귀결이지 이번 감사로 바뀐 게 아님.
  • 포탈은 현재 불가, 그러나 부품은 이미 다 있음 — 막는 건 소유권 규칙이 아니라 "제거 = 파괴"라는 reconcile의 선택 하나뿐이고, Extract/ExtractAll/Splice가 이미 비파괴로 확정돼 있고 attachSlot이 재마운트를 구조적으로 이미 지원함(이번에 넣은 destroySlotTree_mounted 복원이 마침 그 전제 조건이기도 함). "포탈은 별도 메커니즘이 필요하다"는 기존 전제가 틀렸을 가능성이 큼 — 남는 실제 작업은 (1) 비파괴를 어디에 opt-in으로 열지, (2) attachSlot의 등록(setLength/setOffsetSource/자식 observer)에 대응하는 해제 짝이 지금 없다는 것. 확정은 사용자 몫이라 question.md 0-A로 올리고 slot-plan.md에 상세 기록.

State<Tag>는 힌트를 확실히 받는가 — 받음

retractFrom은 힌트를 i == index 자리에만 넘김. 한 겹 (StoreBind@1TagHandler@2)에서는 StoreBind가 retractFrom(inst,k,2,realv)를 부르므로 정확히 그 핸들러에 걸림 — 유실 없음. 두 겹 이상에서 바깥이 재발행할 때만 안쪽이 nil을 받음. 이 계약을 bind-system-plan.md에 명시적으로 못박음(원래는 의사코드에만 있었음).

사용자 제안 — 핸들러 identity를 저장해두고 값을 한 겹 풀어 내려보내기

기각. 사용자 스스로 지적한 이유(=process와 retractor가 1:1이 아니고 retract만 나는 경로가 정상적으로 존재해서 내려보낼 "새 값"이 없는 경우가 있음)에 더해, 더 근본적인 이유가 있음: 값을 한 겹 풀려면 teardown 도중 innerState:Get()투기적으로 호출해야 하는데, State는 pull-recompute라 실제 재계산을 유발하고 곧이어 process가 다시 :Get()을 불러 같은 사이클에 이중 계산이 됨. 게다가 그렇게 얻은 값은 추정이라 실제 process 시점 값과 다를 수 있음("값 비교/캐싱 금지" 원칙과 충돌).

대신 채택 방향(사용자 제시): 값 층에서의 평탄화 (State<State<T>>State<T>). 체인이 한 겹으로 유지되면 위 보장이 그대로 적용되므로 Dispatch에 특수 배관이 필요 없어짐. 백로그로만 등록 (research/operator-sugar-plan.md) — 2026-08-13 두 번째 세션의 Haskell 비교가 이미 "Monad join이 미일반화 후보"로 짚어둔 자리에 구체적 동기가 붙은 것. State<State<T>>는 UB는 아니지만 권장 방향도 아니라는 사용자 판단을 그 문서에 명시.

재발 방지 조치 (사용자 요청)

"새로운 모델로 인해 생겨날 수 있는 흔한 실수들이 재발하지 않도록 잘 정리해주세요. Relate 나 Dispatch/Handler 전반에서 더 실수가 나오지 않도록 조치가 필요해보여요."

  • bind-system-plan.md"Handler 작성 체크리스트 — 새 모델에서 실제로 반복된 실수들" 절 신설(7항목). 이번 세션 버그 4건이 서로 다른 문서에 있으면서도 같은 종류의 착각에서 나왔다는 관찰이 출발점 — 특히 (1) "클로저는 early-return해도 체인에서 소비된다"(no-op 반환 유혹), (2) "다른 키로 위임하며 그 키를 미리 retractFrom하지 말 것"(점유 체크 무력화), (3) hintValue의 세 가지 함정(nil 아님/타입 미보장/깊은 인덱스엔 안 옴)이 실제로 밟힌 것들.
  • relate-plan.md"언제 Relate를 쓰고 언제 쓰면 안 되는가" 절 신설 — 클로저 캡처로 충분한 경우 vs 진짜 필요한 경우의 경계, 그리고 "정리 조건을 실제 정리와 묶을 것"(Ref 버그), "weak라고 GC에 기대지 말 것"(Slot 버그) 두 규칙.

세 번째 라운드 (같은 세션) — 사용자 설계 결정 3건

A. State<Slot> 교체는 파괴가 아니라 언마운트 (확정, 앞 라운드 결론을 뒤집음)

앞 라운드에서 "reconcile이 rawRemove(파괴)를 쓰므로 포탈 불가"라고 보고했더니, 사용자가 설계 자체를 뒤집음: "state에서 slot 빼내면 빠질 수 있는게 나은듯. 애초에 스플라이싱을 지원하는데?"

근거:

  • state<Frame>가 이미 그렇게 동작함 — 다른 값으로 바꿔도 이전 Frame을 quad가 지우지 않고 unmount만 함. State<Slot>만 다를 이유가 없음. Ref("Destroy 무관")/Attribute("명시적 None으로만") 철학과도 같은 결.
  • 비파괴 추출(Extract/ExtractAll/Splice)은 이미 지원되는 개념.
  • "뽑아냈으면 리프의 소유가 아닌 게 맞아보임. 명시 :Destroy()를 하도록 유도하는 게 이로운 것 같음."
  • "슬롯은 '들고 있다 죽으면' 같이 소멸한다" — GC-native 그대로.

부수 효과(사용자 지적): 하위 요소까지 bindLifetime하고 canExecute를 확인하게 하면, "nested로 마운트 후 Instance를 제거하고 그 Slot을 뽑아 쓰려는" 경로가 별도 방어 없이 자연히 막힘 — 기존 게이트를 한 층 더 촘촘히 적용하는 것뿐.

그래서 포탈은 별도 기능이 아니라 이 결정의 귀결이 됨 — 앞 라운드에서 "opt-in으로 열지"를 열린 질문으로 뒀는데, 기본 동작이 되면서 그 질문 자체가 사라짐. 남은 실제 작업은 하나: attachSlot이 등록하는 것들 (자식 observer bindLifetime, 옛 owner의 setLength/setOffsetSource)에 대응하는 해제 짝이 지금 없음.

곁들여 확정된 UB: Set으로 덮어쓰기 전에 이전 값을 직접 Destroy()하는 것(= state<Frame>에서 먼저 frame:Destroy()하고 Set하는 것과 같은 문제). 순서는 항상 Set(언마운트) → 그 다음 정리.

B. dispose(any) 신설 방향 (사용자 제안)

위 UB를 "조심하세요"로만 두지 않기 위해, base 탑레벨 dispose(value)를 제공 — "이 값을 지금 확실히 없앤다, 아직 마운트돼 있으면 먼저 안전하게 떼어낸 뒤 파괴". 사용자 논증이 깔끔했음: "이미 a=Frame{}; Frame{a} Frame{a}가 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고, 그래서 이미 가능한 일"elementOwner를 거꾸로 읽으면 되므로 새 부기가 필요 없음. 시그니처/대상 범위/unbindLifetime과의 분담은 question.md 0-B로.

C. hintValue 폐기 제안 — 검토 결과 사용자 지적이 맞음

사용자 제기: "process 상 newv(hint) 처리가 단일 네스팅이면 None -> AttributeKey 같은걸 탄다던가 하면 머리아픈데. 괜찮은게 진짜 맞나 검토가 필요해."

재현됨. [AttributeKey "foo"] = state5None을 오갈 때 체인 모양이 StoreBind→NoneHandler→AttributeKey(None) ↔ StoreBind→AttributeKey(5)로 바뀌고, 5 → None 전이에서 인덱스 2의 AttributeKeyHandler retractor가 hintValue = None 을 받음. Attribute는 no-op이라 무해하지만 TagHandler였다면 isTag(None)이 거짓이라 이름 전부를 RemoveTag함.

진짜 문제는 깊이가 아니라 "힌트의 타입이 계약으로 정해져 있지 않다"는 것이었음 — 앞 라운드에서 문서화한 "깊이 2 이상에선 nil"은 이 결함의 한 특수 케이스일 뿐이었다는 게 이번에 드러남. 힌트가 None/ State/Tween 등 래퍼일 수 있어서, TagContains skip과 Ref/Slot의 identity 비교가 정확성은 유지한 채 조용히 꺼짐(그래서 지금까지 안 드러났음). Slot은 "가드 없으면 서브트리 전체 파괴 후 재생성"이라 파급이 큼.

사용자 제안 방향: 철거 후 힌트와 재구축 대신, process를 쭉 진행하며 각 자리에서 "이전 핸들러 vs 새 값에 매치될 핸들러"를 비교 — 다르면 그 아래 전부 죽이고, 같으면 값을 계속 전파하며 자기 인덱스에 대해서만 후처리. 전제 계약: "inst에 실질적 처리를 가하는 건 항상 말단 핸들러, 중간 노드는 언워랩만". 결론적으로 "새 프로세싱으로 인한 retract와 단순 retract는 다르다".

검토 결과:

  • 전제 계약은 기존 핸들러 9개 전부에서 이미 성립(표로 확인) — 새 제약이 아니라 이미 있는 성질의 승격이라 채택 비용이 낮음.
  • 단, "같은 핸들러면 유지"는 StoreBind에 그대로는 틀림 — 인덱스 2가 StoreBind인 채 바깥이 새 inner State를 내놓으면 "같은 핸들러"지만 옛 State에 구독돼 있어 갈아타야 함. → "유지"가 아니라 "그 인덱스에 process를 다시 호출(retractor는 안 부름)" 이어야 함.
  • 그러면 핸들러가 이전 값을 알아야 하는데 → 힌트 대신 oldValue를 넘기자는 보완 제안. chains가 retractor 옆에 (handler, value)를 같이 저장하면 됨. oldValue그 핸들러가 직전에 실제로 매치한 값이라 타입이 구조적으로 보장되어 None/래퍼 오염이 원천 차단되고, 방향도 맞아서(hintValue는 "다음", oldValue는 "이전") RefLeafHandler의 dedup용 Relate도 없앨 수 있음. isX(v) 방어 가드 규칙 자체가 힌트의 타입 미보장을 메우던 임시방편이었음이 드러남.
  • 주의: 같은 인덱스 재프로세싱을 허용하려면 점유 체크를 갈라야 하는데, 그 가드가 곧 Attribute 그룹의 소유권 충돌 감지라 약해지지 않는지 반드시 같이 확인해야 함(이번 감사에서 정확히 그 지점이 한 번 무너진 전례가 있음).

실행은 보류base/ 4개 문서 의사코드 전면 재작성 규모라, 같은 날 두 차례 급하게 쓴 의사코드에서 버그가 나온 전례를 감안해 research/dispatch-hint-to-oldvalue-plan.md로 먼저 정리하고 확정 대기 (question.md 0-A). 그때까지 base/의 현행 hintValue 서술이 유효.

D. 평탄화 백로그 상세화 (사용자: "백로그에서 더 자세히 다루도록 업데이트만 하자")

state:Flatten()/Flat()Operator.* 자유 함수가 아니라 State의 메소드로 제공되어야 함(특정 노드를 따라가는 새 노드를 만드는 것이라 :Compute/:With와 같은 층위). 대상은 State<State<T> | T>(섞인 경우까지 흡수). 핵심 난점(사용자 지적): 반환 노드가 동적 의존성을 가짐 — quad가 암묵적 자동 추적을 기각했고 "동적 :With 미지원"을 2026-08-12 열여덟 번째 세션에 의도된 트레이드오프로 확정해뒀는데, 이 도구는 그 유일한 정당한 예외를 요구함. 확정 전 답할 것: 동적 의존성을 노드 내부에 가둬 바깥에선 평범한 State<T> 하나로 보이게 할 수 있는가, 옛 구독 해제 타이밍과 bindLifetime 귀속, 그래서 순수 슈가가 아니라 진짜 새 프리미티브인지(현재 판단은 후자). 착수 안 함.

네 번째 라운드 (같은 세션) — 사용자 반문으로 모델 확정, 내 제안 두 개 철회

dispose는 "떼어낸 뒤 파괴"가 아니라 "거부하고 error"

"dispose 는 정확히 Frame 이든, slot이든 어느 트리에 의해 살아 있는게 요구된다면 Destroy 거부한다, 에러를 낸다고 보면 되겠네요. 실제로 클리어 하거나 Destroy 해도 그냥 로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요."

내가 쓴 "먼저 안전하게 떼어낸 뒤 파괴"보다 훨씬 단순하고 맞음 — 떼어내는 건 Set(언마운트)의 몫이고 dispose는 그 뒤에 부르는 것. 이걸로 "Set 전에 직접 Destroy()"가 UB에서 명확한 에러로 바뀜.

Slot의 "해제 짝"은 애초에 필요 없었음

"옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각. retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...) setOffsetSource(...None...) 될 수 있어요."

맞음 — 이미 확정된 "마운트 안 하는 위치는 0/None 등록" 관용구 그대로라 해제 = 0/None으로 재등록이고 새 API가 필요 없음. 앞 라운드에서 "이게 언마운트 전환의 실제 작업량"이라고 꼽은 판단은 과했음. state<state<Frame>>류 offset 밀림은 state<state<Tag>>와 같은 범주로 "그냥 확인된 것"으로 수용(평탄화 도구가 처리, 케이스 드묾).

디스패치 모델 확정 — 내 반론 두 개가 다 틀렸음

(1) "같은 핸들러면 StoreBind가 구독을 못 갈아탄다" — 내가 사용자 제안을 "같으면 유지"로 잘못 읽은 데서 나온 반론이었음. 실제 제안은 "같으면 내가 처리"(= 그 자리 클로저 호출 → 자기 process 재호출)라 옛 구독 해제와 새 구독이 그 안에서 끝남. 사용자: "다만 뭐가 문제인지 모르겠어요" — 문제 없었음.

(2) oldValue를 따로 넘기자는 보완안 — 사용자: "이전 값인 oldValue 는 처음부터 클로저라 이미 본인이 알지 않아요?" 맞음. 클로저는 자기 v를 캡처하고 힌트로 새 값을 받으므로 old/new를 이미 둘 다 갖고 있음. 진짜 문제였던 힌트의 타입 보장은 "핸들러 비교를 클로저 호출 에 둔다"는 것만으로 해결됨(같은 핸들러일 때만 값이 넘어가고, 그 값은 정의상 isHandlable을 만족) — chains에 추가할 건 비교용 handler 하나뿐.

부수 발견: 이 모델이면 깊은 체인의 힌트 유실도 같이 사라짐. 힌트를 위에서 아래로 전파하는 게 아니라 각 레벨이 자기 재프로세스에서 자기 힌트를 받으므로, State<State<Tag>>에서 바깥이 새 inner를 내놔도 index 3의 TagHandler가 진짜 Tag를 힌트로 받아 Contains skip이 살아남. 앞 라운드에 "구조상 불가피"라고 적었던 건 철거-선행 모델에서만 참이었음. HandlerChanged 마커도 불필요(핸들러가 바뀐 건 retractor가 nil 힌트로 불린다는 사실로 이미 표현됨).

유일하게 이견 — Attribute 소유권은 "자연히" 처리되지 않음

사용자는 "이 방식으로 가면 자연히 Attribute 의 소유권 충돌은 처리되네요" 라고 봤으나, 추적 결과 그렇지 않음: 그룹 A가 잡아둔 이름에 그룹 B가 들어오면 인덱스 1의 핸들러가 양쪽 다 StoreBind라 "같은 핸들러"로 판정돼 조용히 갈아타고, 나중에 A의 클로저가 B의 바인딩을 대신 철거함(교차 오염) — 이번 감사에서 고친 바로 그 증상이 되돌아옴. 다만 사용자의 나머지 절반 ("오류 처리가 필요한 곳이면 직접 처리하면 되니까요")은 맞고, 그 '직접 처리'를 뭘로 할지가 유일한 결정 사항. 권고: 이름별 claimant Relate를 Attribute 쪽에 국소적으로 둠 — 네 번째 세션에 owners로 만들었다 기각됐던 그것인데, 당시 기각 사유("소유권 반납이 v==nil 분기에만 있어 안 지워짐")가 새 모델에선 구조적으로 소멸(클로저가 항상 불리고 거기서 반납). 문서: research/dispatch-redispatch-diff-plan.md(파일명도 dispatch-hint-to-oldvalue-plan.md에서 바꿈, oldValue가 철회됐으므로).

다섯 번째 라운드 (같은 세션, 마무리) — 해제 순서 주의 + Attribute 이관

setOffsetSource(None)setLength(0) 순서 고정 (사용자 지적)

"setLength 먼저 수행하고 setOffsetSource 수행하기 보단 setOffsetSource 를 먼저 날려야할듯 합니다 - 물론 별 상관 없어요. 자기 자신의 length 가 줄어든다는 의미는, 자기 자신 offset은 여전하다는건데, 그래서 업데이트 될 일이 없긴합니다. 다만, 여전히 setLength 로 인해 다시 slot 들의 offset들이 재계산 될 때 invalid 한 offset source 자체가 있다는것 부터 위험합니다. 방어적으로 처리하세요."

setLength는 끝에서 recompute를 돌리고 recomputesourceList를 순회하며 offset:Set(sum)을 호출함 — 그래서 setLength(0)을 먼저 부르면 그 시점에 해제 중인 자리의 sourceList[i]엔 아직 옛 Slot의 offset Source가 남아 있어, 지금 막 떼어내는 서브트리의 Source에 :Set()이 날아가고 그걸 구독하던 (곧 없어질) 자식들의 LayoutOrder 계산이 헛되이 캐스케이드됨. setOffsetSource(None)을 먼저 하면 recomputeoffset ~= None 가드에 바로 걸려 그 Source를 아예 안 건드림.

값이 틀려지는 문제는 아님(자기 length가 줄어도 자기 앞 형제들의 누적합은 그대로라 자기 offset은 갱신될 일 자체가 없음) — 위험한 건 "invalid한 Source가 순회 대상에 남아있다"는 것 자체. 방어적으로 순서를 계약으로 고정하고, 추가 조치 둘을 같이 넣음:

  • 해제 시 slot.Offset = nil(이 문서의 "마운트 전엔 nil" 규칙과 짝),
  • recomputesourceList[i]nilNone과 똑같이 skip(전이 구간에서 관측돼도 크래시 대신 넘어가도록 — 등록 쪽의 "반드시 None" 의무는 그대로).

base/bind-system-plan.md(Length/Offset 절, recompute 의사코드)와 base/slot-plan.md(언마운트 절) 양쪽에 반영.

Attribute 소유권 — 다음 세션 심층 분석으로 명시 이관

"Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요. 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며 심층 분석해보겠습니다. 당장은 이 세션 중 나온 내용, 지식이 누락 없게 stale 없게 처리해주시고. ... 이미 세션이 길고, 이 부분을 더 깊게 파기엔 다른 부분을 누락할 위험이 있어요."

방향은 이미 잡혀 있음(이름별 claimant Relate 부활, 단방향이면 충분, 반납은 클로저 안에서) — 다만 확정은 사용자가 직접 스케치한 뒤로. question.md0-Z(최우선) 로 분리하고, CLAUDE.md "지금 할 일"에 0번 항목으로 올려 핸드오버.

핸드오버에서 가장 중요한 한 가지: base/의 현행 hintValue 서술은 아직 옛 모델(철거 선행)이고, 새 모델은 research/ dispatch-redispatch-diff-plan.md에만 있음 — base만 읽고 구현하면 옛 모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의 파일별 목록대로 4개 base 문서를 한 번에 옮길 것. 반대로 Slot의 언마운트/dispose/해제 순서는 이미 base에 확정 반영됨(재디스패치 모델과 독립적인 결정이라 먼저 들어감) — 이 비대칭을 헷갈리지 말 것.

여섯 번째 라운드 (같은 세션, 마무리) — 첫 실측 + 코퍼스 전반 감사

스파이크 첫 실측

luau/luau-analyze가 사용 가능해져 2026-08-09 이래 처음으로 돌림. 런타임 12개 전원 통과. 상세는 audit/luau-test-first-run-2026-08-13.md, 상태 분류는 신설한 luau-test/STATUS.md(사용자 요청: "사람이 보기엔 뭐를 바로 봐야하는지 눈에 안 띄어서").

핵심 셋: 04가 이번 세션 감사의 버그를 음성 대조군으로 재현(체인 깊이 3→1, 죽은 store가 UI를 덮어씀), 07은 보강해야 실제 검증이 됐고(연쇄 GC 미검증 상태였음 + "weak 엔트리를 셀 방법 없다"는 자기 전제가 틀렸음), 18Relate 상호 순환 경고를 실증.

타입에서 진짜 이슈 하나 — :Compute(fn)의 lazy 핸들 계약이 Luau 양방향 추론과 충돌. 최소 재현으로 직접 좁힘(에이전트 보고를 액면 그대로 안 받음): read/self 표기 조정으로는 안 풀리고 콜백이 raw 값을 받으면 완전 클린 — 즉 계약 자체가 원인. question.md 0-Y 신설. 사용자 방향은 "순환 타입을 만들기보다 State 타입을 구울 때 인라이닝"(Modifier flat 타입 생성과 같은 결)이고, 사람이 직접 확인할 부분이라 0-Z와 함께 사용자 판단 목록.

부수 수확: 17 크래시의 원인이 실은 modifier-plan.md의 문서 결함이었음 — "데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽히는데, 그러면 __indexrawget 성공 시 안 불려 같은 필드 재호출이 죽음. 그 재호출 패턴이 문서 3·4절의 대표 용례라 실사용에서 즉시 터지는 경로.

코퍼스 전반 모순·stale 감사 (사용자 요청)

에이전트 둘로 base 내부 / base↔바깥 정합성을 나눠 감사. 오탐 방지를 위해 "의도적으로 미반영인 것"(⚠️ 배너가 예고하는 hintValue 건, research/의 미래 설계안, archive/·session/의 원문 보존)은 보고 대상에서 제외하도록 지시. 보고받은 것은 전부 직접 사실 확인 후 수정:

가장 위험했던 것 — ROADMAP.md가 최우선 게이트를 전혀 안 짚고 있었음. base 4개 문서엔 ⚠️ 배너를 달아뒀는데 정작 구현 순서의 소스인 ROADMAP엔 없어서, M2/M4/M10 담당자가 그대로 옛 모델로 구현할 위험이 실재했음 — 세 마일스톤 전부에 배너/포인터 추가.

두 번째 — slot-plan.md 앞부분이 뒤집힌 결정을 "확정"으로 자칭. 487-495행("폐기, 옮기지 않음, portal은 오버엔지니어링")과 "열린 질문" 절이 역전 표시 없이 남아 있어, 문서를 앞에서부터 읽는 구현자가 구 모델로 짤 위험. 🔄 역전 배너 추가. 더 심각하게 reconcile 의사코드가 여전히 rawRemove(파괴)를 부르고 있었음 — 같은 문서가 "[반영 완료]"라 태그해둔 것과 정면 모순이라 rawUnmount를 신설해 교체. filter/toggle 근거 절도 "제거=파괴" 전제 위에 서 있어 캐비엇 추가(결론 자체는 언마운트로도 유효).

그 외: ROADMAP.md M11 Tween이 이미 확정된 결정 넷을 미결로 두고 있던 것(override 정책/옵션 값 모양/Animate 시그니처/initValue)과 죽은 링크 4곳, bind-system-plan.md/architecture.md의 "4종 계약" 잔여, store-semantics.md의 "State는 가칭" stale(2026-08-12에 확정됨), luau-test/README.md04/19 판정 기준이 재작성된 파일을 못 따라간 것 (19 C섹션 기준이 정상 동작을 실패로 오판하게 돼 있었음), documentation-content-map.md의 4종 계약/Attribute<T> 옛 이름, modifier-plan.md의 "정정이 원문에 소급 안 됨" 패턴.

판단해서 안 고친 것: effect-plan.md의 "cleanup" 14곳 — Handler retract(내부 배관)와 달리 Effect(fn)에서 사용자가 작성한 fn이 반환하는 React식 콜백이라 다른 층위. 매 감사마다 재지적되므로 lifecycle-pattern.md에 "확인 완료, 의도된 별개 개념"으로 못박아 둠.