diff --git a/.claude/README.md b/.claude/README.md index ba6a4de..be60e47 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — 1~3라운드와 달리 **작성만 끝났고 사용자 회신 대기 중**, 회신 후 "아니오"만 남긴 근거 기록으로 재편 예정. 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20]** 그 회신 처리 결과 — 바로 반영한 것 / 설명이 부족해 풀어 쓴 재질문 / 사용자 판단이 더 필요한 것 / 업스트림 병합으로 stale해진 문항으로 갈라 정리, **B·C절 회신 대기**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음** — 사용자 지시로 **5라운드 문항지는 만들지 않는다**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | @@ -53,7 +53,7 @@ | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | | `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | @@ -90,7 +90,7 @@ | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | -| `slot-attach-decomposition.md` | **[2026-08-21 신설]** `attachSlot`의 책임 분해 **논의 준비 자료**(확정 아님). QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 정본은 여전히 `base/slot-plan.md` | 중 — M2/M3는 안 막지만 **M6(`:List`) 구현 전엔 정해두는 게 나음**. 선행으로 `qa-request/pre-implementation-qa-round4-followup.md`의 `F-3`(`Detach` 보관 위치 + `KeyGone`)이 먼저 닫히면 요구사항이 완전해짐 | +| `slot-attach-decomposition.md` | **[2026-08-21 신설, 같은 날 확정]** `attachSlot`의 책임 분해 근거 기록 — **결론은 후보 (B) 분해 채택**이고 `base/slot-plan.md`에 반영 완료(`materializeSlotTree`+`mountSlotTree`+두 줄짜리 `attachSlot` 래퍼). 아래는 그 논의의 출발점. QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 사용자 확정 논거는 *"지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함"*. 정본은 여전히 `base/slot-plan.md` | 하 — **[2026-08-21] 결론 확정·반영 완료**, 이제 근거 기록용 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index e30bed3..9fc91fe 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -1592,8 +1592,8 @@ end **해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: -1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기 - 자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그 +1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가 + 자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그 owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작 전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 @@ -1611,7 +1611,7 @@ end 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 배치 중이라도 항상 최신값을 보게 됨. 4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 - `attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을 + `materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, `ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위 @@ -1700,7 +1700,10 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 - **각 연산에 적용하면**: - `rawAdd` — `Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) → `element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단). - - `rawRemove`/`rawUnmount` — 파괴/언마운트 → `spliceArraysDown` → + - `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach` + 경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만 + 한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 → + `spliceArraysDown` → `recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과 어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직 트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 5bf1a96..45811fa 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -297,10 +297,22 @@ local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak -- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고) local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고) --- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error -local function claimOwner(element, ownerKey) - if elementOwner:GetWeak(element, OWNER) ~= nil then - error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지") +-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error. +-- [2026-08-21 감사] 예외 하나: **detach 재마운트**. `rawDetach`가 소유권을 +-- 일부러 유지하므로(`slot._detached`가 계속 들고 있음), 다음 사이클에 +-- `prev`를 그대로 돌려주는 문서 권장 패턴이 여기서 무조건 죽고 있었다. +-- top-level `claimOwnerAt`이 이미 "같은 (inst,k) 자리의 재발행은 통과"라는 +-- 같은 모양의 예외를 갖고 있어 대칭이 맞는다. +local function claimOwner(element, ownerKey, fromDetached) + local cur = elementOwner:GetWeak(element, OWNER) + if cur ~= nil then + -- 내가 계속 들고 있던 요소를 내가 다시 넣는 경우만 통과. + -- `fromDetached` 없이 같은 owner라는 것만으로 통과시키면 + -- `Slot{a, a}`가 다시 조용히 새어나간다(2026-08-13 감사가 막은 것). + if not (fromDetached and cur == ownerKey) then + error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지") + end + return -- 이미 내 것이므로 SetWeak도 불필요 end elementOwner:SetWeak(element, OWNER, ownerKey) end @@ -411,7 +423,20 @@ end 이 소유권 논증 자체는 그대로 성립), `rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가 갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건 -error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로 +error가 맞음**. + +**⚠️ [정정, 2026-08-21] 이제 그 경로가 정확히 하나 생겼다 — `Detach` +재마운트.** `rawDetach`는 일부러 `releaseOwner`를 안 부르므로(소유권 유지가 +`Detach`의 핵심), `settle`의 재마운트 분기는 `rawUnmount`를 거치지 않고 +곧바로 `rawAdd`를 부른다 — 위 문단이 "없다"고 단정한 바로 그 모양이다. +그래서 `claimOwner`에 **`fromDetached` 플래그가 참일 때만** 통과하는 좁은 +예외를 뒀다(위 그 함수의 정의). **플래그 없이 "같은 owner면 통과"로 +완화하면 안 된다** — 이 절이 애초에 막으려던 `Slot { a, a }`가 다시 +새어나간다. 나머지 논증(top-level은 `claimOwnerAt`으로 구분)은 그대로 +유효하다. 경위는 `qa-request/pre-implementation-qa-round4-followup.md`의 +`I-1`. + +반대로 top-level은 store 재발행마다 같은 Slot으로 `process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고, 그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함. @@ -451,14 +476,17 @@ Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의 봄: ```lua --- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리 -claimOwner(element, self) -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error +-- rawAdd(self, element, index, fromDetached?) 안, "이미 마운트" 에러 체크 자리 +claimOwner(element, self, fromDetached) -- self = 담는 Slot. 이미 누가(같은 self + -- 포함) 소유 중이면 error — 단 detach + -- 재마운트만 예외(위 그 함수) -- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리 releaseOwner(element, self) --- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(아래 "파괴" 절) -releaseOwner(element, slot) +-- [삭제됨, 2026-08-20 `C-4`] destroySlotTree에는 명시적 releaseOwner가 없다 — +-- 2026-08-13 감사가 넣었던 걸 되돌렸다(자식이 어차피 죽으므로 불필요). +-- 아래 "소유권 반납은 GC에 맡기면 안 됨" 절의 재정정이 소스. ``` **[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.** @@ -470,6 +498,9 @@ releaseOwner(element, slot) 바뀌었으나 둘 다 `releaseOwner`를 부르므로 논증 동일, `rawMove`/`rawSwap`은 클레임 미접촉) 무조건 error가 맞음 — top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. +(**[정정, 2026-08-21]** "재클레임이 정당한 경우가 하나도 없다"는 전제는 +`Detach` 재마운트 하나가 예외로 생겼다 — 바로 위 ⚠️ 문단이 소스. +`claimOwner`가 반환값 없이 error만 낸다는 이 항목의 결론 자체는 그대로다.) **[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/ `rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화, @@ -1116,7 +1147,30 @@ end -- Dispatch/Slot.luau의 process(inst,k,self,index)가 마운트 시점에 1회 호출 -- (self._mounted=true/self._mountedInst=inst, self.Offset 세팅과 같은 자리) -function activateList(self, inst) +-- [리네이밍, 2026-08-21] 2번째 인자는 `inst`였으나 `physicalTarget`으로 통일 — +-- 옆 함수들(materializeSlotTree/mountSlotTree/attachSlot)이 쓰는 이름과 같은 +-- 개념이고, 실제로 항상 물리 Instance다(bindLifetime의 첫 인자로 들어감). +-- Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다. +function activateList(self, physicalTarget) + -- [2026-08-21 감사] **멱등 가드 — 두 번째 호출은 즉시 반환.** + -- `Detach`로 뗐다 돌아온 자식 Slot이 `:List`를 갖고 있으면 + -- `materializeSlotTree`가 `slot._listed`를 보고 여기를 다시 부른다. + -- 가드가 없으면 `data:Observer(fn)` 구독이 하나 더 생기고 + -- `mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져** + -- 옛 상태를 잃은 채 reconcile이 두 벌 돈다(기존 요소를 전부 새 것으로 + -- 오인해 다시 그림). `_crudUsed`/`_listed`와 같은 결의 플래그. + if self._listActivated then + -- 재마운트(포탈 포함) — 클로저 상태(mounted/userdata/keyIndex)는 + -- 그대로 두고 **GC 앵커만 새 target으로 옮긴다.** 이걸 안 하면 + -- 자원이 옛 physicalTarget에 매달린 채로 남아, 그게 죽는 순간 + -- 살아있는 이 Slot의 `:List`가 조용히 반응을 멈추고(`_listObserver`) + -- 살아있는 detached 요소가 파괴된다(`_detachCleanup`). + bindLifetime(physicalTarget, self._listObserver) + bindLifetime(physicalTarget, self._detachCleanup) + return + end + self._listActivated = true + local keyFn, updateFn = self._keyFn, self._updateFn local offset = self.Offset local mounted, userdata, keyIndex = {}, {}, {} @@ -1135,13 +1189,15 @@ function activateList(self, inst) self._detached[key], mounted[key] = wasMounted, nil end elseif result == nil then - -- "지워라". Owned=false면 파괴 대신 언마운트만(위 "Owned" 절). + -- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절). if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end mounted[key], self._detached[key] = nil, nil elseif result == prev then if wasDetached ~= nil then self._detached[key] = nil -- **재마운트** — detach된 걸 되살림 - rawAdd(self, result, pos) + -- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로 + -- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석). + rawAdd(self, result, pos, true) mounted[key] = result elseif keyIndex[key] ~= pos then rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동 @@ -1211,13 +1267,43 @@ function activateList(self, inst) keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다** end + -- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`가 + -- 모든 Slot마다 설치했으나 **여기로 옮겼다**(사용자 판단). `_detached`를 + -- 채우는 유일한 지점이 아래 `settle`의 `rawDetach`이므로, `:List`가 없는 + -- Slot의 `_detached`는 정의상 영원히 빈 테이블이다 — 중첩 트리 크기만큼 + -- no-op Effect와 `bindLifetime` 엔트리를 심고 있었다. 이제 + -- `_listObserver`와 **완전히 같은 취급**을 받는다(생성은 여기 1회, + -- 언마운트 시 앵커만 해제하고 핸들 보존, 재마운트 시 위 가드가 재앵커, + -- 파괴 시 해제+`nil`) — "`activateList`가 소유하고 물리 target에 + -- 앵커되는 자원"이라는 한 범주. + -- Effect가 유일한 도구인 이유: `bindLifetime`은 "실행해도 되는가"만 + -- 게이팅할 뿐 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`). + self._detachCleanup = Effect(function() + return function() + for key, element in pairs(self._detached) do + -- releaseOwner를 **먼저, 두 분기 공통으로**. 빠뜨리면 + -- `_owned == false` 요소가 죽은 Slot을 owner로 달고 남아, + -- 사용자가 그 값을 다른 Slot에 넣을 때 GC 타이밍에 따라 + -- "이미 마운트돼 있음" error가 난다 — 위 "소유권 반납은 + -- GC에 맡기면 안 됨" 절이 경계하는 실패 모드. + releaseOwner(element, self) + if self._owned ~= false then + if isSlot(element) then destroySlotTree(element) else element:Destroy() end + end + self._detached[key] = nil + end + end + end) + bindLifetime(physicalTarget, self._detachCleanup) + local data = self._listData if isState(data) then local observer = data:Observer(function() reconcile(data:Get()) end) + self._listObserver = observer -- [2026-08-21] 재마운트 시 앵커를 옮기려면 보관 필요 -- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute -- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) — -- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속 - bindLifetime(inst, observer) + bindLifetime(physicalTarget, observer) else reconcile(data) end @@ -1312,7 +1398,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 | updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 | |---|---|---| -| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(위 "`Owned` 옵션" 절) | +| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(아래 "`Owned` 옵션" 절) | | `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | | `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** | | `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 | @@ -1415,7 +1501,10 @@ updateFn(item: T | KeyGone, index, offset, prev, ud) `keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번 사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된 것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시 - 나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다. + 나타나면 `prev`로 부활하고, (b) owner가 죽으면 `activateList`가 설치한 + `_detachCleanup` Effect가 정리한다(**[이관, 2026-08-21]** 원래 + `mountSlotTree`에 있었으나, `_detached`를 채우는 건 `:List`뿐이라 + `:List` 없는 Slot마다 no-op Effect를 심고 있었다). - **`userdata`도 유저가 정한다** — `updateFn`이 `nil`을 반환해야 지워진다. 키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면 전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의 @@ -1690,22 +1779,25 @@ nil/None 금지)는 그대로. ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 -> **🔭 [2026-08-21] `attachSlot`의 책임 분해가 확장 논의 대기 중** — 아래 -> 의사코드가 지금 정본이고 그대로 유효하지만, 이 함수 하나가 지고 있는 책임이 -> 일곱 개(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 -> 게이팅, 자식 배치, 재귀)라는 사용자 지적이 있었다 — *"attachSlot 의 기능이 -> 너무 다양해진게 문제같음"*. 특히 **"부모에게 알리는 길이의 최종값은 flush가 -> 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 -> 만족되지 않는다.** 책임 목록·순서 제약의 출처·분해 후보는 -> `research/slot-attach-decomposition.md`에 정리해뒀다. **M2/M3를 막지는 -> 않지만 M6(`:List`) 구현 전엔 결론이 나 있어야 한다.** +> **✅ [해소, 2026-08-21] `attachSlot`의 책임 분해 — 분해로 확정, 아래 +> 반영 완료.** 이 함수 하나가 책임을 일곱 개(부모 등록 offset/length, +> `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀) 지고 +> 있다는 사용자 지적에서 시작했고 — *"attachSlot 의 기능이 너무 다양해진게 +> 문제같음"* — 진단은 **"부모에게 알리는 길이의 최종값은 flush가 끝나야 +> 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 만족되지 +> 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능). +> **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고 +> `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/ +> 호출부 전부 불변. 근거 기록은 `research/slot-attach-decomposition.md`. **✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 `Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그 -해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래 -코드의 `blocker` 관련 줄). +해법이 **`materializeSlotTree`의 등록 루프**에 어떻게 적용되는지만 +다룬다(아래 코드의 `blocker` 관련 줄) — **[2026-08-21]** 분해 전엔 이 게이팅이 +`attachSlot` 본체에 있었고, 물리 마운트 쪽(`mountSlotTree`)은 Blocker가 +필요 없다. `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 @@ -1716,8 +1808,8 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 — `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.** `base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출 순서는 `setOffsetSource` → `setLength`"** 일반 규칙(해제 시점 계약에서 -나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속 -어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게 +나왔지만 등록 시점에도 그대로 적용)과 이 의사코드(**[2026-08-21]** 분해 +후엔 `materializeSlotTree`)가 계속 어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게 되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length`는 `activateList`가 @@ -1777,6 +1869,16 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) Dispatch.setLength(slot, i, 1) end end + -- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해 + -- Blocker가 켜진 채 남는다** — 그 Slot의 `recompute`가 이후 영원히 + -- 게이팅돼 `Length`가 영구 stale해진다. `pcall`로 감싸지 않는 것이 + -- **사용자 판단(2026-08-21)**: 마운트 도중 예외는 quad가 복구를 보장하지 + -- 않는 상태이고(에러 경계는 `base/fallback-plan.md`의 `Fallback`/ + -- `Traceback`이 담당), 아직 실제로 밟은 적 없는 경로다 — + -- `conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운 + -- 원칙 그대로. 실제로 물리면 그때 넣는다. + -- 참고: 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도 + -- 있었을 구조적 갭이다(옛 코드도 같은 구간에 정리 코드가 없었음). blocker:OffWithoutEmit() local bk = getBookkeeping(slot) if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정 @@ -1791,22 +1893,10 @@ end local function mountSlotTree(slot, physicalTarget) slot._mounted = true slot._mountedInst = physicalTarget - -- [2026-08-21] Detach 홀드분의 최종 처분 경로 — physicalTarget이 죽을 때 - -- `slot._detached`를 비운다(아래 "Detach 요소는 slot._detached가 보유한다" 절). - -- Effect가 유일한 도구인 이유: bindLifetime은 "실행해도 되는가"만 게이팅할 뿐 - -- 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`). - slot._detachCleanup = Effect(function() - return function() - for key, element in pairs(slot._detached) do - if slot._owned ~= false then - if isSlot(element) then destroySlotTree(element) else element:Destroy() end - end - slot._detached[key] = nil - end - end - end) - bindLifetime(physicalTarget, slot._detachCleanup) - + -- [이관, 2026-08-21] `_detachCleanup` Effect 설치가 여기 있었으나 + -- `activateList`로 옮겼다 — `_detached`를 채우는 건 `:List`의 `settle`뿐이라 + -- List 없는 Slot마다 no-op Effect를 심고 있었다(위 그 함수의 주석이 소스). + -- 그래서 이 함수는 이제 **정말로 물리 대입만** 한다. for i, element in ipairs(slot._elements) do if isSlot(element) then mountSlotTree(element, physicalTarget) else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행 @@ -1916,14 +2006,16 @@ local function unmountSlotTree(slot) unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제 end end - -- [2026-08-21] Detach 정리용 Effect도 같이 푼다 — 안 풀면 **옛 - -- physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 Slot의 - -- `_detached`를 파괴**한다(포탈 경로에서 실제로 터짐). 재마운트 때 - -- mountSlotTree가 새 target에 다시 건다. - if slot._detachCleanup then - unbindLifetime(slot._detachCleanup) - slot._detachCleanup = nil - end + -- [2026-08-21] `activateList`가 소유하는 두 자원의 **앵커만** 푼다. + -- 안 풀면 옛 physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 + -- Slot의 `:List`가 조용히 멈추고(`_listObserver`) `_detached`가 + -- 파괴된다(`_detachCleanup`) — 포탈 경로에서 실제로 터진다. + -- **핸들과 `_listActivated`는 보존한다**(언마운트는 파괴가 아니다) — + -- 구독과 그 클로저 상태(`mounted`/`userdata`/`keyIndex`)는 재마운트 + -- 후에도 이어져야 하고, 새 target에 다시 걸어주는 건 `activateList`의 + -- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다. + if slot._listObserver then unbindLifetime(slot._listObserver) end + if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end -- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고, -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유). slot._mounted, slot._mountedInst = false, nil @@ -1962,6 +2054,19 @@ local function destroySlotTree(slot) unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음 slot._detachCleanup = nil end + + -- [2026-08-21 감사] `:List` 구독도 푼다 — **파괴이므로 `unmountSlotTree`와 + -- 달리 핸들과 `_listActivated`까지 지운다.** 안 풀면 `gchold[physicalTarget]`이 + -- observer를(그리고 그 클로저가 붙잡은 `mounted`/`userdata`/`keyIndex`와 + -- 파괴된 slot 자신을) 계속 강하게 붙잡는다 — 중첩 Slot은 아무리 깊어도 + -- `physicalTarget`이 트리 최상위 inst 하나라(`materializeSlotTree`가 같은 + -- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한 + -- 경우**다. 그러면 `data`가 emit될 때마다 이미 죽은 자식들에 대해 + -- reconcile이 계속 돈다. + if slot._listObserver then + unbindLifetime(slot._listObserver) + slot._listObserver, slot._listActivated = nil, nil + end local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들 if bk then for i, observer in pairs(bk.observers) do @@ -2075,8 +2180,8 @@ end 동일하게 적용"*). 즉 `bk.N`은 `Dispatch.setLength`가 이전에 본 적 없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는 건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔 - `lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush - 배치도, Slot의 런타임 단건 + `lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`materializeSlotTree`의 + 배치 등록도, Slot의 런타임 단건 `rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를 물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`의 `inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은 @@ -2084,7 +2189,7 @@ end 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은 규칙의 특수한 안정 상태다. - **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/ - `attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker + `materializeSlotTree`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker 게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) — 그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도 안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 @@ -2458,20 +2563,25 @@ state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + `unbindLifetime` + `releaseOwner`를 부름. -2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 - 비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn`이 `nil`/`None`을 - 반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 - 판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. +2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA; + 2026-08-21 `Owned` 도입으로 다시 정정]** 이 항목은 원래 "비파괴가 되는 + 건 **값 교체와 `Detach`뿐**"이라고 적혀 있었는데, **값 교체 쪽은 + 틀렸다**. 지금 확정은 **`Detach`만 비파괴**이고, 값 교체·`nil`/`None`· + 키 소멸은 전부 **`Owned` 플래그가 결정**한다(기본 `true` = 파괴, + `false` = 언마운트만). 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절의 + 표가 소스 — 그 표와 이 항목이 어긋나면 표가 맞다. 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 `:List`에는 안 맞는 일반화였음. **여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()` (CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의 -`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 -지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 -"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" -철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를 -명시한다. +`:List` 경로 전부(`Owned = true`일 때 — 값 교체 포함). 즉 일반 규칙은 +**"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되, +**`:List`가 소유하는 요소는 그 규칙의 예외로 파괴가 기본이다** — +`updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지우기 때문(reconcile +중엔 `dispose`가 거부됨). `updateFn`이 지우지 않길 원하면 `Detach`로 그 +의도를 명시하고, **애초에 `:List`의 것이 아닌 요소**(`state` 등)는 +설치 시점에 `Owned = false`로 선언한다. `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`, diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index d852be9..41c0e9f 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,11 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` +> 마지막 갱신: **2026-08-21** — QA 4라운드 `F-4-1`로 `Dispatch.drive`의 +> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던 +> `01`이 낡아 `done/` → `rewrite-required/` 이동(검증 대상인 순서 계약 +> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이 +> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을 +> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` > 버전 패턴 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → > `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성. > 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 @@ -62,7 +67,7 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건) **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 @@ -77,8 +82,13 @@ `10`은 **Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성 후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다. +**[2026-08-21] `01`이 합류** — 같은 "설계가 바뀐" 유형이다. 통과 상태로 +`done/`에 두면 이제는 구현이 안 하는 두 루프 순회를 "검증됨"으로 오독하게 +된다. + | 파일 | 상태 | 무엇을 고쳐야 하나 | |---|---|---| +| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 | | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | @@ -93,16 +103,16 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (19건) +## ✅ `done/` — 통과 or 판정 끝 (18건) -**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는 +**런타임 13개 전원 통과**(crash 0 / FAIL 0 — **[2026-08-21]** `01`이 +`rewrite-required/`로 나가며 14 → 13) — **[열네 번째 세션] `04`/`19`는 검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19] `05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13` 런타임 절반, PostRef까지 확장)가 합류**: | 파일 | 확인된 것 | |---|---| -| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive`의 순서 계약과 `PreRef` 호이스팅의 전제. **⚠️ [2026-08-21 재작성 필요]** 이 스파이크는 숫자 `for` + 일반화 `for` **두 루프** 버전인데, 구현이 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — 검증 대상(순서 계약)은 그대로이고 스파이크 코드만 그 형태로 다시 쓸 것 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 | @@ -157,3 +167,18 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치 ``` 추측이 아니라 **실제로 GC가 안 됨** — `Slot`의 두-`Relate` 수정이 필수 조치였음이 입증. + + +## 🔵 만들어야 할 스파이크 — 아직 파일이 없음 (2026-08-21 신설) + +폴더가 곧 상태인 이 문서에서 **"아직 파일조차 없는 실측 항목"**은 어느 +폴더로도 표현되지 않아 그냥 잊혔다. 실제로 QA 4라운드 followup(H-7)이 +"실측으로 남은 것"의 소스로 이 문서를 지목했는데 여기 항목이 없었다. +앞으로 이 절이 그 소스다 — 파일을 만들면 `not-run/` 또는 실행 결과에 따라 +해당 폴더로 옮기고 여기서 지운다. + +| 검증할 것 | 왜 | 출처 | +|---|---|---| +| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` | +| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 | +| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` | diff --git a/.claude/luau-test/done/01-two-pass-array-hash-order.luau b/.claude/luau-test/rewrite-required/01-two-pass-array-hash-order.luau similarity index 100% rename from .claude/luau-test/done/01-two-pass-array-hash-order.luau rename to .claude/luau-test/rewrite-required/01-two-pass-array-hash-order.luau diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 2f51f7a..ec1c61a 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -1,6 +1,8 @@ # 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 -**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.** +**상태**: **[2026-08-21] 4차 처리로 종결 + 반영 후 감사까지 완료 — 아래 +I절이 최신.** 감사 4라운드(의사코드 트레이싱)가 **실제 크래시 3건**을 잡아 +같은 날 전부 닫았다(`I-1`~`I-3`). H절이 반영 내용, I절이 그 감사 결과다. `F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다. **이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지 않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록. @@ -373,6 +375,9 @@ observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것 ### C-1. ⭐ `SL-40`/`SL-43`/`SL-45` — `KeyGone` 센티널 신설 +> **✅ [해소, 2026-08-21] 확정·반영 완료 — 아래 H-2가 결론이다.** +> 이 절은 그 제안의 원문이다. + **사용자 제안**: 키가 데이터에서 사라지면 `updateFn`을 `KeyGone`으로 한 번 더 불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임. 이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다. @@ -408,6 +413,9 @@ export**가 일관적이다(`None`/`Detach` 선례). 이름은 `KeyGone`이 의 ### C-2. ⭐⭐ `SL-43` vs `SL-51` — "밀려난 `prev`는 dispose"와 `state` 의미론이 충돌한다 +> **✅ [해소, 2026-08-21] `Owned` 설치 플래그로 확정 — 아래 H-3이 결론이다.** +> 이 절은 그 충돌을 처음 짚은 원문이다. + **이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을 가리킨다: @@ -1140,7 +1148,9 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = → 자식 재귀/`setLength` → `OffWithoutEmit` → `recompute` → **마지막에 `setLength(ownerKey, position, slot.Length)`**. - **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과 - `_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요. + `_mounted = true`만. Blocker 불필요. (**[정정, 2026-08-21 `I-7`]** 여기 + `_detachCleanup` Effect 설치도 있었으나 `activateList`로 이관됐다 — + 이제 이 함수는 정말로 물리 대입만 한다.) - **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부 전부 그대로라 다른 문서의 참조가 안 깨진다. @@ -1175,3 +1185,163 @@ M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠ - 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`), `table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는 `luau-test/STATUS.md`. + +--- + +# I절 — 반영 후 감사 (2026-08-21): 트레이싱이 실제 크래시 3건을 잡음 + +H절의 반영을 커밋한 뒤 `quad-doc-auditor`를 각도를 바꿔 4라운드 돌렸다. +1~3라운드(문서 대조)에서 나온 것은 전부 "고친 결정이 다른 자리에 안 +옮겨졌다" 유형이었고, **4라운드(의사코드 시나리오 손 트레이싱)에서 실제로 +실행이 깨지는 결함 3건**이 나왔다. 셋 다 **`Detach`가 신설한 새 경로가 +기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것**이다. + +## I-1. detach 재마운트가 `claimOwner`에서 무조건 크래시 (⭐⭐ 치명) + +`rawDetach`는 일부러 `releaseOwner`를 안 부른다(소유권 유지가 설계의 +핵심). 그런데 재마운트는 `rawAdd`를 거치고, `rawAdd`가 부르는 +`claimOwner`는 **같은 owner의 재클레임도 무조건 error**다 — 2026-08-13 +감사가 `Slot{a, a}`를 막으려고 일부러 엄격하게 만든 것이다. 결과: +**문서가 권장하는 "다음 사이클에 `prev`를 그대로 돌려주면 재마운트" 패턴이 +그대로 `error("이 요소는 이미 마운트돼 있음")`로 죽는다.** + +**확정(사용자 판단)**: `claimOwner`에 **detach 재마운트 전용 예외**를 넣는다 +— `claimOwner(element, ownerKey, fromDetached)`에서 `fromDetached and +cur == ownerKey`일 때만 통과. top-level `claimOwnerAt`이 이미 "같은 +`(inst, k)` 자리의 재발행은 통과"라는 같은 모양의 예외를 갖고 있어 대칭이 +맞는다. **`fromDetached` 없이 "같은 owner면 통과"로 완화하면 안 된다** — +`Slot{a, a}`가 다시 새어나간다. + +## I-2. 재마운트된 자식 Slot이 `activateList`를 두 번 실행 (⭐⭐) + +`I-1`을 고치면 바로 드러나는 다음 문제. `rawAdd`는 Slot 요소를 이미 +마운트된 부모에 넣을 때 `attachSlot`을 부르고, `materializeSlotTree`는 +`slot._listed`면 **무조건** `activateList`를 다시 실행한다. `:List`를 가진 +자식 Slot이 detach에서 돌아오면 `data:Observer` 구독이 하나 더 생기고 +`mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져** +기존 요소를 전부 새 것으로 오인해 다시 그린다. + +**확정(사용자 판단)**: `activateList`에 **멱등 가드**(`slot._listActivated`). +`_crudUsed`/`_listed`와 같은 결의 플래그다. + +**가드만으로는 반쪽이라 하나 더 닫았다** — `:List`의 `data:Observer`는 +`bindLifetime(inst, observer)`로 **물리 target에 앵커**돼 있는데, +`unmountSlotTree`가 푸는 건 `bk.observers`뿐이라 이 구독은 **옛 +physicalTarget에 매달린 채** 남는다. 포탈로 다른 target에 재마운트하면 +옛 target이 죽는 순간 살아있는 Slot의 `:List`가 조용히 반응을 멈춘다. +그래서 `slot._listObserver`로 핸들을 보관하고, `unmountSlotTree`가 +`unbindLifetime`만 하고(핸들과 `_listActivated`는 보존), 멱등 가드가 +`bindLifetime(inst, self._listObserver)`로 앵커를 새 target에 다시 건다. +`_detachCleanup`이 이미 받고 있던 처리와 같은 모양이다. + +## I-3. `_detachCleanup`이 `releaseOwner`를 안 부름 (⭐) + +owner가 죽을 때 `_detached`를 비우는 `Effect`가 `_owned == false` 분기에서 +요소를 파괴하지 않는 건 맞는데, **소유권 기록도 안 푼다.** 그러면 그 +`state`이 **죽은 Slot을 owner로 달고** 남아, 사용자가 같은 값을 +다른 Slot에 넣을 때 그 죽은 Slot이 GC되기 전까지 비결정적으로 "이미 +마운트돼 있음" error가 난다. 이 문서 스스로 "소유권 반납은 GC에 맡기면 +안 됨" 절에서 경계했던 실패 모드다. + +**반영**: 두 분기 공통으로 `releaseOwner(element, slot)`를 먼저 부른다 +(`rawRemove`도 파괴 전에 부르므로 대칭). + +## I-4. `materializeSlotTree` 중 예외 시 Blocker가 켜진 채 남음 — 문서화만 + +`blocker:On()`과 `OffWithoutEmit()` 사이에서 자식 재귀가 예외를 던지면 +Blocker가 영구히 켜져 그 Slot의 `Length`가 영원히 stale해진다. + +**확정(사용자 판단)**: `pcall`로 감싸지 않고 **문서화만 한다.** 마운트 +도중 예외는 quad가 복구를 보장하지 않는 상태이고(에러 경계는 +`base/fallback-plan.md`), 아직 실제로 밟은 적 없는 경로다 — +`conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운 원칙 +그대로. 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도 +있었을 구조적 갭이다. + +## I-5. 문서 대조 라운드(1~3)에서 나온 것 + +전부 "고친 결정이 다른 자리에 안 옮겨졌다" 유형이라 여기 나열하지 않는다 +— 커밋 diff가 소스. 대표적인 것만: `slot-plan.md`가 한쪽에선 `Owned` +기준 표를, 다른 쪽에선 옛 "값 교체는 비파괴"를 동시에 서술하고 있었고, +`attachSlot`의 flush 루프를 가리키던 문장 5곳이 `materializeSlotTree`로 +안 옮겨져 한 문서 안에 신/구 표현이 공존했으며, `luau-test/STATUS.md`가 +자기 규칙("폴더가 곧 상태")을 어기고 재작성 대상 스파이크를 `done/`에 +두고 있었다. + +## I-6. 5라운드(수정분 회귀 트레이싱) — 3건 더, 전부 반영 + +`I-1`~`I-3`의 수정 자체를 다시 트레이싱한 라운드. 새 결함은 안 나왔지만 +**그 수정이 닿았어야 할 자리 3곳**이 나왔다. + +1. **`destroySlotTree`가 `_listObserver`를 안 푼다.** `unmountSlotTree`엔 + 넣었는데 파괴 경로엔 빠졌다. `physicalTarget`은 중첩 깊이와 무관하게 + **트리 최상위 inst 하나**라(`materializeSlotTree`가 같은 값을 재귀에 + 그대로 넘김), 자식 Slot만 죽고 그 inst는 살아있는 게 흔한 경우 — + 그러면 `gchold[inst]`가 observer와 그 클로저 상태(그리고 죽은 slot + 자신)를 계속 붙잡고, `data`가 emit될 때마다 이미 죽은 자식들에 대해 + reconcile이 계속 돈다. **반영**: 파괴이므로 `unmountSlotTree`와 달리 + 핸들과 `_listActivated`까지 `nil`로 지운다. +2. **`claimOwner`의 옛 논증 두 문단이 `fromDetached`와 정면 모순.** + 2026-08-13 세션의 *"nested엔 재클레임이라는 개념이 애초에 없다 … + 무조건 error가 맞음"*이 그대로 남아 있었다 — 그 논증의 근거(reconcile은 + 항상 release → claim 순서)가 `Detach`로 깨졌는데 갱신이 안 따라갔다. + 함수 정의 옆 주석만 정정돼 있었다. **반영**: 두 문단에 ⚠️ 정정을 달고, + "플래그 없이 같은 owner면 통과로 완화하면 `Slot { a, a }`가 다시 + 새어나간다"는 경계도 같이 명시. +3. **소유권 예시 코드가 `C-4`와 모순.** `-- destroySlotTree(slot) 안, + 자식들을 파괴하기 직전` + `releaseOwner(element, slot)` 예시가 + 2026-08-12 서술로 남아 있었는데, 2026-08-20 `C-4`가 그 호출을 도로 + 뺐고 실제 의사코드도 "부르지 **않는다**"라고 명시한다. 이번 감사 + 각도(`releaseOwner` 이중 호출 추적)에서 걸렸다. **반영**: 예시를 + 삭제 표시로 교체. + +**회귀 없음으로 확인된 것**: `claimOwner`의 early return은 `OWNER_POS`를 +stale하게 남기지 않고(그 필드는 top-level `claimOwnerAt` 전용), +`fromDetached`를 안 넘기는 기존 호출부는 동작이 이전과 완전히 동일하며, +`_listActivated` 가드는 정상 마운트/`Slot:List()` 경로를 막지 않고, +언마운트~재마운트 구간에도 `_listObserver`는 Slot 필드 강참조라 GC되지 +않으며, `releaseOwner` 이중 호출 경로는 없다(`destroySlotTree`가 `C-4` +이후 자식에 대해 안 부르므로). + + +## I-7. `_detachCleanup`을 `activateList`로 이관 + `activateList`의 `inst` 리네이밍 + +사용자가 별도 에이전트와 상의한 내용을 가져와 확정한 두 건. 둘 다 **의미 +변화 없는 배치/이름 정리**지만, 첫 건은 실제 낭비를 없앤다. + +1. **`_detachCleanup` Effect 설치가 `mountSlotTree` → `activateList`로 이관.** + `_detached`를 채우는 유일한 지점이 `settle`의 `rawDetach`이고 그건 + `activateList` 클로저 안에만 있으므로, **`:List`가 없는 Slot의 + `_detached`는 정의상 영원히 빈 테이블**이다. 그런데 `mountSlotTree`는 + 재귀 전체(중첩 포함)에 Effect + `bindLifetime`을 심고 있었다 — 트리 + 크기만큼의 no-op. 확인: `rawDetach` 호출부는 `settle` 한 곳뿐이고 + `_detached`에 쓰는 코드도 전부 그 클로저 안이다. + + **이관하면서 `_listObserver`와 완전히 같은 취급으로 통일했다** — + 생성은 `activateList` 최초 1회, `unmountSlotTree`는 **앵커만 해제하고 + 핸들 보존**, 재마운트는 멱등 가드가 재앵커, `destroySlotTree`만 해제 + + `nil`. 이전엔 이 둘이 서로 다른 함수에서 만들어지고 언마운트 처분도 + 갈렸다(하나는 보존, 하나는 `nil`). 이제 **"`activateList`가 소유하고 + 물리 target에 앵커되는 자원"**이라는 한 범주가 되어, 이런 자원이 또 + 생겨도 훑을 자리가 하나다. + + **⚠️ 이 이관에는 `I-2`의 멱등 가드와의 상호작용이 걸려 있다.** 원 + 제안의 근거는 *"`materializeSlotTree`가 `_listed`면 재마운트 때도 + `activateList`를 다시 부르니 lifecycle이 보존된다"*였는데, 그건 + **가드를 넣기 전 동작**이다(그리고 그게 `I-2`의 버그였다). 가드가 있는 + 지금은 두 번째 호출이 early return하므로, **가드 분기가 + `_detachCleanup`도 같이 재앵커하지 않으면** 포탈 재마운트 후 그 Slot의 + detached는 owner 사망 시 아무도 치우지 않는다. 가드 분기에서 두 자원을 + 같이 `bindLifetime`하도록 반영했다. + + `_detached` **테이블 자체는 Slot 필드로 그대로 둔다** — + `destroySlotTree`가 `activateList` 클로저 **밖**에서 walk해야 하는 게 + 애초에 `ud`를 버리고 필드로 간 이유다(`I-1`의 (2)번 근거). + +2. **`activateList(self, inst)` → `activateList(self, physicalTarget)`.** + 그 인자는 `bindLifetime`의 첫 인자로 들어가니 타입상 물리 Instance일 + 수밖에 없고, 호출부 둘(`materializeSlotTree`의 `physicalTarget`, + `Slot:List()`의 `self._mountedInst`)이 넘기는 값도 같은 개념이다. + Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다. 옆 함수들 + (`materializeSlotTree`/`mountSlotTree`/`attachSlot`)과 이름을 맞춘 + **순수 리네이밍**. diff --git a/.claude/qa-request/pre-implementation-qa-round4.md b/.claude/qa-request/pre-implementation-qa-round4.md index 46df6ec..ab3dbc5 100644 --- a/.claude/qa-request/pre-implementation-qa-round4.md +++ b/.claude/qa-request/pre-implementation-qa-round4.md @@ -1,6 +1,16 @@ # 구현 전 QA **4라운드** — 확정 전수 재심사 문항지 -**상태**: **작성 중 / 사용자 회신 대기**(2026-08-19 세션에서 생성). +**상태**: **[2026-08-21] 완료·종결.** 2026-08-19 세션에서 생성 → 사용자가 +`-response.md`로 회신 → 4차에 걸친 처리로 전량 반영 완료. **처리 결과의 +소스는 `pre-implementation-qa-round4-followup.md`**(마지막 H절이 최신) — +이 파일은 문항지 원본 그대로 남긴다. + +**[2026-08-21 계획 변경] 아래 "회신 방법" 문단이 예고한 "아니오가 나온 +항목만 남긴 근거 기록으로 재편"은 하지 않기로 했다** — 1라운드는 회신을 +문항지 안에 받아서 재편이 곧 정리였지만, 이 라운드는 회신을 별도 파일로 +받았고 처리 결과가 전부 `-followup.md`에 쌓였다. 여기서 또 재편하면 같은 +정보가 두 곳으로 갈라져 이 코퍼스에서 반복적으로 터진 "개수·목록 이중 소스" +패턴을 새로 만든다. **왜 이 라운드가 있는가**: 사용자 요청 — *"요즘 변경이 엄청 많고, 틀려서 정정한게 엄청 많아서, 모든 부분에 있어서 내 심사를 좀 받아야할듯. 예 가 diff --git a/.claude/question.md b/.claude/question.md index bd21959..e531b90 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -61,6 +61,13 @@ - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. +- **`Owned`(3순위, 2026-08-21 신설)**: `:List`/`:Single`의 설치 시점 + 플래그(기본 `true`, `false`면 어떤 경로로도 파괴 안 함). + `elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐지만 + **잠정 이름**이다 — 형용사라 옵션 테이블 키로는 자연스러운데, 실제로 + 묻는 건 "이 Slot이 요소의 수명을 책임지는가"라서 `OwnsElements`처럼 + 주어를 드러내는 쪽이 나을 수도 있음. `base/slot-plan.md`의 + "소유권은 설치 시점에 정해진다" 절. - **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이 있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열 @@ -168,7 +175,8 @@ 선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라 **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가 - 닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이 + 닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`가 + 정리한다. 원래 갭이 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가 **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의 "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절. diff --git a/.claude/research/slot-attach-decomposition.md b/.claude/research/slot-attach-decomposition.md index 5c1af7c..5730067 100644 --- a/.claude/research/slot-attach-decomposition.md +++ b/.claude/research/slot-attach-decomposition.md @@ -1,4 +1,4 @@ -# `attachSlot` 책임 분해 — 확장 논의 준비 자료 +# `attachSlot` 책임 분해 — 결정 근거 기록 (2026-08-21 확정) **상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`에 반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한 diff --git a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md index 3f1b778..32cbbde 100644 --- a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md +++ b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md @@ -137,3 +137,96 @@ detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어 `table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`. - 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는 **일부러 미뤘다** — 지금 그게 아프다는 증거가 없다. + +--- + +## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다 + +H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`를 **6라운드** 돌렸다 +(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이: + +| 라운드 | 각도 | 확실 발견 | +|---|---|---| +| 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`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다. +`:List`의 `data:Observer`는 `bindLifetime(inst, observer)`로 **물리 target에 +앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만 +넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`가 +조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로 +마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라 +새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**( +`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나 +추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인. + +### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택) + +1. **재마운트 경로** → `claimOwner`에 `fromDetached` 예외(대안: 전용 + `rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경). +2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만). +3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고 + `pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래 + 요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는 + `base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라 + 옛 단일 `attachSlot`에도 있었을 구조적 갭이다. + +### 부수 정리 + +2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧 +상태"라면서 재작성 대상 스파이크 `01`을 `done/`에 둔 채 ⚠️ 마커만 달아둔 +것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크` +절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼 +구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를 +지목했는데 정작 항목이 없었다. + +### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건 + +`_detachCleanup` Effect 설치를 `mountSlotTree` → `activateList`로 이관, +그리고 `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라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이 +훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다. diff --git a/.claude/todos.md b/.claude/todos.md index cfbe457..276687b 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -86,7 +86,8 @@ `archive/question-resolved.md`. - **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 — **`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는 - `slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리. + `slot._detached` 필드가 보유하고 owner 사망 시 `activateList`가 건 + `Effect`가 정리. `base/slot-plan.md`의 "`KeyGone`" 절. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 @@ -167,12 +168,11 @@ stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) - — 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 - 완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영 - 완료로 목록에서 빠짐**): `Slot`(2순위), - `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 - 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ - `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). + — **[2026-08-21] 여기 있던 이름 나열은 지웠다.** 바로 위 문장이 이미 + "`question.md` 1번이 최신 소스"라고 선언해놓고 다음 줄에서 목록을 다시 + 나열하고 있었고, 예고대로 실제로 갈라졌다(2026-08-21에 추가된 `Owned`와 + 그 전부터 있던 `hintValue`가 둘 다 빠져 있었음 — 감사가 발견). + **열린 항목이 뭔지는 `question.md` 1번을 열어볼 것.** 3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스 재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`로 이전됨 — 더 이상 상의할 스코프 항목이 아님. diff --git a/ROADMAP.md b/ROADMAP.md index 8d6aae2..b83df25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -529,7 +529,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 "Detach된 요소는 `slot._detached`가 보유한다" 절). **[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은 `KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로 - 한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건 + 한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건 `Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절). **`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본 `true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가