quad/.claude/base/architecture.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

541 lines
51 KiB
Markdown

# quad-v2 전체 아키텍처 (현재 상태 요약)
> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델(0-A)/Attribute 이름
> 소유권(0-Z) 확정·반영 완료 — 아래 소스 트리도 갱신됨.** 이전 ⚠️ 배너가
> 경고하던 "옛 모델로 짜게 됨" 위험은 해소. 디스패치 코어는
> `base/bind-system-plan.md`에서 **`base/dispatch-core-plan.md`로
> 분리**됐고, `Tag`/`Attribute`는 알고리즘까지 quad-base로 재배치됐음
> (엔진 op만 주입). 뒤집힌 옛 모델은
> `archive/dispatch-hintvalue-model-reversed.md`.
**상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트
전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은
`.claude/initreq/raw-userinput.md`(안 옮기고 그대로 둠 — 이 문서들로 나누기 전의
raw chain-of-thought 백업 역할). 현재 v1 구조는 `reference/quad-v1-architecture.md`,
비교 리서치는 `reference/comparison-fusion-vide.md`, `base/lifecycle-pattern.md` 참고.
## 한 줄 요약
quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblox UI 렌더러라는
정체성은 유지하되, 내부를 확장 가능하게 재구현한다. 프로덕트 하나를 빨리 내는 게
목표가 아니라 코드 퀄리티/지속 가능성이 목표 — 빠른 이터레이션보다 정확성이
우선.
## 확정된 결정
1. **DOMless 유지, 하지만 pluggable 하게.** 가상 DOM 없이 즉시 Roblox Instance를
만드는 기존 방식은 유지. 대신 key/value 바인드 디스패치, 렌더 백엔드를
pluggable하게 만들어 확장성 확보(아래 4, 5번).
2. **Class는 이제 "특정 상태의 store를 받는 함수"** — v1의 `Class.Extend()`
OOP 스타일(메서드 체이닝, Getter/Setter) 대신 함수형이 기본. 체이닝은 store
바인드처럼 정말 체인이 자연스러운 곳에만(`:` 문법) 남김. 타입 작성 난이도가
OOP 스타일에서 너무 커진다는 게 이유.
3. **복사(clone) 구현 지양, 팩토리 함수로 대체.** v1의 metatable 체이닝(1-필드
테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기.
store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) —
부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사.
4. **PA님 스타일 특수 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`,
2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와 이름 충돌 방지로
리네임) 같은 특수 바인드 키, store 컴퓨티드 바인드도 가능해야 함
(`retract`, 구 cleanup, `base/lifecycle-pattern.md` 참고). **[정정,
2026-08-08 세 번째 세션]** `Tag`는 더 이상 `[Tag ""] = true` 해시 파트
특수 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨,
`base/tag-plan.md` 참고(`archive/tag-hash-key-model-reversed.md`에 구
모델 보존). **[2026-08-11 아홉 번째 세션]** `Attribute(...)`도 여러
Store를 한 번에 attribute로 묶는 array-part 값 객체로 신설(`Tag`와
동형), `base/attribute-plan.md` 참고.
5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/
`Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유.
네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리
복잡도가 너무 올라간다고 판단 — 당장은 `CollectionService` 그대로 사용. **대신
Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 아니라 "외부에서 이미
관리되고 있는 instance를 quad로 점진적으로 마이그레이션/래핑하기 위해
직접 참조를 얻는 것"(`base/ref-plan.md`의 Ref 절 참고) — 둘을
혼동하지 말 것.
- **2026-08-04 6차: 네임스페이싱 충돌을 심각하게 안 보는 이유 확정.**
충돌을 피해야 하는 단위는 보통 컴포넌트 단위로 나오고, 그 경우는 Ref로
직접 참조를 얻으면 되므로 태그 자체의 전역 네임스페이스가 굳이 필요
없음. 태그는 원래 주로 스타일링(스타일시트 셀렉터) 용도인데, 스타일시트는
적용 위치가 트리 상위에 존재해야 하고 사용자가 직접 그 위치에 심어야
하는 등 스크립팅으로 구성하기 어려워 quad 같은 UI 라이브러리에서는 잘
안 쓰는 접근 — 그래서 스타일시트 대신 modifier kit을 제공하는 것(아래
7번 항목의 modifier 우선순위 규칙 참고).
6. **함수지향 디폴트, `:` 체이닝은 예외적으로만.** 스토어 바인드처럼 체인이 정말
편한 경우만 `:` 사용, 나머지는 외부 함수가 인스턴스를 인자로 받는 모양.
7. **Style(Default) 시스템 폐기.** 대신 modifier(spread되는 값, `...`으로
풀리는 것)를 지향 — 함수형 modifier가 store 바인드를 받을 수도 있음.
(초기 근거였던 "Roblox 자체 스타일시트를 쓰는 게 낫다"는 6차 라운드에서
갱신됨 — 위 5번 항목의 6차 추가분 참고: 스타일시트는 적용 위치 제약과
스크립팅 난이도 때문에 오히려 안 쓰기로 하고 modifier kit으로 대체함.)
- **2026-08-04 세션: modifier 메커니즘 전체 확정, 상세는
`base/modifier-plan.md`로 분리.**[정정: `research/`에서 `base/`
승격됨] 요지만: 런타임 pluggable 핸들러가
아니라 디스패치 이전에 정적으로 flatten되는 값(핸들러 레지스트리 미참여,
CSS cascade 문제 회피). Merge 우선순위는 "배열 순서상 나중 modifier가
우선"과 "인라인 키는 modifier보다 무조건 우선"이라는 독립된 두 규칙(Lua
테이블 리터럴이 배열/해시 파트 간 소스 순서를 보존 안 하므로 하나로 합칠
수 없음). 값은 immutable — 체이닝 메소드(`:FontSize(...)`류)는 항상
`table.clone` 후 반환, 원본 mutate 금지(형제 서브트리 오염/재렌더 드리프트
방지, 비용은 무시 가능한 수준으로 확인됨).
8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""`
같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로
구현(`base/dispatch-core-plan.md`).
9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher,
실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`
연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더
라이브러리 범위 밖으로 판단. 스토리북 구현체(https://ui-labs.luau.page/docs/getstarted)가
이미 존재하므로 대체.
10. **lang 모듈 미구현, 분리.** 로케일/문자열 처리는 리액터블 라이브러리와
별개 라이브러리로 존재해야 함(v1 `lang.lua`의 전역 스코프 버그 등은
`reference/quad-v1-architecture.md` 참고 — 애초에 반면교사).
11. **커스텀 Signal 클래스 미구현.** 콜백 정도로 이벤트 바인드 뒤에 함수를
넣는 것만으로 충분하다고 판단. (이전 초안엔 "rbvm의 Signal이 재사용
가능해 보여 상충한다"는 메모가 있었으나 2026-08-04 검증 라운드에서 최종
확정으로 재확인 — 더 이상 열린 질문 아님.)
12. **멀티 타겟(pluggable 백엔드) — 특히 GTK 지원까지 염두.** Roblox 전용 렌더
기술(react.lua, Fusion)은 결국 외부 개발자 유인이 없어 발전이 더딜 거라는
문제의식. 결과적으로 `quad-base`/`quad-roblox`로 나뉨(5차 라운드에서 확정된
정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도
프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은
`quad-roblox`가 담당.
13. **모듈은 기본 싱글톤, `New()`는 추가 인스턴스가 필요할 때만.**
**[재정정, 2026-08-19 — 이전 정정(`New()`→`Quad()` 전면 치환)이
부정확했음, `/code-review high` 이후 사용자가 직접 바로잡음]** `New`
`Quad`가 이름이 다투는 게 아니다 — 이전 정정이 "미래 API 이름은
`Quad()`"라고 단정하며 `New()`를 전부 `Quad()`로 바꿨던 게 틀렸다.
사용자 원문: *"내가 말한건 Quad() 만 제공하면 항상 모든 Quad 요구처에서
각각의 Quad() 를 수행해서 새로운 모듈 스코프가 나온다는게 문제였어.
그래서 Quad 는 기본적으로 생성된걸 리턴하긴 하는데, Quad.New() 도
제공하는거 어떻냐는거였음, 즉 New() 는 존재하고 기본 리턴은 New() 해서
주는건데, 리턴 안에 New 필드가 있고 그 함수를 쓰면 하나의 새로운 Quad
네임스페이스가 만들어지는식. 안 그러면 모든 컴포넌트를 나눈 모델
등에서 Quad() 를 해서 새로운 모듈 스코프가 나와서, 래핑된 Quad() 를
수행하는 부분을 따로 작성해두어야한다던가 해질꺼임."*
- **`Quad`(`require`의 반환값, 호출 아님)는 이미 만들어진 기본
인스턴스 자체다** — 모듈 로드 시점에 그 모듈 자신이 내부적으로
`New()`를 한 번 불러 만든 결과를 그대로 top-level 반환값으로 내보냄.
그래서 평범한 소비자는 아무것도 호출할 필요 없이 `require(quad)`
돌려준 걸 바로 `Quad.Dispatch`처럼 씀(지금 싱글톤 단계와 동일한
접근 방식 — 달라지는 게 없음).
- **`Quad.New()`가 명시적 opt-in으로 설계됨**(**구현은 아직 미착수** —
지금은 싱글톤 단계라 `New` 필드 자체가 아직 안 노출됨, 바로 아래
"M0 스캐폴딩에 주는 함의" 참고) — 다중 인스턴스화가 실제로 붙는
시점엔 반환된 기본 인스턴스 **안에** `New` 필드(함수)가 생기고,
이걸 실제로 호출하면 완전히 **별도의 새 Quad 네임스페이스**
(자기만의 Dispatch 레지스트리 등)가 나오는 모양으로 짠다. Roblox +
비-Roblox 프로바이더를 진짜로 동시에 써야 하는 드문 경우에만 이걸
쓰게 될 것.
- **왜 "그냥 `Quad()`를 부르면 새 인스턴스"로 안 하는가** — 컴포넌트를
여러 파일/모듈로 쪼갠 실제 앱에서는 각 파일이 독립적으로 "Quad
인스턴스 하나 줘"를 요청하게 되는데, 그 요청 방법 자체가 "새로
만들기"라면 **파일마다 서로 다른, 서로 공유 안 되는 인스턴스**를
만들게 되는 사고가 난다(Dispatch 레지스트리가 안 공유되는 등) —
그러면 앱 전체가 하나의 공유 부트스트랩을 따로 작성해서 그걸 통해서만
Quad를 얻도록 강제해야 함. `require`가 이미 인스턴스화된 기본값을
주면 이 위험 자체가 원천적으로 없음 — 새 인스턴스가 필요한 그 드문
경우만 명시적으로 `.New()`를 부르면 되므로 실수로 스코프가 갈라질
일이 없다.
한 Lua 스레드에서 Roblox/비-Roblox 프로바이더를 동시에 쓸 일이 거의
없을 거라 판단해 지금은 `New()` 없이 싱글톤만 두고, 필요해지면 그때
`New()`를 노출한다. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션,
새 설계 아니라 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸
`Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는
"팩토리가 `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새
`BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. 상세 근거는
`base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절.
**[정정, 2026-08-18 구현 전 QA]** 옛 서술은 그렇게만 하면 지금
module-level state로 사는 모든 것(`_initializedBy` 마커, Dispatch
레지스트리 등)이 **"자동으로" 테이블별 스코핑된다**고 했는데, 사용자
판정은 다르다 — *"모듈이 하나의 인스턴스(dispatch 레지스트리 하나,
canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 …
require 를 감싸지는 않고 단순히 InitModule(module) 등을 받도록 각
코드들을 약간 고쳐서 이것을 해결함."* 즉 **코드 변경 없이 자동으로**
되는 게 아니라, module-level state를 참조하는 코드들이 모듈 인스턴스를
인자로 받도록 **손을 대야** 한다 — `New()`가 실제로 호출되면(위 opt-in
경로) 그 순간 만들어지는 새 `BaseModule` 테이블에 대해 이 손질이 필요.
**지금은 `New()` 자체가 노출 안 된 싱글톤 단계라 `Quad.Dispatch`
바로 접근**한다. `New()` 자신이 내부적으로 어떤 형태로 조립되는지(v1
스타일 `InitXxx(module)` 팩토리 체이닝, 타입 재익스포트)는
`module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고.
- **M0 스캐폴딩에 주는 함의 — [2026-08-19] 정해짐, 실제로는 M0가 아니라
`ROADMAP.md` M1(실제 스캐폴딩)에 적용됨.** 레지스트리를 module-level
upvalue로 직접 잡아두면 나중에 다중 인스턴스화할 때 전면 수정이
된다는 우려가 있었는데, 바로 위에서 가리키는 InitXxx 패턴(각
`InitXxx(module)``module`을 upvalue가 아니라 **파라미터로 받아**
뮤테이션)이 처음부터 그 형태다 — 나중에 바꿀 일 자체가 없게 M1
스캐폴딩부터 이 모양으로 짠다. M0는 독립 스파이크 파일로 개별
가설만 검증하는 단계라 이 구조 자체를 아직 안 씀(`ROADMAP.md`의
"M0 — 스켈레톤 + 기술검증" 절 참고).
14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동
init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고,
`InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를
주는 방식.
## 구현 착수: 소스 트리 구조 확정 (2026-08-04, 5차 라운드)
**상태**: 소스 트리 레이아웃과 `quad-base`/`quad-roblox` 패키지 경계 확정 —
아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제
폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션.
**패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지
매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의
타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립
패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정
(2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다
툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다
독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을
pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트
`workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }`
의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제
자체도 이 전환으로 같이 해소됨 — **[2026-08-19 같은 날 후속 세션]**
pesde/rojo 바이너리를 이 샌드박스에 직접 설치해 `pesde install`/`rojo
build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup-plan.md`가
소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게
따라감).
실제 구현: 루트 `pesde.toml`(`private = true`,
`workspace_members = ["quad-base", "quad-roblox", "quad-types",
"type-version-check"]`) +
`quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml`
(각각 `[target] environment = "roblox"`) + `type-version-check/pesde.toml`
(`[target] environment = "luau"`, 아래 참고), 툴체인은 `mise.toml`로 핀
(`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는
판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). **[2026-08-19 같은
날 후속]** `type-version-check`(`[target] environment = "luau"` — quad에
종속되지 않은 범용 패키지라 다른 멤버와 달리 roblox가 아님)는 워크스페이스
네 번째 멤버로 추가됐고, `quad-types`가 이것에 workspace 의존(자기 target이
roblox라 명시적으로 `target = "luau"` 지정 필요) — `base/quad-types-plan.md`
"`type-version-check`" 절이 소스. 사용자가 나중에 독립 저장소로 분리할
예정(`HUMAN_TODO.md` 9번).
**[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox``quad-base`
아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace
의존** — `quad-base``QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임
주입**받으므로 pesde 의존 선언이 필요 없고, 오히려 무거운 quad-base
전체를 dev-dependency로 두면 게시 후 소비자 환경에서 그 타입 전용
require가 크래시하는 문제가 있어 별도 패키지로 뽑음 —
`base/quad-types-plan.md`가 소스.
pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그
파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`
이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스
루트 자신의 통합 개발/테스트용, 게시되는 패키지 안이 아니므로).
`.luaurc``aliases`는 여전히 **런타임 require에서 엔진이 지원 안 함**
(2026-08-19 이 세션에 커스텀 alias로 직접 재확인 — `require("@alias/x")`
"could not jump to alias"로 실패) — 그래서 alias는 편집기 자동완성/타입체크용으로만
곁들이고, 실제 크로스패키지 require는 상대경로로 쓴다(단, `init.luau`
안에서는 평범한 상대경로가 아니라 **예약 alias `@self`**를 써야 함 —
`init.luau`는 require-by-string 상 "자기 폴더 자체"를 가리키므로 `./`
아니라 `@self/`로 그 폴더 안의 형제 파일에 접근한다, 2026-08-19 사용자 지적
+ Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인 — 실제로 이
세션의 `quad-base/src/init.luau`가 이 착오로 크로스파일 require가 전부
깨졌다가 `@self`로 고치고 나서야 정상화됨). 나중에 실제로 레포를 쪼갤 때는
Rojo `project.json`의 트리 매핑 규칙만 유지하면 되고, require는 그 시점에
한 번 기계적으로 바꾸는 정도로 감수.
**패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도
재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라
**pluggable 디스패치 엔진 자체도 "인터페이스"로 base가 소유**한다(엔진마다
큰 구현을 중복하지 않기 위함 — rbvm이 relation을 하나로 통합하려 했던 것과
같은 동기). `quad-roblox`는 그 인터페이스의 **실제 구현체**만 제공.
**[2026-08-13 열네 번째 세션 확장] 이 원칙은 "값 타입"이 아니라 "부기가
엔진 지식을 요구하는가"로 가른다** — `Tag`의 참조 카운트와 `Attribute`
이름 claim/그룹 위임은 엔진과 무관한 순수 부기라 **핸들러째로 quad-base**,
백엔드는 `addTag`/`removeTag`/`setAttribute` 세 op만 주입한다(웹에도
`className`/`data-*`라는 대응물이 있어서, 그러지 않으면 같은 알고리즘이
백엔드마다 복제됨). 반대로 Property/Event/OnChange는 Reflection·시그널
자체가 로직이라 그대로 백엔드 소속 — 상세 기준과 op 시그니처는
`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진
op" 절.
```
quad/
├── .luaurc # 편집기 경험용 alias(런타임 비의존, `base/project-setup-plan.md` 참고)
├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀
├── pesde.toml # 워크스페이스 루트(private, workspace_members)
├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트
├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad<T,Pattern> 버전체크(`base/quad-types-plan.md`)
│ ├── pesde.toml # type_version_check workspace 의존(target="luau")
│ └── src/init.luau
├── type-version-check/ # quad에 종속되지 않은 범용 버전 패턴 매칭(`base/quad-types-plan.md` "`type-version-check`" 절) — 사용자가 나중에 독립 저장소로 분리 예정(HUMAN_TODO 9번)
│ ├── pesde.toml # [target] environment = "luau"
│ └── src/init.luau # matchesPattern(런타임) + export type function CheckVersion
├── quad-base/
│ ├── pesde.toml
│ └── src/
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
│ ├── Observer.luau # ⭐ [2026-08-25 신설, 7라운드 `H-99`] `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` 전역 레지스트리의 소유 모듈** — `EpochMap.luau`와 같은 이유로 `State.luau`에 묻지 않는다(`Effect`/`Gate`/leaf 핸들러가 전부 이 레지스트리를 본다)
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source<T>` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<<T>>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReserved`(`base/store-plan.md`)
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate`의 `GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`)
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
│ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨** — `InitDebug(module)`, `module.debug = false`(`base/project-setup-plan.md`)
│ ├── Dispatch/
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive`의 `None` 스킵 분기 폐기)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**)
│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈
│ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`)
│ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
│ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정)
│ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음
│ └── init.luau
└── quad-roblox/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절). **[2026-08-24 6라운드 신설] `isInst(value): boolean`**(`H-40` — 요소 타입 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, quad-roblox는 `typeof(value) == "Instance"`)**와 `onDestroying(inst, fn): Connection`**(`H-11` — `Effect`의 leaf 사망 cleanup을 발화시키는 훅, `bindLifetime`이 `isEffect`일 때 부른다, quad-roblox는 `inst.Destroying:Connect(fn)`). **⚠️ 이 둘은 `native*`의 "미주입이면 조합 폴백" 규칙의 예외다 — 조작이 아니라 판정/훅이라 조합으로 만들 수 없어 미주입이면 명확한 에러**(`addTag`/`setAttribute`와 같은 취급)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
│ ├── Event.luau # ReflectionService 기반 자동 판별
│ ├── OnChange.luau # `OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`)
├── D/
│ └── init.luau # **전량 코드 생성 산출물** — 제네릭 생성자 `New`(커링: `New "Frame" {...}`) + 클래스별 정적 별칭 필드(`D.Frame = New<<Frame>> "Frame" :: (({...}) -> Frame)`). 생성 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입/`State<T>`/`None`까지 타입으로 찍음(**[2026-08-18 확정]** `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)
└── init.luau
```
**남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된
`slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서.
existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`
이전**됐고(`archive/existing-instance-bind-rejected.md`), 애초에 이 구조
확정을 막던 항목도 아니었음(`purity-and-effects-plan.md`/`tween-plan.md`는
이미 `base/`로 승격 완료).
## error 계약 — `level` 이분과 메시지 언어 (2026-08-25 확정, 7라운드 `H-104`/`H-105`)
quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전에 정해두면
한 번에 맞고 나중에 바꾸면 전수를 다시 훑어야 한다.**
- **⭐ `level` 이분** — *즉시 error*가 quad의 주 방어선인데, 지금까지 **한
군데도 `level` 인자가 없어** 전부 quad 내부 줄을 가리켰다. 사용자는 자기
코드 어디서 틀렸는지를 볼 수 없었다.
| 종류 | `level` | 예 |
|---|---|---|
| **사용자 입력 검증** | **2**(호출부를 가리킴) | deps 타입/`nil`, 이중 바인드, 예약 키, 요소 타입 |
| **내부 불변식 위반** | **1**(그 자리를 가리킴) | 부기가 깨짐(`lengthList[i] == nil` 등) |
```lua
error("Effect: dep #3 is not a State/Source/Ref", 2)
error("Dispatch.getOffsetAt: lengthList[3] is nil — bookkeeping is broken", 1)
```
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
가리켜야 리포트가 쓸모 있다.
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은
한국어"*는 **이 프로젝트의 대화·문서** 규칙이고 **quad 라이브러리
사용자**에게까지 적용된다고 정해진 적이 없다 — 여기서 정한다. 이미
영어인 6곳(동적 경로 가드 4형제, 모듈 초기화, attribute 이름 충돌)이
핵심 경로라는 것도 같은 방향이다. `base/`의 예시 메시지도 영어로 쓴다.
## 예외 안전성 계약 — 감싸지 않는다 (2026-08-25 확정, 7라운드 🅒)
**예외가 나면 그 파동/그 자리의 부기는 복구되지 않는다.** `pcall`
감싸는 자리는 하나도 두지 않는다.
- 2026-08-21에 `base/slot-plan.md``materializeSlotTree`에 대해 내린
판단(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직
실제로 밟은 적 없는 경로다 … 실제로 물리면 그때 넣는다"*)을 **전 자리로
확장**한 것이다. `.claude/conventions.md`의 *"드문 오용이나 가상의 미래
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다"* 원칙 그대로.
- **적용 자리 넷**(7라운드가 찾은 것): State 전파 루프(구독자당 hot path),
게이트 flush + `Blocker:Off()` 순회, `Dispatch.drive`의 배치 게이팅,
Dispatch 체인 슬롯. 각각의 실패 모드는 그 문서들이 적는다.
- **같은 원칙이 `EffectHandle:Rerun()`에도 적용된다** — `fn`이 던지면
`_running`이 참으로 남는 것까지 포함해 복구하지 않는다(사용자: *"에러가
난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*).
- **yield 금지와 같은 톤으로 사용자 문서에 명시할 것.**
## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설)
지금까지 각 문서가 예시 코드를 쓰며 암묵적으로 따라온 패턴을 사용자가
명시적 규칙으로 정리해달라고 요청 — 실제로 지금까지 나온 모든 이름이
예외 없이 따르는 규칙이라 새로 뭘 바꿀 필요는 없고, 그냥 문서화만:
- **대문자 시작(PascalCase)** — 다음 세 가지, 공통점은 전부 **어떤
프리미티브 타입 자신의 공개 어휘**라는 것:
1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/
`Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/
`Effect(fn, ...deps)`/`PreRef(default)`/`PostRef(default)`.
2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/
`:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`,
`ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/
`:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/
`:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝).
3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 —
`Modifier.Overridden(mod1, mod2, ...)`, `Attribute.Merged(...)`/
`Attribute.Overridden(...)`. **그 프리미티브 타입 고유의 공개 연산**
이라는 점에서 1/2과 같은 부류 — `Modifier()`/`Attribute()` 생성자와
같은 이유로 대문자.
**[정정, 2026-08-18 구현 전 QA]** 옛 서술은 이 분류를 만든 근거로
*"콜론 메서드는 아니지만 (여러 Modifier를 동등한 인자로 받아야 해서
self 하나로 안 됨)"* 을 들었는데 **그 근거는 성립하지 않는다**
`Overridden`은 **콜론 체이닝(`a:Overridden(b)`)으로도 제공**된다
(사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서
직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함"*). 분류 자체는
"닷 접근으로도 부를 수 있는 정적 결합 함수"라는 표면 차이로 유지하되,
2번(콜론 메서드)과 배타적이지 않다는 점에 유의.
4. **`D`(Declarative) 네임스페이스와 그 필드**(`D.Frame`/`D.InstSlot`/
`D.FrameModifier`) 및 생성자 `New`**[2026-08-18 신설]** 프리미티브
타입은 아니지만 사용자가 직접 쓰는 선언형 표면이라 대문자.
**표기 규약: 문서에서 `D`가 처음 나오는 자리에서는 항상
`D`(Declarative)로 풀어쓸 것** — 한 글자 식별자라 grep이 어렵고
이름만으로 뜻이 안 드러난다는 게 2026-08-08부터 개명을 미뤄온 유일한
사유였고, 이 표기 규약이 그 보완책으로 같이 확정됐다
(`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절).
- **소문자 시작(camelCase)** — 특정 프리미티브 타입 하나에 안 묶이고 여러
타입을 넘나드는 범용 유틸(`isState`/`isSource`/`isRef`/`isPreRef`/
`isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/
`bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가
아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/
`getHandler`/`addHandler`/`drive`, `Brand()``:register`/`:is`) — 이 셋은 "타입
고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브
자체가 아닌 것(Dispatch는 `Type(args)` 생성자가 없는 내부 엔진이고,
`Brand`는 생성자가 있지만 사용자 표면이 아닌 base 내부 유틸 — 사용자에게
노출되는 건 `isX` wrapper들이다)의
구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/
`priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract``process`
반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다
구현체가 채워 넣는 구조체 필드.
- **경계 판단 기준**: 새 이름을 지을 때 "이게 특정 프리미티브 타입 하나의
전용 소유물인가?"로 물으면 됨 — 그렇다면 대문자(생성자/메서드/그
타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진
소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는
`base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/
`base/source-state-plan.md`의 "일반 원칙 — 독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터" 절(세 번째 카테고리 문단) 참고.
## 코드 스타일 — Luau 문법 관례: `if-then-else`/`const` (2026-08-12 세션 신설)
**`if cond then a else b` 표현식은 Luau 공식 문법이다 — 환각/오타로
간주해 `and`/`or`로 "고치지" 말 것.** 2021년 10월 Luau에 정식 도입된
표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>)
`cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이
falsy(`nil`/`false`)여도 정확하게 동작함(`dispatch-core-plan.md`의
`Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`
쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그
경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`
항상 더 낫다는 게 재확인됨 — `and`/`or`는 진짜 short-circuit이라 각
단계 truthiness를 테스트하는 분기가 최대 2번 들어가는데(`cond` 테스트 +
`and`의 결과 테스트), `if-then-else``cond` 하나만 테스트하고 단일
분기로 끝남 — 방어적이면서 바이트코드상으로도 더 적은 분기. 단순
2항 fallback(`x or y`, "and" 없이 값 하나를 기본값으로 대체하는
`props.Modifier or None`류)은 애초에 이 문제가 없어 그대로 유지 —
금지 대상은 어디까지나 `A and B or C` 3항 조합.
**`const` 바인딩도 Luau 공식 문법**(<https://luau.org/syntax/#const-bindings>)
이지만 **[2026-08-12 기준] 지금은 채택하지 않음** — 그 시점에 타입
추출/narrowing 등 주변 툴링이 `const`를 폭넓게 지원하지 못해서, 전면
도입하면 나중에 그 간극을 메꾸는 비용이 더 클 수 있다는 판단.
**툴링 성숙도에 매인 판단이라 시간이 지나면 거짓이 될 수 있는데, 그
시점은 에이전트가 확인할 수 없음** — 사용자 설명(2026-08-16): pesde의 타입
추출처럼 `d.ts` 식으로 types를 emit하는 툴링이 아직 미성숙해 `const`
제공하지 못하고, **언제 다시 가능해지는지도 명확하지 않다**. 그래서 이건
사용자가 확인된 정보로 알려주기로 정해졌고, 추적은 루트 `HUMAN_TODO.md`
8번이 소스다. **알려주기 전까지 에이전트는 스스로 판단하지 말 것**
위 "일단 `local`로" 원칙을 그대로 따른다. **원칙**: 새로 짜는 코드는 일단
`local`로 — 나중 리팩터 시점에 특정 바인딩을 `const`로 바꾸는 비용이
싸 보이면 그때 바꿔도 되고, 비싸 보이면 굳이 지금 손대지 않아도 됨.
지금 `const`가 없다고 "이 프로젝트가 구식 Luau를 쓴다"고 오해하지
말 것 — 툴링 성숙도 문제일 뿐 문법 자체를 모르거나 기각한 게 아님.
## 테스트 전략: quad-base용 최소 mock (2026-08-04)
**결정**: quad-base 테스트는 Vide 선례(`initreq/vide/test/mock.luau`, 약
300줄)를 따라 최소한의 mock으로 감 — parent/children 트리 + 타입 검증 없는
property bag + property별 변경 시그널 정도만 흉내내고, `IsA()`/클래스별
프로퍼티 스키마/`WaitForChild`/`DataModel` 같은 건 안 만듦. 순수 `luau` CLI로
Studio/엔진 없이 테스트(Vide가 실제로 이렇게 CI에 물려놓음) — Fusion처럼
Studio 안에서만 도는 방식은 채택 안 함. 근거: quad-base 코어(Store/State/
Source/Modifier/Slot, 디스패치 엔진)는 이미 `inst``any`로 취급하고
Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`dispatch-core-plan.md`
"확정된 디스패치 모델" 절의 "`inst`가 항상 살아있는 엔진 객체일 필요는 없음"), mock이 실제 Roblox 충실도를
가질 이유가 없음.
**스코프는 "정적 디버깅"으로 한정****사용자 확정**: mock으로 확인하려는
건 한 시점의 렌더 결과(정적 스냅샷)지, 시간에 따라 변하는 동적 동작(Tween
애니메이션, 타이밍 등)이 아님. 그래서 지금 단계 mock엔 시간 기반 핸들러를
흉내낼 계획이 없음.
**"quad-roblox로 작성한 컴포넌트가 mock에서도 그대로 돌아가야 한다"는 요구는
없음** — **사용자 확정**("이건 꼭 지켜질 필요까지 있진 않아, 단순하게 가도
됨"). mock은 quad-roblox의 실제 핸들러(ReflectionService 기반 이벤트 판별,
CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체 로직(디스패치
엔진, Store/State/Source, Modifier, Slot)만 검증하면 충분 — quad-roblox
개발과는 무관해도 됨.
**백로그**: 나중에 범용 렌더 결과 디버깅 도구로 키우고 싶어지면(정적
스냅샷을 넘어 Tween mock 같은 동적 동작까지 포함) 그때 스코프를 넓히는
걸로 — 지금은 quad-base 테스트 전용 최소 mock까지만(`.claude/todos.md` 백로그
참고).
## Store/State/Source 온톨로지 — 확정됨 (요약)
Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`
접근하면 **그 키의 Source 객체를 그대로 반환한다** — 별도 wrapper도,
프록시도, **타입 함수도** 없다(`Source`가 구조적으로 `State`를 만족하도록
재구성되며 wrapper 계층 자체가 불필요해짐 — `base/source-state-plan.md`
"Source가 State를 만족함" 절).
**⭐ [2026-08-25] 두 가지가 확정됐다** — (1) 생성은 **명시적 초기화**다:
타입 인자에 `Source<T>`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접
넣는다. 옛 lazy `__index`(없는 키를 그 자리에서 만들어 저장)는 폐기됐고,
그래서 `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립한다.
(2) 레코드 필드 타이핑에 **타입 함수를 안 쓴다**
(`WrapStore`/`ProcessStoreType` 폐기). 쓰기는 `store.key = v`가 아니라
**`store.key:Set(v)`**이고, 동적 키는 `store:Of<<T>>(name)` 하나다.
상세는 `base/store-plan.md`가 소스 — 같은 날 "`store.key`를 값으로"
재설계를 넣었다가 철회한 경위는
`archive/store-value-field-redesign-withdrawn.md`. 전파는
push-invalidate(신호만)/
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는
**노드별 캐시**임을 명시 — `invalid`는 "내 캐시가 낡았다" 표시일 뿐이고
**emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md`
"이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
`archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]**
전파를 접는 판정은 이제 `invalid`가 아니라 **`Epoch` 리비전 비교**가 한다 —
같은 `Epoch`의 같은 리비전이 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서
값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이
사라진다. 그 부기는 재사용 가능한 **`EpochMap`**으로 떼어져 있어 State가 아닌
소비자(`Effect`)도 같은 판정을 쓴다. 규칙 전량은 `base/state-epoch-plan.md`,
명시적 게이트는 `base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만
다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀.
`store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서
정식 확정됨 — **더 이상 열린 질문 아님**, [2026-08-04 기준] 남은 건 정확한
API 이름뿐.
상세는 `base/source-state-plan.md`의 "Source가 State를 만족함"/"핵심
온톨로지" 절 참고.
## 아직 미정 (research/로 분리됨)
**[2026-08-14 세션] 이 절에 유일하게 남아있던 항목(이미 생성된 인스턴스에
대한 바인드)이 기각되어 `archive/existing-instance-bind-rejected.md`
이전됨** — 지금 `research/`에 남은 것은 전부 "착수 시점 미정"이지
아키텍처를 미정으로 남기는 항목이 아님. 전체 색인은 `.claude/README.md`.
바인드 디스패치/Slot/모듈
라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는
위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨
(`bind-system-plan.md`/`dispatch-core-plan.md`/`source-state-plan.md`/
`store-plan.md`/`module-lifecycle-plan.md`/
`slot-plan.md`/`modifier-plan.md`/`component-composition-plan.md`).