quad/.claude/qa-request/pre-implementation-qa-round1.md
qwreey eb321e1747
qa: 7라운드 발견 52건 처리·반영 — Store 재설계 시도와 철회, 타입 함수 원칙 신설
`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).

## 처분

확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.

**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**

## 구조가 바뀐 것

- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
  같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
  Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
  억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
  `:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
  **자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
  아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
  인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
  버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
  `architecture.md`에 신설

## Store — 같은 날 재설계하고 철회했다

`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.

철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.

## 툴체인

**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.

## 검증

`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>

Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 23:40:19 +09:00

87 KiB
Raw Blame History

구현 전 QA 1라운드 — 사용자 심사에서 "아니오"가 나온 항목

상태: 1라운드 완료 + base/ 반영 완료(2026-08-18). 이 파일은 .claude/qa-request/에 라운드별로 쌓인다 — 2라운드는 새 파일 (pre-implementation-qa-round2.md)로 만들 것이고, 이 문서를 이어 쓰지 않는다. 2라운드 대상은 맨 아래 "진행 로그" 절의 "아직 안 본 것" 목록.

1라운드 범위(2026-08-18 세션에 시작). .claude/base/ 확정 문서를 의존성 순서로 훑으며 표면 타입계약 / 내부 구현 메커니즘 / 동작 원리·불변식 세 층위로 "예가 나와야 정상인 주장"을 사용자에게 확인받는 작업의 산출물.

이 문서의 용도: 사용자가 "아니오" 또는 "부분적으로 틀림"으로 판정한 항목만 모은다. [2026-08-18 갱신] 반영 완료 — 이 문서는 이제 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 base/가 소스다. 같은 날 세션에서 아래 항목 전부를 base/(+ROADMAP.md/question.md/ archive/)에 반영했고, 반영 과정에서 판단이 갈리던 네 건(SL-3의 PopOnly, D-7의 재역전 여부, N-4의 NoneHandler/NilHandler 역할 분담, ST-2의 동적 키 경로)은 그 자리에서 사용자에게 물어 확정했다 — 그 답변도 각 항목에 반영돼 있다.

아직 안 닫힌 것(결론 전에 해당 마일스톤 착수 금지)은 question.md 3번과 .claude/todos.md 00번이 소스 — 여기서 다시 나열하지 않는다.

표기: X-NX는 문서 코드(A=architecture, S=source-state, …), N은 그 문서 안 질문 순번. 사용자 답변 원문은 그대로 인용한다(conventions.md의 "사용자 발언을 근거로 인용할 때" 관례).

진행 현황의 소스는 이 문서 맨 아래 "진행 로그" 절 — 어느 문서까지 심사했는지는 거기가 소스이고, 다른 곳에 개수를 적지 않는다.

먼저 볼 것 — 파급이 큰 순서

목록 전체를 순서대로 읽기 전에, 다른 항목의 전제가 되거나 코드가 반대로 도는 것부터 보는 게 효율적이다. 아래 분류는 심사한 에이전트의 판단이고 확정이 아니다 — 회신 시 우선순위가 다르면 그대로 알려주면 된다.

1. 구현하면 반대로 도는 것

  • S-1canBound 게이트 호출부 반전. 지금 문서대로 짜면 정상 첫 바인드가 전부 에러나고 이중 바인드는 통과한다. lifecycle-pattern.md가 진원지이고 source-state-plan.md/ref-plan.md가 전부 이걸 인용한다.
  • RE-1 — gcconn/gchold를 SetStrong으로 적은 두 곳. 그대로 구현하면 같은 문서가 경고하는 두-Relate 상호 강참조 누수에 정확히 걸린다.

2. 설계 자체가 바뀌는 것

  • RF-4 + N-4driveNone 스킵 제거, NoneHandlerk=number를 직접 처리, NilHandler 신설. D-6의 열린 갭이 여기서 닫힌다.
  • EV-1 — 이벤트 disconnect 센티널 falseNone/nil.
  • BS-2 — "이벤트 콜백은 타입 검증 못 한다"가 거짓. onchange-plan.md의 근거까지 같이 무너진다.
  • R-1Ref의 내부 구조(.Callbacks 분리 + .Value를 평범한 필드로).
  • SL-3:List reconcile의 nil 리턴을 파괴로 되돌릴지 + PopOnly.

3. 아직 답이 없는 것 (착수 전 결론 필요)

  • S-12 — 중간 State가 GC되는지 미검증. M3 착수 전 실측 필요.
  • AT-1Frame{a, a}(같은 그룹 객체 이중 배치)를 UB로 둘지 error로 잡을지.
  • D-7 — base Fallback Handler 등록 주체가 다시 뒤집힐 가능성.

4. 이름·표면 확정 (같이 처리하면 효율적)

  • N-8DID 리네임 확정(2026-08-18, question.md 1순위였던 항목). 라이브 문서 19개 파일에 걸쳐 있고, 헤딩 1개와 그걸 절 인용하는 곳 1개가 짝으로 묶여 있어 한쪽만 고치면 doc-check.py ERROR가 난다. 반영 대상 전수 목록이 그 항목에 표로 들어있다.
  • N-9New 커링 + D는 전량 코드 생성 (2026-08-18에 열린 항목 전부 확정됨). N-8과 같은 줄들을 건드리므로 한 번에 처리할 것(architecture.md 소스 트리 주석, ROADMAP.md 체크박스). BS-2와는 같은 문제의 양면이라 (인덱싱으로는 이벤트 콜백 타입이 안 나온다는 것) 반드시 묶어서 볼 것. 부수적으로 bind-system-plan.mdPA님 시그니처 인용"알려진/모르는 타입을 전부 커버" 서술 두 곳이 더 이상 정확하지 않게 됨.

5. 나머지A-3, ST-2, D-1, D-5, M-3, R-3, B-1, SL-1, E-2, 그리고 N-1~N-7(신규 요구사항).


A. base/architecture.md

A-3 — New() 도입 시 "자동으로 테이블별 스코핑"된다는 서술

  • 판정: 부분적으로 틀림 (사용자 답변: "이해가 잘 안가나, …")
  • 문서 위치: base/architecture.md의 "확정된 결정" 13번 항목 (base/dispatch-core-plan.md의 "Dispatch는 프리미티브가 아니다" 절도 같은 주장을 근거로 인용함 — 같이 확인 필요).
  • 문서가 주장하는 것: New()가 생기면 매번 새 BaseModule 테이블을 만들어 팩토리로 채우는 것뿐이고, 그러면 Dispatch 핸들러 레지스트리와 _initializedBy 마커 등 지금 module-level state로 사는 모든 것이 자동으로 테이블별 스코핑된다.
  • 사용자 답변 원문:

    이해가 잘 안가나, 모듈이 하나의 인스턴스(dispatch 레지스트리 하나, canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 모듈이 여러 인스턴스를 지원해, quad-roblox, quad-mock 등을 같이 굴리고 싶어지는 시점이 올 때는 v1 처럼 Init() 수행 가능하도록 두는데, require 를 감싸지는 않고 단순히 InitModule(module) 등을 받도록 각 코드들을 약간 고쳐서 이것을 해결함. Quad() 하면 새로운 quad 가 나오는 식. 단 지금은 단순 싱글톤으로 구현해 Quad() 안하고 Quad.Dispatch 접근 가능.

  • 어긋나는 지점: 문서는 "코드 변경 없이 자동으로 스코핑됨"을 주장하지만, 사용자는 "각 코드들을 약간 고쳐서" 해결한다고 봄 — 즉 module-level state를 참조하는 코드들이 InitModule(module) 같은 걸 받도록 손을 대야 한다는 것. 또 미래 API 이름도 문서의 New()가 아니라 Quad() 로 언급됨(현재는 싱글톤이라 Quad() 없이 Quad.Dispatch 직접 접근).
  • 파급: 실제 코드 배치에 영향 — module-level upvalue로 레지스트리를 잡아두면 나중 다중 인스턴스화 때 전면 수정이 되므로, 지금부터 그 참조 형태를 정해둘지 여부가 M0 스캐폴딩 결정이 됨.
  • ⚠️ [후속 정정, 2026-08-19] 위 "어긋나는 지점"의 이름 해석이 부정확했음 — New()를 통째로 Quad()로 바꾸는 건 사용자 의도가 아니었다. 이 판정을 그대로 base/architecture.md에 반영했다가(2026-08-18) 여러 파일에 New()Quad() 전면 치환이 퍼졌었는데, 사용자가 직접 바로잡음 — "New 와 Quad 가 이름 모순은 아닐꺼야... Quad 는 기본적으로 생성된걸 리턴하긴 하는데, Quad.New() 도 제공하는거 어떻냐는거였음, 즉 New() 는 존재하고 기본 리턴은 New() 해서 주는건데, 리턴 안에 New 필드가 있고 그 함수를 쓰면 하나의 새로운 Quad 네임스페이스가 만들어지는식." 실제 의미: Quad(require의 반환값)는 이미 만들어진 기본 인스턴스이고, New()는 그 안에 있는 명시적 opt-in 필드(호출하면 별도의 새 네임스페이스 생성) — "그냥 Quad()를 부르면 매번 새 인스턴스"가 아님. 컴포넌트를 여러 모듈로 쪼갠 앱에서 각자 Quad()를 불러 인스턴스를 "얻어야" 하는 모델이면 실수로 서로 다른 인스턴스가 생겨버리는 게 진짜 문제였음. 지금 유효한 서술은 base/architecture.md "확정된 결정" 13번의 재정정이 소스.

S. base/source-state-plan.md

S-1 — canBound 게이트 호출부가 뒤집혀 있음 ⚠️ 파급 큼

  • 판정: 아니오 — 호출부가 잘못됨(이름 canBound는 맞음).
  • 문서 위치: base/source-state-plan.md의 "이중 바인딩 금지" 절의 게이트 스케치와, 같은 문서 "bindLifetime이 이 게이트의 두 번째" 절의 bindLifetime 의사코드. base/lifecycle-pattern.md의 "canBound vs canExecute" 절이 이 판정의 소스라고 선언돼 있으므로 거기가 진짜 진원지 — 정정 시 그 문서부터 볼 것.
  • 문서가 주장하는 것:
    if canBound(self) then
      error("이미 :Subscribe()로 전역 바인딩된 값" / "이미 다른 Instance에 바인딩된 값")
    end
    
    canBound == true가 "이미 살아있게 묶여 있다 = 더 못 묶는다"는 뜻으로 쓰이고 있음.
  • 사용자 판정: 이름이 아니라 호출부가 잘못됐다canBound는 이름 그대로 "지금 묶을 수 있는가"(true = 묶어도 됨)여야 하고, 게이트는 if not canBound(value) then error(...) end 형태가 돼야 한다.
  • 연쇄로 같이 무너지는 서술 (정정 시 반드시 함께 볼 것):
    1. "canBoundcanExecute는 판정 로직이 같아서 값이 항상 같다"base/source-state-plan.md "이중 바인딩 금지" 절과 base/lifecycle-pattern.md가 둘 다 "비공개 헬퍼 isBoundAlive를 그대로 부른다"고 서술함. 그런데 canExecute는 true=살아있음이고 canBound는 true=아직 안 묶임이므로, 올바른 관계는 동치가 아니라 부정 (canBound(v) == not isBoundAlive(v))이다. "값은 항상 같지만 호출부의 질문이 달라 이름만 분리했다"는 근거 서술 자체가 성립하지 않게 됨 — 실제로는 반대 방향을 묻는 두 predicate임.
    2. bindLifetime 의사코드의 if canBound(value) then error("이미 바인딩된 값") end — 같은 이유로 뒤집혀 있음. 이 줄엔 [정정, 2026-08-14 열두 번째 세션] 배너까지 붙어 "게이트는 canBound가 맞다"고 못박아 뒀는데, 정작 방향이 틀린 채로 확정된 것.
    3. "죽은 바인딩의 재사용은 허용 — canBound가 거짓이라 게이트를 통과함" — 방향이 뒤집히면 이 문장도 뒤집혀야 함(죽은 바인딩은 canBound이라 통과).
  • 파급: M8(Ref/PreRef/PostRef)과 M3(Observer/Effect) 양쪽의 진입 게이트가 전부 이 predicate를 쓴다. 지금 문서대로 구현하면 정상적인 첫 바인드가 전부 에러나고 이중 바인드는 무사통과하는, 정확히 반대로 도는 게이트가 된다.
  • 정정해야 할 정확한 위치 (base/lifecycle-pattern.md 확인 후 확정):
    1. canBound의 구현 자체(1) 코드 블록:
      function canBound(value)
          return isBoundAlive(value)   -- ← 이름대로면 not isBoundAlive(value) 여야 함
      end
      
      canExecutereturn isBoundAlive(value)가 맞으므로, 둘은 서로의 부정이 된다. 같은 블록의 주석("어느 쪽 진입점에서 물어도 항상 같은 값이라")과 (3) 절의 "오늘 두 문맥의 판정값은 우연히 같다"도 함께 틀리게 됨.
    2. bindLifetime의 가드if canBound(value) then error(...) ((1) 코드 블록).
    3. Observer:Subscribe()의 가드if canBound(self) then error(...) ((2) 코드 블록).
    4. (3) 절 끝의 "죽은 바인딩 재사용" 서술 — "canBound거짓이라 게이트를 통과함" → 방향이 뒤집히면 "이라 통과함"이 됨.
    5. base/source-state-plan.md의 "이중 바인딩 금지" 절 게이트 스케치와 bindLifetime 의사코드(앞서 적은 것).
    6. base/ref-plan.md의 "이중 배치 방지" 절 — 같은 게이트를 쓴다고 선언돼 있으므로 같은 방향 오류가 있는지 확인 필요.
  • ⚠️ 함께 재검토할 것: 이 정정이 들어가면 (3) 절이 두 이름을 나눈 근거였던 *"판정 로직은 하나(isBoundAlive)를 공유하고 값도 항상 같다"*가 깨진다. 부정 관계라면 오히려 이름 분리의 명분은 더 강해지지만, 그 절의 서술은 통째로 다시 써야 함.

S-12 — 중간 State가 GC되지 않고 살아남는지 미검증 ⚠️ 미해결

  • 판정: 결론 보류 — 사용자가 검증 필요로 지목(틀렸다고 확정한 게 아니라, 문서가 이 케이스를 다룬 적이 없음).
  • 문서 위치: base/source-state-plan.md의 "state:Observer(fn)" 절 (구독자를 weak로 담는 근거 서술), 같은 문서 "왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절("각 노드가 자기 구독자 목록 + 자기 캐시만 가지면 된다"), base/lifecycle-pattern.md(gchold/gcconn).
  • 사용자 답변 원문:

    확인해봐야 하는게 State -> State -> State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지 않음이 명확해야함. 물론 compute 등의 callback 상 가져서 안전할 수 있지만, With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지고 있어야 할것임. 이게 되는지는 더 알아봐야할 필요가 있음.

  • 문제의 구조: 확정된 두 서술을 겹치면 체인이 끊길 수 있음 —
    • State는 자기 구독자(하류)를 weak로 담는다(S-12에서 확인된 서술).
    • Observer는 gchold(leaf) 또는 전역 레지스트리가 살려준다.
    • 그러면 A → B → C → Observer 체인에서 중간 노드 B/C를 강하게 붙잡는 주체가 문서 어디에도 명시돼 있지 않다. 아무도 B/C를 로컬 변수로 안 들고 있으면(체이닝 한 줄로 쓰는 흔한 형태), 하류 weak 링크만 으로는 생존이 보장되지 않아 중간 State가 수거되고 전파가 조용히 끊길 수 있음.
  • 사용자가 지목한 해법 방향: 각 노드가 자기 parent(상류)를 강참조로 들고 있어야 한다 — :Compute는 콜백 클로저가 상류를 캡처해 우연히 안전할 수 있지만, :With가 만드는 pass-through 노드는 계산 함수가 없어서 그런 우연한 캡처가 없다. 즉 "구독 엣지는 하류로 weak, 상류로 strong"이라는 방향성이 명시돼야 함.
  • 해야 할 일: (a) base/source-state-plan.md에 이 방향성(상류 strong / 하류 weak)을 불변식으로 명문화할지 결정, (b) luau-test에 실측 스파이크 추가(07이 연쇄 GC를 이미 다루므로 그 옆에). 미검증 상태로 M3에 착수하면 안 되는 항목.

ST. base/store-plan.md

ST-2 — store "key" 문자열 커링은 폐기됐는데 문서엔 "동적 키 폴백으로 유지"로 남아 있음

  • 판정: 아니오 — 틀림. (사용자 재확인 완료)
  • 사용자 답변 원문:

    store "a" 식으로 문자열 호출하는것 또한 기각된 바임. 저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 하고, 우린 더이상 필요하지 않게 된 요소임.

  • 문서가 주장하는 것: dot-access를 1급으로 확정하면서 store "key"는 폐기가 아니라 동적 키용 미타입 폴백(Source<any>)으로 격하해 유지한다.
  • 실제로 맞는 것: 문자열 커링 호출 자체가 기각. 근거는 (a) "a"가 그냥 string으로 들어가 Source<T>T를 알 수 없고, (b) dot-access + type function 타이핑(ST-4)이 자리잡아 더 이상 필요 없는 요소가 됨.
  • 정정해야 할 위치(grep 결과 — 전수):
    • base/store-plan.md:119 — "…store "key"(문자열 커링)로 state<T>를 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고…"
    • base/store-plan.md:128 — "store "key" 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(Source<any>) 폴백으로 격하."
    • base/store-plan.md의 값 설정 문법 절 — "myStore "key"(문자열 커링)는 … 그대로 유지"
    • base/bind-system-plan.md:157 — "store "key"(문자열 커링, 동적 키 폴백)는 이벤트와 달리 …"
    • base/source-state-plan.md:101 — "동적 키 폴백(store "key")은 이제 State<any>가 아니라 Source<any>를 반환"
    • base/source-state-plan.md:465예시 코드 자체가 폐기된 문법으로 쓰여 있음: store "key1":With(store "key2"):Compute(...). 이건 서술이 아니라 예제라 그대로 베껴 쓰일 위험이 큼 — store.key1:With(store.key2) 로 고쳐야 함.
  • 파급: 폐기 사실이 반영되면 "동적 키를 어떻게 다루는가"라는 질문이 다시 열린다 — dot-access만 남으면 런타임에 키 이름이 정해지는 경우의 정식 경로가 없음. ST-1 답변("타입 시간에 Source가 없는 것으로 나와 타입 에러만 나면 됨")과 합치면 "동적 키는 아예 지원 안 함"이 의도로 보이나, 명시적으로 확인받지는 않았음.

D. base/dispatch-core-plan.md

D-1 — "isX(hint) 방어 가드는 죽은 코드"는 과장이다 (한 핸들러가 여러 값 모양을 받는 경우 여전히 필요)

  • 판정: 큰 틀은 맞으나 일반화가 과함 — 사용자가 단서를 붙임.
  • 사용자 답변 원문:

    맞습니다. 더 정확히 표현하자면, State 에 대해 process 하면 후행 처리가 생길텐데, state 의 새로운 값이 나오면 get 을 통해 얻어진 것으로 다시 process 가 수행됩니다. 여기서 이전의 프로세스 슬롯/인덱스와 비교해서 정확히 자신이 만들어낸 것이라면, 단순 retract 처리가 발생하고, 아니라면 retractUnder 로 전부 제거되어 nil 이 됩니다. 즉 process 가 retract 를 담당한다가 성립하므로 위는 옳은 표현입니다. 다만, 처음부터 한 핸들러가 여러 값을 가질 수 있어 is 처리가 필요한건, 그 핸들러의 몫입니다.

  • 문서가 (너무 강하게) 주장하는 것 — 두 군데:
    1. "핸들러 계약" 절: "방어 가드를 남겨둬도 무해하지만 죽은 코드이고, 반대로 그 가드가 있어야만 정확한 코드는 이제 없음."
    2. "Handler 작성 체크리스트" 3번: "isTag(...) 같은 방어 가드는 이제 불필요(넣어도 무해하지만 죽은 코드)."
  • 왜 과한가: 하강 diff가 보장하는 것은 **"넘어온 값이 그 핸들러의 isHandlable을 만족한다"**까지다. isHandlable여러 모양의 값을 받아들이는 핸들러에서는, 그 안에서 어느 모양인지 가르는 is 판별이 여전히 필수다 — 보장은 "같은 핸들러"까지지 "같은 값 모양"까지가 아니기 때문. 실제 사례가 이미 코퍼스에 있음: PropertyHandler는 평범한 값과 Tween<T> 래퍼를 둘 다 받아 isTween(realv)로 분기함 (base/tween-plan.md의 3-상태 릴레이션 슬롯). 즉 "그 가드가 있어야만 정확한 코드"가 지금도 존재한다.
  • 정정 방향: "옛 모델이 요구하던 타입 미보장을 메우는 방어 가드는 불필요"와 "한 핸들러가 여러 값 모양을 다루면 그 판별은 핸들러 자신의 책임"을 나눠 쓸 것. 지금 문장은 앞의 것만 말하면서 뒤의 것까지 부정한다.
  • 파급: 체크리스트 3번은 "새 Handler 짜기 전에 훑을 목록"으로 지정된 곳이라, 이 상태로 두면 다중 모양 핸들러 작성자가 필요한 판별을 빼도록 유도한다.

D-5 — PreRef/PostRef는 "배열 먼저" 보장 위에 성립하는 게 아니라 더 위 루프에서 처리된다

  • 판정: 구조는 맞으나 문서가 근거로 든 관계가 부정확 — 그 가정 위에 쓰인 서술은 재검토 필요.
  • 사용자 답변 원문:

    preref 랑 postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고 flattened 에는 처리됨을 나타내는 값만 놔두는 구현. 이를 위해 Processed*Ref 와 ProcessedRefHandler(nop) 가 존재한다. PreRef 는 해당 보장 아래 성립하는게 아니라, 먼저 처리되는 것. 구조 자체는 틀린것이 없으나 가정을 그렇게 하였다면 재검토가 필요할 수 있음

  • 문서가 주장하는 것: dispatch-core-plan.md의 "props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다" 절 끝 — "결과적으로 배열 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 먼저 처리된다는 게 base 자체의 보장이 됨 — ref-plan.md의 … "PreRef" 절이 이 보장 위에서 성립."
  • 실제로 맞는 것: PreRef/PostRef는 두 패스 순회의 배열 파트 패스에 얹혀 있는 게 아니라, 그보다 위의 별도 pre-pass for 문에서 처리되고, flattened에는 소진 마커(ProcessedPreRef/ProcessedPostRef)만 남는다. 즉 "배열 먼저"라는 보장에 의존하지 않는다 — 독립적으로 더 먼저 돈다.
  • 정정 방향: PreRef 성립 근거를 "배열 파트 우선 보장"에서 떼어낼 것. 두 보장이 서로 독립임을 명시하고, ref-plan.md가 이 문장을 인용하고 있다면 거기도 같이 볼 것.
  • 부수: 사용자는 nop 핸들러를 ProcessedRefHandler로 통칭했는데 문서는 ProcessedPreRefHandler/ProcessedPostRefHandler 둘로 나눠 부름 — 하나로 합칠지 여부는 확인 안 됨.

D-6 — setLength/setOffsetSource 호출 책임자가 미결정이다 ⚠️ 열린 설계 질문

  • 판정: 아니오 — "그 위치를 처음 매치한 Handler"라는 문서의 규정이 부정확하고, 아직 정해지지 않은 케이스가 있음.
  • 사용자 답변 원문:

    그 위치를 처음 매치한 핸들러는 약간 부정확함. State 등 일 수 있음. 최종 말단 요소가 이를 처리하는게 더 올바른것으로 보이는데, Slot 의 retract 처리가 비록 setOffset/Length 를 잘 stale 되지 않도록 처리는 해주지만, 처음부터 State<Slot|None> 에서 None 이 오는 경우는 생각이 필요한듯 보임. 0 으로 채워지는걸 누가 하냐를 지금 정해져있지 않을텐데. 어디 쪽에서 하는게 맞는지 확인해야함. None 처리자는 그 자체로 리프연산이라 볼 수 있느냐 하면 아니란것도 문제. 단순히 모든 핸들러가 k=number 일 때 처리하도록 두는게 맞는지 검토해보고 싶음

  • 문서가 주장하는 것: dispatch-core-plan.md의 "Length/Offset" 절 — "호출 책임은 Slot 자신의 :List/CRUD가 아니라 그 위치를 처음 매치한 Handler(Dispatch/Slot.luau)", 그리고 "둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB".
  • 문제 1 — "처음 매치한 Handler"는 중간 노드일 수 있다: 배열 위치에 State<Slot>이 오면 그 위치를 처음 매치하는 건 StoreBind(중간 노드)다. 그런데 D-3에서 확정된 계약은 중간 노드가 inst에 부작용을 가하지 않는다는 것 — setLength/setOffsetSource 등록이 그 계약과 어떻게 양립하는지가 서술돼 있지 않다. 사용자 판단은 "최종 말단 요소가 처리하는 게 더 올바르다" 쪽.
  • 문제 2 — State<Slot|None>에서 None이 올 때 누가 0을 채우는가가 미정: Slot의 retract는 stale을 막아주지만, 처음부터 None이 흘러오는 경로가 열려 있다. 그리고 None 처리자(NoneHandler)는 중간 노드라 말단(leaf) 연산으로 볼 수도 없어서 "말단이 등록한다"는 규칙으로도 안 덮인다.
  • 사용자가 검토하고 싶어하는 대안: "단순히 모든 핸들러가 k=number일 때 처리하도록 두는 게 맞는지" — 즉 "위치를 매치한 특정 한 핸들러의 책임"이 아니라 "숫자 키를 다루는 모든 핸들러의 공통 의무"로 재규정하는 안.
  • 파급: Length/Offset은 형제 순서 보장의 유일한 메커니즘이라, 이 갭이 남으면 State<Slot|None>이 섞인 배열에서 형제 순서가 조용히 어긋난다(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이 필요한 항목.

D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 해소(같은 세션 내 재역전)

  • 판정: 아니오(잠정) — 사용자는 quad-base 로드 시 등록이 맞다고 했었다고 기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함.
  • 사용자 답변 원문:

    fallback 들은 quad-base 로드 시가 맞다고 했었음. - 안 그러면 quad-roblox 를 로드하지 않았을 때 로드했는지 물어보는 요소가 처리가 안 된다고 했는데, 이것도 더 확인이 필요한 부분으로 보임.

  • 문서의 현재 확정: dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절 — [재정정, 2026-08-14 열두 번째 세션] 으로 "등록 주체는 quad-base 모듈 자체가 아니라 필요한 엔진(백엔드 팩토리)" 로 확정했고, 반대 모델("quad-base가 자기 모듈 로드 시점에 스스로 등록")을 archive/tag-attribute-load-time-registration-reversed.md역전 처리까지 마쳤음. 근거는 "lifecycle-pattern.md가 이미 거부한 InitNamespace류 top-level 부작용 패턴과 같은 클래스".
  • 사용자가 든 반대 근거: 백엔드 팩토리가 등록 주체라면, quad-roblox를 아예 로드하지 않은 상태에서는 그 Fallback Handler들도 없으므로 "provider가 초기화됐는지 물어보는" 안내 경로 자체가 동작하지 않는다.
  • ⚠️ 이건 "역전을 다시 역전"하는 판단이라 특히 신중해야 함 — 정정 회신에 (a) 어느 쪽이 최종인지, (b) 최종이 "로드 시 등록"이면 archive/tag-attribute-load-time-registration-reversed.md를 되살릴지 아니면 새로 쓸지, (c) InitNamespace 거부 원칙과 어떻게 양립시킬지를 같이 적어주시면 좋겠음.
  • 해소(같은 세션 내, 2026-08-18) — (a)/(b)/(c) 전부 답변됨: (a) 최종은 로드 시 등록 — "등록 주체는 다시 quad-base 자신이다 (모듈이 자기 레지스트리를 구성하는 시점)"으로 재역전 확정. (b) 새로 안 쓰고 기존 archive/tag-attribute-load-time-registration-reversed.md에 재역전 배너만 추가(그 문서 자체가 "이번에 재역전됐다"는 걸 스스로 알림 — 새 archive 문서 생성 없음). (c) InitNamespace 거부 원칙과의 양립: 그 원칙이 금지한 건 "사용자가 수동으로 init을 호출하게 만드는 것"과 "모듈 로드 시 남의 상태를 건드리는 것" 둘인데, base가 자기 모듈 안의 자기 레지스트리를 자기가 채우는 건 그 어느 쪽도 아니므로 충돌 없음. 지금 유효한 설계는 base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절의 "[재역전, 2026-08-18 구현 전 QA — 사용자 확정]" 배너가 소스.

RE. base/relate-plan.md

RE-1 — gcconn/gchold 보관이 SetStrong이라고 적힌 두 곳이 틀림 (정답은 SetWeak)

  • 판정: 아니오 — SetWeak이 맞음(사용자 확정).
  • 틀린 위치 2곳:
    1. base/relate-plan.md의 "대체하는 것" 절 — "bindLifetime/canExecute — gcconn/gchold를 RelateSetStrong으로 저장(둘 다 존재 이유가 '안 죽는 것'이므로 strong)". 괄호 안 근거까지 통째로 틀림 — 둘의 생존은 gcconn 클로저 upvalue와 gchold[1]이 이미 보장하므로, 같은 문서의 "다른 곳에서 안전하게 유지되는 것은 항상 SetWeak" 절 규칙에 따라 weak가 맞다.
    2. base/architecture.md 소스 트리의 quad-roblox/src/LifetimeHandle.luau 행 주석 — "Relate:SetStrong으로 gcconn/gchold 저장".
  • 맞는 서술(그대로 두면 됨): relate-plan.md의 "다른 곳에서 안전하게 유지되는 것은 항상 SetWeak" 절, 그리고 base/lifecycle-pattern.md의 구현 스케치(InstData:SetWeak/BindData:SetWeak 전부 weak).
  • 왜 그냥 오탈자가 아닌가: 1번은 근거 문장까지 딸려 있어 읽는 쪽이 "strong이어야 하는 이유가 있구나"로 납득하게 만든다. 실제로 이 서술을 따라 SetStrong으로 구현하면 relate-plan.md가 경고하는 두-Relate 상호 강참조 순환(RE-2, Luau에 ephemeron이 없어 실제 누수)에 정확히 걸린다 — gcholdvalue를 강하게 잡고 BindDatavalue를 키로 gchold를 강하게 잡는 모양이 되기 때문. 같은 문서가 "이 규칙을 지키면 그 위험이 구조적으로 안 생긴다"고 자랑하는 바로 그 사례를 반대로 적어둔 셈.

M. base/modifier-plan.md

M-3 — 예약 필드 이름이 Apply 하나가 아니라 이고, Overridden은 콜론 메소드로도 쓸 수 있다

  • 판정: 아니오 — 문서 두 곳이 틀림.
  • 사용자 답변 원문:

    전부 가능한게 맞음. Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서 직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함

  • 틀린 곳 1 — modifier-plan.md 8번 절 "구현 시 주의": "__index가 고정 메소드 테이블(현재는 Apply 하나)을 먼저 확인하고 … 따라서 Apply는 Modifier 필드 이름으로 예약됨". → 9번 절이 :Peek(key)를 추가했고 Overridden도 콜론 호출을 지원하므로, 고정 메소드는 Apply / Peek / Overridden이고 셋 다 필드 이름으로 예약된다. "현재는 Apply 하나"는 Peek이 생기던 시점에 갱신되지 않은 stale.
  • 틀린 곳 2 — base/architecture.md "코드 스타일 — 네이밍 케이싱" 절: 대문자 3번 항목이 Modifier.Overridden"콜론 메서드는 아니지만 (여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 됨)" 이라고 단정하며, 이를 "정적 결합 함수"라는 세 번째 하위 분류를 새로 만든 유일한 근거로 든다. → 실제로는 콜론 호출도 지원하므로 그 괄호 안 근거("self 하나로 안 됨")가 성립하지 않는다. 정정 시 이 세 번째 분류를 유지할지(다른 근거로) 아니면 2번 항목(콜론 메서드)에 흡수할지 판단 필요.
  • 파급: 예약 필드 이름은 Roblox 프로퍼티 이름과 충돌하면 안 되는 목록이라, 셋으로 늘어난 사실이 FrameModifier류 타입 생성 스크립트의 제외 목록에 반영돼야 함(M7).

R. base/ref-plan.md

R-1 — "Ref 객체 자신이 곧 콜백/대기자 배열" 구조를 재고할 것 (사용자: 별도 .Callbacks 테이블 + .Value는 평범한 해시 필드)

  • 판정: 아니오 — 사용자가 더 단순한 대안을 제시.
  • 사용자 답변 원문:

    단순히 .Callbacks: {fun, thread} 등이 있는게 맞지 않나라는 생각임. .Value 는 단순 해시필드로 주는게 더 간단해보임. 엔지니어링 난이도구 단순 테이블 하나 더 만드는게 쉽고, 크게 비싸지도 않다고 생각됨.

  • 문서가 주장하는 것(2026-08-09 열한 번째 세션 보강): Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키 색인)이고, 그래서 .Valueself.Value = v 로 얹으면 T가 함수/스레드일 때 for i,v in self do 순회가 hash 파트까지 훑어 오분류되므로 .Value__index 메타메소드로 구현해 저장 위치를 배열과 분리해야 한다.
  • 사용자 대안: 콜백/대기자를 별도 필드 .Callbacks 에 담고, .Value는 그냥 평범한 해시 필드로 둔다. 근거는 "테이블 하나 더 만드는 게 쉽고 크게 비싸지도 않다" — 즉 __index 우회 기법을 쓸 이유 자체를 없앤다.
  • 이 정정이 무효화하는 서술들 (같이 볼 것):
    1. .Value__index 구현 근거 문단 전체(위 인용) — 대안에서는 hash 파트 충돌 자체가 안 생기므로 불필요해짐.
    2. "구현 디테일" 문단의 for i, v in <배열> do 단일 순회로 type(v) 분기(thread=대기자 / function=콜백) — 별도 .Callbacks 테이블이 생기면 순회 대상이 self가 아니라 그 테이블이 된다. type(v) 분기 자체는 유지 가능하나 서술 위치가 바뀜.
    3. 아래 R-3의 nil 소진/빈 슬롯 재사용 결정 — 배열이 어디 있든 논리는 그대로 유효하지만, 서술이 "Ref 객체 자신"을 전제로 쓰여 있어 같이 고쳐야 함.
  • 파급: base/architecture.md 소스 트리의 Ref.luau 주석 (범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋))은 그대로 둬도 되지만, 구현 구조를 서술한 곳은 전부 갱신 필요.

R-3 — PreRef pre-pass 소진 슬롯을 None으로 채운다는 서술이 stale

  • 판정: 아니오 — 틀림(이미 2026-08-14에 정정된 내용이 이 문서에 반영 안 됨).
  • 사용자 답변 원문:

    PreRef, PostRef 는 ProcessedPreRef 등의 nop 핸들러가 캐치하는걸로 자리를 채우지 None 으로 채우진 않았던것 같음 sourceList 는 정확. 단, length 는 0 을 넣는게 옳다. length: number|state 와 offset: state|None 차이라고 생각함

  • 틀린 위치: base/ref-plan.md의 "왜 None이 아니라 nil인가" 절 결론 문장 — "순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset sourceList)은 계속 None".
  • 실제로 맞는 것: pre-pass가 소진시킨 자리는 None이 아니라 전용 센티널 ProcessedPreRef/ProcessedPostRef 로 채워지고, 전용 nop 핸들러 (ProcessedPreRefHandler/ProcessedPostRefHandler)가 정상 Dispatch.process 경로에서 그걸 캐치한다. dispatch-core-plan.md는 이미 이렇게 정정돼 있음([정정, 2026-08-14 두 번째 세션]) — ref-plan.md만 갱신에서 빠짐. sourceListNone인 것은 맞음.
  • 부수 확인(정정 아님): 해제 시 setLength숫자 0, setOffsetSourceNone 이 들어가는 비대칭이 의도된 것 — 타입이 각각 number | State<number>Source<number> | None 이라서. 두 문서 모두 이미 이렇게 적혀 있어 고칠 것 없음.

RF-4 — driveNone을 건너뛰는 특수 분기를 없애야⚠️ 설계 변경, D-6과 연결

  • 판정: 아니오 — 전제("배열 파트의 NoneDispatch.process를 절대 안 탄다")가 거짓이고, 그 위에 세운 설계도 같이 바뀜.
  • 사용자 답변 원문:

    직접적으로 Frame{None} 이면 맞긴한데, Frame{Store<Slot|None>} 이면 탈 수 있음. 무엇이냐 상관 없이 탈 수 있는게 맞긴 하고, 그 경우도 k=number 이면 단순 넘어가기를 해야함. 또한, 이 경우 setLength/OffsetSource 를 여기서 처리하는게 맞다고 보임. 즉, drive 는 v == None 인지 확인 안하고 그냥 프로세스 태우는게 가장 적절한 처리로 보임. NoneHandler 의 retract 는 Nop 일 수 있어보이나, process 자체가 이전걸 retract 하는건 필요(Tag -> None).

  • 깨진 전제: 리터럴 Frame{None}만 생각하면 두 패스 루프가 걸러내면 그만이지만, Frame{ State<Slot|None> } 처럼 반응형 값이 None을 내놓으면 그 NoneStoreBind의 재귀를 타고 Dispatch.process에 그대로 도착한다. 즉 "배열 파트의 Noneprocess를 안 탄다"는 보장이 애초에 성립하지 않는다.
  • 틀린 위치:
    • base/ref-plan.md의 옛 명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) 항목 전체(2026-08-18에 전면 정정됨) — "배열 파트의 None애초에 Dispatch.process 자체를 절대 안 탄다", "k=number 조합으로 NoneHandler가 실제로 매치되는 경우는 없음".
    • base/dispatch-core-plan.md의 "None 센티널" 절 — "Dispatch.drive의 두 패스 루프 자신이 NoneHandler/Dispatch.process를 거치지 않고 바로 건너뜀", 그리고 "정말 빈 자리인 None만 두 패스 루프가 직접 건너뜀"이라는 후속 서술.
    • 같은 문서 "Length/Offset" 절이 None 슬롯의 등록 책임을 서술한 부분.
    • base/component-composition-plan.md의 "필수 관용구" 문단 — "props.Modifier or None이 최종적으로 배열 파트에 None인 채로 남으면 두 패스 루프 자신의 array-part None-스킵 규칙이 그대로 적용돼 아무 일도 안 일어남". 스킵 규칙이 사라지면 이 근거가 바뀐다(NoneHandler가 매치돼 setLength(0)/setOffsetSource(None)을 등록하는 경로가 됨). 결론(or None을 쓰는 것)은 안 바뀜 — CC-3에서 관용구 자체는 "맞음"으로 확인됐으므로 근거 문장만 갱신.
  • 사용자가 지시한 새 설계:
    1. Dispatch.drivev == None을 확인하지 않고 전부 Dispatch.process에 태운다 — 특수 분기 제거. (이게 "가장 적절한 처리"라는 판단.)
    2. NoneHandlerk == number인 경우를 스스로 처리한다 — 실제 값 세팅은 하지 않고 넘어가되, 그 자리의 setLength(0) / setOffsetSource(None) 등록을 여기서 한다.
    3. NoneHandler반환하는 retractor는 no-op이어도 되지만, process 자체는 이전 것을 retract시키는 역할을 해야 한다TagNone 전환에서 이전 Tag 기여가 실제로 걷혀야 하기 때문.
  • ⚠️ D-6과의 관계: D-6이 "State<Slot|None>에서 None이 올 때 누가 0을 채우는가가 미정, 그리고 None 처리자는 말단이 아니라 중간 노드라 '말단이 등록한다' 규칙으로도 안 덮인다" 고 남겨둔 갭의 답이 여기 나옴NoneHandlerk=number일 때 직접 등록한다. 다만 이러면 NoneHandler가 "중간(래핑) 노드"이면서도 등록 책임을 지게 되므로, D-3의 "중간 노드는 inst에 부작용을 가하지 않는다" 계약과의 관계를 정리해야 함 (setLength/setOffsetSourceinst의 프로퍼티를 건드리는 게 아니라 Dispatch 부기라 계약 위반이 아니라고 볼 여지가 크지만, 문서가 그렇게 명시하고 있지는 않음).
  • 정정 시 같이 확인할 것: 3번의 "process가 이전 것을 retract"가 하강 diff 모델에서 자동으로 성립하는지(핸들러가 TagHandlerNoneHandler로 바뀌므로 (B) 분기가 retractFrom을 부름) 아니면 별도 코드가 필요한지. 자동이면 문서에 그 경로를 명시만 하면 되고, 아니라면 NoneHandler.process에 명시적 정리가 들어가야 함.

B. base/brand-plan.md

B-1 — Brand.getNone을 특수 분기하는 설계는 의존성을 만들므로 기각

  • 판정: 부분적으로 틀림 — 포함 관계(isRef(preRef)==true)와 테이블 아이덴티티 태그는 맞으나, None 처리 방식이 틀림.
  • 사용자 답변 원문:

    맞음. 그런데 Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일.

  • 문서가 주장하는 것: brand-plan.md의 옛 None은 이 레지스트리에 안 들어감 문단(2026-08-18에 정정됨) — "Brand.get(x)가 … 범용 introspection 창구 역할까지 겸하게 하려면 None도 빠지면 안 되므로, Brand.get이 내부적으로 x == None을 먼저 확인하는 특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — isNone은 바로 이 특수 분기의 실제 구현체가 됨".
  • 실제로 맞는 것: Brand 모듈은 아무 의존성도 갖지 않아야 한다None을 참조하는 특수 분기를 넣으면 Brand → None 의존이 생긴다. isNone필요한 곳에서 v == None 으로 하면 되고, isNone이라는 이름의 함수를 두더라도 그 구현이 그냥 v == None이면 된다. None 자체를 레지스트리에 태깅하는 것 자체는 무방.
  • 파급: "Brand.get이 quad가 아는 모든 값을 답해주는 단일 introspection 창구"라는 서술도 같이 재검토 필요 — None이 그 창구에서 빠지는 걸 받아들일지, 아니면 None도 평범하게 레지스트리에 등록해 특수 분기 없이 답이 나오게 할지(사용자 답변은 후자를 허용함).

AT. base/attribute-plan.md

AT-1 — 같은 그룹 객체를 두 위치에 놓는 경우(Frame{a, a})가 미검토 ⚠️ 열린 항목

  • 판정: 확정 내용 자체는 맞음. 다만 사용자가 새 검토 항목을 제기.

  • 사용자 답변 원문:

    맞음. 이러면 a=Attribute() Frame{a,a} 가 되어도 이미 문제가 안 나는것으로 보이긴 하는데, UB로 둘지 에러를 쉽게 낼 수 있는지는 확인이 필요해보임.

  • 왜 검토가 필요한가 (문서 의사코드 기준 손 트레이싱): groupKey(v, name)그룹 값 객체별·이름별 메모이즈이므로, 같은 객체 ak=1k=2에 놓이면 양쪽이 완전히 같은 키 객체로 위임한다. 그러면:

    • nameClaims 체크는 cur == k통과한다(에러가 안 남).
    • 그런데 두 위치가 (inst, 같은 key)라는 하나의 체인을 공유하게 된다 — k=1processDispatch.process(inst, key, source, 1)을 부르고, k=2같은 인자로 같은 체인을 다시 부른다.
    • 더 중요한 건 철거: k=1이 retract되면 그 클로저가 Dispatch.retractFrom(inst, key, 1)을 불러 k=2가 아직 쓰고 있는 바인딩까지 통째로 철거한다.
    • 이건 Ref의 "이중 배치 방지"(base/ref-plan.md)가 막은 것과 정확히 같은 클래스의 문제로 보인다 — 다만 RefbindLifetime 게이트로 잡히는데 그룹 Attribute엔 대응 게이트가 없다.
  • 후속 답변으로 결론 나옴 (SL-1 답변에 덧붙여짐)위치별 claim을 하나 두는 쪽으로:

    이전 답변에 대해 diff 를 보다 더 생각이 나서 말하자면, Attribute 는 bindLifetime 를 못함. Ref 와 다르게 여기저기서 사용 가능하기 때문. 한 곳에서 바운딩 했다고 다시 바운딩 못할 순 없음. 따라서 위치별 claim 을 하나 두어야한다고 생각함.

    Ref처럼 bindLifetime을 재사용하는 방식은 쓸 수 없다Ref는 "한 곳에만 배치"가 규칙이지만 Attribute 그룹 값은 여러 곳에서 쓸 수 있어야 하므로, 한 번 바인딩했다고 다시 못 하게 만들면 안 된다. 대신 위치별 claim 레지스트리를 하나 추가해서 같은 그룹 객체가 같은 위치 집합을 이중 점유하는 것만 잡는다.

  • 정정 시 설계할 것: 그 위치별 claim이 무엇을 키로 하는지 ((inst, groupValue) → k 인지, groupKey 단위인지), 그리고 기존 nameClaims와 어떻게 공존하는지.

  • 같이 볼 것: Tag는 같은 객체를 여러 위치에서 재사용하는 게 정상 관례로 확정돼 있고(T-1) 위치(k) 기준 참조 카운트로 안전하다 — 그룹 Attribute만 왜 다른지(자원이 "값 하나"라 겹침=충돌)가 정정 시 같이 서술되면 좋겠음.


SL. base/slot-plan.md

SL-1 — Ref의 leaf 바인딩은 배열(숫자 키) 전용이고, RefLeafHandlerk 체크가 빠져 있음

  • 판정: 아니오 — "배열 전용"이 맞고, 이를 부정하는 서술과 구현이 틀림.
  • 사용자 답변 원문:

    배열 전용이 맞음. 컴포넌트 일 때는 함수의 인자에 맞게 위처럼 보낼 수 있겠으나, 기본 의도는 리프에선 숫자 바인딩임. 이유는 Ref끼리는 순서가 통하므로, 다른 Ref처리를 먼저 해야하는 순서 의존이 있을 때에도 가능하게 하고자였음.

  • 고쳐야 할 곳:
    1. base/ref-plan.mdRefLeafHandler.isHandlableisRef(v) and not isPreRef(v) and not isPostRef(v) 로만 적혀 있어 type(k) == "number" 체크가 빠졌다. 짝인 ObserverEffectLeafHandler엔 그 체크가 필수라고 명시돼 있고 (S-8에서 확인), 빠지면 named 자리로 흘러온 값을 잡으려는 FALLBACK 가드가 죽은 코드가 된다 — 같은 이유가 Ref에도 그대로 적용된다.
    2. base/ref-plan.md의 옛 일반 Ref는 계속 Modifier/Store 어디든 자유롭게 들어감 항목(2026-08-18에 한정 서술 추가) — 이 문장이 "named 해시 키에 놓아도 leaf 바인딩이 된다"로 읽힌다. 실제 의미는 "Modifier 필드나 Store 값으로 전달될 수 있다"(=값으로서 어디든 흘러갈 수 있다)이지 "leaf 바인딩 자리가 아무 데나 된다"가 아니므로, 오해가 없게 다시 써야 한다.
    3. base/slot-plan.md의 예시 slot:Add(Frame { Ref = myRef }) — 여기 Frame컴포넌트 함수라서 Ref가 그 함수의 named 파라미터인 경우에만 성립한다. 실제 Instance 리터럴로 읽히면 위 1번 규칙과 정면으로 어긋나므로, 예시에 그 전제를 명시해야 한다.
  • 배열 전용인 이유(사용자 논거, 문서에 없던 것): Ref끼리는 배열 index 순서가 통하므로, "다른 Ref 처리를 먼저 해야 하는 순서 의존"이 있을 때도 표현이 가능하게 하려는 것. (PreRef/PostRef의 "계열 안 순서 보장"과 같은 결의 근거인데, 일반 Ref에 대해서는 어디에도 안 적혀 있었음.)

SL-3 — :List reconcile의 nil 리턴을 비파괴로 확정한 것을 재검토해야 함 ⚠️ 확정 뒤집기 후보

  • 판정: 부분적으로 틀림 — 사용자는 파괴가 기본이 맞다고 보며, 현재 문서 확정(비파괴)과 어긋남.
  • 사용자 답변 원문:

    List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지, 파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데, Instance.new Destroy 비용을 아끼고 싶은, filter 부분에 있어서는 단순 Parent = nil 로 두고 싶을수도 있음. 이를 위해 특정 리턴 등은 Parent = nil 로 만들고 홀드해둔 다음, 나중에 prev 상 이를 사용할 수 있게 두는 방법이 가능한가 모색해보고 싶음. 아마 ud 는 명시적으로 nil 안 하면 안 지워지니, PopOnly 등을 만들어 PopOnly, { old = ..., source... } 등을 하면 Parent 를 빼고 slot 에서 적절히 빼주는게 방법으로 보임.

  • 문서의 현재 확정: slot-plan.md의 "구현상 바뀌어야 하는 것" 절 2번 — ":Listreconcile — 교체/소멸 시 rawRemove(파괴) 대신 같은 비파괴 경로. 데이터에서 빠진 아이템도 파괴되지 않고 언마운트만 되며, 아무도 안 들고 있으면 GC". 그리고 "자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"라는 일반 규칙.
  • 어긋나는 지점: 사용자는 :List reconcile의 nil 리턴에 한해 파괴가 기본이라고 봄. 즉 "자동 경로 = 전부 언마운트"라는 일반화가 :List에는 안 맞을 수 있다. (State<Slot> 교체가 언마운트인 것은 SL-3 질문에서 별도로 부정되지 않았으므로 그대로 유효해 보임 — 두 경로를 분리해서 정할 필요.)
  • 사용자가 원하는 추가 기능 — 재사용을 위한 PopOnly(가칭): filter 용도에서 Instance.new/Destroy 비용을 아끼려면, 파괴하지 않고 Parent = nil로만 두고 홀드해뒀다가 나중에 prev(:Computeprevious와 같은 자리)로 재사용할 수 있어야 함. 구체안: updateFnPopOnly, { old = ..., source = ... } 같은 걸 반환하면 reconcile이 Parent만 빼고 Slot에서 적절히 제거하는 방식.
    • 참고로 사용자가 짚은 전제: userdata는 명시적으로 nil을 안 하면 안 지워지므로 홀드만 해두면 살아있다.
  • 결정해야 할 것: (a) :List reconcile nil 리턴의 기본을 파괴로 되돌릴지, (b) PopOnly를 이번 설계에 넣을지 백로그로 뺄지, (c) 넣는다면 updateFn 반환 규약(현재는 "반환값을 해석하는 :List 내부 로직")이 어떻게 확장되는지.

E. base/effect-plan.md

E-2 — :Unsubscribe()가 leaf 바인딩에도 cleanup을 부르게 한 건 틀림 (Subscribe의 짝으로 좁혀야 함)

  • 판정: 아니오 — 계약을 축소해야 함.
  • 사용자 답변 원문:

    표면적으로 맞아보이긴 하나, subscribe 한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선 그래선 안 됨. leaf 에 바운딩 된 경우, 특히 state 또는 observer 가 들어갈 때, 단순 emit 에서 최적화로 인해 이전과 동등이라 retract 가 아무 일을 하지 않음. (그런데, 별개로, retract 가 아무것도 안 하고 나서, process 쪽에서도 아무것도 안 하는지는 확인이 필요해보임) 그래서 다시 바운딩 안 먹는것이라 의도한 바가 아님. subscribe 는 unsubscribe 의 짝이라고 생각함.

  • 문서가 주장하는 것: effect-plan.md의 "EffectHandle:Subscribe()/ :Unsubscribe()" 절 — ":Unsubscribe()는 Observer의 것을 그냥 위임하지 않는다 — Effect 계층에서 의미가 확장됨 … 직전(또는 유일한) cleanup을 정확히 1회 호출 — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것".
  • 실제로 맞는 것: :Unsubscribe():Subscribe()의 짝이다 — :Subscribe()로 등록하지 않은 핸들(=leaf 바인딩된 핸들)에는 지원하지 않거나, 최소한 leaf 바인딩 경로에서는 cleanup을 앞당기면 안 된다.
  • 왜 위험한가 (사용자 논거): leaf 바인딩 + State<Effect>/State<Observer> 조합에서, 값이 실제로 안 바뀌면 dedup 최적화 때문에 retract가 아무 일도 안 한다(S-8의 old ~= v dedup). 그런데 :Unsubscribe()가 cleanup을 미리 실행해버리면, 뒤이은 재-dispatch에서 dedup 때문에 재바인딩이 안 일어나 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
  • ⚠️ 함께 확인해야 할 별건(사용자가 괄호로 남긴 것): dedup 경로에서 retract가 아무것도 안 한 뒤 process 쪽도 정말 아무것도 안 하는지 대칭이 실제로 성립하는지 확인 필요. ObserverEffectLeafHandler 의사코드 기준으론 processif old ~= v then bindLifetime(...) end와 클로저의 if nextValue ~= v then unbindLifetime(...) end가 짝을 이루지만, EffectHandle은 내부 Observer로 cascade까지 해야 하므로(E-2의 (2)번, 그 자체는 "맞음"으로 확인됨) 그 cascade가 dedup 분기 안에 제대로 들어가 있는지가 별도 확인 대상.
  • 정정 범위: base/effect-plan.md의 해당 절, 그리고 같은 논리가 적용되는 base/source-state-plan.md의 Observer :Unsubscribe() 서술(거기는 이미 "전역 경로 전용"으로 좁혀져 있어 괜찮아 보이나 같이 확인).

EV. base/event-plan.md

EV-1 — 이벤트 disconnect 센티널을 false에서 None/nil로 바꿀 것

  • 판정: 아니오 — 센티널 선택이 이제 낡음.
  • 사용자 답변 원문:

    다만, 이젠 None 이 있어서 false 을 사용해야할 이유가 없어졌다고 봄. false 대신 None/nil 을 사용하지 말아야할 이유가 없다면 일관적이게 None/nil 을 주는게 맞다는 생각

  • 문서가 주장하는 것: event-plan.md의 "이벤트도 store-bind 가능" 절(제목의 센티널 표기는 2026-08-18에 None/nil로 갱신됨) — "false로 disconnect, nil 아님. nil은 Lua 테이블에서 '키가 아예 없음'과 구별이 안 됨 … 대신 false(Luau에서 실재하는 싱글톤 타입)를 '연결 없음' 센티널로 씀".
  • 왜 낡았나: 그 결정(2026-08-06)은 None 센티널이 확정되기 전에 "테이블에 실재하는 값으로 표현 가능한 것"이 필요해서 false를 고른 것인데, 이후 None이 정확히 그 역할로 도입됐다(base/modifier-plan.md 2-1, base/dispatch-core-plan.md "None 센티널"). 지금은 같은 문제를 푸는 센티널이 두 개 있는 셈이고, 이벤트만 다른 걸 쓸 이유가 없음.
  • 정정 시 같이 설계해야 할 것:
    • None으로 바꾸면 NoneHandler가 매우 높은 우선순위로 먼저 매치해서 nil로 재귀시킨다 — 그러면 EventHandler(k=이벤트키, v=nil)을 받아 disconnect로 처리할 수 있어야 한다. isHandlablenil 값에도 매치되도록 규정이 필요.
    • 문서의 기존 근거였던 "이벤트인지 여부는 값이 아니라 키(리플렉션)로 결정되므로 다른 boolean 프로퍼티 핸들러와 (k, false) 매칭이 겹칠 위험 없음"false를 안 쓰면 아예 불필요해지므로 삭제 대상.
    • N-4(NilHandler 신설)와 상호작용 — k가 숫자가 아닌 이벤트 키에서 nil이 어느 핸들러에 가야 하는지 같이 정할 것.

BS. base/bind-system-plan.md

BS-2 — "이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 전제가 거짓 ⚠️ 두 문서의 근거가 무너짐

  • 판정: 아니오 — 사용자가 반례 코드를 직접 작성해 제시.
  • 사용자 답변 원문:

    ReflectionService 사용은 맞음. 단 새로 프로젝트 루트에 생성된 test.luau 를 보면 알 수 있지만, 이벤트의 콜백 또한 타입을 지정해주는게 가능함. D가 만들어지는 제네레이터 상에서 타입을 적절히 제공하면 콜백 시그니처는 충분히 처리 가능한것으로 보임. 이는 OnChange 와 다르게, 필드이기 때문에 타이핑이 가능함.

  • 반례 (2026-08-18 사용자가 직접 작성해 보여준 코드 — 당시 프로젝트 루트에 test.luau로 뒀다가 이후 삭제, 아래가 그 전문):
    function Frame (prop: {MouseButton1Click: ((a: number)->())?})
    end
    
    Frame{
        MouseButton1Click = function(a) -- a: number
        end
    }
    
    props 테이블 타입에 필드로 선언돼 있으면 콜백 파라미터가 그대로 추론된다 — 런타임 판별을 ReflectionService로 하는 것과 타입을 생성기가 제공하는 것은 완전히 별개 축인데, 문서가 둘을 묶어버렸다.
  • 틀린 위치 1 — base/bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절: "이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 Luau가 검증 못 함apply<T,U>(instance: T, properties: U): T & U가 스키마 검증 없이 구조적으로만 merge), 이미 UB로 남긴 … 와 같은 급의 한계라 손해가 크지 않고". → D 생성기가 클래스별 props 타입에 이벤트 필드까지 정확한 콜백 타입으로 포함시키면 검증된다. "감수하는 대가"가 아니라 생성기가 챙겨야 하는 구현 체크리스트 항목이다(ui-shorthand-plan.mdUICorner/UIPadding/ UIScale을 Modifier 타입 메소드 목록에 끼워 넣으라고 한 것과 같은 성격).
  • 틀린 위치 2 — base/onchange-plan.md의 "확정" 절: OnChange<<T>> 제네릭을 안 만드는 근거로 "이미 확정된 '이벤트 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다'는 결정 … 과 같은 급의 트레이드오프라 새로 정당화할 것 없음" 을 듦. → 그 전제가 거짓이므로 이 근거는 통째로 무효. 다만 사용자 답변에 따르면 결론(제네릭 없음)은 유지되고 근거만 바뀐다 — 이벤트는 필드라 타이핑이 되지만 OnChange(name)이름을 인자로 받는 팩토리라 그 경로가 없다는 것이 진짜 이유.
  • 파급:
    • base/architecture.md의 소스 트리 DI/init.luau 주석("제네릭 생성자 + ~25개 정적 필드")과 D/DI 생성기 설계에 **"이벤트 필드의 콜백 타입도 생성한다"**가 추가돼야 함.
    • base/store-plan.md의 "타입 추론 문제" 절이 "이벤트는 이 관습의 유일한 예외" 라고 서술하는데, 예외의 성격이 바뀐다 — "타입을 포기하는 예외"가 아니라 "이름 지정 방식만 문자열 키인 예외"다.
    • 이벤트가 store-bind될 때(State<function>/false→EV-1에 따라 None/nil)의 타입은 어떻게 되는지도 같이 정해야 함 — 필드 타입이 ((a: number)->())? 뿐이면 State<...>나 센티널을 못 받는다.

추가 요구사항 — 심사 중 사용자가 새로 지시한 것

기존 서술이 틀려서가 아니라, 확인 과정에서 사용자가 새로 요구한 사항. 정정이 아니라 설계 추가라 위 결함 목록과 분리해 둔다.

N-1 — FALLBACK 가드의 에러 메시지에 실제 k 타입을 실어줄 것

  • 출처: S-8 답변("정확함. 다만 …").
  • 사용자 답변 원문:

    정확함. 다만 Priority Fallback 이 type(k) == "string" 인 상황에서는 가장 위에 Ref/Observer binding should be array index item, but got typeof k 처럼 알려줄 필요는 있는듯 - 안 그러면 핸들러 미등록 이슈인지, MyRef = Ref 같이 아에 잘못 쓴 이슈인지 분간이 안가서 최종 유저에게 혼선을 줄 여지가 존재하는듯.

  • 요구 내용: HANDLER_PRIORITY_FALLBACK 가드(Observer/Ref/PreRef/ PostRef의 동적 경로 가드)가 에러를 낼 때, 단순히 "배열 리터럴에만 놓을 수 있음"이 아니라 실제로 들어온 k의 타입을 메시지에 포함할 것 — Ref/Observer binding should be array index item, but got <typeof k> 형태.
  • 근거(사용자 논거): 메시지에 k 타입이 없으면 사용자가 두 원인을 구분할 수 없다 — (a) 핸들러가 등록이 안 된 것인지, (b) MyRef = Ref(...)처럼 named 자리에 잘못 쓴 것인지. 최종 사용자에게 혼선을 줌.
  • 반영할 곳: base/source-state-plan.md "동적 경로 가드" 절의 가드 스케치, base/ref-plan.mdPreRef/PostRef 동적 경로 가드, 그리고 base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절(FALLBACK 자리 서술).

N-2 — 없는 Store 키는 "타입 에러"로 잡히면 충분 (구현 시 확인)

  • 출처: ST-1 답변.
  • 사용자 답변 원문:

    네, 맞고, Store<{ field: type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요

  • 내용: lazy __index 생성이 오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용. 방어선은 런타임이 아니라 타입 — Store<{field: T}>로 선언된 Store에 없는 이름을 쓰면 type function이 합성한 결과 타입에 그 프로퍼티가 없어 타입 에러가 나야 한다.
  • 확인 필요: 사용자도 "아마 지금 설계가 그럴 것"이라며 단정하지 않았음 — type function으로 합성한 테이블 타입이 미선언 프로퍼티 접근을 실제로 거부하는지(인덱서를 안 붙였을 때 Luau가 에러를 내는지) M0에서 확인할 것. ST-4의 스파이크(luau-test/done/16-...)에 이 음성 대조군이 있는지도 같이 볼 것.

N-3 — 동률 경고 print는 전역 디버그 플래그 Quad.debug로 게이팅

  • 출처: D-8 답변.
  • 사용자 답변 원문:

    동률 print 는 라이브러리가 debug 모드일 때만. (Quad.debug: boolean = default false) 식이고, true 로 하면 디버깅 가능. 다른건 OK

  • 내용: dispatch-core-plan.md의 "디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고" 항목은 무조건 찍는 게 아니라 모듈 표면의 불리언 플래그 Quad.debug(기본 false)가 true일 때만 찍는다.
  • 파급: Quad.debug는 지금 어느 문서에도 없는 새 공개 API 표면이다 — base/module-lifecycle-plan.md(모듈 표면)와 base/architecture.md의 소스 트리에 반영이 필요하고, New()/Quad() 다중 인스턴스화(A-3) 시 이 플래그가 인스턴스별인지 전역인지도 같이 정해야 함. Dispatch.listHandlers()도 같은 디버그 표면에 속하는지 확인 필요.

N-4 — NilHandler 신설 요구 (State<Slot|nil>도 동작해야 함)

  • 출처: B-1 답변에 덧붙인 별건("이 답과는 연관 없는 말을 하자면").
  • 사용자 답변 원문:

    State<Slot|None> 일 수도 있지만, State<Slot|nil> 이여도 작동은 함. 이것도 NoneHandler 유사하게 NilHandler 가 필요함. 오직 k=number v=nil 일 때만 받고, NoneHandler 와 유사하게 retract 처리를 담당. 단, 재귀만 안 할 뿐임. 혹은, NoneHandler 가 다시 NilHandler 가 불리도록 하는게 더 나을수도 있겠다는 생각(단순 재귀로 NoneHandler 는 재귀처리만 담당함).

  • 요구 내용: 반응형 값이 None이 아니라 진짜 nil 을 내놓는 경우 (State<Slot|nil>)도 정상 동작해야 하므로, NilHandler를 신설한다.
    • isHandlable: k == number 이고 v == nil 일 때만 매치.
    • 역할: NoneHandler와 마찬가지로 retract 처리 담당. 단 재귀는 하지 않음(NoneHandlernil로 바꿔 재귀하는 게 일이지만, NilHandler는 이미 nil이라 더 내려보낼 곳이 없음).
    • 사용자가 선호한 대안 구조: NoneHandler재귀 처리만 담당하게 두고, 그 재귀가 결국 NilHandler를 부르게 만드는 쪽이 더 깔끔할 수 있음 — 즉 실질 정리 로직을 NilHandler 한 곳에 모으는 구성.
  • RF-4와 함께 봐야 함: RF-4가 "driveNone 스킵을 없애고 전부 process에 태운다 + NoneHandlerk=number에서 setLength(0)/ setOffsetSource(None)을 등록한다"였는데, 여기에 NilHandler가 들어오면 그 등록 책임이 둘 중 어디에 있는지를 같이 정해야 함(재귀 구조를 택하면 자연히 NilHandler 쪽으로 모임).
  • 반영할 곳: base/dispatch-core-plan.md의 "None 센티널" 절(핸들러 목록과 재귀 구조), base/architecture.md 소스 트리(Dispatch/ 아래 파일 목록), base/dispatch-core-plan.md "Length/Offset" 절의 등록 책임 규정.

N-5 — Attribute.MergedAttribute.Overridden둘 다 제공 (열려 있던 결정 해소)

  • 출처: AT-4. question.md 3번에 "사용자 확인 대기"로 열려 있던 항목의 답이며, 선택지 둘 중 하나가 아니라 제3안이 채택됨.
  • 사용자 답변 원문:

    차라리 Merged, Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자는 그냥 조용히 덮어써주는것. 사용자 의도에 따라 달라질 부분이라 분리해주는것이 이로워보임.

  • 결정: 이름 겹침의 처리 방식을 API로 분리한다.
    • Attribute.Merged(a, b, ...) — 같은 이름이 겹치면 error.
    • Attribute.Overridden(a, b, ...) — 겹치면 조용히 뒤가 이김(덮어쓰기).
    • 근거: 어느 쪽이 맞는지는 사용자 의도에 달린 문제라 프레임워크가 하나로 정하지 말고 골라 쓰게 한다.
  • 파급 / 정리할 것:
    • base/attribute-plan.md의 "채택안 — Tag와 동형인 array-part 값 객체" API 목록에 Attribute.Overridden 추가, "열린 질문" 절의 해당 항목 해소 처리, .claude/question.md 3번에서 제거.
    • Merged/Overridden이라는 이름 쌍의 의미가 코퍼스 전체에서 재정렬됨 — 지금까지는 Merged=무손실 합집합(Tag), Overridden=필드 단위 덮어쓰기(Modifier)로 연산의 종류를 가르는 이름이었는데, Attribute에선 충돌 시 정책(error냐 덮어쓰기냐)을 가르는 이름이 된다. base/tag-plan.mdTag.Merged 코드 주석에서 Merged를 집합 합치기, Overridden을 이미 계산된 것 합치기로 대조해둔 서술과 같이 볼 것.
    • Tag에도 Overridden이 필요한가는 자동으로 따라오지 않음 — Tag는 합집합이라 애초에 충돌 개념이 없음. 확인 불필요해 보이나 정정 시 한 줄 명시해두면 좋겠음.

N-6 — SetAndDispose 류 편의 콤비네이터 검토 (백로그 후보)

  • 출처: SL-2 답변.
  • 사용자 답변 원문:

    정확하나, source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 생각해보았음(단 여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). Get해놓고 Set 이후 나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 source 자체에 :콜론 메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음.

  • 문제: dispose는 "Set(언마운트) → 그 다음 dispose" 순서를 요구하는데(SL-2), 그러려면 호출부가 Get()으로 이전 값을 미리 잡아두고 → Set(new) → 잡아둔 옛 값을 dispose 하는 3단계를 매번 손으로 써야 해서 편의성이 떨어진다.
  • 후보 두 가지:
    1. source:Apply(SetAndDispose(new)) — 콤비네이터. 단 여기서의 ApplyState가 아니라 Source를 넘겨주는 함수여야 함(사용자 명시) — 지금 확정된 state:Apply(factory)factory(self)State를 넘기므로, Source 전용 변형이 필요한지 같이 정해야 함.
    2. Source콜론 메서드로 직접 얹기(예: source:SetAndDispose(new)).
  • 미결: 어느 쪽을 택할지, 그리고 애초에 이번 범위에 넣을지 백로그로 뺄지. state:Apply의 시그니처((State<T>) -> U)에 영향이 갈 수 있으므로 M3 착수 전에 방향만이라도 정해두는 게 좋음.

N-7 — UI 숏핸드가 만든 자식을 FindFirstChild 대신 Relate로 기억할 것

  • 출처: UI-1 답변.
  • 사용자 답변 원문:

    다만, FindFirstChild 는 비용이 ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 릴레이션으로 저장하는것도 좋은 생각. 각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운 dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능.

  • 문서가 주장하는 것: base/ui-shorthand-plan.md는 재사용 대상을 **"quad가 이전에 만든 고정 이름(_quad_corner류) 자식"**으로 한정하는데, 그 "찾기"를 어떻게 하는지는 v1처럼 이름으로 조회(FindFirstChild)하는 것으로 읽힌다.
  • 요구 내용: 이름 조회 대신 Relate(inst, 숏핸드키) → child로 저장해서 다시 찾을 것. 근거는 (a) FindFirstChild가 참조 저장보다 비싸고, (b) spring 등으로 자식이 계속 움직이는 상황이면 그 조회가 반복 비용이 됨.
  • 주의 — 고정 이름 규약을 없애자는 뜻은 아님: 이름(_quad_corner류)은 디버깅 가시성(research/debug-tooling-plan.md 9번)과 "사용자가 만든 UICorner를 건드리지 않는다"는 판정에 여전히 필요해 보임. Relate조회 경로를 대체하는 것이고, 두 가지가 어떤 관계인지(이름은 표시용, 릴레이션은 조회용) 정정 시 명시할 것.
  • 주의 — Relate 키 전제: inst-키 Relate는 gcconn 셋업 위에서만 성립하는데(RE-1/L-1), 숏핸드가 만드는 자식도 quad가 만든 Instance라 그 셋업을 거치는지 확인 필요.

N-8 — DID 리네임 확정 (2026-08-18) + 전수 반영 목록

  • 출처: 사용자가 이 QA 라운드 중 직접 확정("이거 하면서 DI => D 확정하자"). 2026-08-08 용어 정리 라운드부터 question.md 1순위로 열려 있던 항목.
  • 확정 내용 두 갈래 (사용자 판정):
    1. 네임스페이스/모듈 자체는 DID. D.Frame / D/init.luau / D.InstSlot / D.FrameModifier.
    2. "특수 DI 키"라는 설명용 표현은 D로 바꾸지 않고 "특수 키"로 단순화. (사용자 선택: "특수 키"로 단순화) — "특수 D 키"라고 쓰지 않는다. 수식어 자체를 빼도 문맥상 통한다는 판단.
  • D로 가는 근거(이미 기록돼 있던 것): (1) "Instance" 전용 개념이 아니라 quad-* 전반의 declare 요소로 확장 가능한 이름, (2) 엔진 종속 없이 다른 백엔드에서도 재사용 가능, (3) D.FrameModifier류 타입 프리픽스가 짧아야 한다는 실용적 제약. 원래 이름 DI의 문제는 "Dependency Injection"과 완전히 겹쳐 실제로 오해가 있었던 전례가 있다는 것.
  • ⚠️ 같이 정해야 할 것 — 한 글자 식별자의 검색성/자기설명력 보완책 (사용자 선택: 예, 같이 넣어둘 것). 2026-08-08에 D를 확정 못 하고 미룬 유일한 사유가 이거였음 — D 한 글자는 grep도 어렵고 이름만으로 뜻이 안 드러난다. 정정 시 아래를 같이 정할 것:
    • 문서에서 D가 처음 나올 때 항상 "Declarative"로 풀어쓰는 규약을 둘지(예: "D(Declarative) 네임스페이스").
    • conventions.md의 "문서 표기 규약"에 넣을지, 아니면 base/architecture.md "코드 스타일 — 네이밍 케이싱" 절에 넣을지.

반영 대상 — 전수 (2026-08-18 기준 grep, session/·session-summary.md·initreq/ 제외)

갈래 ① DID (네임스페이스/모듈)

파일 무엇
base/architecture.md 195 소스 트리의 DI/ 디렉토리 + 그 아래 init.luau 주석
base/slot-plan.md 98 DI.InstSlot = Slot<<Instance>>"(DI 네임스페이스 이름 자체는 question.md 1번 용어정리 대기 중…)" 괄호도 삭제
base/bind-system-plan.md 126 ""DI"는 Dependency Injection이 아니라 "Declarative Instance"" — 이 문단이 개명의 근거 자체이므로 재작성(왜 D가 됐는지로)
base/bind-system-plan.md 130 DI.Frame vs DI.New<<Frame>> "Frame"
base/bind-system-plan.md 199 열린 질문 "DI(또는 다른 이름) 등 정확한 모듈 이름"항목째 삭제(해소됨)
base/modifier-plan.md 313 "DI 쪽 '제네릭 생성자 함수 하나 + …' 패턴 재사용"
base/ui-shorthand-plan.md 74 "quad-roblox의 각종 타입(DI 인스턴스 타입, Modifier 타입 등)"
base/attribute-plan.md 66 "이미 확정된 DI 인스턴스 생성 패턴"
base/attribute-plan.md 449 표의 백엔드(quad-roblox의 D/DI 층)이미 병기 중, D로 단일화
base/attribute-plan.md 492 "최종 이름은 다른 가칭들(DID/…)과 함께 대기열" — 목록에서 제거
base/dispatch-core-plan.md 628 그 백엔드(quad-roblox의 D/DI 층)이미 병기 중, D로 단일화
ROADMAP.md 333 체크박스 DI/init.luau(제네릭 생성자 + ~25개 정적 필드)
ROADMAP.md 429430 DI.InstSlot + "DI 네임스페이스 이름 자체는 question.md 1번" 대기 문구
ROADMAP.md 726 quad-roblox의 D/DID로 단일화
ROADMAP.md 825 용어 정리 스윕 체크박스의 State/DI/Slot 목록 — DI 제거
question.md 43, 46, 47 DI 항목 자체 — 해소 처리해 archive/question-resolved.md로 이전(MergeOverridden 등 기존 해소 항목과 같은 방식). 4647행의 파급(DI.FrameModifier류 타입 프리픽스)은 이번 리네임에 실제로 포함되므로 반영 목록에 흡수
question.md 183 "M3 Source/M5 DI 생성자"
todos.md 95 용어 정리 목록의 "DID(1순위)" — 해소로 제거
research/additional-primitives-plan.md 21 프리미티브 나열 .../Slot/DI)
research/debug-tooling-plan.md 5, 126, 379, 460, 479 "Source/DI 생성자", DI/init.luau, "DI 제네릭 생성자", "Dispatch/DI", "M5(quad-roblox DI 제네릭 생성자)"
research/pre-implementation-audit.md 537, 538, 541 "DI 쪽 패턴 재사용", "DI 타입 생성 계층(M5)", "M5 DI 체크리스트"
이 문서 자신 BS-2의 파급 문단 DI/init.luau 주석과 D/DI 생성기 언급

갈래 ② "DI 키" → "특수 키" (설명용 표현)

파일
base/architecture.md 37, 42, 191
base/onchange-plan.md 20, 76(헤딩 — 아래 ⚠️)
base/attribute-plan.md 33, 74
base/lifecycle-hooks-plan.md 28, 82, 83, 106, 111, 186, 294, 296, 300, 303
base/dispatch-core-plan.md 315
base/bind-system-plan.md 164
base/ref-plan.md 637
ROADMAP.md 135, 711
.claude/README.md 64
luau-test/README.md 81, 117
  • ⚠️ 헤딩 1개 + 그 헤딩을 절 인용하는 곳 1개가 짝으로 묶여 있음 — 반드시 같은 커밋에서 함께 고칠 것.
    • base/onchange-plan.md:76## 다른 특수 DI 키와의 대조 (헤딩)
    • base/lifecycle-hooks-plan.md:300 이 그 절을 "다른 특수 DI 키와의 대조"절 인용하고 있음.
    • 한쪽만 고치면 doc-check.py절 참조 불일치 ERROR로 잡아 커밋이 막힌다(conventions.md의 "절 인용 규약"이 ERROR로 승격돼 있음).
  • base/ref-plan.md:637 / base/lifecycle-hooks-plan.md:106 은 같은 문장의 원문·인용 관계lifecycle-hooks-plan.mdref-plan.md의 문장을 blockquote로 그대로 옮겨 적었으므로 문구가 계속 일치해야 함.
  • archive/·session/·session-summary.md 는 건드리지 말 것 — 히스토리 문서라 당시 표기 그대로 두는 게 conventions.md 관례 (archive/tag-hash-key-model-reversed.md 제목의 "DI 키" 등).

반영 후 확인

  • python3 .claude/tools/doc-check.pyERROR 0(특히 위 절 인용 짝).
  • grep -rn '\bDI\b' --include='*.md' . | grep -v '/initreq/\|/session/\|session-summary.md\|/archive/'0건이어야 함.
  • 인덱스 3층 갱신: .claude/README.md, question.md(항목 이전), 루트 ROADMAP.mdconventions.md의 중대 변경 핸드오버 체크리스트 6번.

N-9 — New를 커링으로 명시하고 D를 "처리 없는 별칭 테이블"로 규정 (사용자 제안)

  • 출처: 사용자 제안(2026-08-18, N-8 확정 직후).
  • 사용자 발언 원문:

    D.Frame 같은건 New 에서 커링되어, New 함수는 New(name)({}) 되는게 이롭다 생각하는데 어떰? 실제 사용 상 New<> "Frame" {...} 로도 쓸 수 있고, D 에선 별다른 처리 없이 D.Frame = New<> "Frame" :: (({...타입명시}) -> Frame) 으로 쉽게 만들 수 있다는게 내 생각임.

  • 제안 내용:
    1. New는 커링New(name)이 생성자 함수를 반환하고, 그걸 다시 props 테이블로 호출: New(name)({...}).
    2. 직접 사용도 같은 모양New<<Frame>> "Frame" {...} (Lua 문법상 New("Frame")({...})).
    3. D는 별다른 처리가 없다 — 필드마다 D.Frame = New<<Frame>> "Frame" :: (({...타입명시}) -> Frame) 로 캐스팅만 얹은 순수 별칭 테이블.
  • 지금 문서와의 관계 — 뒤집는 게 아니라 명시화: base/bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 인용한 PA님 패턴이 이미 constructor.Frame = new("Frame") 이라 사실상 커링이었음 — 다만 (a) "커링이다"라는 것과 (b) 2단계 호출 계약과 (c) New(name)({...}) / New "Name" {...} 라는 직접 호출 형태가 문서에 명시된 적이 없다. BS-1에서 "eager 정적 테이블"은 이미 확인됐으므로 이 제안은 그 위에 "그 정적 필드가 정확히 무엇인가"를 채우는 것.
  • BS-2와 정합적 — 필드별 :: 캐스트로 타입을 주는 방식은, D 생성기가 어차피 클래스별 props 타입(이벤트 콜백 시그니처 포함)을 뽑아야 한다는 BS-2의 결론과 맞물린다. 기존에 인용돼 있던 new<ClassName>(className): from<index<UIInstances, ClassName>> (타입 레벨 인덱싱)보다 생성기 입장에서 단순함.
  • 기각된 "2트랙"과 혼동하지 말 것 — 같은 절이 기각한 건 "필드=1급 타입 경로, 문자열=폴백" 이라는 능력 차이였지 New라는 이름이나 문자열 호출 자체가 아니었다. 이 제안은 오히려 두 형태가 완전히 같은 것(하나가 다른 하나의 미리 적용된 결과)임을 못박는다.

후속 확정 (2026-08-18, 같은 대화에서 1~4번 전부 확정)

2. 이름은 대문자 New로 확정. 사용자 답변: "2. New입니다." 지금 코퍼스는 둘 다 씀 — bind-system-plan.md가 PA님 코드를 인용할 땐 소문자 new(className), 같은 절의 (기각된) 2트랙 구상엔 대문자 DI.New<<Frame>> "Frame". 대문자로 통일하고, base/architecture.md의 "코드 스타일 — 네이밍 케이싱" 절에 D.New가 어느 부류로 들어가는지도 같이 적을 것.

3. D는 전부 코드 자동 생성이 맞음 — New 호출문까지 생성기가 찍는다.

  • 사용자 답변 원문:
    1. 코드로 자동 생성되는것이 맞는게, index 가지고만 하면 MouseButton1Click 이 RBXScriptConnection 이 되어버림. 처리하기 힘들다 생각하는게, lsp 마다 Frame 타입을 어떻게 다루냐 다를 수 있음. 게다가 T|State 같은것 또한 처리해야하는데, 이걸 타입 함수로 다 처리하게 만드는것 보다 단순히 D 파일이 자동 생성되는게 좋다고 생각함. 그리고 코드 생성 산출물인가는 맞음, 전부 코드 생성이나, New 같은것도 생성기에서 같이 적어주어야할 부분.
  • ⚠️ 이건 단순 확인이 아니라 bind-system-plan.md가 인용한 시그니처를 부정한다. 그 문서는 PA님 코드를 그대로 인용해 new<ClassName>(className): from<index<UIInstances, ClassName>> — 즉 타입 레벨 인덱싱으로 클래스 타입을 뽑는 모양인데, 사용자가 든 세 가지 이유로 그 방식만으로는 부족하다:
    1. 이벤트 필드가 콜백 타입이 안 된다. Roblox 타입 정의에서 MouseButton1Click은 시그널 계열 타입이라, 인덱싱으로 뽑으면 그 타입이 그대로 나와버리고 quad가 원하는 ((...) -> ())? 콜백 시그니처가 안 나옴. 이게 곧 BS-2가 요구한 "생성기가 이벤트 필드의 콜백 타입까지 만들어야 한다"의 직접적 근거 — 두 항목은 같은 문제의 양면이므로 반드시 같이 처리할 것.
    2. LSP마다 Frame 타입을 다루는 방식이 다를 수 있어 타입 함수/인덱싱에 의존하는 게 위험하다.
    3. T | State<T>(그리고 T | Tween<T>, None 등)까지 타입 함수로 조립해야 하는데, 그럴 바엔 D 파일을 통째로 생성하는 쪽이 단순하다.
  • 따라서: D는 타입뿐 아니라 D.Frame = New<<Frame>> "Frame" :: (...) 라는 값 선언까지 생성기가 찍어내는 파일이다. 손으로 쓰지 않는다.
  • 반영: base/bind-system-plan.md의 PA님 시그니처 인용을 이 결론에 맞게 다시 쓸 것 — "PA님 코드 그대로 채택"이라는 프레이밍 자체가 타입 조립 방식에 한해서는 더 이상 정확하지 않음(호출 모양은 그대로 채택, 타입은 생성으로 감).

4. 생성 범위는 "GUI에 쓰이는 모든 인스턴스" — 전량은 부적합.

  • 사용자 답변 원문:
    1. 는 모든 인스턴스를 넣기는 부적합함. GUI에 쓰이는 모든 인스턴스를 자동 생성한다로 잡아줘도 좋을것으로 보임. 안 그럼 D 파일이 너무 커짐.
  • 즉 기존 "자주 쓰는 ~25개" 도 아니고 Roblox 전체 클래스도 아닌, "GUI에 쓰이는 것 전부" 가 기준. 근거는 파일 크기 — 전량 생성하면 D 파일이 너무 커진다.
  • 반영: base/bind-system-plan.md가 생성 대상을 ~25개(자주 쓰는 Frame/TextButton/UICorner 등, UIInstances 타입 테이블 등록분)로 적어둔 서술과, base/architecture.md 소스 트리의 # 제네릭 생성자 + ~25개 정적 필드(UIInstances) 주석을 이 기준으로 교체. "GUI에 쓰이는"의 정확한 판정 기준(Roblox API 덤프에서 무엇을 GUI로 볼 것인가 — GuiObject 하위 + UIComponent 하위 + LayerCollector류 등)은 생성기 구현 시점에 정할 것으로 남김.

1. D에 없는 클래스는 느슨하게 any — 필요하면 사용자가 직접 채운다.

  • 사용자 답변 원문:

    a. 느슨하게 any 로 하고, 필요하면 이를 직접 구현 가능하게 둡니다. cast 를 하든, 유저의 자유

  • 내용: D가 커버하는 범위(위 4번의 "GUI에 쓰이는 모든 인스턴스") 의 클래스를 New<<X>> "X" {...} 로 직접 쓰면 props 타입은 느슨하게 any. quad는 그 자리에서 타입 안전성을 보장하지 않고, 필요하면 사용자가 직접 좁힌다:: 캐스트를 쓰든 자기 래퍼를 만들든 자유.
  • 왜 이게 자연스러운가: D.Frame 자체가 애초에 New<<Frame>> "Frame" :: (({...}) -> Frame)캐스트 한 줄이다(위 N-9 본문). 즉 생성기 산출물이 특권적인 게 아니라, 사용자가 임의 클래스에 대해 똑같은 한 줄을 직접 쓸 수 있다. "직접 구현 가능하게 둔다"가 새 확장 지점을 만든다는 뜻이 아니라, 이미 있는 패턴을 그대로 쓰면 된다는 뜻.
  • 파급 — BS-1의 서술 수정 필요: base/bind-system-plan.md가 확정해둔 "제네릭 생성자 함수 하나가 알려진 타입과 모르는 타입을 전부 커버" 는 이제 정확하지 않다. 런타임은 여전히 전부 커버하지만 타입은 아니다D 범위 안은 생성된 정확한 타입, 밖은 any. 그 문장을 이 구분이 드러나게 다시 쓸 것.
  • 이걸로 N-9의 열린 항목은 전부 닫힘 — [2026-08-18 기준] 반영도 같은 날 완료.

반영할 곳

  • base/bind-system-plan.md "인스턴스 생성 / 이벤트 네이밍 인체공학" 절 — 커링 계약과 두 호출 형태를 본문에 추가, PA님 시그니처 인용을 이 결론에 맞춰 갱신.
  • base/architecture.md 소스 트리의 D/init.luau 주석(현 DI/init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances)) — N-8과 같은 줄이라 한 번에 같이 고칠 것.
  • ROADMAP.mdD/init.luau 체크박스(현 333행) 및 M5 관련 항목.
  • base/slot-plan.mdD.InstSlot = Slot<<Instance>> — 이것도 같은 "별칭 테이블" 패턴인지(즉 D가 인스턴스 생성자 말고 타입 별칭도 담는지) 확인 필요.

부수 발견 — 오탈자/표기 불일치 (사용자 판정 불필요, 확인만 됨)

여기 있는 것은 설계 오류가 아니라 문서 표기 실수다. 위 절들과 달리 사용자 회신을 기다릴 필요 없이 정정 라운드 때 같이 고치면 된다.

  • architecture.md "Store/State/Source 온톨로지 — 확정됨" 절의 .value (소문자) — A-6에서 ref.Value(대문자)가 맞음으로 확인됨. 같은 문서 소스 트리 주석(Ref.luau 행)은 .Value로 맞게 적혀 있어 한 문서 안에서 두 표기가 섞여 있음. 다른 문서에도 .value가 퍼져 있는지 정정 시 grep 필요.
  • source-state-plan.md ":Compute(fn)의 선택적 두 번째 인자" 절의 fn(value, previous) — S-2에서 최종 시그니처가 fn(self, previous?, ...deps) 로 확인됨. 그 절만 self가 lazy 핸들로 통일되기 전의 구 표기(value)로 남아 있어, 그 절만 읽으면 첫 인자가 raw 값인 줄 오해하게 됨.

진행 로그

1라운드(2026-08-18) 완료. base/ 25개 문서를 의존성 순서로 훑으며 AskUserQuestion으로 확인. 문서당 3~4개 주장씩, 총 22배치.

문서 결함/열린 항목 신규 요구
architecture.md A-3
source-state-plan.md S-1, S-12 N-1
store-plan.md ST-2 N-2
lifecycle-pattern.md (S-1의 진원지)
dispatch-core-plan.md D-1, D-5, D-6, D-7 N-3
relate-plan.md RE-1
modifier-plan.md M-3
ref-plan.md R-1, R-3, RF-4 N-4
brand-plan.md B-1
attribute-plan.md AT-1 N-5
slot-plan.md SL-1, SL-3 N-6
effect-plan.md E-2
event-plan.md EV-1
bind-system-plan.md BS-2
ui-shorthand-plan.md N-7
(전역 이름·표면) N-8 DID, N-9 New 커링
blocker-plan.md / tag-plan.md / tween-plan.md / typing-limits.md / component-composition-plan.md / module-lifecycle-plan.md / onchange-plan.md / purity-and-effects-plan.md / fallback-plan.md / lifecycle-hooks-plan.md 전부 통과

아직 안 본 것 (2라운드 대상 — 새 파일 pre-implementation-qa-round2.md에 쓸 것):

  • slot-plan.md:List 내부(reconcile 구현, keyFn, userdata 생명주기, 구독 시점, Slot-in-Slot 재귀)와 dispatch-core-plan.mdrecompute손으로 트레이싱하는 검증 — 이번 라운드는 "확정된 주장이 맞는가"를 물었지 의사코드를 실행해보진 않았다.
  • reference/ (v1 스냅샷, 프레임워크 비교) — 확정 문서가 아니라 제외했으나, base/가 근거로 인용하는 사실들이라 인용이 정확한지는 미검증.
  • research/ 11개 — 확정 전이라 제외.
  • 루트 ROADMAP.md의 마일스톤 분할이 이번 발견들과 맞는지.