quad/.claude/qa-request/pre-implementation-qa-round5.md
qwreey c6fdf1b348
qa: 구현 전 QA 5라운드 — 문항지·회신·전량 반영 + 감사 2라운드
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항).
범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup /
quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것
(Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐
받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다.

주요 확정/역전:
- slot._detached lazy화, KeyGone엔 새 값 반환도 error,
  Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount)
- Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음)
- raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결.
  래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸
- 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) —
  base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음
- Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커
  분리, 4라운드 D-56 역전(archive로)
- Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom),
  setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정
- recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던
  결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈),
  bk.N or 0(빈 Slot 크래시)
- Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦"
  역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k
- 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로
  바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목)

새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료),
state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성
결정이라 M3 전 결론 필요).

감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼
코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순,
그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는
M3에서"로 남아 있던 사각지대.

doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md
(A~K절, 마지막이 최신).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 18:19:58 +09:00

82 KiB

구현 전 QA 5라운드 — 새 영역 + 심화 문항지

상태: [2026-08-21] 완료·종결. 작성 → 사용자 회신(-response.md) → 4차에 걸친 처리로 전량 반영 완료. 처리 결과의 소스는 pre-implementation-qa-round5-followup.md(마지막 절이 최신) — 이 파일은 4라운드와 같이 문항지 원본 그대로 남긴다.

왜 이 라운드가 있는가: 사용자 요청 — "5차 qa 를 할게, 4차에서 예로 넘어갔던건 스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을 쌓아보자." 4라운드(510문항)는 base/ 전 문서의 확정 주장을 한 겹으로 전수 훑었고, 그중 실제로 "아니오"가 나온 것들은 이미 -followup.md가 전량 처리했다. 그래서 이번엔 같은 자리를 다시 묻지 않고 아래 셋에만 집중한다:

  1. 4라운드에 문항이 아예 없던 영역 — 신규 문서 base/project-setup-plan.md/ base/quad-types-plan.md, 그리고 문서가 아니라 실제로 커밋된 M1 코드 (quad-base/src, quad-types/src, type-version-check/src). 4라운드는 base/ 문서만 대상이었다.
  2. 4라운드 회신 이후에 새로 확정된 것Detach 보존 주체 (slot._detached), KeyGone, Owned, attachSlot 분해 (materializeSlotTree/mountSlotTree), raw 3형제, settle/releaseElement, "부기가 물리보다 먼저"의 일반 계약 승격, flatten의 ProcessedModifier, recomputenilerror, Tween<T>:Map, RunInit 재설계 등. 전부 확정된 지 하루도 안 된 것들이라 4라운드가 검증한 적이 없다.
  3. 4라운드가 얕게만 훑은 큰 문서의 심화 — 예: debounce-throttle-plan.md는 1100줄이 넘는데 4라운드 문항은 9개뿐이었다. 이번엔 그 안의 실제 의사코드/ 상태 전이/에러 경로를 묻는다.

그래서 문항의 성격이 4라운드와 다르다. 4라운드가 "문서에 적힌 확정을 그대로 문장으로 옮긴 것"이었다면, 이번엔 문서가 명시적으로 말하지 않았지만 설계상 따라나와야 하는 귀결을 많이 묻는다 — 그게 "아니오"가 나올 확률이 높은 자리이기 때문이다. 그런 문항은 로 표시했다.

회신 방법: 각 문항은 예/아니오로 답할 수 있게 썼다. 아니오인 것만 알려주면 되고(어디가·어떻게 틀렸는지 + 원래 뭐가 맞는지), 판단이 애매하면 "보류"라고만 해도 된다 — 4라운드처럼 풀어 쓴 답을 followup에 준비하겠다.

표기: X-NX는 영역 코드, N은 그 영역 안 문항 순번. 4라운드와 코드 체계가 다르다(4라운드 S-1과 이 문서의 코드는 무관) — 이번엔 문서 단위가 아니라 주제 단위로 묶었다.

코드 영역 주 대상
PS 프로젝트 셋업 (4라운드 문항 없음) base/project-setup-plan.md
QT quad-types / 버전 체크 (4라운드 문항 없음) base/quad-types-plan.md
IM 실제 커밋된 M1 코드 (문서 아님) quad-base/src, quad-types/src, type-version-check/src
DE Detach/_detached/KeyGone/Owned (신규 확정) base/slot-plan.md
AS attachSlot 분해 (신규 확정) base/slot-plan.md, research/slot-attach-decomposition.md
DC 디스패치 코어 심화 base/dispatch-core-plan.md
SS Source/State 심화 base/source-state-plan.md
LC 생명주기 배관 심화 base/lifecycle-pattern.md, base/relate-plan.md
EF Effect/Observer 심화 base/effect-plan.md
MO Modifier 심화 base/modifier-plan.md
AT Attribute/Tag 심화 base/attribute-plan.md, base/tag-plan.md
TW Tween/숏핸드 심화 base/tween-plan.md, base/ui-shorthand-plan.md
DT Debounce/Throttle 심화 base/debounce-throttle-plan.md
ML 모듈 생명주기 심화 base/module-lifecycle-plan.md
TL 타입 한계 심화 base/typing-limits.md, base/store-plan.md
CR 크로스컷 — 여러 문서에 걸친 상호작용/순서/에러 경로 여러 문서

문항 수와 읽는 순서

총 205문항(이 문서가 그 수의 소스 — 다른 곳에 적지 않는다). 4라운드가 510문항이었던 것에 비해 적은 건 범위를 줄였기 때문이지 깊이를 줄인 게 아니다 — 이미 "예"로 넘어간 자리를 다시 묻지 않는다.

읽는 순서 권고

작성한 에이전트의 추정이지 확정이 아니다.

  1. DE/AS — 어제 확정돼 아직 아무도 재심사 안 한 것. 잘못 굳으면 M6 구현 전체가 그 위에 얹힌다.
  2. IM — 유일하게 실제로 돌아가는 코드를 묻는 영역. 문서와 코드가 갈라졌다면 여기서 드러난다.
  3. CR — 문서 하나만 봐서는 안 보이는 자리라 stale이 가장 잘 낀다.
  4. PS/QT — 새 문서. 확정이 맞는지보다 "이 결정을 계속 유지할 것인가"(예: pesde.lock 커밋, symlink 수동 치환)를 묻는 문항이 섞여 있다.
  5. 나머지 — 순서 무관.

PS. base/project-setup-plan.md — 프로젝트 셋업 (4라운드 문항 없음)

PS-1 — pesde 전환의 판정 기준

wally → pesde 전환의 근거는 "네이티브 workspace가 있다"가 아니라 dev-dependency 1급 지원 등 툴링 전반이 낫다는 사용자 판단이고, 모노레포 모양(루트 통합 개발 + 서브패키지별 독립 게시)이라는 결론 자체는 wally 시절과 안 바뀌었다. pesde의 workspace 기능은 그 모양에 맞아떨어진 부수 이득이다. → 예/아니오

PS-2 — 폴더 이름과 패키지 이름이 다르다

폴더는 하이픈(quad-base), pesde.tomlname은 언더스코어(qwreey/quad_base)로 의도적으로 다르게 간다 — pesde 패키지 이름이 a-z/0-9/_만 허용해서다. 폴더 이름을 언더스코어로 통일하지 않는 이유는 architecture.md가 이미 확정한 소스 트리를 흔들지 않기 위해서다. → 예/아니오

PS-3 — 워크스페이스 의존성은 손으로 적는다

pesde add는 레지스트리 검색 전용이라 로컬 워크스페이스 형제를 못 찾는다. [dependencies]workspace = "scope/name" 줄은 항상 손으로 쓴다. 이건 버그가 아니라 pesde의 설계이므로 우회 스크립트를 만들 계획도 없다. → 예/아니오

PS-4 — target이 다르면 target =을 명시

quad-types(target roblox)가 type-version-check(target luau)에 의존할 때는 { workspace = ..., version = "^", target = "luau" }처럼 target을 명시해야 하고, 빠뜨리면 no workspace member found ... and target robloxinstall 자체가 실패한다(조용히 넘어가지 않는다). → 예/아니오

PS-5 — 툴체인은 mise, rokit은 폐기

rokit.toml은 완전히 폐기하고 mise.toml로 간다 — pesde/rojo/luau-lsp/ selene 넷을 핀한다. 채택 근거의 절반은 "이 샌드박스가 이미 mise를 쓰고 있어서 실제로 검증할 수 있었다"는 것이고, rokit은 끝내 한 번도 직접 검증하지 못했다. → 예/아니오

PS-6 — luau 자체는 툴체인 핀에 없다

mise.toml이 핀하는 건 위 넷뿐이고 luau/luau-analyze 자체는 안 핀한다 — 지금은 샌드박스가 제공하는 버전을 그대로 쓴다. 타입 스파이크 결과가 솔버 버전에 민감하다는 걸 typing-limits.md가 이미 아는데도 이걸 안 핀해둔 건 의도된 현재 상태이고, 사람이 로컬에서 다른 luau 버전으로 돌리면 결과가 갈릴 수 있다는 걸 감수한다. → 예/아니오

PS-7 — darklua 미채택의 정확한 경계

darklua를 안 쓰는 근거는 "Roblox가 require-by-string을 지원해서"가 아니라 더 좁다 — 예약 alias(@self/@game)는 darklua가 아예 안 건드리고 런타임이 직접 처리하는 반면, 커스텀 alias(@pkg 등)는 darklua 같은 빌드 스텝 없이는 배포 시점에 해소된다는 근거가 없다. quad가 커스텀 alias를 안 쓰기 때문에 지금은 불필요할 뿐이고, @pkg/quad_base류를 도입하고 싶어지는 순간 darklua(또는 동급 변환)가 실질적으로 필요해진다. → 예/아니오

PS-8 — init.luau 안의 상대경로 기준점

init.luau 안에서 ./X그 폴더의 형제를 가리키므로, 같은 폴더 안 형제 파일에 접근하려면 @self/X를 써야 한다. 그래서 quad-base/src/init.luaurequire("@self/Relate")이고, quad-base/src/Debug/init.luau가 부모 폴더의 Relate.luau를 가져올 땐 require("./Relate")(../Relate가 아님)가 맞다. → 예/아니오

PS-9 — 이 규칙이 조용히 깨진다

@self 대신 ./를 쓰는 실수는 런타임에선 크래시하지만 luau-analyze는 진단 0건으로 조용히 통과한다(Unifiable<Error>로 샘). 즉 정적 검사만 돌리는 라운드는 이 실수를 절대 못 잡으므로, init.luau를 새로 추가하는 커밋은 반드시 런타임으로 한 번 require해봐야 한다. → 예/아니오

pesde install이 워크스페이스 의존성을 symlink로 걸고, Luau standalone CLI의 require-by-string은 보안상 의도적으로 symlink를 안 따라간다. 이건 Rojo/Studio 경로와는 무관하고(rojo sourcemap/rojo build 둘 다 투명하게 통과함을 실측), 순수하게 luau/luau-analyze CLI 실행만의 문제다. → 예/아니오

PS-11 — 그 우회는 아직 수동이다

지금 쓰는 우회는 "pesde installroblox_packages/.pesde/의 심볼릭 링크를 실제 디렉토리 복사본으로 손으로 치환"이고, 스크립트/mise task로 정식화하지 않았다. 다음 세션이 CLI 테스트를 돌리려면 같은 수동 작업을 반복해야 한다. 이 상태를 계속 두는 게 맞고, 정식 스크립트화는 "실제로 자주 아파질 때" 하면 된다. → 예/아니오

PS-12 — 그런데 이 우회가 이제 출하 소스에도 걸린다

quad-base/src/init.luaurequire("./roblox_packages/quad_types")를 쓰게 되면서, 이 함정은 더 이상 "스파이크/mock 테스트에만 해당"이 아니다 — 실제 출하 진입점이 CLI에서 안 도는 상태다. 그래도 배포 경로(Rojo/Studio)는 멀쩡하니 문제로 취급하지 않는 게 맞다. → 예/아니오

PS-13 — 패키지별 selene.toml

selene의 config 탐색이 파일 트리를 거슬러 올라가지 않고 CWD 고정이라, 루트 단일 설정을 두면 Luau 타입 문법이 전부 가짜 파싱 에러로 쏟아진다. 그래서 패키지마다 selene.toml을 두고 항상 패키지 디렉토리 안에서 실행한다. → 예/아니오

PS-14 — ⚠️ pesde.lock 커밋 여부는 아직 확정이 아니다

루트 1개 + 멤버마다 1개씩 생기고, 셋 다(=전부) 커밋하는 게 지금의 잠정 권고다("게시물에 안 들어가므로 라이브러리 lockfile 딜레마가 성립 안 함"). 이건 아직 사용자 확정이 아니라 열려 있는 항목이 맞다. → 예/아니오

PS-15 — 아직 검증 안 된 것 셋

(a) 루트 pesde.toml[target]이 실제로 의미가 있는지, (b) Studio 실물 동기화(계정 분리 대기), (c) roblox_sync_config_generator 미설정 WARN의 실제 영향 — 셋 다 지금은 방치가 맞고, 실제로 문제가 드러날 때 열면 된다. → 예/아니오

PS-16 — .luaurc alias는 편집기 전용

.luaurcquad-base/quad-roblox alias는 런타임 require에서 여전히 안 먹는다(could not jump to alias). 이건 위 symlink 문제와 다른 원인이고, @self가 예약 alias라 항상 동작하는 것과도 구분된다. alias는 편집기 자동완성/ 타입체크용으로만 남긴다. → 예/아니오

PS-17 — 패키지 디렉토리 이름은 의존 대상의 target을 따라간다

roblox_packages/luau_packages/가 갈리는 기준은 의존하는 쪽이 아니라 의존 대상 패키지 자신의 target이다. 그래서 quad-types(roblox)가 type-version-check(luau)를 쓸 때 경로가 ./luau_packages/type_version_check가 된다. → 예/아니오

PS-18 — CI가 없고, 당분간 만들 계획도 없다

이 저장소엔 .github/ 워크플로가 없고, 스모크 테스트(quad-base/test/*.luau)는 사람이나 에이전트가 직접 luau로 돌리는 것이 전부다. mise task/Justfile 같은 태스크 러너도 아직 없다. M2 이후 코드가 붙기 시작해도 당분간 이 상태로 가는 게 맞다(테스트 하네스 자체가 ROADMAP 항목). → 예/아니오

PS-19 — 테스트는 게시물에 안 들어간다

quad-base/pesde.tomlincludes = ["src/*"]test/는 게시 대상이 아니다. 그래서 테스트가 프로덕션 의존성을 늘리는 걱정 없이 자유롭게 mock을 둘 수 있고, 반대로 소비자가 mock을 재사용할 방법은 없다(의도된 것 — 필요해지면 quad-mock이 백로그에 있음). → 예/아니오


QT. base/quad-types-plan.md — 타입 계약 패키지 + 버전 체크 (4라운드 문항 없음)

QT-1 — quad-types가 존재하는 진짜 이유

문제의 뿌리는 "타입만 쓰는 require도 런타임에 실제로 실행된다"는 Luau 사실 하나다. 그래서 quad_base를 dev-dependency로만 두면 게시 후 소비자 환경에서 그 require가 그 자리에서 크래시한다. quad-types는 "항상 안전하게 실 의존성으로 둘 수 있을 만큼 작은 것"이라는 자리를 채운다. → 예/아니오

QT-2 — 폴더로는 못 나눈다

pesde workspace 의존은 패키지 단위quad-base/types/ 같은 서브폴더 의존 문법이 없다. 그래서 "가벼운 타입만" 효과를 실제로 얻으려면 반드시 별도 워크스페이스 멤버여야 한다. → 예/아니오

QT-3 — quad-base도 자기 타입을 재선언하지 않는다

quad-baseQuad 타입을 스스로 선언하지 않고 type Quad = QuadTypes.Quad로 가져다 쓴다 — 진실을 한 곳에 두고, 구현이 계약과 어긋나면 구조적 타입에러로 자연히 드러나게 하기 위해서다. → 예/아니오

QT-4 — 이 패턴을 모든 백엔드가 따를 필요는 없다

quad-spring/quad-spring-roblox 같은 쌍은 타입 분리 없이 그냥 일반 의존성으로 둬도 된다. quad-types 분리는 사실상 모든 패키지가 의존하는 핵심 계약일 때만 값어치가 있다. → 예/아니오

QT-5 — Version은 리터럴 타입

Version: "0.0.0"string이 아니라 싱글턴 리터럴이고, 그 자체만으로도 이미 구조적 타이핑이 버전 불일치를 어느 정도 잡는다. CheckVersion이 더해주는 건 감지가 아니라 사람이 읽을 수 있는 진단 메시지다. → 예/아니오

QT-6 — AddPlugin<Self, P>Self는 반드시 제네릭

SelfQuad로 고정하면 두 번째 AddPlugin이 첫 확장을 잃는다. 제네릭이라 Quad & A & B로 누적되는 게 실측 확인됐고, 플러그인 추가 전 그 메소드에 접근하면 정확히 Key 'X' not found로 거부된다. → 예/아니오

QT-7 — 런타임 AddPlugin은 mutate + 같은 테이블 반환

새 테이블을 만들지 않고 self를 직접 mutate해 그대로 반환한다 — RunInit의 멱등 추적이 module identity에 의존하므로, 새 테이블을 반환하면 그 추적이 끊기기 때문이다. → 예/아니오

QT-8 — 그래서 플러그인 키 충돌은 조용히 덮어쓴다

AddPlugin이 확장 테이블의 필드를 그냥 대입하므로, 두 플러그인이 같은 키를 제공하면 나중 것이 이긴다 — 경고도 에러도 없다. 지금은 이걸 방어하지 않는 게 맞고, 실제로 충돌이 관측되면 그때 다룬다. → 예/아니오

QT-9 — type-version-check를 뺀 이유

정확 일치만 보면 독립 게시되는 플러그인 쌍(quad-spring-roblox류)이 매번 재게시를 요구받게 된다. 그래서 글롭/캐럿 패턴을 지원하는 quad 비종속 패키지로 분리했고, 파일 안에 Quad류 이름을 절대 안 섞는다(나중에 독립 저장소로 분리 예정이므로). → 예/아니오

QT-10 — 패턴 문법

.로 나뉜 각 자리가 "*"(와일드카드) / "N^"(N 이상) / 그 외(정확 일치)다. 자릿수가 다르면("0.0" vs "0.0.0") 그냥 불일치다. → 예/아니오

QT-11 — type function은 바깥 로컬을 못 본다

Type function cannot reference outer local 'X'컴파일 자체가 실패하므로, 런타임 matchesPatternCheckVersion 내부 매칭은 물리적으로 중복된 두 함수다 — 하나를 고치면 반드시 다른 하나도 고쳐야 하고, 이 중복은 없앨 방법이 없다. → 예/아니오

QT-12 — 함정 1: error() 대신 print + types.never

type function 안에서 error()를 부르면 "이 type function이 실행에 실패함"으로 판정돼 메시지가 안 뜬다. print(...) + return types.never 조합만 호출부에 TypeError: <메시지>로 뜬다. → 예/아니오

QT-13 — 함정 2: lazy 평가라 필드를 실제로 참조해야 한다

local _ = checked.__versionCheck 줄이 빠지면 검사가 조용히 스킵된다. 함수 본문 안의 로컬 타입 별칭(type _Check = ...)으로는 제네릭 인스턴스화 시점에 재평가되지 않아 아무 진단도 안 뜬다. → 예/아니오

QT-14 — 함정 3: type function을 거친 이력만으로 오염된다

패스스루(return t)여도 그 타입에 이후 AddPlugin 같은 제네릭 self 메소드를 부르면 Expected this to be exactly 'P & Self', but got 'P & Self'처럼 앞뒤가 같은 진단을 내며 깨진다. 그래서 CheckVersion은 원본을 절대 반환하지 않고 트리비얼한 true만 반환하며, 결과는 별도 필드(__versionCheck)로 완전히 격리한다. → 예/아니오

QT-15 — 그 교훈은 일반 규칙이다

"검증/변형용 type function은 원본 타입을 절대 반환하지 말고 트리비얼한 마커만 반환해 별도 필드로 격리한다"는 건 이 한 사례의 우회가 아니라 앞으로 quad가 type function을 쓸 때마다 따를 규칙이고, typing-limits.md의 설계 체크리스트에 들어가야 한다. → 예/아니오

QT-16 — 버전 문자열이 세 곳에 하드코딩돼 있다

지금 "0.0.0"은 (1) quad-base/pesde.tomlversion, (2) quad-typesQuad.Version 리터럴 타입, (3) quad-base/src/init.luau가 대입하는 실제 값 — 세 곳에 각각 적혀 있고, 이걸 동기화해주는 기계 장치는 없다(주석의 "항상 맞출 것"이 전부). 버전을 올릴 땐 사람이 셋을 같이 고쳐야 하고, 지금 단계에선 그게 맞다. → 예/아니오

QT-17 — CheckedQuad는 소비자가 자기 패턴을 고른다

Pattern이 타입 파라미터인 이유는 관계마다 엄격도가 달라야 하기 때문이다 — 같은 모노레포에서 함께 개발되는 quad-base/quad-roblox는 "0.0.0"(정확 일치), 독립 게시되는 플러그인은 "0.*.*" 같은 느슨한 패턴. quad가 강제하는 기본 패턴은 없다.예/아니오

QT-18 — 런타임 버전 체크는 안 만든다

CheckedQuad컴파일 타임 전용이고, 런타임에 버전을 비교해 error를 내는 경로는 만들지 않는다(그러려면 quad-roblox 소스에 버전을 하드코딩해야 하는데 과하다는 사용자 판단). type-version-check가 런타임 matchesPattern을 export하는 건 테스트/문서화용이지 quad가 그걸 부르기 위한 게 아니다. → 예/아니오

QT-19 — quad-roblox-types는 백로그, 다만 관례는 지금부터

quad-roblox의 공개 타입을 단일 src/init.luau(또는 types.luau)에 몰아두는 관례는 지금부터 지킨다 — 나중에 quad-roblox-types를 쉽게 뽑기 위해서다. 패키지 자체를 지금 만들 필요는 없다. → 예/아니오


IM. 실제로 커밋된 M1 코드 (문서가 아니라 코드가 대상)

이 영역만 성격이 다르다 — 4라운드는 base/ 문서만 봤고, 아래는 지금 저장소에 실제로 들어 있는 quad-base/src/init.luau, quad-base/src/Relate.luau, quad-base/src/Debug/init.luau, quad-types/src/init.luau, type-version-check/src/init.luau, quad-base/test/*를 읽고 뽑은 문항이다. "문서가 이렇게 말한다"가 아니라 "코드가 실제로 이렇게 돼 있다"를 묻는다.

IM-1 — 최상위 반환값

quad-base/src/init.luaureturn New()이미 만들어진 인스턴스를 반환하고, 그 인스턴스가 자기 필드로 New를 갖는다. 즉 Quad.New()는 지금도 호출 가능한 상태이고, architecture.md가 말한 "지금 단계에선 New 필드가 아직 노출되지 않는다(싱글톤)"는 더 이상 코드와 안 맞는다(코드 쪽이 맞고 문서 표현이 stale). → 예/아니오

IM-2 — 메소드는 인스턴스마다 새 클로저

RunInit/AddPlugin은 공유 메타테이블이 아니라 New() 안에서 매 인스턴스마다 새로 만들어지는 클로저다. New()가 사실상 한 번만 불리는 지금은 이 비용이 무의미하고, 메타테이블로 바꿀 이유가 없다. → 예/아니오

IM-3 — 멱등 기록은 파일 스코프 Relate 하나

runInitRelateNew() 안이 아니라 파일 스코프에 하나뿐이고, module을 weak 키로 쓴다. 그래서 New()로 만든 인스턴스들이 서로의 RunInit 기록을 공유하지 않으면서도(키가 다름) 별도 정리 코드가 필요 없다(module이 GC되면 기록도 같이 사라짐). → 예/아니오

IM-4 — 실행 전에 먼저 표시한다

runInitRelate:SetStrong(self, initFn, true)initFn(self) 호출 전에 찍는다 — 순환 의존(InitAInitB를 부르고 그 반대도)에서 무한 재귀를 막기 위해서다. 그 결과 initFn이 도중에 error를 던져도 "실행됨"으로 기록이 남고, 다시 RunInit해도 재실행되지 않는다. 이건 알려진 트레이드오프로 그대로 두는 게 맞다. → 예/아니오

IM-5 — :: Quad 캐스트가 타입 구멍을 만든다

New() 안에서 moduleNew/Version 두 필드만 가진 채 :: Quad로 캐스트되고, debug는 그 뒤 module:RunInit(InitDebug)가 채운다. 그래서 RunInit(InitDebug) 줄을 실수로 지워도 타입 에러가 안 나고 런타임에 Quad.debugnil이 될 뿐이다(스모크 테스트만이 이걸 잡는다). 지금 단계에선 이 구멍을 감수하는 게 맞다. → 예/아니오

IM-6 — InitDebug는 자기 가드를 안 둔다

Debug/init.luau는 "이미 초기화됐는가"를 스스로 확인하지 않는다 — 멱등성은 전적으로 호출부(module:RunInit)의 책임이다. 앞으로 추가되는 모든 InitXxx도 같은 규약을 따른다(각자 가드를 두지 않는다). → 예/아니오

IM-7 — Relate 구현이 계획과 일치하는지

실제 Relate.luau는 (a) 바깥 buckets__mode = "k", (b) 버킷과 StrongMap/WeakMap각각 lazy 생성, (c) WeakMap의 메타테이블은 파일 스코프 WEAK_VALUE_MT 하나를 모든 맵이 공유, (d) Get*는 서브맵이 없으면 만들지 않고 그냥 nilrelate-plan.md의 "실제 구조" 절 그대로다. → 예/아니오

IM-8 — 삭제 API가 없다

Relate의 공개 표면은 SetStrong/GetStrong/SetWeak/GetWeak 넷뿐이고, Delete/Clear 같은 명시적 삭제 메소드가 없다 — 지우려면 SetStrong(inst, key, nil)을 부른다. relate-plan.md가 "명시적으로 만든 기록은 명시적으로 지울 것"이라고 강하게 요구하는데도 전용 API를 안 두는 게 맞다 (nil 대입으로 충분하므로). → 예/아니오

IM-9 — 빈 버킷은 회수되지 않는다

어떤 inst의 마지막 키를 nil로 지워도 그 inst의 버킷 테이블(그리고 빈 StrongMap/WeakMap)은 그대로 남는다 — 버킷 자체는 inst가 GC될 때만 사라진다. 빈 버킷을 즉시 청소하는 로직은 일부러 안 넣는다(살아있는 inst당 작은 테이블 하나이고, 청소하면 다음 Set에서 다시 만들어야 함). → 예/아니오

IM-10 — AddPlugin의 런타임 계약

pluginFn(self)의 반환값을 일반화 for로 순회해 그대로 self에 대입한다. 그래서 (a) 반환값이 테이블이 아니면 그 자리에서 런타임 에러, (b) 배열 파트/해시 파트 구분 없이 전부 복사, (c) 검증/충돌 체크는 전혀 없다 — 지금 단계에선 이게 맞다. → 예/아니오

IM-11 — 플러그인은 self를 이미 받는다

pluginFn이 인자로 받는 self아직 자기 확장이 반영되기 전의 module이다. 그래서 플러그인이 자기 확장 필드를 그 시점에 self에서 읽을 수는 없고, 반대로 자기가 반환하는 테이블 안에서 클로저로 self를 캡처하는 건 자유다 (그때는 이미 mutate가 끝나 있으므로). → 예/아니오

IM-12 — 테스트는 프레임워크 없는 assert 스크립트

quad-base/test/*.luauassert + print("PASS") 조합이고 테스트 러너가 없다 — 사람이/에이전트가 luau quad-base/test/smoke.init.luau처럼 직접 돌린다. 실패는 assert가 그 자리에서 터지는 것으로 표현된다. M2 이후에도 당분간 이 형태로 간다. → 예/아니오

IM-13 — mock의 의도적 범위 축소

test/mock.luau는 parent/children 트리 + 타입 검증 없는 property bag + property별 변경 시그널만 만들고 IsA()/클래스별 스키마/WaitForChild를 안 만든다. Vide mock의 GC-독립 userdata 프록시 트릭도 일부러 안 쓴다 (그건 quad-roblox의 gcconn 트릭이 다루는 자리라 quad-base 정적 테스트 범위 밖). → 예/아니오

IM-14 — 지금 코드에 없는 것

현재 quad-base/src에는 DispatchSource/StateSlot도 없고, Relate/Debug/모듈 골격뿐이다. 즉 4라운드·5라운드가 검증하는 설계 중 실제 코드로 존재하는 건 Relate와 모듈 생명주기뿐이고, 나머지는 전부 의사코드 상태다. 이 인식이 맞다. → 예/아니오


DE. Detach / _detached / KeyGone / Owned — 2026-08-21 신설 확정

4라운드 회신 이후에 확정된 것들이라 아직 아무도 재심사하지 않았다.

DE-1 — 보존 주체가 필드여야 하는 이유

detach된 요소를 userdata가 아니라 slot._detached 필드가 들고 있어야 하는 이유는 셋 다다 — (1) userdata는 opaque라 :List가 뭘 죽여야 할지 모르고, (2) 소유권 기록이 Slot에 남아야 남이 못 가져가고, (3) destroySlotTree의 walk가 닿아야 한다. → 예/아니오

DE-2 — GC 폴백이 아예 없다

Parent = nil인 detach 노드는 자기 gcconn 클로저가 자기를 캡처해 아무도 안 들고 있어도 영원히 살아남는다. 그래서 "언젠가 GC가 치운다"가 성립하지 않고 명시적 정리 경로가 필수다. → 예/아니오

DE-3 — raw 3형제가 갈리는 축은 둘

rawRemove(반납+파괴) / rawUnmount(반납+안 죽임) / rawDetach(유지+안 죽임) — "파괴하는가"와 "소유권을 놓는가"가 독립된 두 축이고, 네 번째 조합(유지+파괴)은 의미가 없어 안 만든다. → 예/아니오

DE-4 — 재클레임 예외는 플래그로만

Detach 재마운트는 rawUnmount를 안 거치고 곧바로 rawAdd를 부르므로, claimOwnerfromDetached 플래그가 참일 때만 통과하는 좁은 예외를 뒀다. 이걸 "같은 owner면 통과"로 완화하면 Slot { a, a }가 다시 새어나가므로 절대 완화하면 안 된다. → 예/아니오

DE-5 — 재-Detach는 nop, prev 반환은 재마운트

이미 _detached에 있는 키에 또 Detach를 반환하면 아무 일도 안 일어나고, 그 요소를 prev로 그대로 반환하면 _detached에서 빠지며 재마운트된다. 매 사이클 filter에 걸리는 흔한 경로라 여기서 반복 작업이 생기면 안 된다. → 예/아니오

DE-6 — ud로 붙잡을 필요가 없어졌다

reconcilemounted[key] or self._detached[key]prev로 넘기므로 updateFnreturn Detach, ud만 하면 되고, 옛 서술의 return Detach, { old = prev } 관용구는 폐기됐다. ud에 담는 것 자체는 자유지만 :List가 정리해주지 않는다. → 예/아니오

DE-7 — _detached는 모든 Slot이 갖는다

settle/destroySlotTreeself._detached[key] / pairs(slot._detached)조건 없이 인덱싱하므로, _detached:List를 설치하지 않은 Slot에도 생성 시점에 빈 테이블로 초기화돼 있어야 한다(안 그러면 파괴 walk가 nil 인덱싱으로 터진다). 즉 _owned처럼 "없으면 기본값"으로 다루는 필드가 아니다. → 예/아니오

DE-8 — KeyGone의 인자 규약

키가 사라진 자리는 updateFn(KeyGone, 0, offset, prev, ud)로 한 번 더 묻고, index0(자리가 없으므로), offset은 Slot의 것을 그대로 넘긴다. 반환값 의미는 정상 사이클과 같고, prev를 그대로 반환하는 것만 error다. → 예/아니오

DE-9 — KeyGone새 값을 반환하면 어떻게 되나

지금 의사코드는 소멸 루프에서도 같은 settlepos = 0으로 부르므로, updateFnKeyGone에 대해 새 요소를 반환하면 교체 분기로 들어가 rawAdd(self, result, 0), 즉 인덱스 0에 마운트를 시도한다(Add는 범위 밖 인덱스에 clamp 없이 error). "반환값 의미는 정상 사이클과 완전히 같다"는 서술과 이 코드가 어긋난다 — KeyGone에 새 값을 반환하는 것도 prev 반환처럼 error여야 하는 게 맞나? (대안: 마지막 위치에 붙이기 / 조용히 무시) → 예/아니오 + 어느 쪽인지

DE-10 — 다시 안 묻는다

소멸 루프가 **직전 사이클의 keyIndex**만 순회하므로 사라진 키는 다음 사이클 대상이 아니다 — "한 번만 묻는다"를 위한 별도 플래그가 필요 없다. → 예/아니오

DE-11 — 그래서 홀드된 것은 owner가 죽을 때까지 남는다

KeyGoneDetach로 답한 요소는 (a) 키가 다시 나타나 prev로 부활하거나 (b) owner가 죽어 _detachCleanup이 정리할 때까지 무한정 _detached에 쌓인다. 키 churn이 큰 리스트에서 이게 메모리 증가로 보일 수 있다는 걸 감수하고, 잘라내는 정책(LRU/최대 개수 등)은 만들지 않는다. → 예/아니오

DE-12 — 정리 Effect의 소유 층위

_detachCleanupmountSlotTree가 아니라 **activateList**가 만들고, _listObserver완전히 같은 취급을 받는다 — 생성 1회, 언마운트 시 앵커만 해제하고 핸들 보존, 재마운트 시 새 target에 재앵커, 파괴 시 해제 후 nil. → 예/아니오

DE-13 — cleanup이 하는 일의 순서

_detachCleanup의 cleanup은 각 요소에 대해 releaseOwner를 먼저, 두 분기 공통으로 부르고 그 다음에만 _owned ~= false일 때 파괴한다. 반납을 빠뜨리면 Owned = false 요소가 죽은 Slot을 owner로 달고 남아, 사용자가 그 값을 다른 Slot에 넣을 때 GC 타이밍에 따라 "이미 마운트됨" error가 난다. → 예/아니오

DE-14 — 상태 없는 Effect가 이 용도의 유일한 도구다

Effect(function() return function() ... end end)처럼 state 없이 쓰는 건 "leaf가 죽을 때 cleanup 1회"만 얻으려는 것이고, bindLifetime은 "실행해도 되는가"만 게이팅할 뿐 죽는 순간의 콜백을 안 주기 때문에 대안이 없다. → 예/아니오

DE-15 — Owned는 설치 시점에 고정되는 축

Owned는 사이클마다 달라지는 판단이 아니라 설치 시점 플래그(기본 true)이고, Detach("지금은 안 쓰지만 내 것")와 직교한다. 그래서 반환값 계열에 "unowned replace" 센티널을 하나 더 만들지 않는다. → 예/아니오

DE-16 — _owned는 필드여야 한다

destroySlotTree/dispose가 읽어야 하므로 클로저 업밸류가 아니라 Slot 필드 (slot._owned)이고, 코드는 _owned == false / _owned ~= false로 판정한다 (= 미설정이면 owned). → 예/아니오

DE-17 — Owned = false인데 updateFn이 새 요소를 만들면

Owned는 리스트 전체 단위라, Owned = false로 설치된 :List에서 updateFn이 자기가 만든 새 Instance를 반환하면 그건 아무도 파괴해주지 않는다(밀려날 때도 언마운트만, owner가 죽을 때도 언마운트만). 이건 "혼합은 표현 못 함"의 구체적 귀결이고, 그래도 지금은 새 축을 안 만드는 게 맞다. → 예/아니오

DE-18 — Slot:Add(state) sugar가 Owned = false의 유일한 정규 진입점

사용자가 직접 opts.Owned = false를 넘길 수도 있지만, 실제로 이게 필요한 정규 경로는 반응형 raw 요소 sugar(:Single + identity updateFn)다. state<Frame> 교체가 이전 값을 파괴하지 않는다는 확정 의미론이 이걸로 지켜진다. → 예/아니오

DE-19 — opts는 네 번째 위치 인자다

Slot:List(data, updateFn, keyFn?, opts?)keyFn 없이 opts만 주려면 nil을 명시적으로 끼워 넣어야 한다(slot:List(data, fn, nil, { Owned = false })). 테이블 하나로 합치거나 named 파라미터로 바꾸지 않고 이 모양으로 간다. → 예/아니오

DE-20 — settle을 뽑은 이유

정상 사이클과 소멸 루프가 같은 처분 함수를 공유해야 두 경로가 갈라지지 않는다. 그래서 Detach/nil/prev/교체 네 분기가 전부 settle 한 곳에만 있다. → 예/아니오

DE-21 — prev가 그대로인데 위치도 같으면 아무 일도 안 한다

result == prev이고 detach 상태가 아니면 keyIndex[key] ~= pos일 때만 rawMove를 부른다 — 즉 값도 위치도 그대로인 흔한 경로는 테이블 조회 몇 번이 전부이고 물리 트리에 손을 안 댄다. → 예/아니오

DE-22 — 언마운트 중에 들어온 데이터 변경은 재마운트가 따라잡지 않는다

unmountSlotTree_listObserver의 앵커만 풀기 때문에 언마운트 동안의 emit은 canExecute에서 걸러진다. 그리고 activateList의 재마운트 분기는 앵커만 다시 걸고 reconcile을 다시 돌리지 않는다. 그래서 포탈로 옮겨온 Slot은 다음 emit이 올 때까지 옛 스냅샷 그대로다 — 이게 의도된 동작이 맞나? (대안: 재마운트 시 1회 강제 reconcile) → 예/아니오


AS. attachSlot 분해 — materializeSlotTree + mountSlotTree (2026-08-21 확정)

AS-1 — 쪼갠 건 함수가 아니라 재귀다

공개 attachSlot은 이름/시그니처/호출부가 하나도 안 바뀌고 몸통만 두 줄이 됐다 — 실제로 갈라진 건 트리를 도는 재귀 자체(부기 재귀 / 물리 재귀)다. 그래서 다른 문서의 attachSlot 참조가 전부 그대로 유효하다. → 예/아니오

AS-2 — 분해의 진짜 동기

"책임이 일곱 개라 읽기 어렵다"가 아니라, C6("부모에게 미는 길이는 최종값")과 C7("부기가 물리보다 먼저")이 단일 함수 안에서는 setLength 슬롯이 하나뿐이라 원리적으로 동시 만족 불가였다는 것이 진짜 이유다. 분해로 둘이 처음 동시에 성립한다. → 예/아니오

AS-3 — 순서가 함수 경계로 강제된다

_mounted를 켜는 코드가 materializeSlotTree에는 아예 없으므로, RC-3/RC-4 류(한 함수 안에서 줄 순서를 잘못 잡는) 실수 클래스가 구조적으로 사라진다. 이게 "지금 의사코드를 건들이는 비용이 추후 실수 누적 비용보다 싸다"의 구체적 내용이다. → 예/아니오

AS-4 — materializeSlotTree 안의 순서

setOffsetSource(즉시 계산) → slot.Offset 대입 → (_listed면) activateListblocker:On() → 자식 루프(재귀 또는 setOffsetSource(None)+setLength(1)) → OffWithoutEmit()recompute마지막에 setLength(ownerKey, position, slot.Length). 이 일곱 줄의 순서가 전부 실제로 밟은 버그에서 나온 제약이다. → 예/아니오

AS-5 — activateList가 Blocker 에서 불리는 게 맞나

지금 코드는 blocker:On()보다 먼저 activateList를 부른다. 그 시점의 :List 최초 reconcile이 게이팅 없이 도는데도 RC-1류 크래시가 안 나는 이유는 그 Slot이 아직 _mounted == falserawAdd_elements에만 넣고 끝나는 경로를 타기 때문이고, 부기(setLength/recompute)를 아예 안 건드리므로 게이팅할 대상 자체가 없다. → 예/아니오

AS-6 — Blocker의 범위가 정의와 일치한다

이제 Blocker가 감싸는 건 자식 등록 루프뿐이고 물리 마운트는 감싸지 않는다 — "배치 등록 게이팅"이라는 이름과 실제 범위가 처음으로 정확히 같아졌다. mountSlotTree는 Blocker가 필요 없다. → 예/아니오

AS-7 — 예외 시 Blocker가 켜진 채 남는 건 감수한다

자식 재귀가 예외를 던지면 OffWithoutEmit()에 도달하지 못해 그 Slot의 recompute가 영원히 게이팅되고 Length가 영구 stale해진다. pcall로 감싸지 않는 이유는 (1) 마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 (에러 경계는 Fallback/Traceback 몫), (2) 실제로 밟은 적 없는 경로이며, (3) 이건 분해가 만든 창이 아니라 옛 단일 attachSlot에도 있던 갭이기 때문이다. → 예/아니오

AS-8 — mountSlotTree는 정말로 물리 대입만 한다

_mounted/_mountedInst 세팅과 Parent 대입 재귀가 전부이고, _detachCleanup 설치가 activateList로 이관되면서 부기가 한 줄도 안 남았다. → 예/아니오

AS-9 — 관측 가능한 차이는 "부기 완료 후 일괄 물리"

Parent 대입 순서 자체는 동일하고(깊이 우선 같은 순서), 달라지는 건 부기와 물리가 더 이상 인터리브되지 않는다는 것뿐이다. 그래서 첫 ChildAdded 핸들러가 볼 때 서브트리의 Length/Offset이 이미 최종값이고(옛 코드는 inner.Length == 0인 미완성 스냅샷), slot.Length 구독자도 0 → 최종 두 번이 아니라 최종값 한 번만 본다 — 엄밀히 더 정확해지는 방향이다. → 예/아니오

AS-10 — 재귀는 같은 physicalTarget을 그대로 내려보낸다

중첩이 아무리 깊어도 materializeSlotTree/mountSlotTree가 자식에게 넘기는 physicalTarget트리 최상위의 물리 Instance 하나다(ownerKey만 부모 Slot으로 바뀐다). 그래서 모든 중첩 Slot의 bindLifetime 앵커가 그 한 inst에 몰리고, 자식 Slot만 파괴돼도 그 inst는 살아있는 게 흔한 경우라 파괴 경로가 _listObserver/자식 observer를 명시적으로 unbindLifetime해야 한다. → 예/아니오

AS-11 — 런타임 단건 Add는 여전히 게이팅 없이 attachSlot 한 줄

이미 마운트된 outer에 나중에 nested Slot을 Add하는 경로는 공개 attachSlot을 그대로 부르고 Blocker가 필요 없다 — 그 시점엔 앞선 모든 position이 이미 안정적으로 채워져 있어 nil 자리가 생길 여지가 없기 때문이다. → 예/아니오

AS-12 — 백엔드 seam

mountSlotTree가 부기를 안 건드리는 순수 walk라, 일괄 삽입이 유리한 백엔드 (웹 DocumentFragment 등)는 이 함수 하나만 갈아끼우면 된다. 그게 분해의 이득 넷 중 하나로 문서화돼 있다. → 예/아니오

AS-13 — attachSlot 두 줄 사이에서 중단되는 상태를 정의하지 않는다

materializeSlotTree만 돌고 mountSlotTree가 아직 안 돈 중간 상태(부기는 있고 물리는 없음)는 공개 API로 노출하지 않고, 그 상태에 이름도 안 붙인다 — 공개 진입점이 항상 둘을 연달아 부르기 때문. 나중에 "prepare만 하고 나중에 mount"를 실제로 원하게 되면 그때 표면을 만든다. → 예/아니오

AS-14 — Dispatch.drive는 같은 모양으로 안 맞춘다

분해 후보 (C)는 Dispatch.drive(일반 props 마운트)까지 같은 부기/물리 2단 모양으로 수렴시키는 것이었는데, 채택한 건 (B)라 drive는 지금 형태 그대로다. Slot만 2단이고 일반 props는 아닌 비대칭을 감수한다. → 예/아니오


DC. 디스패치 코어 심화

DC-1 — "부기 먼저"는 이제 일반 계약이다

rawAdd 한 곳의 관습이던 것이 모든 raw*가 따르는 일반 계약으로 승격됐고, 근거는 "일관성 있는 동작이 백엔드 작성자에게 전제를 준다"는 것이다(연산마다 선후가 다르면 백엔드가 매번 확인해야 함). → 예/아니오

DC-2 — 방향이 반대로 보이는 건 같은 원칙의 두 얼굴

"넣기는 부기 먼저"(rawAdd)와 "빼기는 물리 먼저"(rawRemove/rawUnmount/ rawDetach)는 모순이 아니라 항상 좁은 쪽이 먼저라는 하나의 원칙이다 — 빼면서 부기를 먼저 줄이면 아직 트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다. → 예/아니오

DC-3 — 진짜 이유는 깜빡임이 아니다

process/attachSlot 체인 도중 코루틴 yield가 금지돼 있으므로 이 순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 계약의 목적은 "안 지키면 깜빡인다"가 아니라 "백엔드가 전제할 수 있게 하나로 고정한다"이다. → 예/아니오

DC-4 — SpliceMove/Swap

Splice는 "제거는 물리 먼저, 삽입은 부기 먼저"를 이어 붙인 것이고, shift/recompute를 1회로 묶는 최적화는 그 사이에서만 일어난다. rawMove/rawSwapParent를 안 건드리므로 이 계약의 대상이 아니다. → 예/아니오

DC-5 — recomputenil은 skip이 아니라 error

sourceList[i] == nil은 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수 / 배치 중엔 Blocker 게이팅 / 해제는 None / spliceArraysDown은 압축), 보이면 부기가 깨진 것이라 조용히 건너뛰지 않고 즉시 error다. → 예/아니오

DC-6 — error와 "Offset을 nil로 안 되돌린다"는 한 결정의 양면

해제 시 slot.Offsetnil로 갈아치우지 않고 stale하게 두기로 한 것 (SL-75 정정)과, sourceListnil이 보이면 error를 내는 것은 같은 불변식의 두 표현이다 — "이 자리는 항상 실재하는 값(Source 또는 None)으로 채워져 있다". → 예/아니오

DC-7 — bk.N은 고정값이 아니라 그때그때 실제 개수

setLength가 새 최대 position을 등록할 때 늘고 spliceArraysDown이 줄인다. setOffsetSourcebk.N을 안 건드리는데, 그 이유는 호출 순서가 항상 setOffsetSource(i)setLength(i)라서 lengthList[i]가 아직 안 채워진 채 bk.N만 먼저 커지는 창을 막기 위해서다. → 예/아니오

DC-8 — Blocker 게이팅의 현재 근거는 비용이다

bk.N 모델이 바뀌면서 "배치 중 nil을 읽어 크래시"는 더 이상 발생 경로가 아니고, 게이팅이 여전히 필요한 이유는 등록마다 recompute가 도는 O(N²)를 배치 끝 1회로 줄이는 것이다. → 예/아니오

DC-9 — 그런데 setOffsetSource의 즉시 계산이 O(N²)다

setOffsetSource(ownerKey, i, source)는 등록될 때마다 bk.lengthList[1..i-1]매번 다시 합산하므로, 배치 전체로 보면 그 자체가 O(N²)다 — Blocker로 recompute를 O(N²)→O(N)으로 줄인 이득이 여기서 상쇄된다. 그래도 (a) N이 보통 작고 (b) recompute의 비용은 순회가 아니라 Set이 트리거하는 다운스트림 캐스케이드라 성격이 다르므로, 누적합을 배치 상태로 들고 다니는 최적화는 지금 안 한다가 맞나? → 예/아니오

DC-10 — setOffsetSourceNone 스킵

source == None이면 즉시 계산 자체를 건너뛴다 — 참여 안 하는 자리는 계산할 게 없기 때문이고, 이건 recomputeoffset ~= None일 때만 Set하는 것과 같은 판정이다. → 예/아니오

DC-11 — 배치 끝의 recompute는 명시적 1회 호출이다

OffWithoutEmit()이 스스로 recompute를 트리거하는 게 아니라, 배치를 연 쪽이 끄고 나서 직접 한 번 recompute(ownerKey, bk)를 부른다. → 예/아니오

DC-12 — yield 금지는 새 방어 로직이 아니다

"process/attachSlot 체인 도중 yield 금지"는 런타임 검사를 넣겠다는 뜻이 아니라 어기면 UB라는 계약 문서화다(재진입/무한루프를 방어하지 않는다는 기존 원칙과 같은 톤). → 예/아니오

DC-13 — 컴포넌트 함수도 그 계약 아래 있다

그래서 사용자가 쓰는 컴포넌트 함수·updateFn·Handler 전부가 동기 함수여야 한다 — 안에서 task.wait()류로 yield하면 그 순간 UB다. 이건 quad의 공개 계약으로 문서에 나가야 하는 제약이다. → 예/아니오

DC-14 — recompute 재진입 가드는 없다

Slot마다 Relate(자기 자신)으로 독립된 bk를 가지므로 중첩만으로는 같은 (ownerKey, bk)가 재진입되는 경로가 없다. 사용자 코드가 updateFn 안에서 같은 Slot에 Add/Remove를 다시 거는 경우만 남는데 그건 사용자 버그로 간주한다. → 예/아니오

DC-15 — 배열 자리의 센티널 선택은 자리마다 다르다

sourceList/flattened처럼 "채워짐 여부"를 엄밀히 구별해야 하는 배열은 실재 센티널(None/ProcessedPreRef)을 쓰고, Ref의 콜백/대기자 배열은 nil로 되돌아갔다(table.insert가 구멍을 되찾아 쓰므로). 두 결정은 충돌이 아니라 서로 다른 요구에서 나온 것이다. → 예/아니오

DC-16 — props 순회: 계약은 그대로, 구현만 1회로

"배열 파트 전체가 해시 파트보다 먼저"는 여전히 base가 보장하는 계약이고 백엔드가 자기 드라이버를 짜도 지켜야 한다. 바뀐 건 quad-base 자신의 구현이 그 보장을 단일 일반화 for 한 번으로 얻는다는 것뿐이다. → 예/아니오

DC-17 — 그 전제는 flattened가 항상 평범한 Luau 테이블이라는 것

props는 사용자가 쓴 Lua 테이블 리터럴에서 오고 flatten도 그걸 제자리에서 뮤테이션할 뿐이라, __pairs/__ipairs를 갈아끼운 값이 들어올 경로가 없다. inst가 백엔드마다 다를 수 있다는 것과는 무관한 얘기다. → 예/아니오

DC-18 — 스파이크 01이 지금 계약과 안 맞는다

luau-test/done/01-two-pass-array-hash-order.luau는 두 루프 버전이라 재작성 대상이고, 재작성 시 확인할 것은 "배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index 순서" 둘이다. 이 재작성은 M2 착수 전에 하는 게 맞다. → 예/아니오

DC-19 — Get 가드의 목적

recomputeoffset:Get() ~= sum일 때만 Set하는 건 순회 비용 때문이 아니라 다운스트림 리액티브 캐스케이드(이미 마운트된 원소들의 LayoutOrder 재적용)를 막기 위해서다. 같은 원칙이 rawAddLength:Set 호출부에도 적용된다. → 예/아니오

DC-20 — Slot.Length는 개수가 아니라 기여도의 합

plain 요소는 1, nested Slot은 그 .Length를 기여한다. plain만 있는 흔한 경우엔 합 == 개수라 체감 차이가 없다. 그리고 수동 Visible 토글은 Length가 못 잡는 게 맞다(그건 사용자가 별도 State로 계산할 몫). → 예/아니오

DC-21 — 웹 백엔드는 offset 관측을 no-op으로 둔다

insertBefore류는 물리 삽입만으로 뒤 형제가 밀리므로, quad-web의 offset 핸들러는 아무것도 안 하고 숫자는 "다음에 스스로 insert/remove할 물리 인덱스"를 위해서만 부기된다. base 로직은 완전히 동일하다. → 예/아니오

DC-22 — 동적 자식의 유일한 정당 경로

Slot 또는 state<Frame>류 store-bind만이 setLength/setOffsetSource를 정확히 부르는 경로이고, 사용자가 newInst.Parent = parentInst로 직접 끼워 넣는 건 방어 로직 없는 UB다(부기가 조용히 어긋남). → 예/아니오


SS. Source/State 심화

SS-1 — invalid는 전파 장치가 아니다

invalid는 "내 캐시가 낡았다"는 표시 하나뿐이고, 이미 invalid여도 신호는 그대로 아래로 전파된다. 평범한 State는 절대 신호를 삼키지 않으며, 신호를 늦추거나 흡수할 수 있는 건 명시적 게이트(Blocker, 그리고 설계만 끝난 시간 기반 게이트)뿐이다. → 예/아니오

SS-2 — 다이아몬드에서 접는 것과 안 접는 것

중복 재계산은 pull + 캐시가 막고, 중복 통지는 의도적으로 안 막는다 — d 아래의 Observer가 한 사이클에 두 번 우는 건 정상 동작이고, 접고 싶으면 Blocker 같은 명시적 게이트를 쓴다. → 예/아니오

SS-3 — 나중에 넣을 dedup은 파동 단위여야 한다

순회 비용이 실측에서 문제가 되면 "한 번의 전파 파동 안에서만 같은 노드를 두 번 방문하지 않는다"(방문 집합/에포크)를 넣을 수 있지만, 시간에 걸쳐 유지되는 invalid 플래그로 하면 절대 안 된다 — 그게 2026-08-14에 뒤집힌 바로 그 실수다. → 예/아니오

SS-4 — table.clone은 관측이 아니다

구조적 복사는 State 핸들을 옮길 뿐 :Get()을 안 부르므로 계산을 트리거하지 않는다. 그래서 Modifier의 clone 기반 체이닝과 "관측해야 실체화된다" 원칙이 충돌하지 않는다. → 예/아니오

SS-5 — ⚠️ 중간 State 생존은 여전히 열려 있다

A → B → C → Observer에서 중간 B/C강하게 붙잡는 주체가 문서 어디에도 없다는 게 미해결 항목이고, 해법 방향은 "구독 엣지는 하류로 weak, 상류로 strong"이다. M3 착수 전에 (a) 불변식 명문화 여부와 (b) luau-test 실측이 둘 다 필요하다. → 예/아니오

SS-6 — :Compute가 "우연히" 안전한 것에 기대면 안 되는 이유

:Compute는 콜백 클로저가 상류를 캡처해 우연히 살릴 수 있지만 :With가 만드는 pass-through 노드는 계산 함수가 없어 그 우연이 없다. 즉 이 문제는 "거의 항상 괜찮은데 가끔 새는" 게 아니라 :With 체인에서는 구조적으로 끊긴다. → 예/아니오

SS-7 — 상류 strong이 순환을 만들지 않는다

"상류 strong / 하류 weak"를 채택해도 A↔B 강참조 순환이 생기지 않는 이유는 방향이 한쪽뿐이기 때문이고, 그래서 relate-plan.md가 경고하는 두-Relate 상호 강참조(ephemeron 부재로 실제 누수)와는 다른 모양이다. → 예/아니오

SS-8 — canBoundcanExecute는 서로의 부정

canBound(v) == not isBoundAlive(v), canExecute(v) == isBoundAlive(v)이고 판정 로직만 isBoundAlive 하나로 공유한다. 게이트 형태는 항상 if not canBound(v) then error(...) end이고, 전파 루프만 canExecute를 부정 없이 쓴다. → 예/아니오

SS-9 — 죽은 바인딩의 재사용은 허용이다

inst가 Destroy됐거나 unbindLifetime된 값은 canBound이라 다시 다른 inst에 걸 수 있다 — 이 게이트는 살아있는 바인딩만 막는다. → 예/아니오

SS-10 — 두 predicate가 벌어질 여지를 남겨둔 이유

지금은 정확히 부정 관계지만, 나중에 "구조적으로는 묶여 있으나 일시적으로 발화만 멈춘" 상태가 생기면 관계가 단순 부정에서 벌어질 수 있다 — 이름을 둘로 나눠 둔 게 그때를 위한 여지이기도 하다. 다만 그런 상태를 지금 만들 계획은 없다.예/아니오

SS-11 — SetAndDisposeSource의 콜론 메서드

source:SetAndDispose(value)로 확정됐고 :Set과 한 세트다. Apply 오버라이딩 경로가 불가능한 이유는 SourceState단방향이라 ApplySource를 넘겨주는 시그니처가 타입으로 성립하지 않기 때문이다. → 예/아니오

SS-12 — 그래서 StateSetAndDispose가 없다

쓰기 API가 Source에만 있다는 기존 원칙 그대로이고, state:Apply(...) 시그니처는 이 결정에 전혀 영향받지 않는다. → 예/아니오

SS-13 — dispose는 여전히 "값을 지우는" 단일 경로다

SetAndDisposeGet()Set(new) → 옛 값 dispose 3단계의 sugar일 뿐 새 파괴 경로가 아니고, reconcile 도중 호출이 거부되는 등 dispose의 기존 제약을 그대로 물려받는다. → 예/아니오


LC. 생명주기 배관 심화 (bindLifetime/Relate)

LC-1 — ⚠️ bindLifetime의 첫 인자는 Instance가 아닐 수 있다

Dispatch.setLength(ownerKey, ...)가 Slot-in-Slot에서 Slot 자신을 넘기므로, 백엔드의 bindLifetime은 이 경우를 반드시 핸들링해야 한다. gcconn 트릭은 GetPropertyChangedSignal에 의존해 평범한 Lua 테이블엔 못 건다. → 예/아니오

LC-2 — 계약 둘은 그대로, 구현만 갈린다

"(1) 바인딩이 유효한 동안 value가 최소한 inst만큼 산다" / "(2) valueinst의 생존을 스스로 확인할 수 있다" 두 계약은 안 바뀌고, Slot일 땐 그걸 Relate(slot)SetStrong(또는 Slot 필드) + weak-keyed 기록으로 구현한다. → 예/아니오

LC-3 — ⚠️ isBoundAlive의 세 번째 분기가 아직 미확정

지금 스케치는 gcconn이 없으면 .Subscribed 폴백으로 떨어지는데, Slot-owned 바인딩은 둘 다 없어서 살아있는데도 canBound가 참으로 잘못 나온다(이중 바인딩 가드가 이 경로에선 안 걸림). 세 번째 분기의 정확한 형태는 M2/M3에서 정한다. → 예/아니오

LC-4 — 그래서 지금 Slot-owned 바인딩엔 이중 바인딩 가드가 사실상 없다

그 상태를 M2/M3까지 안고 가는 게 맞고(그 경로를 만드는 건 quad 자신의 내부 배관뿐이라 사용자가 이중 바인딩을 만들 방법이 없음), 사용자 표면에 노출되는 가드에는 영향이 없다. → 예/아니오

LC-5 — Subscribed는 이 계약과 무관

bindLifetime/unbindLifetime.Subscribed읽지도 쓰지도 않는다 — 그 필드는 전역 :Subscribe() 경로 전용이고, 옛 스케치가 여기서 그걸 세팅하던 게 오염 지점이었다. → 예/아니오

LC-6 — gcconn/gchold는 Instance 생성 시점에 만든다

bindLifetime이 만드는 게 아니다. 그래서 quad가 만들지 않은 Instance를 키로 쓰는 건 UB이고, Relateinst 자리는 항상 weak다. → 예/아니오

LC-7 — 단일 Relate 안의 자기참조가 안전한 조건

"값이 자기 키를 되참조해도 단일 Relate 안이면 안전"이라는 서술은, Luau에 ephemeron이 없다는 걸 감안하면 그 값이 weak로 보관될 때(또는 값이 키를 강하게 되잡지 않을 때)만 정확한 것 아닌가? SetStrong으로 보관하면서 값이 키를 캡처하면 단일 Relate 안에서도 그 엔트리가 영원히 안 죽는 것 아닌가? (실제 코드는 gcconn/gchold를 전부 SetWeak으로 두어 이 문제를 피하고 있다) → 예/아니오

LC-8 — SetStrong 전에 물어야 할 것

"이 값을 붙잡는 다른 근거가 이미 있는가" — 있으면 SetWeak가 맞다. 이 규칙의 목적은 성능이 아니라 "이 값의 수명이 어디서 끝나는가"의 답을 항상 하나로 유지하는 것(디버깅 가능성)이다. → 예/아니오

LC-9 — 파괴 관측 지점은 지금 정확히 하나

Instance 파괴를 관측하는 quad 내부 지점은 Effect 하나뿐이고, 사용자가 Destroy-time 처리를 원하면 Effect(그 슈가 OnDestroyed)를 쓴다 — [Event "Destroying"]을 직접 바인드하라는 안내는 정본이 아니다. → 예/아니오

LC-10 — quad는 라이프사이클 "중간"에 없다

엔진이 Destroy 시 Tag/Attribute/실행 중인 Tween을 정리해주므로 라이브러리가 따로 처리하지 않고, retract는 Destroy 시점에 호출되지 않는다. → 예/아니오


EF. Effect/Observer 심화

EF-1 — leaf 바인딩된 핸들엔 :Unsubscribe()가 아예 안 먹는다

Observer와 정확히 같은 규칙으로 통일됐다 — :Subscribe()의 짝이 :Unsubscribe(), leaf 해제는 unbindLifetime 전용. 옛 "안 되거나/최소한 cleanup을 앞당기면 안 됨"이라는 어정쩡한 서술은 폐기됐다. → 예/아니오

EF-2 — 그 통일이 dedup 시나리오를 원천 차단한다

"값이 안 바뀌어 retract가 no-op인데 :Unsubscribe()가 cleanup만 앞당겨 Effect가 조용히 죽는" 경로는 leaf에서 :Unsubscribe()가 안 먹으면서 발생 경로 자체가 없어진다.예/아니오

EF-3 — 그런데 effect-plan.md가 아직 "미해결"이라고 말한다

4라운드 followup A절은 E-10(dedup 경로의 process/retract 대칭)을 **"확인 완료 — relate로 이전 값을 들고 old ~= v / nextValue ~= v 두 분기 안에서만 bind/unbind가 일어나므로 성립"**으로 처리하고 effect-plan.md에 반영했다고 적었는데, 실제 effect-plan.md에는 그 문장이 없고 여전히 "⚠️ 같이 확인해야 할 별건(미해결) … M3 착수 전 확인할 것"이 그대로 남아 있다. .claude/todos.md도 이 항목을 아직 열린 것으로 센다. 즉 반영이 실제로는 안 됐다(followup의 과잉 보고). 이 인식이 맞나? → 예/아니오

EF-4 — EffectHandle의 cascade 요구

state가 있는 Effect는 leaf 부착/:Subscribe() 어느 경로든 내부 Observer까지 같이 바인드/등록해야 한다 — 안 하면 그 Observer에겐 canExecute 판정 근거가 없어 재실행이 통째로 죽는다. unbindLifetime(handle)도 대칭으로 내부 Observer를 같이 푼다. → 예/아니오

EF-5 — 그 cascade가 dedup 분기 에 있어야 한다

ObserverEffectLeafHandlerif old ~= v then bindLifetime(...) end / if nextValue ~= v then unbindLifetime(...) end 짝 안에 내부 Observer cascade도 같이 들어가야 한다 — 한쪽만 dedup되면 handle과 내부 Observer의 바인딩 상태가 갈린다. 이게 E-10이 실제로 확인해야 하는 내용이다. → 예/아니오

EF-6 — :Subscribe()는 GC 원칙의 의도적 예외

강참조 레지스트리라 로컬 참조를 다 놓아도 GC되지 않고 계속 실행된다 — quad의 다른 프리미티브가 전부 GC-native인 것과 정반대라 사용자 문서에 명시적 경고가 필요하다. 용도는 top-level 사이드 이펙트로 한정. → 예/아니오

EF-7 — leaf 부착과 :Subscribe() 동시 사용은 즉시 error

"같은 liveness 게이트를 공유하니 안전"이던 옛 판단은 뒤집혔고, 지금은 canBound 게이트로 즉시 에러다(한 핸들은 라이프사이클 경로를 하나만 갖는다). → 예/아니오

EF-8 — state 없는 Effect의 :Unsubscribe()

Effect(fn)(state 없음)은 install이 호출 시점에 이미 끝났으므로 :Unsubscribe()는 "leaf-사망 cleanup을 수동으로 앞당기는 것"과 완전히 동치이고, 그 이상의 분기가 필요 없다 — 단 이것도 :Subscribe()한 핸들에서만 의미가 있다. → 예/아니오


MO. Modifier 심화 — flatten 확정 이후

MO-1 — flatten은 새 테이블을 안 만든다

props 테이블 리터럴은 그 호출 한 번을 위해 만들어져 소비되고 끝이라 원본 보존 의무가 없으므로, flatten은 입력을 제자리에서 뮤테이션한다. Pre/PostRef 소진이 이미 flattened를 제자리에서 갈아치우는 것과 같은 취급이다. → 예/아니오

MO-2 — 소진 자리는 ProcessedModifier 센티널

Modifier를 뽑아낸 배열 자리를 그냥 지우면 구멍이 생겨 배열 파트 순서 보장을 잃으므로, ProcessedModifier로 채우고 전담 nop 핸들러 ProcessedModifierHandler가 정상 process 경로에서 캐치해 setOffsetSource(None)/setLength(0)을 등록한다 — 새 규칙이 하나도 안 는다.예/아니오

MO-3 — 반복은 반드시 역순

"이미 있으면 건너뛴다"는 먼저 쓴 쪽이 이기는 규칙이라, 정방향으로 돌면 "배열 순서상 나중 modifier가 우선"이라는 확정 규칙과 정반대가 된다. 그래서 for i = #input, 1, -1이다. → 예/아니오

MO-4 — 인라인 우선은 ~= nil 하나로 성립

인라인 해시 키는 flatten 전에 이미 테이블에 있고 명시적 unset(None)도 실재값이라, "이미 값이 있으면 건너뛴다" 검사 하나가 곧 "인라인이 modifier를 이긴다" 규칙이 된다 — 별도 분기가 없다. → 예/아니오

MO-5 — 세 센티널이 같은 패턴을 공유한다

ProcessedPreRef/ProcessedPostRef/ProcessedModifier는 전부 "pre-pass가 소진한 배열 자리"를 표시하고 각각 nop 핸들러가 받는다 — 앞으로 pre-pass가 하나 더 생기면 같은 모양을 그대로 복제하는 게 맞고, 이걸 하나의 공용 "소진됨" 센티널로 합치지는 않는다(핸들러가 무엇을 소진했는지 구별해야 하므로). → 예/아니오

MO-6 — None만이 명시적 unsetter

mod:X(nil)은 "그 필드가 없는 새 Modifier", mod:X(None)은 "None으로 채워진 Modifier"이고 차이는 Overridden/Peek에서 관측된다. → 예/아니오

MO-7 — Getter를 안 만든다와 :Peek은 모순이 아니다

"Getter를 안 만든다"는 필드별 setter의 짝 getter를 말하는 것이고, :Peek은 키를 받는 범용 접근자라 층위가 다르다. → 예/아니오


AT. Attribute/Tag 심화

AT-1 — groupClaimKeys는 이름만 확정됐다

위치별 claim 레지스트리의 이름은 groupClaimKeys로 확정됐지만 키 설계는 여전히 미정이다 — (inst, groupValue) → k인지 groupKey 단위인지, nameClaims와 어떤 순서로 확인하는지는 M10 구현 전에 정한다. → 예/아니오

AT-2 — 그래서 M10 전까지 Frame { a, a }는 여전히 뚫려 있다

그룹 Attribute를 같은 객체로 두 위치에 놓으면 지금 설계로는 claim 체크를 통과하고 k=1 retract가 k=2 바인딩까지 철거한다 — 레지스트리를 실제로 구현하기 전까지 이 갭은 열린 채로 간다(M10 전엔 그룹 Attribute 자체가 없으니 실사용 위험이 없음). → 예/아니오

AT-3 — Tag가 다른 이유는 자원의 성질이다

Tag는 자원이 "이름 집합"이라 겹치면 합집합이면 되고 위치별 참조 카운트로 안전하지만, 그룹 Attribute는 자원이 값 하나라 겹침이 곧 충돌이다. → 예/아니오

AT-4 — 생존 이름 최적화는 원리적으로 불가능

이름이 같아도 값이 바뀌었을 수 있고, 값을 비교하려면 :Get()이 필요한데 그건 State 계약 위반이다 — "부품이 늘어나서 안 한다"가 아니라 할 수 없다.예/아니오

AT-5 — Attribute(a, b, ...) 생성자는 뒤가 이긴다

Merged(겹치면 error) / Overridden(뒤가 이김)과 별개로 **생성자 자신의 겹침 정책은 "뒤가 이김"**이다. → 예/아니오

AT-6 — plain =에 State/Source도 온다

Attribute(store1, store2, { plain = ... })의 plain 테이블 값 자리에는 raw 값뿐 아니라 State/Source도 올 수 있고, 이건 새 엔지니어링이 아니라 원래 그렇게 구현될 예정이었다 — 문서에 그렇게 적는 게 맞다. → 예/아니오

AT-7 — 해제→재클레임 순서는 Dispatch가 보장한다

같은 핸들러 재프로세스는 process가 이전 retractor를 먼저 굴리고 자기 일을 하는 모양(processretractor(v) → 새 process)이고, 핸들러가 바뀌면 retractFromprocess다 — 어느 경로든 옛 claim 반납이 먼저다. → 예/아니오

AT-8 — GetKey는 비공개다

키를 반출하면 사용자가 같은 키를 두 자리에 놓아 claim으로도 못 잡는 수렴이 생긴다(0-W와 같은 형태의 갭). → 예/아니오


TW. Tween / UI 숏핸드 심화

TW-1 — mapTweenValue는 공개 메소드로 승격됐다

로컬 헬퍼가 아니라 Tween<T>의 공개 메소드다 — table.cloneValuefn(Value)로 교체해 새 Tween<U>를 반환하고, 원본은 안 건드린다. → 예/아니오

TW-2 — ⚠️ 이름이 아직 안 정해졌다

MapMapped 둘 다 열려 있고, 코퍼스의 -ed 관례(즉시 확정되는 raw 값은 과거분사)만 보면 Mapped가 더 일관적이다. 최종 결정이 필요하다. → 예/아니오 (+ 어느 쪽인지)

TW-3 — 호출부 분기는 그대로 남는다

isTween(v)인지 판정하는 분기는 여전히 숏핸드 호출부에 있고, Tween이면 v:Map(wrap), 아니면 wrap(v)다 — 승격된 건 변환 로직이지 분기가 아니다. → 예/아니오

TW-4 — 부수 이득은 좁다

local FAST = Tween{Value = 0, Time = 0.15}를 두고 FAST:Map(...)으로 옵션만 재사용하는 패턴이 열리지만 사용 케이스가 넓지는 않을 것으로 보고, 어차피 내부에 필요해서 만드는 걸 공개하는 것뿐이다. → 예/아니오

TW-5 — CanAnimate는 처음부터 State<boolean> | boolean | nil

resolve:Get()으로 푼다 — 4라운드 문항이 "단순 boolean"으로 축약한 게 부정확했을 뿐 문서는 처음부터 맞았다. → 예/아니오

TW-6 — 자식 파괴 시 retractFrom은 정석이 아니다

엔진이 Destroy 시 실행 중인 Tween을 알아서 정리하고 PropertyHandler의 retractor는 애초에 no-op이라, retractFrom(child, prop, 1)두 겹으로 무의미하다. → 예/아니오

TW-7 — 숏핸드 자식의 gcconn/gchold는 확인된 전제다

process 위임을 하는 이상 gcconn/gchold가 없으면 옵저버 바인딩부터 실패하므로 "조용히 미아"가 아니라 즉시 드러나는 전제 조건이고, ensureManagedChild가 일반 인스턴스 생성과 같은 경로를 타야 한다는 계약으로 승격됐다. → 예/아니오


DT. Debounce/Throttle 심화 (4라운드에선 9문항뿐이었던 영역)

DT-1 — 두 도구의 차이는 정확히 한 비트

"신호가 창 타이머를 리셋하는가" 하나뿐이고 leading/trailing/통과 후 창 재개방은 완전히 동일하다. 그래서 공용 게이트 + Reset 불리언 하나로 둘 다 나온다. → 예/아니오

DT-2 — lodash식 maxWait로 throttle을 흉내내지 않는다

초안이 그 공식을 옮겼다가 trailing 통과 직후 타이머를 회수해 바로 뒤 신호가 "창 밖"으로 판정돼 1초 안에 두 번 나가는 버그를 냈고, 지금 정식화는 그 구멍이 구조적으로 없다. → 예/아니오

DT-3 — MaxTime은 디바운스 전용

"신호가 안 끊기면 영원히 발화 안 함"은 디바운스의 정의 그 자체라 안전장치가 필요하지만, 스로틀은 원래 주기적으로 발화하므로 필요 없다. → 예/아니오

DT-4 — 공개 Blocker API 위에는 못 얹는다

Blocker엔 "상류 신호가 지금 도착했다"는 통지가 없어서 타이머를 (재)시작할 순간을 알 수 없다. 그래서 게이트 노드 내부 훅이 필요하다. → 예/아니오

DT-5 — 그래서 M3에서 해야 할 일이 하나 생긴다

Blocker를 구현할 때 게이티드 노드를 공용 Gate 노드로 한 겹 일반화해 두는 것만은 그 시점에 해야 한다(나중에 하면 같은 설계를 두 번 한다). 새 노드 종류를 만드는 게 아니라 이미 있는 하나에 이름을 붙여 꺼내는 것이다. → 예/아니오

DT-6 — 제어 핸들은 세 번째 모양으로 수렴했다

State 자신에 메소드를 붙이지도(타입이 갈림), Blocker식 공유 외부 객체도 아니고(상태 기계라 공유가 위험), **개별은 Ref 아웃파라미터(Handle = Ref()), 전체는 팩토리 자신의 :Flush()/:Cancel()**이다. → 예/아니오

DT-7 — 팩토리는 자기 게이트를 weak로만 추적한다

strong으로 잡으면 다운스트림이 다 죽어도 게이트가 GC되지 않아 "정리는 GC에 위임" 원칙과 충돌한다. → 예/아니오

DT-8 — Flush/Cancel의 의미

Flush()는 pending이면 창 끝을 안 기다리고 즉시 커밋(+창 재개방), pending이 없으면 no-op(idempotent). Cancel()은 타이머를 정리하고 pending을 버린다 (전파 없음). → 예/아니오

DT-9 — 주입 op는 base 범용 유틸 그룹이다

setTimeout/clearTimeout은 핸들러 op 3종(addTag/removeTag/setAttribute)이 아니라 bindLifetime/canExecute와 같은 층위이고, 게이트 전용도 아니다 (나중에 타이머가 필요한 다른 것이 재사용). → 예/아니오

DT-10 — JS 어휘를 고른 이유와 그 대가

task.delay를 안 따라간 건 task가 한 엔진의 것이라 base 층에 새기면 안 되기 때문이고, 그 대가로 Roblox 배선에서 인자 순서가 뒤집히는(시간이 먼저) 조용히 틀리기 좋은 자리가 생긴다 — 백엔드당 한 곳뿐이라 감당한다. → 예/아니오

DT-11 — 가변인자를 안 받는 이유

게이트 콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저(onWindowEnd)라 호출마다 새로 만들 필요가 없다 — varargs로 아낄 할당이 애초에 없다. → 예/아니오

DT-12 — Timeout은 마커 필드가 있는 전용 타입

type Timeout = {}는 구조적으로 모든 테이블과 호환이라, __type_timeout: true 싱글턴 마커로 사실상 nominal하게 만들고 런타임에도 실제로 그 필드를 넣는다 (isTimeout 판별이 공짜로 따라옴). _native는 백엔드 페이로드고 base는 절대 안 읽는다. → 예/아니오

DT-13 — clearTimeout은 모든 백엔드에 요구해도 되는 계약

네이티브 취소가 없는 엔진도 "플래그를 뒤집는 래퍼"로 항상 구현 가능하다. 다만 그 경우 타이머가 예정대로 깨어나 아무 일도 안 하므로 "대기 타이머가 게이트를 붙잡는다"는 성질은 남는다(유계라 문제 아님). → 예/아니오

DT-14 — os.clock()은 주입하지 않는다

Luau 표준이고 고정밀이라 base가 그냥 부른다. 단 절대 시각이 아니라 차이 계산 전용이고, 이 설계는 전부 deadline - os.clock() 형태의 차이만 쓴다 (부수 이득: 벽시계 보정에 영향 안 받음). → 예/아니오

DT-15 — 결국 순수 슈가라 우선순위가 낮다

제어 핸들까지 닫히고 나니 quad-base에 새 코어 메커니즘을 추가하지 않는 순수 슈가로 확인됐고, M0/M3를 막지 않는다 — 예외는 위 DT-5(Gate 일반화)뿐. → 예/아니오


ML. 모듈 생명주기 심화 — RunInit 재설계 이후

ML-1 — 가드 소유 위치가 파일별에서 공유로 옮겨졌다

InitXxx가 자기 Relate()+INITED 센티널을 두던 옛 설계는 폐기되고, module 인스턴스가 공유하는 RunInit 하나가 판단을 전담한다 — 결론(멱등)은 같고 가드가 어디 있느냐만 바뀌었다. → 예/아니오

ML-2 — 함수 자신이 릴레이션 키다

(module, initFn) → boolean 표가 곧 "이 함수를 이 모듈에 실행했는가"라서 센티널 키가 아예 필요 없다. → 예/아니오

ML-3 — 의존성 있는 InitXxx도 같은 패턴

자기 의존성을 module:RunInit(InitLifetime)처럼 부르기만 하면 되고 순서/중복은 RunInit의 멱등성이 흡수한다 — 최상위 New()가 순서를 관리하지 않는다. → 예/아니오

ML-4 — _initializedBy와는 계속 별개다

RunInit은 "이 함수가 이미 돌았는가"(멱등 실행)이고 _initializedBy는 "이 슬롯을 누가 채웠는가"(다른 팩토리면 error)라 표현하는 게 다르다. 억지로 합치면 두 의미가 한 API에 섞인다. → 예/아니오

ML-5 — 그래서 backend 설치는 RunInit을 안 거친다

InitRoblox(module)module:RunInit(InitRoblox)로 부르는 게 아니라 자기가 _initializedBy를 직접 확인/기록한다 — 두 메커니즘이 한 호출에 겹치지 않는다. → 예/아니오

ML-6 — 타입 재익스포트는 실측 확인됐다

type Dispatch = InitDispatch.Dispatch 형태가 Luau에서 문제없이 동작하고, 이건 typing-limits.md의 재귀 제네릭 한계와 다른 자리(단순 alias)다. → 예/아니오

ML-7 — Quad.debug의 게이팅 범위

Quad.debug가 게이팅하는 건 라이브러리가 스스로 콘솔에 쓰는 동작뿐이고, Dispatch.listHandlers()는 이 플래그와 무관하게 항상 호출 가능하다(목록을 반환만 하고 출력은 사용자 몫). → 예/아니오

ML-8 — 다중 인스턴스에서 debug는 인스턴스별이다

InitDebugmodule.debug = false를 각 module에 설치하므로 New()로 만든 인스턴스마다 독립적이다 — "전역이냐 인스턴스별이냐"는 이제 미정이 아니라 코드가 이미 인스턴스별로 답했다.예/아니오


TL. 타입 한계 / Store 심화

TL-1 — §6은 §1과 다른 한계다

§1(재귀 제네릭이 자기를 다른 인자로 반환)은 Luau 솔버의 상위 한계라 기다리는 것이고, §6(type function을 거친 이력이 제네릭 self 체이닝을 깨뜨림)은 설계로 회피 가능한 것이라 quad가 실제로 회피 규칙을 세웠다. → 예/아니오

TL-2 — 회피는 세 조각이 다 필요하다

error() 대신 print+types.never, ②검증을 리턴/필드 타입 표현식 자체에 박아 호출부마다 재평가되게, ③원본을 절대 반환하지 않고 별도 필드로 격리 — 셋 중 하나라도 빠지면 안 된다. → 예/아니오

TL-3 — 체크리스트 7번이 그 일반화다

"type function으로 검사/변형한 값에 제네릭 self 메소드를 나중에 부를 계획인가?"가 새 항목으로 들어갔고, 앞으로 같은 조합을 설계할 때마다 이걸 먼저 본다. → 예/아니오

TL-4 — 진단 0건은 안전의 증거가 아니다

luau-analyze 0건이어도 타입이 제대로 해소됐다는 뜻이 아니므로 --annotate로 실제 추론 타입을 눈으로 확인하고, 음성 대조군(일부러 틀린 대입)을 같이 둔다. → 예/아니오

TL-5 — 이 규율이 실제로 두 번 물렸다

(a) 재귀 제네릭이 조용히 Unifiable<Error>로 새던 것, (b) init.luau의 require 경로를 @self 없이 써서 런타임만 깨지고 정적 검사는 통과하던 것 — 원인은 다르지만 "진단 0건이 통과가 아니다"라는 같은 함정이다. → 예/아니오

TL-6 — Store 미선언 키 거부는 실측 확인됐다

ProcessStoreType이 합성한 레코드 타입엔 인덱서가 없어 미선언 키 접근이 정확히 TypeError로 거부된다(luau-test/done/21-*) — 더 이상 열린 항목이 아니다. → 예/아니오

TL-7 — ⚠️ store:GetDynamic의 위치는 여전히 미정

콜론 메서드로 두면 GetDynamic모든 Store의 예약 키가 되어 lazy __index와 충돌한다는 게 확인된 문제이고, 탑레벨 함수로 둘지 아직 안 정했다. M3/M4 착수 전 결론이 필요하다. → 예/아니오

TL-8 — 그 충돌은 Quad.debug류와 성격이 다르다

Quad는 필드 집합이 고정된 모듈 테이블이라 이름을 추가해도 사용자 키와 안 부딪히지만, Store사용자 키가 곧 필드라 어떤 메서드 이름을 붙이든 그 이름이 예약어가 된다 — 그래서 Store엔 콜론 메서드를 늘리는 것 자체가 비용이다. → 예/아니오


CR. 크로스컷 — 여러 문서에 걸친 것

CR-1 — followup 표를 신뢰 소스로 쓰면 안 된다

4라운드 followup의 "반영 완료" 표에 적혀 있어도 실제 base/에 안 들어간 항목이 최소 하나 있다(E-10, 위 EF-3). 그래서 "무엇이 확정됐나"의 소스는 언제나 base/ 본문이고, followup은 경위 기록일 뿐이다 — 다음 세션이 followup만 읽고 "이건 이미 닫혔다"고 판단하면 안 된다. → 예/아니오

CR-2 — Tween:Map/Mapped 이름이 어느 대기열에도 없다

tween-plan.md는 "최종 이름 미확정"이라고 적어뒀는데 question.md 1번(용어 정리)에도 todos.md에도 이 항목이 없다 — 결정이 필요한데 추적되지 않는 상태다. Owned는 제대로 올라가 있는 것과 대비된다. → 예/아니오

CR-3 — ⚠️ M2가 M3의 Blocker.luau에 의존하는 순서 문제는 여전히 열려 있다

Dispatch.drive의 배치 등록이 Blocker 게이팅을 전제하므로, Blocker(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 M2 착수 전에 정해야 한다. 지금은 ROADMAP에 각주만 달린 임시 상태다. → 예/아니오

CR-4 — gcconn 전제가 여러 설계의 뿌리다

"quad가 만든 Instance는 참조를 놓아도 GC로 안 죽는다"는 사실 하나에서 (a) userdata에 quad Instance를 담지 말라는 제약, (b) _detached가 영구 누수라는 결론, (c) dispose라는 명시적 경로의 필요성이 전부 나온다. → 예/아니오

CR-5 — 그런데 그 전제는 백엔드마다 다르다

gcconn 트릭은 quad-roblox의 것이고, mock/웹 백엔드에는 그런 장치가 없어 그냥 GC될 수 있다. 그러면 "detach 요소는 명시적으로 안 지우면 영구 누수"라는 논증도 백엔드에 따라 참/거짓이 갈린다 — 그래도 base는 항상 명시적 정리를 하는 쪽(가장 강한 요구)으로 통일하는 게 맞다. → 예/아니오

CR-6 — userdata만 GC에 맡기는 유일한 자리다

Slot이 죽을 때 userdata는 명시적으로 비우는 코드가 없고 activateList 클로저가 통째로 GC되는 것에 의존한다(그래서 파괴 경로가 _listObserverunbindLifetime하는 게 중요하다 — 안 풀면 gchold가 그 클로저를 계속 붙잡는다). 이 비대칭(_detached는 명시적, userdata는 GC)은 의도된 것이다. → 예/아니오

CR-7 — 사용자 표면의 파괴 경로는 셋뿐

Slot:Remove/Clear(명시적 CRUD), dispose(value), 그리고 :ListOwned = true 자동 처분 — 사용자가 직접 Destroy()를 부르는 건 quad가 관리 중인 값에 대해선 UB다. → 예/아니오

CR-8 — M2 착수 전에 남은 것의 전체 목록

지금 열려 있어 M2/M3를 실제로 막을 수 있는 건 다음이 전부다 — (1) M2 vs Blocker 순서(CR-3), (2) 중간 State GC 불변식 + 실측(SS-5), (3) store:GetDynamic 위치(TL-7), (4) E-10 dedup 대칭 확인(EF-3/EF-5), (5) isBoundAlive의 세 번째 분기(LC-3), (6) 스파이크 01 재작성(DC-18), (7) table.insert 구멍 재사용 실측, (8) Tween:Map 이름(CR-2), (9) 그룹 AttributegroupClaimKeys 키 설계(M10 전). 이 목록에 빠진 게 없나?예/아니오 (+ 빠진 것)

CR-9 — 그중 (5)와 (8)은 어디에도 추적되지 않는다

isBoundAlive의 세 번째 분기는 lifecycle-pattern.md 본문에만 ⚠️로 있고 question.md/todos.md 어디에도 없다. Tween:Map 이름도 마찬가지(CR-2). 둘 다 추적 목록에 올려야 한다.예/아니오

CR-10 — 동기 계약은 공개 문서로 나가야 한다

"컴포넌트 함수/updateFn/Handler 안에서 yield 금지"는 지금 dispatch-core-plan.md 안쪽에만 있는데, 이건 사용자가 지켜야 하는 공개 계약이라 사용자 문서에도 반드시 나가야 한다. → 예/아니오

CR-11 — 이 라운드 이후의 계획

4라운드 처리 때 "이후 stale만 잡는 것으로 끝낼 수 있어보임"이라고 했고 그래서 5라운드를 안 만들 예정이었는데, 실제로 만들어보니 새 확정분(어제 것)과 아예 문항이 없던 영역이 남아 있었다. 그러면 앞으로도 "큰 확정이 있은 뒤엔 그 부분만 대상으로 하는 소규모 라운드"를 두는 게 맞나, 아니면 이번이 마지막이고 이후엔 감사(quad-doc-auditor)+/code-review만으로 가는 게 맞나? → 둘 중 어느 쪽인지