`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
18 KiB
quad-types — 구현 없는 Quad 타입 계약 + 컴파일 타임 버전 체크
상태: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스
세 번째 멤버 quad-types의 존재 이유, AddPlugin/CheckedQuad의 정확한
사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. [같은 날 후속]
버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지
type-version-check(워크스페이스 네 번째 멤버)로 분리됐고, CheckedQuad<T>는
CheckedQuad<T, Pattern>으로 확장돼 그 위에 얹힌다 — 아래 "type-version-check"
절.
왜 필요한가 — dev-dependency로는 못 푸는 문제
quad-roblox는 QuadRoblox(Quad): QuadRoblox처럼 quad-base 인스턴스를
런타임에 함수 인자로 주입받는다(base/module-lifecycle-plan.md
"Bind는 누가, 어떻게 구현하는가" 절이 확정해둔 팩토리 패턴 — quad-roblox
자신은 quad-base를 require할 필요가 없어 보인다).
그런데 타입 주석 하나 때문에 얘기가 달라진다. QuadRoblox의 시그니처가
Quad 타입을 참조하려면 그 타입이 정의된 모듈을 require해야 하고,
이 require는 "타입만 쓰려는 목적이어도 런타임에 실제로 실행된다"
(실측 확인, 2026-08-19 — require가 반환하는 모듈에서 export 타입만
꺼내 써도 그 require 문 자체는 평범한 런타임 호출이라, 대상이 없으면
그 자리에서 크래시함). 그래서:
quad_base를 일반 의존성으로 두면: 소비자가quad-roblox를 설치할 때마다 무거운 quad-base 전체가 통째로 딸려온다(quad-base를 이미 따로 설치해서QuadRoblox(Quad)에 넘기는 상황이면 완전히 중복).quad_base를 dev-dependency로 두면: 로컬 개발 중엔 문제없지만,quad-roblox가 게시된 뒤 소비자 환경엔 dev-dependency가 전파되지 않아 그 타입-전용 require가 못 찾고 그 자리에서 런타임 크래시난다.
해법: Quad의 타입 계약만 담은, 런타임 구현이 사실상 없는 세 번째
워크스페이스 패키지 quad-types를 두고, quad-roblox는 이것만 일반
의존성으로 둔다 — 항상 안전하게 실 의존성으로 넣을 수 있을 만큼
작고, quad-base 전체를 안 끌고 온다.
quad-base 안에 폴더로 두면 안 되는가 — 안 됨
pesde의 워크스페이스 의존성은 패키지 단위로만 걸린다
({ workspace = "scope/name" }) — 서브폴더 단위 의존 문법이 없다.
quad-base/types/처럼 폴더로 만들어도 quad-roblox가 그걸 가져오려면
결국 quad_base 패키지 전체를 의존성으로 선언해야 하고, 실제로 링크되는
것도 quad-base 전체 소스 트리다(어느 파일을 실제로 require하는지와
무관). 그래서 반드시 별도 pesde 패키지(workspace_members의 새
멤버)여야 "가벼운 타입만" 효과가 실제로 생긴다.
구조
quad-types/
├── pesde.toml # name = "qwreey/quad_types", type_version_check workspace 의존
└── src/init.luau # export type Quad, export type CheckedQuad<T, Pattern>
type-version-check/ # 워크스페이스 네 번째 멤버, quad에 종속되지 않음
├── pesde.toml # name = "qwreey/type_version_check", environment = "luau"
└── src/init.luau # matchesPattern(런타임), export type function CheckVersion
quad-base는quad_types에 workspace 의존 — 자기Quad타입을 따로 선언하지 않고type Quad = QuadTypes.Quad로 그대로 가져다 씀 (한 곳에만 진실이 있게, 구현이 계약과 어긋나면 구조적 타입에러로 자연히 드러남). 실제로quad-base/src/init.luau에 반영됨.quad-roblox도quad_types에 workspace 의존(quad-base 아님).- [참고, 2026-08-19 사용자 판단] 모든 백엔드/플러그인 패키지가 이
패턴을 따를 필요는 없다 — 예: 가상의
quad-spring/quad-spring-roblox쌍은 타입 분리 없이quad-spring-roblox가quad-spring을 평범하게 일반 의존성으로 둬도 된다("주입만 하면 Spring이 같이 따라오도록").quad-types분리는 quad-base처럼 사실상 모든 패키지가 의존하는 핵심 계약일 때만 값어치가 있다.
Quad 타입 — 확정된 표면
export type Quad = {
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
debug: boolean,
New: () -> Quad,
RunInit: (self: Quad, initFn: (Quad) -> any) -> (),
AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
}
⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 H-25 — 실측] 이 레코드는 닫혀
있고, 마일스톤마다 서브시스템 필드를 여기 추가해야 한다.
실제 커밋된 quad-base/src/init.luau의 New(): Quad가 이 별칭을 그대로 반환
타입으로 쓰는데, RunInit은 (self: Quad, initFn: (Quad) -> any) -> ()로
반환값이 없어 타입을 못 넓히고, 타입을 넓히는 유일한 경로인
AddPlugin<Self, P>는 이 문서 자신이 외부 플러그인용이라고 선을 긋는다.
그런데 base/architecture.md의 확정된 결정 13번은 *"require(quad)가 돌려준
걸 바로 Quad.Dispatch처럼 씀"*을 표준 사용법으로 확정해뒀다.
실측(InitDebug와 완전히 같은 모양의 InitDispatch로 재현):
local quad = New()
quad.Dispatch.addHandler(function() end)
luau-analyze → TypeError: Key 'Dispatch' not found in table 'Quad'.
즉 M3가 Dispatch.luau를 만들고 module:RunInit(InitDispatch) 패턴(이 문서가
이미 예시로 보여준 그 패턴)으로 붙이면 런타임엔 붙지만 타입엔 영원히 안
보인다. ROADMAP.md M5의 quad-roblox 주입 경로도 같은 벽에 부딪힌다 —
quad-roblox는 quad-types의 좁은 Quad만 본다.
확정(사용자, 2026-08-24): quad-types의 Quad를 마일스톤마다 갱신한다.
- 규칙이 쓰인 계기는
Dispatch이고, M3가Dispatch: Dispatch필드와 그 타입 재수출을 여기 추가한다 —ROADMAP.mdM3 체크리스트에 항목으로 명시한다(지금까지 아무도 이 필요성을 항목화해두지 않았다). - [2026-08-24 정정] 다만 규칙이 처음 적용되는 마일스톤은 M2다 —
마일스톤 순서 교체로 반응형 코어가 앞에 오면서,
Source/State/Store필드 추가가Dispatch보다 먼저 온다(ROADMAP.mdM2의H-25파생 항목). - 이후 서브시스템도 같은 규칙을 따른다 — 서브시스템을 붙이는 모든 마일스톤(M2 · M3 · M6 · M7 · M8 · M10)이 같은 항목을 진다.
- "가벼운 타입 계약"이라는 이 패키지의 존재 이유와 상충하지 않는다 — 타입만 재수출하므로 런타임 무게는 안 는다.
- 검토했다 기각된 둘: quad-base 내부만 넓은 로컬 교차 타입
(
New(): Quad & { Dispatch: Dispatch }) — quad-roblox도 결국Dispatch.addHandler를 부르므로 그 경로엔 별도 노출이 또 필요해진다.(quad :: any).Dispatch캐스트 — 가장 싸지만base/typing-limits.md가 시종 강조하는 명시 바인딩 원칙과 정면으로 배치되고 quad-base 자기 코드가--!strict의 이득을 잃는다.
Version은 리터럴(singleton) 타입 — string이 아니라 정확히 "0.0.0".
이 리터럴이 아래 CheckVersion의 판정 근거이자, 그 자체로도 평범한
구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴
"0.1.0"은 "0.0.0"과 구조적으로 호환 안 됨) — CheckVersion이 주는
추가 가치는 감지 자체가 아니라 사람이 읽을 수 있는 진단 메시지다
(아래 절).
AddPlugin<Self, P> — 실측 검증된 플러그인 체이닝
AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P
Self를 고정된 Quad가 아니라 제네릭으로 둬야 체이닝이 누적된다
(고정하면 두 번째 AddPlugin 호출이 첫 번째 확장을 잃어버림). 실측
확인(2026-08-19):
quad:AddPlugin(springFn)(→Quad & SpringPlugin):AddPlugin(otherFn)체이닝 결과가 정확히Quad & SpringPlugin & OtherPlugin로 누적됨.- 플러그인 추가 전 그 메소드에 접근하면 정확히
Key 'X' not found로 거부됨(음성 대조군).
런타임 구현(quad-base, 실제 반영됨): pluginFn(self)를 호출해 얻은
확장 테이블의 필드를 self에 직접 mutate하고 self 그대로 반환 —
새 테이블을 만들지 않는다. 이유: RunInit의 멱등 추적이 module
identity에 의존하므로(base/module-lifecycle-plan.md의 "New()의 내부 구성" 절),
AddPlugin이 새 테이블을 반환하면 그 추적이 끊긴다.
type-version-check — 범용 버전 패턴 매칭 패키지
[2026-08-19 신설] 처음엔 CheckVersion<T>가 정확 일치("0.0.0")만
보는 quad-types 내부 함수였다. 그런데 정확 일치는 quad-spring/
quad-spring-roblox처럼 독립적으로 게시되는 백엔드 플러그인 쌍엔 너무
빡빡하다 — 최신 quad-spring-roblox가 예전 quad-spring도 잘 다루는
경우가 흔할 텐데, 정확 일치를 강제하면 그때마다 재게시가 필요해진다
(사용자 판단: "구현해주는것 정말 쉽고... 있으면 좋다고 생각함").
그래서 글롭/캐럿 패턴을 지원하는 별도 패키지로 뺐다 — quad 전용 이름을
안 섞어서 quad-spring류가 quad-base 전체를 끌고 올 필요 없이 이것만
가볍게 의존하게 하기 위함이기도 하다.
[2026-08-19] 지금은 quad 모노레포 워크스페이스의 네 번째 멤버로 두지만,
사용자가 나중에 독립 저장소로 직접 분리할 예정 — HUMAN_TODO.md 참고.
패턴 문법(.로 나뉜 각 자리): "*" = 와일드카드, "N^" = 그 자리
숫자값이 N 이상이면 통과(caret), 그 외 = 정확히 같은 문자열이어야
통과. 예: "3.*.*"(메이저만 고정), "3.3^.4^"(마이너 3 이상 + 패치
4 이상), "0.0.0"(정확 일치 — quad-types가 지금 쓰는 패턴).
export type function CheckVersion(actual: type, pattern: type): type
actual/pattern 둘 다 문자열 리터럴(singleton) 타입이어야 하고, 일치하면
트리비얼한 true(types.singleton(true)) 하나만 반환 — quad-types
"함정 3"과 같은 이유로 원본 타입을 절대 반환하지 않는다.
Luau 신규 실측 함정 2건(이 세션에 처음 발견, typing-limits.md가
다루는 "타입 시스템 해석 한계"와는 결이 달라 여기 기록):
type function은 같은 파일의 바깥 스코프 로컬 함수를 아예 참조 못 한다 —Type function cannot reference outer local 'X'로 컴파일 자체가 실패. 그래서 런타임용matchesPattern과CheckVersion내부의 매칭 로직은 물리적으로 별개 함수로 중복돼 있다 (type-version-check/src/init.luau) — 하나를 고치면 반드시 다른 하나도 같이 고칠 것.- cross-package 사용엔
export type function이 필요하다(type function만으론 안 됨) — 안 그러면 다른 파일에서Unknown type 'Module.CheckVersion'으로 막힌다. 그리고 명시적 제네릭 인스턴스화가 2개 이상이면 단일 꺾쇠(Foo<A, B>)가 비교 연산자로 오파싱되니 반드시 이중 꺾쇠(Foo<<A, B>>)를 써야 한다(코퍼스에 이미 있던AttributeKey<<T>>관례와 같은 이유). ⭐ [2026-08-25 확장, 7라운드H-73] 이 관례는 타입 자리 전용이 아니다 — Luau의 generic type instantiation은 값 호출부에서도, 콜론 메소드에서도 동작해T를 실제로 묶는다 (store:Of<<number>>("x")).luau-analyze음성 대조군까지 확인했다 — 상세는base/store-plan.md의 "타입 추론 문제" 절.
Version 필드는 Luau 내장 index<T, "Version"> type function으로 뽑는다
(수동 t:readproperty(...)보다 간결 — 사용자 제안으로 채택, 실측 확인
완료).
CheckedQuad<T, Pattern> — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로
왜 필요한가: quad-roblox가 quad_base를 pesde [dependencies]로
선언하지 않고 런타임 주입으로만 받게 되면서, pesde 자신의 semver 충돌
방지 장치가 이 관계엔 전혀 안 걸린다 — 선언된 의존성이 아니라 그냥
함수 인자라서. CheckedQuad<T, Pattern>이 그 빈자리를 메꾸는 컴파일 타임
대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox
소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로
충분"). Pattern은 위 type-version-check의 글롭/캐럿 패턴 문자열 —
quad-base/quad-roblox처럼 같은 모노레포에서 항상 같이 개발되는 관계는
정확 일치("0.0.0")를, quad-spring-roblox류 독립 게시 플러그인은
"0.*.*" 같은 느슨한 패턴을 직접 골라 쓴다.
export type CheckedQuad<T, Pattern> = T & { __versionCheck: TypeVersionCheck.CheckVersion<index<T, "Version">, Pattern> }
사용법:
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T, "0.0.0">
return quad :: any
end
local checked = CheckQuad(injectedQuad)
local _ = checked.__versionCheck -- ⚠️ 필수 — 아래 "함정 2" 참고
local quad = checked:AddPlugin(installRobloxBackend) -- 이후 정상적으로 체이닝
실측으로 깨진 시도들 (전부 순서대로 실제로 시도하고 버림)
함정 1 — error()로 에러 내면 안 됨. type function 안에서
error()를 부르면 "이 type function 자체가 실행에 실패함"으로 판정돼
버려져서 원하는 메시지가 안 뜬다. print("메시지") + return types.never 조합만 호출부에 정확히 TypeError: <메시지>로 뜬다
(실측 확인 — 3줄짜리 최소 재현으로 검증).
함정 2 — 함수 본문 안의 로컬 타입 별칭으론 절대 평가 안 됨.
-- ❌ 이렇게 하면 아무 진단도 안 뜬다(제네릭 인스턴스화 시 재평가 안 됨)
local function QuadRoblox<T>(quad: T): T
type _Check = CheckVersion<T>
return quad
end
체크는 리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리에
박아 넣어야 한다. 이게 CheckedQuad<T, Pattern>이 함수 파라미터/반환
타입 표현식 안에 직접 나타나야 하는 이유고, __versionCheck 필드도 실제로
참조해야만 평가된다(lazy) — 위 사용법 예제의 local _ = checked.__versionCheck 줄이 빠지면 검사가 조용히 스킵된다.
함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 type function을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다.
처음엔 CheckVersion<T>가 성공 시 T를 그대로 패스스루(return t)하는
버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접
써서 즉시 평가되고, AddPlugin도 안 뭉개지는 것처럼 보였다). 그런데
CheckVersion<T>의 결과를 다른 타입과 &로 합친 뒤 AddPlugin을
호출하는 조합에서 Expected this to be exactly 'P & Self', but got 'P & Self'처럼 앞뒤가 똑같은, 의미 없는 진단이 뜨며 깨졌다. 더
좁혀보니, 심지어 &로 안 합쳐도, CheckVersion<T>를 거친 값에
AddPlugin을 부르기만 해도 똑같이 깨졌다 — 재구성 비용(값을
types.newtable()로 다시 조립하는 것) 문제가 아니라, **"이 타입이
type function을 거쳤다는 이력 자체"**가 이후 제네릭 self 추론을
방해하는 것으로 보인다. 그래서 최종 설계는 CheckVersion이 T를
전혀 참조/반환하지 않고(성공 시 트리비얼한 types.singleton(true)
하나만), 검증 결과를 원본과 절대 안 섞이는 별도 필드
(__versionCheck)로 완전히 격리한다 — T 자신은 type function을
한 번도 거치지 않은 "순수한" 타입으로 계속 흘러가므로, 그 뒤
AddPlugin 체이닝이 몇 번이 되든 전혀 안 깨진다(실측 확인).
교훈: type function의 부작용은 "재구성이 원본을 뭉갠다"는 상상보다
넓다 — 패스스루도 이력만으로 오염된다. 검증/변형용 type function을
설계할 때는 원본 타입을 절대 반환하지 말고, 트리비얼한 마커만
반환해서 완전히 별도 필드로 격리할 것 — typing-limits.md의 "새
타입/API를 설계할 때 체크리스트"에 추가할 후보.
실측 근거
.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau —
실제 quad-types/quad-base/type-version-check를 require해서 위
사용법 그대로 재현: 양성 경로(버전 일치 + AddPlugin 2회 체이닝 + 이전
확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서
TypeError: type-version-check: version "9.9.9" does not match pattern "0.0.0" 하나만.
quad-base/test/smoke.plugin.luau — 실제 런타임 AddPlugin 구현(mutate
- identity 보존 + 체이닝)을 실행 레벨로 검증.
남은 것
quad-roblox가 실제로CheckedQuad<T, Pattern>을 쓰는 진입점 (QuadRoblox등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 예제가 실제 위치는 아직 없음._initializedBy(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는base/module-lifecycle-plan.md의 "New()의 내부 구성" 절 참고 —CheckedQuad는 버전 호환성만 보고, 누가 이미 backend를 설치했는지는 별개 문제로 계속_initializedBy가 담당한다.- [백로그, 2026-08-19 신설]
quad-roblox-types(가칭) —quad-types와 같은 패턴으로,quad-roblox전체 대신 그 타입만 필요한 모듈을 위한 패키지. 사용자가 지금 만들 필요는 없다고 명시적으로 후순위 지정 — 다만 이후 쉽게 뽑을 수 있게quad-roblox의 공개 타입은 지금부터 단일src/init.luau(또는types.luau) 형태로 몰아두는 걸 관례로 유지할 것 (quad-types 자신이 이미 이 형태 —Quad/CheckedQuad둘 다src/init.luau하나에 있음). - [HUMAN_TODO]
type-version-check는 사용자가 나중에 독립 저장소로 직접 분리할 예정 — 루트HUMAN_TODO.md참고.