qa: 손 트레이싱 7라운드 5차 패스 — 타입 실측, 발견 7건 (H-94~H-100)

1~4차가 안 쓴 각도: **확정된 M2 표면을 문서에 적힌 시그니처 그대로 Luau
타입으로 선언하고 `luau-analyze`에 걸었다.** 2차 패스도 타입을 봤지만
대상이 Store의 타입 합성 하나였다(H-73~H-76). 이번엔 호출부 관점 —
"확정된 타입이 확정된 관용구를 통과시키는가"다. 루트 .luaurc가
languageMode: strict라 아래 진단은 프로젝트에 그대로 적용된다.

🔴 H-94 `__call` 테이블은 `(State<T>) -> U` 자리에 안 들어간다 →
     `state:Apply(Debounce{...})` 확정 관용구가 타입에러. gate-plan이
     "확인할 필요도 없어졌다"며 접은 불확실성이 Debounce/Throttle 쪽에
     그대로 남아 있었다(팩토리에 Flush/Cancel을 붙이는 이상 콜러블
     테이블일 수밖에 없다). 런타임은 멀쩡하고 타입만 막힌다
🔴 H-95 콜백이 "선언보다 적게 반환"하면 strict에서 에러 — Effect의
     `fn -> (() -> ())?`는 cleanup 없는 흔한 모양이, :List의
     `updateFn -> (T?, UD?)`는 userdata를 안 쓰는 모든 모양이 안 통과한다.
     통과하는 두 형태(가변 반환 팩 / 함수 타입 유니온)를 대조군으로 확인
🟡 H-96 trailing deps가 붙는 순간 콜백 파라미터 무주석 추론이 깨진다 —
     typing-limits ②쪼개기가 푸는 범위 밖인데 경계가 어디에도 없다.
     2차 패스 부록의 "무주석 콜백도 살아 있다"도 이 구분을 안 했다
🟡 H-97 M2의 `mock 대상 테스트`가 전파 루프를 한 번도 못 돈다 — 루프가
     매 발화마다 부르는 canExecute가 M8에서만 구현되고, 미주입 슬롯의
     확정 기본값은 에러 스텁이다. 커밋된 mock엔 재료(Signal/Connection)가
     이미 있는데 쓰는 쪽이 없다
🟡 H-98 `:Subscribe()`의 공개 계약("참조를 안 담아도 계속 돎")이 중간
     State GC 미해결에 종속 — 잘못 닫히면 "GC도 안 되고 발화도 안 하는"
     조합이 된다(실제 collectgarbage로 재현)
🟢 H-99 Observer가 파일 자리를 못 받았고 :Subscribe() 전역 강참조
     레지스트리의 주인이 어디에도 없다 (Effect.luau는 있는데)
🟢 H-100 `{[Source<T>]: true}`가 `{[Epoch]: true}` 자리에 안 들어간다
     (인덱서 키 불변). 단일 Epoch 전달과 §2의 구조적 만족은 성립 확인

부록에 "걸어봤는데 문제가 없던 것" 6건 — state:Gate + blocker:Policy가
타입으로 성립하고 gate-plan의 2026-08-24 정정(오답 b.Policy)은 타입이
잡아준다, source:Apply에 State용 팩토리 통과, Effect의 이형 deps는 타입으로
표현 가능, 게이트 2겹 unfold가 어느 순서로 풀려도 안 샌다 등.

아무것도 base/에 반영하지 않았다 — 회신 대기.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-25 11:35:00 +09:00
parent 03219af33f
commit 3247ea5907
Signed by: qwreey
GPG key ID: D28DB79297A214BD
2 changed files with 445 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,15 @@
# 구현 전 손 트레이싱 **7라운드** — M2(반응형 코어) 범위 + M2→M3 경계 # 구현 전 손 트레이싱 **7라운드** — M2(반응형 코어) 범위 + M2→M3 경계
**구성**: **패스 4개, 발견 39건(`H-55`~`H-93`).** 1차 패스는 문서 대 문서 **구성**: **패스 5개, 발견 46건(`H-55`~`H-100`).** 1차 패스는 문서 대 문서
손 트레이싱(`H-55`~`H-70`), **2차 패스는 문서가 "확인했다"고 적은 주장을 손 트레이싱(`H-55`~`H-70`), **2차 패스는 문서가 "확인했다"고 적은 주장을
실제로 `luau`/`luau-analyze`에 걸어본 실측**(`H-71`~`H-76`, 2026-08-25 추가), 실제로 `luau`/`luau-analyze`에 걸어본 실측**(`H-71`~`H-76`, 2026-08-25 추가),
**3차 패스는 (a) 실제로 커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려본 **3차 패스는 (a) 실제로 커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려본
것과 (b) 1·2차가 범위에서 뺐던 "M2를 *소비하는* 문서"**(`H-77`~`H-84`, 것과 (b) 1·2차가 범위에서 뺐던 "M2를 *소비하는* 문서"**(`H-77`~`H-84`,
2026-08-25 추가), **4차 패스는 (a) M2 코어를 문서 그대로 Luau로 짜서 돌린 2026-08-25 추가), **4차 패스는 (a) M2 코어를 문서 그대로 Luau로 짜서 돌린
참조 구현과 (b) 아무 문서도 안 정한 예외 경로**(`H-85`~`H-93`, 2026-08-25 참조 구현과 (b) 아무 문서도 안 정한 예외 경로**(`H-85`~`H-93`, 2026-08-25
추가). 발견 번호는 패스를 가로질러 이어서 매긴다(6라운드와 같은 방식). 추가), **5차 패스는 확정된 M2 표면을 실제 Luau 타입으로 선언해
`luau-analyze`에 건 것**(`H-94`~`H-100`, 2026-08-25 추가). 발견 번호는
패스를 가로질러 이어서 매긴다(6라운드와 같은 방식).
**상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은 **상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은
사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을 사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을
@ -116,6 +118,20 @@ GC 실측, `store:GetDynamic` 위치)에서 파생되는 것. 아래에서 그
| `H-92` | 🟢 | 확정된 구독자 스냅샷이 **emit마다·노드마다 배열 하나**를 할당 — §2가 테이블 리비전을 기각한 GC 근거와 어긋난다 | `ROADMAP.md` M2, `state-epoch-plan.md` §2 | 구현 시 정하면 | | `H-92` | 🟢 | 확정된 구독자 스냅샷이 **emit마다·노드마다 배열 하나**를 할당 — §2가 테이블 리비전을 기각한 GC 근거와 어긋난다 | `ROADMAP.md` M2, `state-epoch-plan.md` §2 | 구현 시 정하면 |
| `H-93` | 🟢 | `EpochMap` 키가 weak라, 중간 State GC 미해결이 **"낡았는데 최신이라고 오판"**으로도 나타난다 | `state-epoch-plan.md` §3 | 미해결의 파생 | | `H-93` | 🟢 | `EpochMap` 키가 weak라, 중간 State GC 미해결이 **"낡았는데 최신이라고 오판"**으로도 나타난다 | `state-epoch-plan.md` §3 | 미해결의 파생 |
**⭐ [2026-08-25] 5차 패스 — 확정된 표면을 실제 타입으로 선언해봤다 (`H-94`~`H-100`).**
상세는 아래 "5차 패스" 절. **루트 `.luaurc``languageMode: strict`이므로
아래 진단은 이 프로젝트에 그대로 적용된다.**
| 번호 | 심각도 | 한 줄 | 주 대상 | 실측 |
|---|---|---|---|---|
| `H-94` | 🔴 | **`__call` 테이블은 `(State<T>) -> U` 자리에 안 들어간다** — `state:Apply(Debounce{...})` 확정 관용구가 타입에러. `gate-plan.md`가 *"확인할 필요도 없어졌다"*며 접은 불확실성이 `Debounce`/`Throttle` 쪽에 남아 있었다 | `source-state-plan.md` `:Apply`, `debounce-throttle-plan.md` 5-4 | ✅ 재현 |
| `H-95` | 🔴 | **콜백이 "선언보다 적게 반환"하면 strict에서 에러**`Effect``fn``:List``updateFn` 둘 다, 문서가 드는 정상 용례가 전부 안 통과한다 | `effect-plan.md`, `ROADMAP.md` M2, `slot-plan.md` | ✅ 해법 대조까지 |
| `H-96` | 🟡 | **trailing deps가 붙는 순간 콜백 파라미터 무주석 추론이 깨진다** — ②쪼개기가 푸는 범위 밖인데 그 경계가 어디에도 없다 | `source-state-plan.md`, `typing-limits.md` §1②·§7 | ✅ 0-dep/N-dep 대조 |
| `H-97` | 🟡 | **M2의 `mock 대상 테스트`가 전파 루프를 한 번도 못 돈다** — 루프가 매 발화마다 부르는 `canExecute`가 M8에서만 구현되고 미주입 슬롯은 에러 스텁이다 | `ROADMAP.md` M2, `architecture.md`, `module-lifecycle-plan.md` | 표면 대조 |
| `H-98` | 🟡 | **`:Subscribe()`의 공개 계약이 중간 State GC 미해결에 종속돼 있다** — 잘못 닫히면 "GC도 안 되고 발화도 안 하는" 조합 | `source-state-plan.md` Subscribe 절 | ✅ 재현 |
| `H-99` | 🟢 | `Observer`가 파일 자리를 못 받았고 `:Subscribe()` 전역 레지스트리의 주인이 없다 — `Effect.luau`는 있는데 | `architecture.md` 소스 트리 | 표면 대조 |
| `H-100` | 🟢 | `{[Source<T>]: true}``{[Epoch]: true}` 자리에 안 들어간다(인덱서 키 불변) | `state-epoch-plan.md` §3 | ✅ 재현 |
--- ---
# 1차 패스 — 문서 대 문서 손 트레이싱 # 1차 패스 — 문서 대 문서 손 트레이싱
@ -2122,6 +2138,420 @@ GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근
--- ---
# 5차 패스 — 확정된 표면을 실제 Luau 타입으로 선언해봤다 (2026-08-25)
**왜 이 패스가 있는가**: 사용자 요청 — *"계속 이어서 5차 패스로 더 찾아봐"*.
**1~4차와 다른 각도**:
1. **M2 공개 표면을 문서에 적힌 시그니처 그대로 타입으로 선언하고
`luau-analyze`에 걸었다.** 2차 패스도 타입을 봤지만 대상이 **`Store`
타입 합성 하나**였다(`H-73`~`H-76`). 이번엔 **호출부 관점**이다 —
`:Apply`/`:Gate`/`:Compute(fn, ...deps)`/`Effect(fn, ...deps)`/`EpochMap`
/`updateFn`을 사용자가 실제로 쓰는 모양 그대로 써보고, **문서가 확정한
시그니처가 그 모양을 받아주는지**를 봤다. 이 축은 "타입이 표현
가능한가"(2차)가 아니라 **"확정된 타입이 확정된 관용구를 통과시키는가"**다.
2. **그 과정에서 드러난 패키지·테스트 배선 공백**도 같이 적는다 — 타입
실험을 하려면 "이 값이 어디서 오는가"를 따라가야 하는데, 그때
`mock 대상 테스트``Observer`의 파일 자리가 비어 있는 게 드러났다.
**실측 환경**: `luau-analyze`
(`~/.local/share/mise/installs/luau/latest`), 2026-08-25 실행. **루트
`.luaurc``languageMode: strict`이므로 아래 진단은 전부 이 프로젝트에
그대로 적용된다.** 선언 스타일은 `base/typing-limits.md`
"그래서 우리가 하는 것 — ② 타입 선언은" 절이 확정한 데이터부/메소드부
쪼개기를 그대로 따랐다(그래야 이 문서가 이미 확인한 것과 조건이 같다).
**이 패스의 범위**: `base/source-state-plan.md``state:Apply` ·
trailing deps · Observer의 `:Subscribe()` 절 / `base/gate-plan.md` 2·5번 /
`base/blocker-plan.md``Policy` / `base/state-epoch-plan.md` §2·§3 /
`base/effect-plan.md``fn` 시그니처 / `base/debounce-throttle-plan.md`
5-1·5-4 / `base/slot-plan.md``updateFn` 시그니처 / `base/typing-limits.md`
§1②·§7 / `base/architecture.md`의 테스트 전략·소스 트리 /
`base/module-lifecycle-plan.md`의 미주입 슬롯 규약 / `ROADMAP.md` M2 /
커밋된 `quad-base/test/mock.luau`. **`H-55`~`H-93`과 겹치는 항목은 없다.**
| 번호 | 심각도 | 한 줄 | 주 대상 | 실측 |
|---|---|---|---|---|
| `H-94` | 🔴 | **`__call` 테이블은 `(State<T>) -> U` 자리에 안 들어간다** — `state:Apply(Debounce{...})`라는 확정 관용구가 그대로 타입에러다. `gate-plan.md`가 *"확인할 필요도 없어졌다"*며 접은 그 불확실성이 `Debounce`/`Throttle` 쪽에 그대로 남아 있었다 | `source-state-plan.md` `:Apply`, `debounce-throttle-plan.md` 5-1·5-4, `gate-plan.md` 2번 | ✅ 재현 |
| `H-95` | 🔴 | **콜백이 "선언된 것보다 적게 반환"하면 strict에서 에러다**`Effect``fn(self) -> (() -> ())?``:List``updateFn -> (T?, UD?)` 둘 다, 문서가 정상 용례로 드는 모양이 전부 안 통과한다 | `effect-plan.md`, `ROADMAP.md` M2, `slot-plan.md` | ✅ 재현·해법 대조까지 |
| `H-96` | 🟡 | **trailing deps가 붙는 순간 콜백 파라미터 무주석 추론이 깨진다**`typing-limits.md` ②쪼개기가 해결한 범위 밖인데 그 경계가 어디에도 없고, 7라운드 부록의 서술도 이 구분을 안 했다 | `source-state-plan.md` trailing deps 절, `typing-limits.md` §1②·§7 | ✅ 0-dep/N-dep 대조 |
| `H-97` | 🟡 | **M2의 `mock 대상 테스트`는 전파 루프를 한 번도 못 돈다** — 루프가 매 발화마다 부르는 `canExecute`가 M8(quad-roblox)에서만 구현되고, 미주입 슬롯의 확정 기본값은 **에러내는 스텁**이다 | `ROADMAP.md` M2, `architecture.md` 테스트 전략, `module-lifecycle-plan.md`, `lifecycle-pattern.md` | 표면 대조 + 커밋된 mock 확인 |
| `H-98` | 🟡 | **`:Subscribe()`의 공개 계약(*"참조를 아무 데도 안 담아도 정상"*)이 중간 State GC 미해결에 걸려 있다** — 잘못 닫히면 "GC도 안 되고 발화도 안 하는" 최악의 조합이 된다 | `source-state-plan.md` Subscribe 절 · 미해결 절 | ✅ 재현 |
| `H-99` | 🟢 | **`Observer`가 파일 자리를 못 받았고, `:Subscribe()`가 요구하는 전역 강참조 레지스트리의 소유 모듈이 어디에도 없다** — `Effect.luau`는 있는데 | `architecture.md` 소스 트리, `ROADMAP.md` M2 | 표면 대조 |
| `H-100` | 🟢 | `{[Source<T>]: true}``{[Epoch]: true}` 자리에 안 들어간다(인덱서 키는 불변) — 집합을 만드는 자리마다 캐스트가 필요하다 | `state-epoch-plan.md` §3 | ✅ 재현 |
---
## 🔴 `H-94``__call` 팩토리는 `:Apply`의 타입 자리에 안 들어간다 (실측)
**어디**: `base/source-state-plan.md`의 "`state:Apply(factory)`" 절
(*"타입은 `factory: (State<T>) -> U): U`로 완전히 열어둠"*),
`base/debounce-throttle-plan.md`의 "5-4. 제어 핸들" 절(팩토리 자신에
`:Flush()`/`:Cancel()`을 붙임)과 5-1절의 확정 관용구
`state:Apply(Debounce{Time = 0.3})`, `base/gate-plan.md` 2번.
**무엇이 어긋나나**: `gate-plan.md` 2번은 `__call` 타입 불확실성을 이렇게
접었다 — *"`__call` 테이블이 Luau에서 `(State<T>) -> U` 함수 타입 자리에
그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력). `Apply`를 쓸 이유
자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시 되살아나면 `luau-test`
스파이크 한 개로 판정할 것."*
*"쓸 이유가 없어졌다"***`Gate` 자신에 대해서만** 참이다. 같은 항목이
바로 아래에서 *"그래서 `Debounce`/`Throttle`은 `:Apply` 그대로 둔다"*고
확정했고, `debounce-throttle-plan.md` 5-4절은 그 팩토리를 **메소드 두 개가
달린 콜러블 값**으로 확정했다. Lua 함수 값에는 필드를 못 붙이므로 그
모양은 **`__call` 테이블일 수밖에 없다.** 즉 접어둔 불확실성이 확정된
공개 관용구 위에 그대로 남아 있었다.
**실측**:
```lua
type GateFactory = typeof(setmetatable(
{} :: { Flush: () -> (), Cancel: () -> () },
{} :: { __call: (any, State<number>) -> State<number> }
))
local Debounced: GateFactory = nil :: any
local b = s:Apply(Debounced) -- ← 여기
```
```
TypeError: Expected this to be '(t2) -> U ...' but got 'GateFactory'
```
- **제네릭 `:Apply`만의 문제가 아니다** — 평범한
`(f: (State<number>) -> State<number>)` 파라미터 자리에서도 똑같이 걸린다.
- **직접 호출은 된다**`Debounced(s)`는 진단이 없다. 즉 런타임은
멀쩡하고 **타입만** 막힌다. 그래서 `--!nocheck`로 짠 스파이크에선 안
드러나고, strict인 실제 코드에서만 터진다.
**갈래(결정 전 목록)**: (a) `:Apply`의 파라미터 타입을 **함수와 콜러블의
유니온**으로 연다 — 실측에서 유니온은 양쪽 다 통과했다(정상 함수 팩토리도
같이 통과, 엉뚱한 반환 타입은 여전히 잡힘). 대가는 `Apply` 구현 안에서
캐스트 한 줄. (b) `Debounce{...}`**함수만** 돌려주고 전체 브로드캐스트
`Flush`/`Cancel`은 다른 표면으로 옮긴다(5-4절의 "전체는 팩토리로" 결정을
되짚어야 함 — 예: `opts.Handle` 아웃파라미터와 같은 방식으로 그룹 핸들을
따로 받기). (c) `:Apply`의 factory 타입을 `any`로 열어둔다 — 지금 열려
있는 건 **반환 타입**뿐이고 파라미터까지 열면 오타를 못 잡으므로 권하지
않는다.
**이건 `H-86`과 별개다** — 그쪽은 정책이 런타임에 상태를 못 읽는 문제이고,
이쪽은 팩토리 값이 타입 자리에 못 들어가는 문제다. 다만 **둘 다
`Debounce`/`Throttle`의 확정 표면에서 나왔다.**
---
## 🔴 `H-95` — 콜백이 "선언된 것보다 적게 반환"하면 strict에서 에러다 (실측)
**어디**: `base/effect-plan.md``ROADMAP.md` M2가 확정한
**`fn(self: EffectHandle) -> (() -> ())?`**, 그리고 `base/slot-plan.md`
확정한 **`updateFn(item, index, offset, prev, userdata) -> (T|nil, UD?)`**.
**무엇이 어긋나나**: Luau strict는 함수 타입의 **반환 개수**를 맞춘다.
선언보다 적게 반환하거나 아예 반환하지 않으면 통과하지 않는다.
**실측 1 — `Effect``fn`**:
```lua
local function EffectA(fn: (self: EffectHandle) -> (() -> ())?) end
EffectA(function(self) end) -- ❌
EffectA(function(self) return nil end) -- ✅
EffectA(function(self) return function() end end) -- ✅
EffectA(function(self) -- ❌
if cond then return function() end end
end)
```
```
TypeError: Not all codepaths in this function return '(() -> ())?'. (× 2)
```
**cleanup이 없는 이펙트**(가장 흔한 모양)와 **조건부로만 cleanup을
돌려주는 이펙트**가 둘 다 안 통과한다. `React``useEffect`와 동형이라고
확정해둔 그 관용구가 Luau에선 `return nil`을 손으로 붙여야 성립한다.
**실측 2 — `:List``updateFn`** (더 나쁘다). 확정 반환은 **두 값**
(`(T|nil, UD?)`)인데:
```lua
local function take(fn: (n: number) -> (El?, number?)) end
take(function(n) return { tag = "a" } end) -- ❌ Expected 'El?, number?', but got 'El?'
take(function(n) return nil end) -- ❌ 같은 에러
take(function(n) end) -- ❌ Not all codepaths...
take(function(n) return { tag = "a" }, 1 end) -- ✅
take(function(n) return nil, nil end) -- ✅
```
**`userdata`를 안 쓰는 모든 `updateFn`이 여기 걸린다.** `userdata`
`base/slot-plan.md`가 **선택 기능**으로 확정한 것이고, 문서 예시도 대부분
값 하나만 돌려준다 — 그 전부가 `, nil`을 손으로 붙여야 한다.
**해법 대조(실측)**: 두 가지가 통과한다.
- **가변 반환 팩**`-> ...(() -> ())`: `function(self) end`와 cleanup
반환 둘 다 통과. 단일 옵셔널 반환(`Effect`)에 잘 맞는다.
- **함수 타입의 유니온**`Fn2 | Fn1 | Fn0`: 네 모양(2개/1개/nil/없음)이
전부 통과하고, **엉뚱한 타입을 돌려주면 여전히 잡힌다**(음성 대조군
확인). 두 값짜리(`updateFn`)엔 이쪽이 맞는다.
**갈래(결정 전 목록)**: (a) 두 시그니처를 위 형태로 고친다(문서 수정 +
구현 시 그 타입으로 선언). (b) 시그니처는 그대로 두고 **"항상 명시적으로
반환하라"를 계약으로 문서화**한다 — `return nil` / `return nil, nil`.
비용 0이지만 `useEffect` 동형이라는 확정 서술과 인체공학이 어긋나고,
사용자가 빠뜨리면 컴파일이 아니라 **타입 검사**에서만 걸리므로
`--!nocheck` 코드에선 조용히 지나간다. (c) `Effect`만 (a), `updateFn`
(b) — `updateFn`은 이미 인자가 5개짜리 저수준 훅이라 명시 반환이 덜
어색하다.
**이건 `H-70`과 별개다** — 그쪽은 deps의 런타임 검증이고, 이쪽은 `fn`
자신의 반환 타입이다.
---
## 🟡 `H-96` — trailing deps가 붙는 순간 콜백 파라미터 무주석 추론이 깨진다 (실측)
**어디**: `base/source-state-plan.md`
"trailing deps를 `fn`에 lazy positional 인자로도 노출" 절,
`base/typing-limits.md`의 "그래서 우리가 하는 것 — ② 타입 선언은" 절과
"7. 성립이 확인된 것" 절, 그리고 이 문서 2차 패스의 부록.
**무엇이 어긋나나**: ②쪼개기가 해결한다고 확인한 것은 **deps가 없는**
`:Compute(fn)`이다. deps가 붙으면 로컬 제네릭 팩 `D...`가 콜백 파라미터에
나타나므로 같은 문제가 되살아난다 — 그리고 **그 경계가 어디에도 안 적혀
있다.**
**실측(같은 파일 안 대조)**:
```lua
-- (A) deps 0개 — 무주석 통과 ✅
local r1 = s:Compute(function(self) return self:Get() * 2 end)
-- (B) deps 1개 + 무주석 — ❌
local r2 = s:ComputeN(function(self, prev, d1) return self:Get() + d1:Get() end, a)
-- (C) deps 1개 + dep 파라미터에만 주석 — ✅
local r3 = s:ComputeN(function(self, prev, d1: SourceData<number>) ... end, a)
```
(B)가 내는 진단은 둘이다 — `Consider annotating the return with number`와,
dep 인자가 `{ read Get: (t1) -> (number, ...unknown) }` 같은 미해소 모양으로
남아 실제 `SourceData<number>`와 안 맞는다는 것.
**이건 2차 패스 부록의 서술을 정정한다.** 그 부록은 *"무주석 콜백도 타입
검사가 살아 있다"*고 적었는데, 그때 돌린 코드의 **dep 파라미터엔 주석이
달려 있었다**(살아 있던 건 콜백 *본문*의 검사다). 위 (B)가 그 구분을
명확히 한다.
**부수로 확인된 것 — 이형 *종류* 섞기는 된다.** 2차 패스 부록은
`StateData<number>`/`StateData<boolean>`처럼 **같은 종류, 다른 타입 인자**만
봤다. 실제 코드는 `store.a`(**`Source`**)와 파생 State가 섞이는데,
**주석을 달면 그것도 정확히 좁혀지고**(순서를 바꿔 넘기면 두 자리 모두
잡힌다) 문제가 없다.
**왜 지금 적나**: `base/typing-limits.md` §7("성립이 확인된 것 — 다시
의심하지 말 것")에 *"콜백 파라미터/본문의 타입 체크(1번의 쪼개기 적용 시)
— 진짜 살아있음"*이 있는데, 여기에 **"단 trailing deps가 붙으면 dep
파라미터엔 주석이 필요하다"**가 빠져 있다. M2 구현자가 `:Compute(fn, ...)`
관용구를 문서화할 때 이걸 모르면 예시가 전부 타입에러가 된다.
**갈래**: 이건 결정이 필요한 게 아니라 **문서에 경계를 적는 것**이다 —
§7과 `source-state-plan.md`의 그 절에 한 줄씩. 굳이 갈래를 든다면 (a)
그대로 두고 "deps를 쓰면 주석을 달라"를 관용구로 명문화, (b) dep 1개짜리
비제네릭 오버로드를 따로 두는 안 — 2차 패스 부록이 이미 *"dep을 팩이
아니라 고정 인자로 선언하면 무주석 추론이 깨진다"*고 확인했으므로 (b)는
효과가 없다. **(a)가 사실상 유일하다.**
---
## 🟡 `H-97` — M2의 `mock 대상 테스트`는 전파 루프를 한 번도 못 돈다
**어디**: `ROADMAP.md` M2의 마지막 체크박스(`mock 대상 테스트`)와
"State 전파 루프" 체크박스, `base/architecture.md`
"테스트 전략: quad-base용 최소 mock" 절, `base/module-lifecycle-plan.md`
미주입 슬롯 규약, `base/lifecycle-pattern.md``canExecute` 실 구현 스케치.
**무엇이 어긋나나**: 세 확정이 겹치면 M2 테스트가 아예 안 돈다.
1. M2의 전파 루프는 **발화마다 각 구독자에 대해 `canExecute`를 부른다**
(그 체크박스가 *"이게 `canExecute`의 유일한 실제 호출부"*라고 못박음).
2. `canExecute``LifetimeHandle`의 일부이고, M2가 만드는 건
**인터페이스뿐**이다(그 체크박스 자신이 *"실 구현 없음 — quad-roblox
실 구현은 M8"*). `base/architecture.md`의 소스 트리도 실 구현을
quad-roblox 쪽에 두고, `bindLifetime`/`canBound`/`canExecute`가 백엔드
팩토리 뮤테이션으로 주입된다고 적는다.
3. `base/module-lifecycle-plan.md`가 확정한 미주입 슬롯의 기본값은
*"quad-base가 명시적으로 에러내는 스텁"*(조용한 no-op 아님)이다.
→ 백엔드가 없는 순수 `luau` 테스트에서 **`Source:Set()` 한 번이면 그
스텁에 닿는다.** 그런데 M2 체크리스트엔 mock 쪽 배선 항목이 없고,
`base/architecture.md`의 그 절은 mock의 범위를 *"parent/children 트리 +
타입 검증 없는 property bag + property별 변경 시그널 정도"*로 좁히면서
생명주기 쪽은 명시적으로 뺐다 — 커밋된 `quad-base/test/mock.luau`의 머리
주석도 같은 말을 한다(*"그 동일성 문제는 quad-roblox의 LifetimeHandle
(gcconn 트릭)이 다루는 자리라 quad-base 정적 스냅샷 테스트 범위 밖"*).
**흥미로운 사실 하나 — 재료는 이미 있다.** 커밋된 mock은
`GetPropertyChangedSignal``Destroying`, 그리고 `.Connected`를 갖는
Connection까지 이미 구현해뒀다. 즉 gcconn 트릭을 흉내낼 재료는 다 있는데
**그걸 쓰는 쪽(mock용 LifetimeHandle 플러그인)이 없을 뿐**이다.
**갈래(결정 전 목록)**: (a) M2에 **mock 백엔드 플러그인** 항목을 추가한다 —
`AddPlugin`으로 `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`를
넣고, 이미 있는 mock의 signal/Connection 위에 얹는다. (b) **패키지 경계를
다시 긋는다** — `canExecute`/`canBound`의 본문은 사실 순수 Lua다
(`BindData:GetWeak(value, "gcconn")`의 `.Connected``.Subscribed`를 읽을
뿐이고, 엔진이 필요한 건 gcconn을 **만드는** `bindLifetime` 쪽이다). 판정
둘과 `BindData`를 quad-base로 내리고 주입은 `bindLifetime`/`unbindLifetime`
둘로 좁히면, base 테스트가 백엔드 없이 전파를 돌 수 있다. (c) M2의
`mock 대상 테스트` 범위를 "전파를 안 타는 것"(`:Get()` 캐시, `EpochMap`
단위 테스트, Store lazy 생성)으로 명시적으로 좁히고 전파 테스트는 M8
이후로 미룬다 — 그러면 M2의 핵심(전파 규칙)이 마일스톤 안에서 한 번도
검증 안 된다.
---
## 🟡 `H-98``:Subscribe()`의 공개 계약이 중간 State GC 미해결에 걸려 있다 (실측)
**어디**: `base/source-state-plan.md`
"Observer의 `:Subscribe()`/`:Unsubscribe()`" 절, 같은 문서의
"미해결 — 중간 State가 살아남는가" 절.
**무엇이 어긋나나**: `:Subscribe()` 절은 두 가지를 **공개 계약으로**
못박았다 — *"`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안
담아도 정상 … 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 포인트)"*,
그리고 ⚠️ 항목의 *"로컬 변수 참조를 전부 놓아도 **GC되지 않고 영원히 계속
실행됨**"*.
그런데 전파 모델은 **구독자(하류)를 weak로만** 담고, Observer를 살리는 건
전역 강참조 레지스트리다. 그 레지스트리는 **Observer만** 붙잡는다 —
Observer가 자기 상류 State를 강하게 들고 있다는 서술은 어디에도 없다.
그래서 한 줄 관용구에서 중간 노드를 아무도 안 들고 있으면 그게 수거되고,
**Observer는 레지스트리에 살아남은 채 다시는 안 울린다.**
**실측**(Observer가 상류를 되참조하지 않는 형태 + 실제 `collectgarbage`):
```
Observer 발화: 0 ← 공개 계약("예외 없이 그냥 계속 돎")대로면 1
A의 구독자 수: 0 ← 중간 State가 수거됐다
Observer는 레지스트리에 살아있다: true | 그러나 다시는 안 울린다
```
대조군으로 Observer가 상류를 강참조로 들게 하면 정상적으로 1회 발화한다.
**왜 따로 적나**: 이건 `question.md` 최우선의 중간 State GC 항목과 **같은
뿌리**지만, 지금까지 그 항목은 *"전파가 조용히 끊길 수 있다"*는 내부
구현 문제로만 서술돼 있었다. 여기서 드러나는 건 **확정된 공개 계약 문장이
그 미해결의 결과에 종속돼 있다**는 것이고, 결과가 나쁜 쪽으로 나면
`:Subscribe()`**"GC도 안 되고 발화도 안 하는"** — 그 절이 경고하는
누수와 그 절이 약속하는 동작을 **둘 다 어기는** 조합이 된다.
**그래서 필요한 것**: 그 미해결을 (a)(상류 strong / 하류 weak)로 닫으면
계약이 그대로 참이 된다. 다른 쪽으로 닫는다면 `:Subscribe()` 절의 두
문장을 같이 고쳐야 한다 — **어느 쪽이든 그 항목을 닫을 때 이 절도 같이
볼 것**이라는 표시가 지금 어디에도 없다. `H-93`이 같은 미해결의 또 다른
얼굴(값이 최신이라고 오판)이므로 셋을 한 번에 보는 게 낫다.
---
## 🟢 `H-99``Observer`가 파일 자리를 못 받았고, 전역 레지스트리의 주인이 없다
**어디**: `base/architecture.md`의 소스 트리(`State.luau` 줄이
*"`:With`/`:Compute`/`:Observer`…/`:Gate` … 전부 여기 소속"*이라고 적고,
`Observer.luau`는 트리 어디에도 없다), `ROADMAP.md` M2의
`state:Observer(fn)` 체크박스.
**무엇이 어긋나나**: `Observer`는 브랜드(`isObserver`)를 갖고, children
배열에 놓이는 leaf 값이며, `:Subscribe()`/`:Unsubscribe()`라는 자기 표면을
갖는다 — `Effect`와 정확히 같은 급인데 `Effect.luau`만 파일을 갖는다.
6라운드 `H-46``Slot`에 top-level 파일을 준 근거(*"다른 값 타입과 같은
대칭"*)가 여기 그대로 적용된다.
**더 구체적인 공백 — 전역 강참조 레지스트리의 주인.** `:Subscribe()` 절이
확정한 `SubscribedObservers: {[observer]: true}`(**weak 아닌 강참조**)가
어느 모듈에 사는지 트리에도 체크리스트에도 없다. 그리고 M2 체크리스트는
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 같이 만들라고 하므로 **그
레지스트리를 `Observer``Effect`가 공유해야 한다.** 지금처럼 `State.luau`
안에 묻으면 `Effect.luau`가 그걸 쓰려고 `State.luau`를 require하는
모양이 되는데(단방향이라 순환은 안 나지만) "구독 레지스트리"라는 관심사가
State 모듈에 얹히는 건 `EpochMap.luau`를 *"`State.luau`에 묻지 말고 별도
모듈로 낼 것"*이라고 못박은 판단과 결이 다르다.
**갈래**: (a) `Observer.luau`를 신설하고 레지스트리를 거기 둔다(`Effect`가
require) — `EpochMap.luau` 판단과 같은 결. (b) 레지스트리만 별도
`Subscription.luau`류로 빼고 `Observer``State.luau`에 그대로 둔다.
(c) 지금대로 두되 **트리 주석에 "레지스트리도 여기"라고 적는다**
최소한 어디 사는지는 정해져야 한다.
---
## 🟢 `H-100``{[Source<T>]: true}``{[Epoch]: true}` 자리에 안 들어간다 (실측)
**어디**: `base/state-epoch-plan.md`의 "2. `Epoch` — 판정의 최소 인터페이스"
절과 §3의 `EpochSet = { [Epoch]: true }`.
**실측 — 좋은 소식 먼저**: §2가 *"`Source`가 이 인터페이스를 구조적으로
만족한다"*, *"`Revision`은 공개 필드다. 비공개면 구조적 만족이 타입
레벨에서 성립하지 않는다"*고 확정한 것은 **그대로 성립한다.**
`Source<number>``Epoch` 파라미터에 그대로 넘길 수 있고,
`EpochMap:Update(src)`도 통과한다(§7의 "성립이 확인된 것" 목록엔
`Source`**`State`**를 만족한다는 항목만 있고 `Epoch` 쪽은 없었는데,
이제 실측됐다).
**걸리는 자리는 집합 쪽 하나**다. 인덱서 **키** 타입은 불변이라:
```lua
local rawSet: { [Source<number>]: true } = { [src] = true }
local u3 = m:Update(rawSet) -- ❌
```
```
TypeError: Expected this to be 'Epoch | EpochSet' but got '{ [SourceData<number> & {...}]: true }'
```
`local set: EpochSet = { [src :: Epoch] = true }`처럼 **키를 `Epoch`
캐스트해 넣으면** 통과한다.
**영향 범위는 좁다** — 게이트의 `withheld`나 시딩 코드는 필드 타입을
`EpochSet`으로 선언하고 `self._withheld[epoch] = true`로 넣을 것이므로
키가 이미 `Epoch`다. 문제가 되는 건 **집합을 리터럴로 만들어 넘기는
자리**(테스트, 그리고 `EpochSet`을 손으로 조립하는 유틸)뿐이다. **구현 시
정하면 되는 것**이지만, `EpochSet` 타입 별칭을 쓰는 자리마다 캐스트가
필요하다는 걸 모르면 "왜 안 되지"로 시간을 쓴다.
---
## 부록 — 5차 패스에서 걸어봤는데 **문제가 없던 것**
타입으로 선언해 돌려봤지만 확정된 서술과 일치했던 것들.
- **`state:Gate(function(emit) return b:Policy(emit) end)`가 타입으로
성립한다** — `gate-plan.md` 5번의 확정 형태 그대로. 그리고
**같은 절이 2026-08-24에 정정한 오답(`st:Gate(b.Policy)`)은 타입 검사가
잡아준다**(`Expected the 1st parameter to be a supertype of '() -> ()',
but got 'Blocker'`) — 그 정정이 문서 규율이 아니라 컴파일 게이트로
강제된다는 뜻이라 다시 밟을 위험이 없다.
- **`Debounce`처럼 자기 `Blocker`를 사적으로 갖는 정책 클로저**도 타입이
깨끗하게 맞는다(`H-86`은 타입 문제가 아니라 런타임 상태 접근 문제다).
- **`source:Apply(팩토리)``State`용 팩토리를 그대로 넘길 수 있다** —
`base/tween-plan.md``mySource:Apply(Animate{...})` 관용구가 성립한다.
`typing-limits.md` §7이 확인해둔 "Source가 State를 만족"이 **콜백
파라미터 자리(반변)에서도** 유지되는지는 따로 확인된 적이 없었는데,
된다.
- **`Effect(fn, ...deps)`의 deps를 `State<any> | Ref<any>` 가변인자로
선언하면 이형 조합이 통과한다** — `Effect(fn, state, ref)`가 정상이고,
엉뚱한 테이블(`{foo = 1}`)은 정확히 거부된다. 즉 `Effect`의 다중 dep은
**타입으로 표현 가능하다**(`H-70`이 남긴 건 런타임 검증 쪽이다).
- **게이트 2겹 unfold가 어느 순서로 풀려도 안 샌다**`gate-plan.md`
4번의 *"게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느
층이 먼저 풀리든 정보가 안 샌다"*를 4차 패스의 참조 구현으로 확인했다.
상류 먼저 풀든 하류 먼저 풀든 **Observer는 정확히 1회**, 배치 원소는
2개(두 루트), 값도 정확했다.
- **`:Compute(fn, ...deps)``Source`와 State가 섞여도 주석만 달면
정확히 좁혀진다** — 순서를 바꿔 넘기면 두 자리 모두 잡힌다(`H-96`의
부수 관찰).
---
## 회신 방법 ## 회신 방법
6라운드와 같다 — 항목 번호로 결정만 적어주면 `-followup.md`를 만들고 6라운드와 같다 — 항목 번호로 결정만 적어주면 `-followup.md`를 만들고
@ -2142,6 +2572,18 @@ GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근
같은 뿌리(`Epoch` 일반화 때 게이트 쪽 요구가 표면에 덜 반영됨)라 그것과 같은 뿌리(`Epoch` 일반화 때 게이트 쪽 요구가 표면에 덜 반영됨)라 그것과
같이 결정하는 게 낫다. 같이 결정하는 게 낫다.
**5차 패스**: `H-94`/`H-95`/`H-96`/`H-100`은 전부 **"확정된 시그니처가
확정된 관용구를 통과시키는가"** 하나에서 나왔고, 넷 다 결정이 아니라
**시그니처를 어떻게 적을지**의 문제라 한 번에 보면 된다(각 항목에 통과하는
형태를 실측으로 붙여뒀다). 이 중 `H-94`/`H-95`는 **M2/M6 구현 시작 전에
닫는 게 낫다** — 나중에 고치면 공개 표면이 바뀌는 자리다. `H-97`은 설계가
아니라 **마일스톤 구성** 결정이고(mock 플러그인을 M2에 넣을지, 패키지
경계를 다시 그을지, 테스트 범위를 좁힐지), M2가 끝났을 때 "전파 규칙이 한
번도 검증 안 된 채"가 되지 않으려면 지금 정해야 한다. `H-98``H-93`
함께 **중간 State GC 항목을 닫을 때 같이 볼 목록**이다 — 그 항목의 결론이
공개 계약 문장을 바꾼다는 게 지금 어디에도 표시돼 있지 않다. `H-99`
파일 배치 하나다.
**4차 패스**: `H-88`/`H-89`는 **한 질문의 두 얼굴**이다 — "예외가 나면 부기를 **4차 패스**: `H-88`/`H-89`는 **한 질문의 두 얼굴**이다 — "예외가 나면 부기를
어디까지 되돌리는가". 지금 코퍼스에 그 문장이 하나도 없어서 어느 쪽도 안 어디까지 되돌리는가". 지금 코퍼스에 그 문장이 하나도 없어서 어느 쪽도 안
정하면 구현자가 임의로 정하게 되고, `H-87`이 그 임의 결정이 실제로 얼마나 정하면 구현자가 임의로 정하게 되고, `H-87`이 그 임의 결정이 실제로 얼마나