quad/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md
qwreey 0498816c10
design: Epoch/EpochMap/Brand 전면 승격 + 해소 기록 flatten
앞 세션이 컨텍스트 피로로 미뤄둔 승격(todos 000번)을 수행하고, 코퍼스에
쌓여 있던 [해소]/[정정] 층을 걷어냈다. 감사 3라운드 + /code-review high로
15건을 잡아 전부 반영했다.

승격 — base/ 넷 + 파급 넷
- state-epoch-plan.md 재작성: Epoch 인터페이스({Revision:number}, 그 자체로
  키가 되는 unique 테이블, Source가 구조적으로 만족)와 EpochMap(Update/
  Refresh/Sync/TrackFrom) 신설. State는 EpochMap을 둘 컴포지션 —
  sourceCountMap/sourceEmitMap -> valueEpochMap/emitEpochMap. §1~§8로 재편.
- brand-plan.md 전면 재작성: 공유 레지스트리 + Brand.get(객체당 태그 하나)
  -> 인스턴스 브랜드 Brand() + :register/:is, 다중 태깅 허용. 발단은 Source가
  SourceBrand이면서 동시에 EpochBrand여야 하는데 옛 모양으로 표현 불가.
  역조회는 제거(전수 조사에서 쓰는 자리 0). weak-key/테이블 아이덴티티/
  duck-typing 기각 근거/predicate 합성은 전부 유지.
- source-state-plan.md: Source가 Epoch도 구조적으로 만족(Revision은 공개여야
  타입 레벨에서 성립), Observer 클로저가 fn(self, from: (Epoch|EpochSet)?).
-  effect-plan.md: 다중 의존성 중복 발화 미해결 항목이 닫힘 —
  EffectHandle이 자기 EpochMap을 들어 첫 번째만 통과시킨다.
- gate-plan.md/architecture.md/bind-system-plan.md/ROADMAP.md 어휘 통일,
  EpochMap.luau가 M2에도 필요하다는 것 반영(GateNode가 씀).

리비전 갱신은 bit32.bnot(-rev) — 사용자 확정
- a>0이면 a-1, 0이면 4294967295인 랩어라운드 감소. 갱신과 랩이 FASTCALL
  하나로 끝난다(luau 실측). 근거: 2^53 포화는 도달 불가능한데 그걸 피하려고
  값을 double 영역까지 키울 이유가 없다, 매번 도는 hot path다.
- 에이전트가 이걸 band(rev+1, mask)로 잘못 옮기고 "그러니 bit32가 더 싼 건
  아니다"라는 틀린 단서까지 달았다가 사용자 정정("제가 말한건 bit32.bnot(-a)
  입니다"). 세 문서에 정정 경위를 남겼다.
- 따름정리: 리비전은 증가가 아니라 감소한다. ==/~= 만 쓰는 지금 규칙에서만
  무해하다는 경고를 §2에 명시.

archive / flatten
- archive/brand-shared-registry-reversed.md 신설(옛 Brand 표면 원문).
- question.md 421->208줄: 해소 항목 18건을 archive/question-resolved.md로
  이관. 그 문서가 스스로 정한 규칙("해소하면 여기서 지우고 archive로")을
  다시 어기고 있었다. 이관분의 옛 필드명은 소급 수정하지 않고 머리에 경고만.
- todos.md 000번 삭제, "M3 착수 전 필요" 목록에서 해소 항목 일곱 제거
  (실제로 열린 건 중간 State GC와 store:GetDynamic 둘뿐).
- research/ -> reference/ 이동 둘(epoch-brand-composition,
  slot-attach-decomposition). 확정된 결정의 근거 기록은 research(상의 필요)도
  archive(뒤집힘)도 아니므로, reference의 폴더 기준에 그 용도를 명문화했다.

감사 3라운드(각도: base 정합성 / 인덱스+luau-test / archive+qa-request)
- §8에 "기각된 대안 — 게이트를 에포크 경계로" 논거 신설(재작성 때 떨어뜨렸고,
  두 문서가 서로 다른 없는 §번호를 대고 있었다).
- luau-test 스파이크 22를 done/ -> rewrite-required/(옛 Brand.set/get을 직접
  구현). STATUS.md 개수와 절 제목의 하드코딩 개수 정리.
- state-epoch/source-state가 "Revision을 증가시킨다"고 적어놓고 20줄 뒤에
  "감소한다"로 반박하던 자기모순 정정.

커밋 전 /code-review high — 9건, 전부 유효(감사자가 못 보는 축)
-  {Epoch}는 Luau에서 배열인데 실제 게이트 배치는 {[Epoch]:true} 집합.
  그대로 ipairs로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다(gate-plan 4번이
  애초에 고치려던 그 버그) -> EpochSet으로 확정.
-  새 노드 시딩이 확정된 EpochMap 표면으로 표현 불가능했다(:With의 상류는
  State이지 Epoch가 아니고, 키 열거/병합 연산이 없었음) -> :TrackFrom 신설.
- GateNode 예외(emitEpochMap을 전파 시점에 갱신)가 §4에 미기록.
- 설치 발화엔 from이 없다 -> 옵셔널로, Effect의 억제 플래그가 Update보다
  먼저여야 함을 명시.
- 2^32 랩을 "똑같이 도달 불가능"이라 한 근거가 틀렸다(같은 척도로 285년 vs
  72분). 실제 안전 근거는 충돌 조건이 한 점이라는 것으로 정정.
- 그 외 :Sync 용도 충돌, isEpoch 누락, TweenTag 3곳, Effect(fn,state?) 4곳,
  § 참조 3곳.

에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정
- :Refresh 유지 — "Update는 받은 것을 처리, Refresh는 내가 받았던 걸 처리라
  표면적 의미 자체가 다르다"(오버로드로 합치지 않음).
- :Absorb -> :TrackFrom 개명 — absorb는 상위에서 제거할 것처럼 읽히고,
  gate-plan이 이미 "흡수 집합"을 다른 뜻으로 쓴다. TrackFrom은 이 맵의 존재
  이유("내가 뭘 추적하고 있나")를 그대로 쓰고 From이 비파괴를 못박는다.

Epoch/EpochMap/Brand에 열린 설계 항목 없음. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-22 01:27:18 +09:00

14 KiB

2026-08-21-01 — Detach 보존 주체 / KeyGone / Owned 확정, attachSlot 분해

한 줄: QA 4라운드 followup의 마지막 열린 항목(F-3)이 사용자 회신으로 전량 닫히면서, :List의 요소 소유권 모델(slot._detached + KeyGone + Owned)과 attachSlot의 책임 분해(materializeSlotTree/mountSlotTree)가 확정돼 base/slot-plan.md에 반영됐다.

소스 관계: 지금 유효한 설계는 항상 base/slot-plan.md가 소스이고, 처리 경과 요약은 qa-request/pre-implementation-qa-round4-followup.md의 H절, 분해 근거는 reference/slot-attach-decomposition.md. 이 파일은 그 결정들이 어떤 논의를 거쳐 나왔는지의 원문 기록이다.


1. 발단 — F-3을 제안하게 된 관측

3차 처리(G절)까지 오면서 Detach(옛 PopOnly) 경로에 두 개의 구멍이 남아 있었다.

  1. 보존 주체가 userdata였다. 기존 문서는 updateFnDetach, { old = ..., source = ... }를 반환하면 "보존은 반환한 userdata가 담당"한다고 확정해뒀다.
  2. 키가 데이터에서 사라졌을 때의 처분이 미결이었다(question.md 3번, ROADMAP.md M6에 ⚠️로 박혀 있었음).

2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 gcconn 트릭이다 — base/lifecycle-pattern.md가 확정해둔 대로 quad가 만든 모든 Instance는 GetPropertyChangedSignal("ClassName") 커넥션의 클로저가 자기 자신과 gchold를 캡처하므로 참조를 놓는 것만으로는 회수되지 않는다. 즉 detach된 요소를 userdata 안에 넣어두고 :List가 그 키를 잊어버리면 GC 폴백이 아예 없이 영구 누수다. userdata:List에게 opaque라 "이 안에 뭐가 들었으니 죽여라"를 알 수 없다.

그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가 죽을 때의 정리는 Effect가 한다, (3) 키가 사라지면 updateFn에게 한 번 더 묻는다.

2. 사용자 회신 — 두 가지를 더 얹었다

"Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."

여기서 **"이미 detach인데 또 detach → nop"**과 **"prev를 그대로 반환 → 재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가 slot._detached로 옮겨간 순간 ud에 홀드할 필요 자체가 없어진다는 따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 prev로 그대로 돌려주니까 사용자 코드가 oldud에 담아 다니지 않아도 된다.

3. 반영 — settle()이 두 경로를 강제로 합친다

구현상 가장 중요한 판단은 정상 사이클과 소멸 루프가 같은 처분 함수를 공유하게 만든 것이다. settle(key, result, detach, pos) 하나가 wasMounted/wasDetached를 함께 보고 분기한다:

  • detach면 → rawDetach(언마운트하되 소유권 유지), _detached로 이동. 이미 _detached에 있으면 wasMounted == nil이라 자연히 nop.
  • result == prev인데 wasDetached면 → _detached에서 빼고 rawAdd = 재마운트.
  • result == nil이면 → releaseElement(self, prev, wasDetached ~= nil).

두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로 났던 버그(RC-1/RC-3/RC-4)가 전부 "비슷한 두 경로 중 한쪽만 고쳤다" 계열이었다.

rawRemove(소유권 해제 + 파괴) / rawUnmount(소유권 해제 + 파괴 안 함) / rawDetach(소유권 유지 + 파괴 안 함) 3형제로 분화한 것도 같은 이유 — "파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, Detach는 정확히 파괴 안 하면서 소유권은 유지하는 조합이라 기존 둘로는 표현이 안 됐다.

4. OwnedDetach와 직교하는 두 번째 축

C-2(:List의 "밀려난 prev는 dispose"가 state<Frame> 의미론과 충돌)를 닫은 것이 이 축이다. 정리하면:

  • Detach = 사이클 단위. "내 건데 잠깐 빼둠."
  • Owned = 설치 단위. "애초에 내 게 아님."

이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는 Owned = true일 때 맞다(그 요소는 updateFn이 만들었고, :List가 안 지우면 아무도 못 지운다). 사용자가 state<Frame>에 담아 넘긴 요소는 Owned = false로 설치되고 어떤 경로로도 파괴되지 않는다.

5. attachSlot 분해 — 사용자가 비용 논거로 확정

3차 처리에서 F-4-3(setLength 위치)을 물었을 때, 사용자가 문제를 더 크게 재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."

핵심 진단은 C6과 C7이 한 함수 안에서 동시에 만족될 수 없다는 것이었다:

  • C6: 부모에게 미는 길이(setLength(ownerKey, position, ...))는 flush가 끝나야 정해지는 최종값이어야 한다.
  • C7: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.

한 함수엔 setLength 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회 헛돎), 뒤에 두면 C7 위반이다. 함수를 둘로 쪼개면 각자 하나씩 만족한다.

사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:

"오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가 많아질 위험이 보임."

"이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함."

이건 conventions.md의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 충돌하지 않는다 — 이 분해가 방어하는 건 가상의 미래 요구가 아니라 이미 세 번 관측된 버그 클래스(RC-1/RC-3/RC-4, 전부 "줄 순서를 잘못 잡아서")이기 때문이다. 분해하면 그 순서 제약이 줄 순서가 아니라 함수 경계로 강제된다.

사용자가 스스로 답한 우려

같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:

"다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고... 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한, 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운 함수를 만드는건 옳지 않아보임."

확인해보니 Parent 대입 순서 자체는 안 바뀐다 — 바뀌는 건 ChildAdded 핸들러가 볼 때 서브트리의 Length/Offset이 이미 최종값이라는 점뿐이고, 옛 코드는 거기서 미완성 스냅샷을 보여줬다. 즉 이 변화는 동치성 손실이 아니라 엄밀히 더 정확해지는 방향이다. 분석 원문은 reference/slot-attach-decomposition.md 7절.

6. 이 세션이 안 한 것

  • 5라운드 문항지를 만들지 않았다 — D절 회신의 "이후 stale 만 잡는것으로 끝낼 수 있어보임"에 따름.
  • 스파이크 두 건은 미실행(01 재작성 = 단일 generalized for, table.insert 구멍 재사용 = R-11). 상태의 소스는 luau-test/STATUS.md.
  • 이 분해로 Dispatch.drive도 같은 모양(부기/물리 분리)으로 맞출지는 일부러 미뤘다 — 지금 그게 아프다는 증거가 없다.

7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다

H절 반영을 커밋한 뒤 사용자 요청으로 quad-doc-auditor6라운드 돌렸다 (관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:

라운드 각도 확실 발견
1 base/slot-plan.md 내부 문장 정합성 4
2 인덱스 레이어(README/ROADMAP/question/todos/STATUS) 6
3 archive/ + 바깥 base/ + reference/ 2
4 의사코드 시나리오 손 트레이싱 3 (실제 크래시)
5 그 수정분의 회귀 트레이싱 3
6 수렴 확인 0

이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다. 1~3라운드는 전부 "고친 결정이 다른 자리에 안 옮겨졌다"는 문서 정합성 유형이었고, 각도를 트레이싱으로 바꾼 4라운드에서야 실행이 깨지는 결함이 나왔다. 문서 대조를 몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고, 서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는 종류였기 때문이다.

4라운드가 잡은 것의 공통 구조

셋 다 Detach가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것이다. 특히 I-1이 뼈아프다:

  • rawDetach일부러 releaseOwner를 안 부른다 — 소유권 유지가 이번 세션 설계의 핵심이었다.
  • claimOwner일부러 같은 owner의 재클레임도 error다 — 2026-08-13 감사가 Slot { a, a }를 막으려고 엄격하게 만든 것이다.
  • 둘 다 각자 맞는데, "prev를 그대로 돌려주면 재마운트"라는 이 세션이 확정한 권장 패턴이 정확히 그 교차점을 지난다. 그대로 구현했으면 문서가 권장하는 사용법이 첫 실행에서 죽었다.

claimOwner의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시 클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고 단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그 문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.

내가 감사 결론을 한 번 넘어선 곳

I-2의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다. :Listdata:ObserverbindLifetime(inst, observer)물리 target에 앵커돼 있는데 unmountSlotTree가 푸는 건 bk.observers뿐이라, 가드만 넣으면 포탈 재마운트 후 옛 target이 죽는 순간 살아있는 Slot의 :List가 조용히 반응을 멈춘다. _listObserver 핸들을 보관해 앵커만 옮기는 방식으로 마저 닫았고, 이건 _detachCleanup이 이미 받고 있던 처리와 같은 모양이라 새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 파괴 경로 누락( destroySlotTree에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나 추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.

사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)

  1. 재마운트 경로claimOwnerfromDetached 예외(대안: 전용 rawReattach 신설 / rawDetach가 소유권을 놓게 변경).
  2. activateList 재실행 → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
  3. materializeSlotTree 중 예외 시 Blocker 잔류문서화만 하고 pcall 안 씀. 근거는 conventions.md의 "드문 오용이나 가상의 미래 요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는 base/fallback-plan.md가 담당한다. 이건 이번 분해가 만든 창이 아니라 옛 단일 attachSlot에도 있었을 구조적 갭이다.

부수 정리

2라운드가 luau-test/STATUS.md의 자기 규칙 위반을 잡았다 — "폴더가 곧 상태"라면서 재작성 대상 스파이크 01done/에 둔 채 ⚠️ 마커만 달아둔 것. git mv로 실제 이동시키고 개수를 맞췄으며, 같이 만들어야 할 스파이크 절을 신설했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼 구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를 지목했는데 정작 항목이 없었다.

감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건

_detachCleanup Effect 설치를 mountSlotTreeactivateList로 이관, 그리고 activateList(self, inst)의 2번째 인자를 physicalTarget으로 리네이밍. 상세는 followup I-7.

여기서 짚어둘 것은 상호작용 하나다. 이관 제안의 근거가 *"materializeSlotTree_listed면 재마운트 때도 activateList를 다시 부르니 lifecycle이 보존된다"*였는데, 그건 오늘 오후에 내가 멱등 가드를 넣기 전의 동작이고 사실 그게 I-2의 버그였다. 가드가 있는 지금은 두 번째 호출이 early return하므로, 가드 분기가 _detachCleanup도 같이 재앵커하지 않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만 근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.

부수적으로 _listObserver_detachCleanup같은 범주로 통일됐다 — "activateList가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는 앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 nil). 오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이 훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.