quad/.claude/qa-request/pre-implementation-handtrace-round11.md
qwreey-agent-selene e0cb530b6b
feat: round11 §4 전량 종결(회신 3) — H-198 재시작 모델, H-186 UB, H-205 level 3, H-208/H-209/H-211
- H-198(사용자 안): 상류 스탬프(dep:_track)를 fn 직전으로 + Get을 수렴까지의
  재시작 루프로 — 계약 강화: 모든 Get이 fn 도중 변경(재진입·게이트 유보)을
  같은 호출 안에서 수렴시켜 항상 최신 반환. 매 패스 자기 dep Set은 UB.
  state-epoch-plan §4 세 절 + H-85 bullet 정정, spec.state 6 새 계약,
  spec.gate 10(리뷰의 영구 stale 재현 → 같은 Get에서 99 + flush 통지만)
- H-186(b): 교차 인스턴스 값 혼용 UB 문서화 — architecture 13번 + content-map
  §4 22번(H-116 이웃), 코드 주석. M5 재검토
- H-205(a): Modifier 가드 level 2→3(직접 Get이면 유저 호출부), spec.state 12 단언
- H-208: Ref:Set 스냅샷을 table.clone + 집합 병합으로(사용자 — 더 싸고 dedup 공짜)
- H-209: src 전 파일 pairs/ipairs → generalized iteration(사용자 — 최적화로 더
  빠름; 메타테이블 있는 테이블의 raw 순회 실측 확인). 문서 의사코드 표기는
  H-178과 같은 급으로 무변경
- H-211: Relate:SetWeak의 캐스트 없는 setmetatable 대입이 IDE(strict)에서
  TypeError(플레인 luau-analyze는 솔버 차로 조용) → 로컬 주석 + :: any 경유
- §4 열린 문항 0, 코드 마커 0. 감사 2라운드 수렴(확실 1·의심 1 → 0)

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
2026-08-31 13:33:09 +09:00

820 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# M2 구현 **11라운드** — 발견 원문 + 배치 문항지
> **이 파일이 무엇인가**: **[2026-08-28 신설]** M2 자율 구현 구간
> (`-round11-brief.md`가 규약)에서 나온 발견 전부. 앞 라운드들과 달리 **종이
> 트레이싱이 아니라 실제 코드를 옮기고 돌리다 나온 것**이다. 번호는 `H-165`부터
> (10라운드 후속이 `H-164`까지 썼다).
>
> **갈래 표기**(규약 §2): **①** 자율로 고침(같은 커밋에서 `base/`+코드) /
> **②** §4 표에 쌓아 배치 회신 대기 / **③** 즉시 중단·보고.
>
> **상태의 소스는 이 파일 자신** — 요약 표의 상태 열이 최신.
## 요약 표
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|---|---|---|---|---|---|
| `H-165` | ① | 1 | 🟡 | `quad-types``export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) |
| `H-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) |
| `H-167` | ① | 1 | 🟡 | 옮기며 `Ref<T>(default: T?)`/`.Value: T?`로 바꿔 놓았다 — 문서는 `Ref<T>(T)` 단일 파라미터, nil은 `Ref<<T?>>(nil)`로 | ✅ 코드를 문서에 맞춤(감사 2라운드) |
| `H-168` | **②** | 1 | 🟡 | `Ref<T>(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | ✅ (a) 사용자 확정 — 시그니처 유지, 관용구는 `Ref<<T?>>()`로 읽음(`ref-plan.md` "제네릭 시그니처") |
| `H-169` | **②** | 1 | 🟡 | `:Set` 블록이 스냅샷 콜백에 닫힌 인자 `value`를 넘겨서, 콜백 안 재진입 `ref:Set(new)` 뒤 남은 콜백이 **옛 값**을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 | ✅ 사용자 확정(권고 (a) 아님) — 순회가 자기 리비전이 바뀌면 놓고 후행 `Set`이 전부 호출(`spec.ref` 11) |
| `H-170` | **②** | 1 | 🟡 | `coroutine.resume(k, self)`는 에러를 올리지 않는다 — 대기자 안의 에러·죽은 thread resume이 `:Set`에서 조용히 삼켜짐. `ref-plan.md` *"나중에 `coroutine.resume`이 에러남"*은 사실이 아님 | ✅ (a) 사용자 확정 — 즉시 반환된 실패만 `error(err, 0)`(`spec.ref` 10) |
| `H-171` | ① | 1 | 🟡 | mock lazy claim: Destroy된 inst에 다시 bind하면 GC 전엔 죽은 gcconn 재사용, GC 뒤엔 새 Connected gcconn — 결과가 GC 타이밍에 따라 갈림 | ✅ mock: 죽은 inst의 새 gcconn은 즉시 Disconnect(`spec.lifetime` 6b) |
| `H-172` | ① | 1 | 🟡 | mock `Destroy`가 자손을 안 죽이고(조상 파괴 계약 검증 불가), `Parent` 변경 시그널이 연결 해제 뒤라 관측 불가, 재귀 Destroy 무한 루프 | ✅ mock: Destroying → Parent nil → 자손 Destroy → 연결 해제, 이중 Destroy no-op(`spec.lifetime` 6c) |
| `H-173` | ① | 1 | 🟢 | `ROADMAP.md` M7 체크박스·`tween-plan.md` 352가 `isTween`/`TweenBrand`를 `Tween.luau`에 둔다고 아직 서술(감사 3라운드가 두 곳만 고침) | ✅ 반영 |
| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) |
| `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 |
| `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) |
| `H-181` | ① | 2~4 | 🔴 | 인스턴스별 임플을 `module` 키의 weak-key 맵에 뒀는데 값(임플 클로저)이 키(`module`)를 캡처 — Luau엔 ephemeron이 없어 `Quad.New()`마다 영영 안 죽고 그 강한 레지스트리의 Observer/Effect 그래프까지 핀됨 | ✅ 임플을 `module._impl`(비공개 필드 — `H-174` (a)안 원문 모양)에, `spec.init` 2 |
| `H-182` | **②** | 3 | 🟡 | leaf `Destroying` 콜백이 cleanup을 소진한 뒤에도 같은 파동 안에서는 `canExecute`가 참(gcconn은 마지막에 끊김) → 파동 후반의 dep 변경이 죽는 leaf 위에서 `fn`을 다시 돌리고 아무도 소진 안 할 cleanup을 저장 | ✅ (a) 사용자 확정(2026-08-31) — `_dying` 플래그(`Destroying` 콜백이 세우고 재바인드·`Subscribe`류가 내림; Slot `_destroyed`와 다른 이름인 건 의도 — 재바인드 가능), `spec.effect` 10 |
| `H-183` | **②** | 3 | 🟡 | Observer 설치 발화 안에서 `self:Subscribe()`/`bindLifetime(inst, self)`를 부르면 `_catchUp``fn`을 중첩 재생하고 생성자가 "already subscribed"로 죽음 — Effect의 `isRunning` 가드(`H-147`)에 해당하는 것이 Observer엔 없음 | ✅ (a) 사용자 확정(2026-08-31) — Observer `_running` 가드(네 진입점 + `_assertBindable`; fn error 시 플래그 잔류는 설계상 인정), `spec.observer` 9 |
| `H-184` | **②** | 1·3 | 🟡 | `bindLifetime`이 부기를 커밋한 **뒤** `_bindDestroying``isRunning` 가드가 던지면 Effect가 묶인 채(`canExecute` 참) `Destroying` 연결 없이 남음 — 순서는 `lifecycle-pattern.md` (1) 그대로라 quad-roblox도 상속 | ✅ (a) 사용자 확정(2026-08-31) — `bindLifetime`이 커밋 전 `value:_assertBindable()` 문의(Effect·Observer 공통 훅), `lifecycle-pattern.md` (1)·mock 동시 반영, `spec.effect` 10·`spec.observer` 9 |
| `H-185` | **②** | 3 | 🟢 | `EffectFn``-> ...(() -> ())` 팩(H-95)인데 런타임은 첫 반환만 cleanup으로 저장 — `return stopA, stopB`가 타입은 통과하고 `stopB`는 조용히 버려짐 | ✅ 사용자 확정(2026-08-31, (a) 아님) — cleanup은 **하나만**: 목록 소진은 표면만 넓혀 기각, 다중 정리는 클로저로 묶는 게 계약(문서·타입 주석 명시, 런타임 무변경) |
| `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | ✅ (b) 사용자 확정(2026-08-31 회신 3) — **UB 문서화**, 추후 생각해볼 점으로("확인. UB로 놓는게 맞아보여") — `architecture.md` 13번 `H-186` 항목 + `documentation-content-map.md` §4, 코드는 주석만 |
| `H-187` | **②** | 3·4 | 🟢 | `quad-types`의 새 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)이 `base/`에 없는 이름 — 시그니처는 문서 그대로, 이름은 구현이 붙임 | ✅ (a) 사용자 확정(2026-08-31) — 이름 그대로 승인("부분부분 타입을 뽑아 재사용성 … 이의 없음"), `quad-types-plan.md`에 기록 |
| `H-188` | ① | 4 | 🟡 | `state:Gate(setup)`가 검증 실패로 error할 때 반쯤 만든 노드가 상류 `_subs`에 남아 다음 `Set``nil` 호출로 죽음(GC 타이밍 의존) | ✅ 실패 시 detach + setup 전 `_onUpstreamEmit = Void`, `spec.gate` 1 |
| `H-189` | ① | 3 | 🟢 | `Observer.Subscribed`가 초기화되지 않아 `nil`(타입은 `boolean`, Effect는 `false`) | ✅ `false`로, `spec.observer` 1 |
| `H-190` | ① | 2 | 🟢 | `Apply`의 비함수 분기가 검증 없이 `factory:__apply`를 불러 quad 내부 줄을 가리키는 raw 에러 | ✅ `level 2` 검증(형제 생성자들과 같은 급), `spec.state` 10 |
| `H-179` | ① | 4 | 🟡 | `state:Apply(factory)`의 파라미터를 유니온 하나로 선언하면 `state:Apply(blocker)`가 strict에서 막힌다(필드가 더 있는 객체는 제네릭 `U` 자리에서 너비 서브타이핑 실패) | ✅ 교집합 오버로드로 확정(`luau-test/done/26-*`, `typing-limits.md` §1②) |
| `H-180` | ① | 4 | 🟢 | 폐기된 `state:Block` 표기가 라이브 문서 둘에 남아 있었다(`source-state-plan.md` `_hold` 파생 노드 목록, `blocker-plan.md` 사용 예시) | ✅ 정정 |
| `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) |
| `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 |
| `H-191` | ① | 3 | 🟡 | `effect-plan.md` "`Ref` 의존성의 해제 경로" 절 꼬리가 아직 *"`Ref` 콜백은 본문 맨 앞에서 `canExecute(handle)`를 확인하고 거짓이면 그대로 리턴"*(= 버림)이고 절 머리 배너가 그걸 *"그대로 유효"*라 재확인한다 — `H-159` 뒤 코드는 `fire`가 판정 없이 `Update → Rerun`, `rawRerun`**홀드**(안 묶인 채 온 `ref:Set`이 바인드 때 1회 재생 — `spec.effect` 2) | ✅ 반영(2026-08-29, 메인 세션)|
| `H-192` | ① | 3·4 | 🟢 | 설치 발화 N회의 억제 주체가 두 문서에서 `canExecute`로 서술됨(`gate-plan.md` 7번 *"첫 가드에서 떨어진다"*, `effect-plan.md` "`Effect(fn, ...deps)`" 절 *"설치 발화를 전부 떨어뜨리므로"*) — 실제 억제는 `fire``from == nil` 가드(같은 문서 생성자 주석·코드). `H-159``canExecute` 경로는 버리는 게 아니라 홀드라, 그리로 갔다면 첫 바인드에서 `H-58`(바인드마다 재실행)이 되살아난다 — 실측 3-dep `Effect`: 생성 `runs=1`, `_rerunRequired=false`, 바인드 뒤에도 1 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-193` | ① | 4 | 🟢 | `gate-plan.md` "`GateNode` 조립"의 `H-152` 주석이 `H-163` 이전 모양 — *"`_emitDown`은 자식을 `isState(sub)`로만 가르므로 … `canExecute(gate)`도 거짓"*, *"GateNode는 State 생성자를 안 지나고 이 절이 곧 생성자"*. 지금 `_emitDown``EmitReceive`만 부르고 게이트는 `canExecute`를 안 타며, 코드는 `newNode`가 만들어 첫 줄에서 등록한다(결론 "등록"은 유효 — `newNode` dep 검증·`Effect` deps·`isState`가 본다) | ✅ 반영(2026-08-29, 메인 세션)|
| `H-194` | ① | 3 | 🟢 | `H-174` 잔재 둘 — `effect-plan.md` "`EffectHandle:Subscribe()`" 절 블록 머리 *"`canBound` 게이트(`LifetimeHandle.luau`의 탑레벨 함수)"*, `lifecycle-pattern.md` "Observer 인스턴스 필드 목록" *"레지스트리 두 테이블은 … `Observer.luau`의 모듈 로컬"* — 지금은 인스턴스 필드 `module.canBound` / 인스턴스별 임플 클로저 로컬(`Impl._Subscribed`) | ✅ 반영(2026-08-29, 메인 세션)|
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`**라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | ✅ 사용자 확정(2026-08-31 회신 3, 사용자 안) — 상류 스탬프를 `fn` **직전**으로 + `Get`을 수렴까지 **재시작 루프**로(항상 최신 반환, `EpochMap` 새 연산 없음 — "이미 있는 표면들로 충분"), 매 패스 자기 dep `Set`은 UB. `state-epoch-plan.md` §4·`spec.gate` 10·`spec.state` 6 |
| `H-199` | ① | 2 | 🟡 | `With`/`Compute`의 vararg 수집(`deps[#deps + 1]`)이 nil dep을 조용히 버림 — 중간 nil은 뒤 dep들을 왼쪽으로 밀어 `fn`의 positional lazy 인자가 엉뚱한 자리에 감(실측) | ✅ `collectDeps` 공유 + `dep #N is nil` error(level 3), `spec.state` 13 |
| `H-200` | **②** | 4 | 🟡 | `Impl.Gate`의 detach(`H-188`)는 "비함수 반환" 경로만 — `setup`**던지면** 반쯤 만든 노드가 상류 `_subs`에 좀비로 남음(`_onUpstreamEmit = Void`라 크래시는 없지만 유보 집합을 영영 못 비움). `spec.gate` 1의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | ✅ (b) 사용자 확정(2026-08-31) — `setup`이 도는 동안 `_subs`에서 떼어두고 성공 후 재등록(pcall 없음), `gate-plan.md` 조립 절·`spec.gate` 1 |
| `H-201` | ① | 2 | 🟡 | `store:Of(nil)`이 RESERVED·rawget을 통과해 Source를 할당한 **뒤** `self[name] =`에서 Luau 내부 에러로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`이 숫자를 반환 | ✅ `type(name) ~= "string"` → error level 2, `spec.store` 3 |
| `H-202` | ① | 2 | 🟢 | `Compute``fn`이 함수인지 안 봄(형제 표면 Gate/Observer/Apply는 전부 검증) — 첫 `Get``_recompute` 안 내부 프레임에서 죽음 | ✅ level 2 검증(형제와 같은 급), `spec.state` 13 |
| `H-203` | **②** | 4 | 🟡 | `Off()`의 스냅샷 순회가 `IsBlocked`를 다시 안 읽음 — 첫 게이트 flush의 하류가 `b:On()`을 다시 켜도 남은 핸들이 전부 flush돼 `IsOn() == true`인데 통지가 새어나감(실측). 문서의 미지원 선언은 네스팅(`On`/`On`/`Off` 카운팅)만 다룸 | ✅ (a) 사용자 확정(2026-08-31) — 순회가 핸들마다 `IsBlocked` 재확인, 멈추면 잔여는 다음 `Off`로(절반 emit 상태는 quad 정의상 무문제), `blocker-plan.md`·`spec.blocker` 8 |
| `H-204` | ① | 2 | 🟢 | `Store(defaults)``defaults`가 평범한 테이블인지 안 봄 — 비테이블은 `table.clone` 내부 프레임에서, 맨 Source는 내부 필드(`Revision`)를 지목하는 에러로 죽음 | ✅ 테이블·메타테이블 검증(level 2), `spec.store` 2 |
| `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | ✅ (a) 사용자 확정(2026-08-31 회신 3, "권고대로") — level 3(직접 `Get`이면 유저 호출부, 체인 경유면 현행과 동일), `spec.state` 12 |
| `H-206` | ① | 2·3 | 🟢 | `implsOf` 헬퍼 + 4줄 ephemeron 근거 주석이 세 파일(`State`/`Observer`/`Effect`)에 verbatim 중복 — 저장 방식이 바뀌면 세 곳을 다 고쳐야 하고, 미묘하게 다른 네 번째 사본이 레지스트리를 조용히 가름 | ✅ `ImplRegistry.luau` 신설(순수 데이터 접근 — 공유 허용 범위) |
| `H-207` | ① | 2 | 🟢 | `Source.Set``Emit`의 꼬리(리비전 범프 + `emitDown` + self 반환) 전체를 복제 — 한 파일 안에 같아야 하는 계약 두 벌 | ✅ `Set``Impl.Emit(self)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) |
| `H-208` | ① | 1 | 🟢 | (사용자 코드 검토, 2026-08-31) `Ref:Set`의 스냅샷을 배열 재구축으로 뜨고 있었다 — `table.clone` + 집합 병합이 더 싸고 dedup도 공짜 | ✅ 반영(`Ref.luau`, `ref-plan.md` `:Set` 블록) |
| `H-209` | ① | 1~4 | 🟢 | (사용자 코드 검토, 2026-08-31) 전반의 `pairs`/`ipairs` 순회 — Luau generalized iteration(`for k, v in t do`)이 최적화로 더 빠름(`next`와 다르게 동작) | ✅ src 전 파일 일괄 전환(메타테이블 있는 테이블도 raw 순회 실측 확인). 문서 의사코드의 `pairs` 표기는 `H-178`(코드 관례)과 같은 급으로 무변경 |
| `H-210` | ① | 툴링 | 🟡 | (사용자 발견, 2026-08-31) 루트 `default.project.json`이 각 패키지의 `roblox_packages`를 트리에 안 실어 rojo 통합 luau-lsp가 *"Unknown require: game/ReplicatedStorage/roblox_packages/quad_types"* — pesde 가이드는 패키지마다 `src`·`roblox_packages` 형제 매핑 | ✅ 패키지별 Folder(`quad-base`/`quad-roblox`) 아래 `src`+`roblox_packages`로(공유 하나면 quad-roblox 의존성 생기는 순간 충돌 — 사용자 지적), `luau-lsp analyze` unknown-require 0 실측. `project-setup-plan.md` 셋째 함정 |
| `H-211` | ① | 툴링·1 | 🟢 | (사용자 발견, 2026-08-31) `Relate:SetWeak``bucket.WeakMap = setmetatable(…)` 대입이 IDE(luau-lsp, strict)에서 TypeError — 메타테이블 붙은 타입은 평범한 인덱서의 서브타입이 아님. 플레인 `luau-analyze`(test.sh)는 조용(솔버 차) | ✅ 로컬 주석 + `:: any` 경유(모든 솔버 통과). 이웃 `::` 직접 캐스트들(Brand/EpochMap/Observer/Relate 생성자)은 사용자 IDE에서 무발화라 무변경 — 신형 솔버 CLI만 "unrelated"로 봄, 그쪽이 게이트가 되면 그때 일괄 |
## 상세
(단위별로 `### \`H-nnn\` 🔴/🟡/🟢 — 제목` 절을 이어 붙인다. 각 절엔 (1) 어디서
(파일:줄 / `base/` 절), (2) 무엇이, (3) 문서가 이미 답을 갖고 있는가, (4) 어떻게
처리했는가(①이면 커밋 해시).)
### 단위 1 — 공통 기반 (2026-08-28)
### `H-165` 🟡 — pesde shim은 생성 시점의 export 타입만 안다
- **어디서**: `quad-base/roblox_packages/quad_types.luau`(pesde 생성물, 커밋 안 됨) /
`base/project-setup-plan.md``test.sh` 절.
- **무엇이**: `quad-types/src/init.luau``Ref<T>`/`Relate`/`Epoch`를 `export type`으로
추가하고 `test.sh`를 돌리자 `luau-analyze``Unknown type 'QuadTypes.Ref'`. shim이
`export type Quad = module.Quad` / `CheckedQuad`만 손으로 나열한 파일이라
`return module`로는 타입이 안 넘어온다. `relink.sh`는 복사만 갱신한다.
- **문서가 답을 갖고 있었나**: 아니다 — 첫 함정(심볼릭 링크)만 적혀 있었다.
- **처리**: `pesde install` 재실행으로 shim 재생성(같은 세션 실측), `project-setup-plan.md`
둘째 함정으로 기록. `test.sh`가 이제 `luau-analyze`를 같이 돌려 조용히 지나가지 않는다.
### `H-166` 🟢 — `Ref.Revision` 초기값이 문서에 없다
- **어디서**: `base/ref-plan.md` "`Ref`는 `Epoch`를 만족한다" 절 / `base/state-epoch-plan.md` §2.
- **무엇이**: 갱신식(`bit32.bnot(-rev)`)과 표만 있고 시작값이 없다.
- **처리**: `0`으로 구현하고 그 절에 한 줄 추가. 계약이 `==`/`~=`뿐이라 값은 무관.
### `H-167` 🟡 — 구현이 `Ref` 시그니처를 `T?`로 바꿔 놓았다 (자기 실수)
- **어디서**: `quad-types/src/init.luau`·`quad-base/src/Ref.luau` vs `base/ref-plan.md`
"제네릭 시그니처(2026-08-07 확정)".
- **무엇이**: 문서는 `Ref<T>(T) -> Ref<T>`, `.Value: T` — nil이 올 수 있는 자리는 호출자가
`Ref<<T?>>(nil)`로 넓힌다(그 문서의 언바인딩 절도 같은 전제). 옮기면서 `Ref()` 관용구를
타입에서 받으려고 `default: T?`/`Value: T?`로 적었는데 그러면 `Ref(5).Value`까지
nil 검사를 강요한다 — 문서가 이미 기각한 모양. 감사 2라운드가 발견.
- **처리**: 코드를 문서대로 되돌림. 테스트의 `Ref()``Ref<<number?>>(nil)`로.
### `H-168` 🟡 — `Ref<T>(T)`와 무인자 `Ref()` 관용구가 양립하지 않는다 (②)
- **어디서**: `quad-base/src/Ref.luau` 생성자 / `base/ref-plan.md` "제네릭 시그니처" vs 같은
문서의 `Ref():Callback(fn)` 관용구(`H-120` 문단), `base/lifecycle-hooks-plan.md`
`PreRef():Callback(guard(fn))`, `base/debounce-throttle-plan.md``Handle = Ref()`.
- **무엇이**: `H-167``default: T`가 되자 `local r = Quad.Ref()`가 strict에서 *"Argument
count mismatch. Function expects 1 argument, but none are specified"*(리뷰 재현). 문서는
명시 확장 `Ref<<T?>>(nil)`만 다루고 **빈 호출**은 안 다룬다. M8 훅 슈가가 문서대로
짜이면 타입 검사를 못 통과한다.
- **갈래**: §4.
### `H-169` 🟡 — 재진입 `:Set` 뒤 남은 콜백이 옛 `value`를 받는다 (②)
- **어디서**: `Ref.luau` `:Set``k(value, self)``base/ref-plan.md` "`:Set(value)`의 순서"
블록을 그대로 옮긴 것.
- **무엇이**: 콜백 A가 `ref:Set(2)`를 다시 부르면 안쪽 파동이 먼저 다 돌고, 바깥 파동의 남은
콜백 B는 `.Value == 2`인 채로 인자 `value == 1`을 받는다(리뷰 재현: B가 `(2,2)` 다음
`(1,2)`). 문서의 *"모든 콜백이 새 값을 본다 … 옛 값이 보이는 창이 없다"*와 어긋남.
문서가 재진입을 안 다뤄서 **문서 블록 수정이 필요한 자리** — 코드 임의 변경 금지.
- **갈래**: §4.
### `H-170` 🟡 — `coroutine.resume`은 에러를 올리지 않는다 (②)
- **어디서**: `Ref.luau` `:Set``coroutine.resume(k, self)` / `base/ref-plan.md` `:Wait(thread)`
*"나중에 `coroutine.resume`이 에러남"* / `spec.ref.luau` 9번의 꼬리 `Set`.
- **무엇이**: resume은 `(false, err)`를 **반환**하지 던지지 않는다. 대기자 안에서 난 에러와
죽은 thread resume이 `:Set`에서 조용히 사라진다 — `architecture.md` "예외 안전성 계약 —
감싸지 않는다"의 취지(에러는 전파된다)와 어긋나고, 문서 서술과 테스트 주석의 전제가
틀려 있었다(주석은 고침).
- **갈래**: §4.
### `H-171` 🟡 — mock lazy claim이 GC 타이밍에 따라 갈린다
- **처리**: `claim`이 이미 Destroy된 inst면 새 gcconn을 즉시 `Disconnect` — Roblox에선
`nativeClaim`이 생성 시 1회라 이 경로가 없다. `spec.lifetime.luau` 6b가 GC 전/후 둘 다 고정.
### `H-172` 🟡 — mock `Destroy` 의미론
- **처리**: Destroying 발화 → Parent nil(변경 시그널 관측 가능) → 자손 재귀 Destroy → 연결
해제, 이미 파괴됐으면 no-op. 6c가 조상 파괴 계약(5라운드 *"조상 파괴 시 unowned 요소도
같이 죽는다"*)을 mock에서 검증 가능하게 한다.
### `H-173` 🟢 — `TweenBrand`/`isTween` 위치 잔재 둘
- **처리**: `ROADMAP.md` M7 체크박스, `tween-plan.md` "base 프리미티브 아님" 문단.
### 단위 1 — 탐사자 (2026-08-28, 커밋 `d9898d6`..`325f3f0`)
### `H-174` 🔴 — `canExecute`/`canBound`는 인스턴스 필드인데 반응형 의사코드는 자유 함수로 부른다 (②)
- **어디서**: `quad-base/src/LifetimeHandle.luau`(`Init(module)`이 `module.bindLifetime = …`
네 슬롯을 **인스턴스마다** 심음) + `quad-base/test/mock.luau` `installLifetime(quad)`(그
`quad` 하나만 덮어씀) + `spec.lifetime.luau` 8("다른 `New()`엔 안 퍼짐"을 고정) —
vs `base/source-state-plan.md` "전파 루프 — 확정 의사코드"의 `Observer:_receive`
(`if canExecute(self) then`), `base/lifecycle-pattern.md` (2)의 `Observer:WeakSubscribe`
(`if not canBound(self) then`), `base/effect-plan.md``rawRerun`(`canExecute(self)`)과
`EffectHandle` 네 진입점.
- **무엇이**: 이 단위가 확정대로 옮긴 결과 네 함수는 **자유 함수가 아니라 `quad` 인스턴스의
필드**다(`lifecycle-pattern.md` 186행 *"평평한은 모듈 인스턴스의 필드라는 뜻"*, `architecture.md`
13번 *"모듈이 하나의 인스턴스(… canExecute 등 계약 필드 하나)"*). 그런데 다음 단위가
옮길 의사코드는 전부 `canExecute(self)`처럼 **어느 인스턴스인지 말하지 않고** 부른다.
`Ref.luau`처럼 `Source.luau`/`State.luau`/`Observer.luau`를 인스턴스 간 공유되는 잎
모듈로 쓰면(`spec.init` 27행이 `Ref`/`Void`/`Relate`에 대해 고정한 모양) 발화 시 **어느
`quad``canExecute`를 부를지 알 수 없다** — 백엔드가 둘이면(mock + roblox, `New()`
존재 이유) 잘못된 인스턴스의 게이트를 타거나, 아직 스텁인 인스턴스에서 `error`가 난다.
코퍼스를 grep해도(`InitSource`/`InitState`/`InitObserver`/`_quad`/`_module`) 반응형 모듈의
조립 형태를 정한 문장이 없다 — `module-lifecycle-plan.md` "New()의 내부 구성"이 정한 건
`InitDispatch(module)` 하나의 예시뿐이고, `ROADMAP.md` "반응형 본체" 체크박스도 침묵한다.
- **같이 걸리는 하위 함정(결정과 무관하게 참)**: 백엔드는 `New()`**끝난 뒤** 필드를
덮어쓴다(mock도 같은 순서). 그래서 반응형 모듈이 `Init` 시점에 `local canExecute =
module.canExecute`로 **스텁을 캡처하면 영원히 스텁**이다 — 발화 시점마다
`module.canExecute(self)`**늦게 읽어야** 한다. `spec.lifetime` 8은 `quad.bindLifetime ==
before`(재설치 무시)만 보고 이 순서는 안 본다.
- **문서가 답을 갖고 있나**: 아니다. `architecture.md` 13번의 *"module-level state를
참조하는 코드들이 모듈 인스턴스를 인자로 받도록 손을 대야 한다 — `InitModule(module)`
등"*이 방향만 준다. 어느 모양이든 **새 조립 메커니즘**이라 §4.
- **갈래**: §4. 단위 1 코드는 어느 선택지에서도 그대로다(③ 아님).
### 단위 2 — `EpochMap` → `Source`/`State`/`Store` (2026-08-28)
### `H-176` 🟡 — 타입팩 deps 선언은 strict에서 안 산다 (①)
- **어디서**: `quad-types/src/init.luau` `State<T>.Compute` / `base/source-state-plan.md` "trailing
deps를 `fn`에 lazy positional 인자로도 노출"의 "(B) 이형 다중 deps를 제네릭 팩으로".
- **무엇이**: `<U, D...>(self, fn: (self, U?, D...) -> U, D...)`로 선언하자 `spec.state.luau`
정상 호출 셋(3·7·9)이 *"Expected `{ read Get: (t1) -> (number, ...unknown) }` but got
`Source<number>`"*로 막혔다 — 팩이 콜백 파라미터 쪽으로 역추론되며 `Get`을 read-only
`...unknown` 반환으로 뒤튼다. 문서가 "실측 필요"로 남겨둔 바로 그 (B)다.
- **처리**: deps 자리 `...any`, 콜백 안에서 `dep: StateData<U>` 주석(콜백 파라미터 주석 관례
그대로). 런타임 계약 무변경. 문서에 실측 기록, 스파이크 `15` 닫힘.
### `H-177` 🟢 — 반응형 Init의 순서 의존 (①)
- **어디서**: `quad-base/src/{Source,Store}.luau``Init` / `init.luau``RunInit` 순서 주석 vs
`base/module-lifecycle-plan.md` "New()의 내부 구성" 절의 *"순서 의존성은 각 `InitXxx`
`require`처럼 멱등하게 만들어서 해소한다"*.
- **무엇이**: 단위 2 첫 커밋은 `init.luau`가 State → Source → Store 순서를 지키게 하고
`implFor`가 어기면 error하게 했다 — 동작은 하지만 문서 원칙과 다른 길. 감사 3라운드가
"사용자 판단"으로 올렸으나 문서가 이미 답을 갖고 있어 ①.
- **처리**: `Source.Init``module:RunInit(State.Init)`, `Store.Init``module:RunInit(InitSource)`
직접 호출. `init.luau` 순서 주석을 "무관"으로.
### 단위 3 — `Observer` → `Effect` (2026-08-29)
### 단위 4 — `GateNode` → `Blocker` (2026-08-29)
### `H-179` 🟡 — `:Apply`의 파라미터 타입 표기 (①)
- **어디서**: `quad-types/src/init.luau` `State<T>.Apply` / `base/source-state-plan.md`
"`state:Apply(factory)`"(`H-94`: *"함수 또는 그 필드를 가진 객체를 받고 반환 `U`
열어둔다"*) / `base/typing-limits.md` §1②.
- **무엇이**: 단위 2가 `((State<T>) -> U) | { __apply: (self, State<T>) -> U }` 유니온으로
적었고 함수 팩토리·`__apply`만 가진 객체는 통과했는데, 단위 4의 실제 `Blocker`(필드가
더 있음)를 `s:Apply(b)`에 넣자 *"Expected this to be t1 where t1 = … | { __apply: … }"*.
스파이크 4개(유니온·인덱서·제네릭 `__apply`·`any` 파라미터)로 좁힌 결과 유니온-제네릭
파라미터에서 추가 필드가 있는 객체의 너비 서브타이핑이 안 된다. **교집합 오버로드**
(함수 쪽 제네릭 `U` / 객체 쪽 `-> any`)만 양쪽을 다 받고 음성 대조군도 잡는다.
- **처리**: 그 표기로 확정, 스파이크를 `luau-test/done/26-*`로 보존, `typing-limits.md`
§1②에 기록. `H-94`의 뜻(둘 다 받고 반환은 열어둔다)은 그대로 — 객체 쪽 반환이 `any`
호출부가 결과 타입을 명시한다(§1① 관례).
### 단위 3·4 `/code-review high` (2026-08-29) — 10건 중 ① 넷(`H-181`/`H-188`/`H-189`/`H-190`) 반영, ② 여섯(`H-182`~`H-187`) §4
### `H-181` 🔴 — 인스턴스별 임플의 저장 자리가 인스턴스를 영영 살렸다 (①)
- **어디서**: `State.luau`/`Observer.luau`/`Effect.luau`의 `implByModule`(weak-key, `module` 키).
- **무엇이**: 값인 임플의 메소드 클로저가 `module`을 캡처 — weak-key 테이블의 값이 키를 되참조하면
ephemeron이 없는 Luau에선 절대 수거되지 않는다(`relate-plan.md`가 `H-71`로 경고한 바로 그
모양). `init.luau``New()`마다 세 Init을 무조건 돌리므로 **아무것도 안 써도** 모든 인스턴스가
핀되고, 강한 레지스트리(`Subscribed`)에 든 Observer/Effect 그래프까지 같이 산다. 리뷰 실측.
- **처리**: 임플을 `module._impl`(비공개 필드)에 둔다 — `H-174` (a)안 원문이 *"RunInit 뒤의
비공개 필드로 형제 Init에 넘김"*이라 새 모양이 아니다. `module ↔ impl` 순환은 자기완결이라
인스턴스를 놓으면 통째로 수거된다(`spec.init.luau` 2). `State.Impl._module` 미사용 필드도 제거.
### `H-188` 🟡 — 반쯤 만든 `GateNode` (①)
- **처리**: 검증 실패 시 `self._subs[node] = nil`로 떼고 error; setup이 돌기 전 `_onUpstreamEmit`
`Void`로 두어 setup이 던져도 나중 `Set``nil`을 부르지 않게(예외 후 부기는 UB라는 계약은
그대로 — 이건 우리가 통제하는 검증 경로만 닫은 것).
### `H-189` 🟢 / `H-190` 🟢 (①)
- `Observer` 생성자에 `Subscribed = false`; `Apply`의 객체 분기에 `type(factory.__apply) ==
"function"` 검증(`level 2`). 둘 다 형제 코드와 같은 급.
### `H-182` ~ `H-187` (②) — 코드엔 `-- TODO(H-nnn)` 마커만, 결정은 §4
- **`H-182`**: `Destroying` 콜백(cleanup 소진·연결 해제) 뒤 같은 파동에서 `canExecute`가 아직
참. 리뷰 실측: 부모 Effect A(dep `count`) / 자식 Effect B의 cleanup이 `count:Set(...)`
`parent:Destroy()``parent-run:0, child-run, parent-clean, parent-run:-1, child-clean`,
끝에 `A._cleanup ~= nil`, `A._destroyConn == nil`. `H-160`은 cleanup **실행 중**만 막았다.
- **`H-183`**: 설치 발화(`o.fn(state, o, nil)`) 중 `_rerunRequired`가 아직 참이라 그 안의
`Subscribe``_catchUp`으로 `fn`을 중첩 재생. `H-147`의 원칙(*"`fn`은 자기 생명주기를 못
바꾼다"*)을 Observer에도 적용할지, UB로 문서화할지.
- **`H-184`**: `bindLifetime`의 순서(부기 커밋 → 훅). 훅의 `isRunning` 가드가 던지면 반쯤 묶임.
- **`H-185`**: cleanup 팩. 런타임이 첫 반환만 쓴다.
- **`H-186`**: 교차 인스턴스 dep. 리뷰 실측: `A.Effect(fn, B.Source(0))`가 B의 미주입 스텁
에러로 죽는다(A는 다 갖췄는데).
- **`H-187`**: 타입 별칭 이름 넷.
### `H-180` 🟢 — `state:Block` 잔재 (①)
- **처리**: `source-state-plan.md``_hold` 파생 노드 목록과 `blocker-plan.md` 사용 예시에서
`:Apply(blocker)`로. `gate-plan.md` 149-151은 정정 문구가 이미 뒤따라 그대로.
### `H-178` 🟢 — 사적 필드의 `_` 접두 (①)
- **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의
`self.valueEpochMap` vs 코드 `_valueEpochMap`(단위 2부터).
- **무엇이**: 단위 2가 State의 사적 필드를 전부 `_` 접두로 옮겼고(`_subs`/`_hold`/`_cache`/
카운터 둘/맵 둘), 단위 3 `Effect``d._valueEpochMap`로 읽는다. 문서는 접두 없음. 뜻·개수는
1:1이라 문서를 고칠 이유가 없고(의사코드는 문서 안에서 자기완결), 코드 쪽 관례로 기록만.
- **처리**: 없음 — 다음 단위(`GateNode`가 `valueEpochMap`을 컴포지션)도 같은 접두로.
### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①)
- **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석.
- **무엇이**: *"불변 업밸류만 잡는 클로저는 프로토에 캐시돼 영영 GC되지 않는다"*는 범위가
넓다. Luau 컴파일러 `shouldShareClosure`의 실제 조건은 **업밸류가 없거나, 전부
`functionDepth == 0`(톱레벨) 불변 로컬**일 때뿐이다 — 함수 안의 지역/인자를 잡는 클로저는
불변이어도 공유되지 않는다(컴파일러 주석: 공유하면 *"임시 객체가 영구가 된다"*라
휴리스틱으로 좁혀둠). 실측(스크래치, `luau`): `mk(handle)`가 돌려준 클로저는 GC됨,
업밸류 없는 클로저는 GC 뒤에도 살아남음(생존 1/2). 단위 3 `Effect``handle`을 잡고
`ref:WeakCallback(cb)`로 거는 모양을 그대로 흉내내면 `WeakCallbacks` 항목이 0으로
사라진다 — 즉 지금 문장대로면 단위 3 GC 테스트가 잘못 경보를 낼 수 있다.
- **처리**: ✅ 반영(2026-08-28, 메인 세션). §5 문장을 *"업밸류가 없거나 톱레벨 불변 업밸류만
잡는 클로저"*로 좁히면 된다.
### 단위 3·4 — 탐사자 (2026-08-29, 커밋 `dca789e`..`3670e88`)
전부 ①이고 전부 **문서 쪽**이다 — 코드는 확정 의사코드를 그대로 옮겼고(대조 결과는 §5),
어긋난 자리는 모두 "더 최근 결정(`H-159`/`H-163`/`H-174`)이 옛 문장을 못 지운 것"이다.
②·③ 없음. `H-182`~`H-187`은 재발견하지 않았고 반박 근거도 없다(`H-186`에 이웃 실측 하나를
§6에 보탰다).
### `H-191` 🟡 — `Ref` 콜백의 "거짓이면 리턴"은 `H-159` 이전 모양 (①)
- **어디서**: `base/effect-plan.md` "`Ref` 의존성의 해제 경로" 절 끝 문단(*"확정된 형태: `Effect`
거는 `Ref` 콜백은 본문 맨 앞에서 자기 핸들에 대해 `canExecute(handle)`를 확인하고, 거짓이면
그대로 리턴한다"*)과 그 절 머리 배너(*"아래 `canExecute` 게이팅은 그대로 유효하다 — 뒤쪽
절반"*) vs `quad-base/src/Effect.luau``fire`/`rawRerun`.
- **무엇이**: 코드는 `onRefFire → fire(ref) → _epochs:Update(ref) → Rerun → rawRerun`이고
실행 불가면 `_rerunRequired`로 **홀드**한다 — 콜백 본문엔 판정이 없다(`H-159` *"fire 는 그냥
rerun 을 호출해도 될것"*). 관측 차이가 있다: 문서대로면 안 묶인 채 온 `ref:Set`**버려지고**
`_epochs`도 안 움직이는데, 코드는 홀드했다가 바인드 때 1회 재생한다(`spec.effect` 2가 이걸
고정 — `r:Set(2)``bindLifetime``runs == 2`). 같은 문서의 생성자 의사코드는 이미
`H-159` 모양이라 **문서 안에서 두 절이 서로 다른 계약**을 말한다.
- **문서가 답을 갖고 있나**: 있다 — `H-159`(`qa-request/pre-implementation-handtrace-round10-followup.md`)와 같은 문서의 생성자 블록.
- **처리**: ✅ 반영. 그 문단을 "`fire`는 판정하지 않고 `rawRerun`이 홀드한다(생성자 블록이
소스)"로, 배너의 "뒤쪽 절반"도 같은 뜻으로 좁히면 된다.
### `H-192` 🟢 — 설치 발화 억제 주체 서술이 둘로 갈린다 (①)
- **어디서**: `base/gate-plan.md` 7번(*"생성자 안에선 아직 안 묶여 있어 설치 발화가 첫 가드에서
떨어진다"*), `base/effect-plan.md` "`Effect(fn, ...deps)`" 절의 "최초 1회를 한 번만 돌리는
장치" 항목(*"`canExecute`(지금은 `rawRerun` 진입에서 본다)가 설치 발화를 전부
떨어뜨리므로"*) vs 같은 문서 생성자 의사코드 주석(*"즉시-1회 호출(설치 발화)은 `fire`
`from == nil` 가드가 거른다(옛 `Blocker`/`canExecute` 억제 서술은 폐기)"*)과 코드.
- **무엇이**: 내부 Observer의 설치 발화는 `onStateFire(_, _, nil) → fire(nil) → return`에서
끝난다 — `rawRerun`에 닿지 않는다. `H-159``rawRerun``canExecute` 가드는 **버리는
게 아니라 홀드**이므로, 그리로 갔다면 dep마다 `_rerunRequired = true`가 서고 첫 바인드에서
`fn`이 한 번 더 돈다 — 바로 `H-58`이다. 실측(스크래치): `Effect(fn, s1, s2, s3)` 생성 직후
`runs == 1`, `_rerunRequired == false`, `bindLifetime` 뒤에도 `runs == 1`.
- **처리**: ✅ 반영. 두 자리를 "설치 발화는 `from == nil` 가드가 거른다(`canExecute`는 그
뒤의 진짜 emit을 홀드한다)"로.
### `H-193` 🟢 — `GateNode` 조립의 `H-152` 근거가 `H-163` 이전 모양 (①)
- **어디서**: `base/gate-plan.md` "`GateNode` 조립" 블록 머리 주석(`H-152`) vs
`base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`H-163`)와 `quad-base/src/State.luau`.
- **무엇이**: 주석은 *"`_emitDown`은 자식을 `isState(sub)`로만 가르므로 … 등록이 빠지면 상류
emit이 `_receive`로 안 오고 `canExecute(gate)`도 거짓이라"*, *"GateNode는 State 생성자를 안
지나고 이 절이 곧 생성자"*라 한다. 지금 `_emitDown``sub:_receive(from)`만 부르고
(`isState` 분기 없음), 게이트는 `canExecute`를 안 타며(`H-56`), 코드의 `Impl.Gate`
`newNode({self}, passThrough, GateImpl)`로 만들어 **`newNode` 첫 줄**에서 `StateBrand:register`
한다 — 별도 생성자가 아니다. 등록 자체는 여전히 필요하다(`newNode`의 dep 검증, `Effect`
deps 검증, `Quad.isState`가 본다 — `spec.gate` 1이 고정).
- **처리**: ✅ 반영. 근거 문장만 지금 모양(`newNode` 공유, 등록의 소비자는 검증·술어)으로.
### `H-194` 🟢 — `H-174` 잔재 둘 (①)
- **어디서**: `base/effect-plan.md` "`EffectHandle:Subscribe()`" 절 블록 머리(*"`canBound`
게이트(`LifetimeHandle.luau`의 탑레벨 함수)"*), `base/lifecycle-pattern.md`
"Observer 인스턴스 필드 목록" 끝(*"레지스트리 두 테이블은 인스턴스 필드가 아니라 `Observer.luau`
모듈 로컬"*).
- **무엇이**: `H-174` (a) 뒤 게이트는 `module.canBound`(인스턴스 필드, 발화 시점에 늦게 읽음)
이고, 레지스트리 둘은 `Observer.Init(module)`이 만드는 **인스턴스별 임플의 클로저 로컬**
(`Impl._Subscribed`/`_WeakSubscribed`, `spec.observer` 8이 인스턴스 분리를 고정)이다.
`lifecycle-pattern.md``H-174` 문단은 게이트 쪽만 정정했고 레지스트리 문장은 안 건드렸다.
- **처리**: ✅ 반영. 두 문장만(`effect-plan.md` 쪽은 스윕이 먼저 고쳤다).
### `H-195` 🟢 — `_consumeCleanup` 머리 주석의 자기모순 (①)
- **어디서**: `base/effect-plan.md` "의사코드 — 생성자" 절, `_consumeCleanup` 블록 위 주석
*"이 순서라야 이중 호출이 없고, `_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"* vs 바로 아래
⚠️ 문단 *"'설치돼 있는가'를 `_cleanup`의 유무로 판정하면 안 된다 — 별도 플래그"*와
`Effect.luau`의 같은 자리 주석(*"`_cleanup`'s presence is not 'installed' — the flag is"*).
- **처리**: ✅ 반영. 주석 뒷절 삭제.
### `H-196` 🟢 — Observer 쪽 "죽었으면 no-op" 둘 (①)
- **어디서**: `base/source-state-plan.md` "`state:Observer(fn)`" 절의 *"발화 시점과 처리 시점
사이에 owning leaf가 이미 죽었으면 no-op"*, 같은 문서 "Slot 생존 확인" 절의 *"거짓이면 그냥
no-op"*.
- **무엇이**: `H-159``Observer:_receive`는 거짓이면 `_rerunRequired`를 세우고, 재바인드
(`bindLifetime`)·`Subscribe`·`WeakSubscribe`가 `_catchUp`으로 1회 재생한다 — 죽은 leaf에
묶인 채로는 no-op과 같지만 `H-65`(죽은 바인딩의 재사용)와 겹치면 관측이 다르다
(`spec.observer` 2: Destroy 뒤 `Set` → 홀드). 같은 절의 두 줄 위는 이미 "홀드"로 고쳐져 있어
한 절 안에서 표현이 갈린다.
- **처리**: ✅ 반영. "no-op" → "홀드(재바인드 시 1회)".
### `H-197` 🟢 — `spec.init` 1이 단위 3·4 탑레벨 값을 안 본다 (①)
- **어디서**: `quad-base/test/spec.init.luau` 1절 vs 그 파일 헤더(*"`init.luau`의 `{...} :: Quad`
캐스트는 빠진 필드를 안 잡으므로 이 런타임 확인이 유일한 가드"*).
- **무엇이**: `Relate`/`Void`/`Ref`/`Source`/`Store`/술어 11/생명주기 4만 단언하고
`Effect`/`Blocker`/`onDestroying`은 없다 — 단위 3·4가 `Quad` 타입에 더한 셋이 가드 밖이다
(`spec.init` 2가 `q.Effect`를 부르긴 하지만 그건 `H-181` GC 검사다).
- **처리**: ✅ 반영. 1절에 세 줄.
### 단위 2 — `/code-review high` (2026-08-31, 체크포인트 재개 첫 항목) — 10건 중 ① 여섯(`H-199`/`H-201`/`H-202`/`H-204`/`H-206`/`H-207`) 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`) §4
체크포인트의 "못 한 것 하나"였던 단위 2 파일 리뷰. 8개 각도 파인더가 후보 22건 →
dedup 10건, 정확성 8건 전부 실제 코드에 재현 스크립트로 확인(효율 배치는 "관측된
병목에만 구조" 원칙 위반으로, 약한 정리 항목들은 심각도 미달로 기각). 대상은 단위 2
파일 넷이었지만 교차 추적이 단위 4 표면(`Gate`/`Blocker`) 둘을 같이 잡았다.
### `H-198` 🔴 — 닫힌 게이트 너머의 `fn` 도중 `Set`이 영구 stale 캐시가 된다 (②)
- **어디서**: `State.luau` `_recompute``dep:_track(self._valueEpochMap)` 꼬리 —
`state-epoch-plan.md` "재계산이 끝나면"(*"`valueEpochMap`은 자기가 읽은 상류 전부에
대해 갱신한다"*, 사용자 확정 *"단순히 전부 업데이트 하는건 맞아보입니다"*)을 그대로
옮긴 것.
- **무엇이**: `S = Source(1); g = S:Apply(blocker); blocker:On(); N = g:Compute(fn)`에서
`fn` 도중 `S:Set(99)`가 나면 — 게이트가 emit을 유보하므로 N의 `_receive`가 안 불려
카운터 쌍(`H-85`)이 안 움직이고, `_track`은 g의 **라이브** 맵(S@새 리비전)을 그대로
받아 "이미 본 것"으로 기록한다. `Get``Refresh` 안전망도 눈멀고, 나중에
`blocker:Off()`가 flush하면 `Update`가 false(이미 최신)라 **`_invalidate` 없이 하류로
전달만** 한다 — `N:Get()`은 S가 다시 움직일 때까지 영원히 1(재현: *"N:Get() #2 after
flush = 1 (should be 99)"*), 그런데 하류 구독자들은 통지를 **받았다**. `H-85`가 닫은
것과 같은 실패 계열(재계산 도중 상류 쓰기 — 그 절이 "흔하다"고 명시)의 게이트 경유
변형이고, **확정 의사코드 자체의 구멍**이라 옮기기 실수가 아니다.
- **갈래**: §4 — 처방이 §4 확정 의사코드 수정이라 어느 모양이든 사용자 결정.
### `H-199` 🟡 — nil dep이 조용히 사라지고 뒤 dep들이 밀린다 (①)
- **어디서**: `State.luau` `With`/`Compute`의 수집 루프(`deps[#deps + 1] = (select(i, ...))`).
- **무엇이**: 실측 — `s:Compute(fn, typoNilVar)`가 에러 없이 dep 1개짜리 노드가 되어
의도한 dep 변경에 영영 반응 안 함; `s:Compute(fn, a, nil, b)`는 b가 nil 자리로 밀려
`fn`의 positional 인자가 어긋남. 진단 0으로 조용히 틀린 반응성.
- **문서가 답을 갖고 있나**: 있다 — `architecture.md` error 계약의 예시(*"dep #3 is not
a State/Source/Ref"*)와 `newNode`의 기존 dep 검증이 이 자리의 계약. nil만 수집 단계에서
빠져나가고 있었다.
- **처리**: 수집을 `collectDeps` 하나로(두 메소드 공유), nil이면 `dep #N is nil`
error(level 3 — `newNode`와 같은 번호 체계, self가 #1). `spec.state` 13.
### `H-200` 🟡 — `setup` throw 경로의 좀비 GateNode (②)
- **어디서**: `State.luau` `Impl.Gate``H-188`의 detach는 `setup` **반환값이 비함수**인
경로에만 있다.
- **무엇이**: `pcall(function() s:Gate(function() error("boom") end) end)``s._subs`
노드가 남는다(재현). `_onUpstreamEmit = Void`(H-188)라 이후 `Set`이 크래시하진 않지만,
노드는 `_hold`로 s를 강참조한 채 매 emit을 받아 유보 집합을 쌓고 영영 flush 못 한다
(weak `_subs` 키라 GC가 언젠가 걷어갈 때까지). `spec.gate` 1의 *"실패한 Gate는 구독자
집합에 아무것도 안 남긴다"* 단언은 반환 경로만 돌려서 이 경로를 가려왔다.
- **문서 긴장**: `architecture.md` "예외 안전성 계약 — 감싸지 않는다"(예외 후 부기는
복구 안 됨)를 따르면 UB가 맞고 spec 문구만 좁히면 되지만, `H-188` 자신이 "우리가
통제하는 검증 경로"를 닫은 전례라 어느 쪽인지 사용자 결정 자리.
- **갈래**: §4.
### `H-201` 🟡 / `H-202` 🟢 / `H-204` 🟢 — 입력 검증 셋 (①)
- **`H-201`**: `store:Of(nil)`이 Source를 **할당한 뒤** `self[name] = src`에서 *"table
index is nil"*(내부 프레임)로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`
숫자가 섞임(재현). `Of`는 정확히 계산된 이름을 위한 문이라 이게 주 오용 경로.
`type(name) ~= "string"` → error level 2, 할당 전에. `spec.store` 3.
- **`H-202`**: `s:Compute(42)`가 노드를 만들고 첫 `Get``State.luau` 내부 프레임
(*"attempt to call a number value"*)에서 죽음 — 형제 표면(Gate 249/Observer 269/
Apply 279)은 전부 자기 자리에서 검증. 같은 급으로 추가. `spec.state` 13.
- **`H-204`**: `Store(5)``table.clone` 내부 프레임에서, `Store(Source(100))`(감싸는
테이블을 잊음)은 Source 내부 필드를 지목하는 에러(*"default for "Revision" is not a
Source"*)로, 보호 메타테이블은 clone에서 죽음(전부 재현). clone 전에 "테이블 + 메타테이블
없음" 검증(level 2). `spec.store` 2.
- 셋 다 `H-190`·단위 3 `fn` 검사와 같은 급(형제 검증과 동급의 입력 검증, 문서 무변경)이라 ①.
### `H-203` 🟡 — `Off()` 순회 중 재차단이 안 먹힌다 (②)
- **어디서**: `Blocker.luau` `runHandles`의 스냅샷 순회.
- **무엇이**: 한 Blocker에 게이트 둘 — 첫 게이트 flush의 하류 `_receive``b:On()`
다시 켜도, 스냅샷의 남은 핸들은 무조건 `handle(true)`로 flush된다(재현, 순서 무관) —
`b:IsOn() == true`인데 통지가 나간다. `Off` 63행 주석(*"IsBlocked = false first, so a
flush that re-enters sees the blocker open"*)은 여는 방향 재진입만 다룬다.
- **문서 긴장**: 단위 3·4 탐사자는 같은 관측을 `blocker-plan.md` "재진입(네스팅)" 미지원의
연장으로 보고 발견으로 안 세었다(§5 끝 "관측만"). 리뷰는 `IsOn` 불변식 위반으로 봤다 —
그 절의 미지원 선언은 카운팅 네스팅만 명시하므로 어느 쪽인지는 계약 결정.
- **갈래**: §4.
### `H-205` 🟢 — Modifier 가드의 level 2가 항상 quad 내부를 지목 (②)
- **어디서**: `State.luau` `_recompute``isModifier(result)` 가드(`modifier-plan.md` 7번).
- **무엇이**: `_recompute`의 유일한 호출자가 `Impl.Get`의 내부 두 줄이라 level 2는 항상
`State.luau``Get` 본문을 지목한다 — `architecture.md` error 계약이 사용자 입력을
level 2로 정한 근거(*옛 코드가 quad 내부만 지목*)를 정확히 재현하는 결과. lazy
체인(passThrough → 상류 `Get`)이라 **어떤 고정 level도 유저 코드에 못 닿는다**
직접 `Get`일 때만 level 3이 유저 호출부를 가리킴.
- **갈래**: §4 — level 선택이 계약 결정.
### `H-206` 🟢 / `H-207` 🟢 — 중복 제거 둘 (①)
- **`H-206`**: `implsOf` + ephemeron 근거 주석 4줄이 `State`/`Observer`/`Effect`에
verbatim 세 벌 — 저장 방식 변경이 세 파일 수정이 되고, 미묘하게 다른 사본이 인스턴스별
임플 레지스트리를 조용히 가른다. **`ImplRegistry.luau` 신설**(잎, 의존 없음)로 한 벌 —
순수 데이터 접근이라 `conventions.md` 설계 원칙의 공유 허용 범위("공유해도 되는 건
데이터와 순수 술어")이고, 새 표면이 아니라 코드 배치(스텁 파일 배치 전례와 같은 급).
- **`H-207`**: `Source.Set``Emit`의 꼬리(리비전 범프 + `emitDown` + `return self`)를
복제 — `Set``return Impl.Emit(self)`로 위임(직접 호출 — 같은 타입·같은 임플 테이블,
서브타입 오버라이드가 없어 콜론 위임 함정(`H-144` 계열) 해당 없음). 기존 spec이 동작
고정(`spec.source` 2·3).
### 단위 2 리뷰가 이상 없다고 확인한 것 (§5 성격, 여기 둠)
`EpochMap` 5 연산 ↔ §3, 다이아몬드 dedup 규칙 1~3, `GateNode` swap/`Sync`/discard,
`bit32` 리비전 카운터, `RESERVED``CheckReservedKeys` 동기, quad-types 표면 이름·인자
수 일치, `init.luau` `RunInit` 멱등 배선(마킹-후-실행, 순환 없음, 키 충돌 없음). 리뷰가
파일했다 기각한 것 중 기록할 것 하나 — Effect의 `Ref` dep 등록이 생성 중 `onRefFire`
`from = ref`로 즉시 발화해 `_epochs:Update` + 홀드 `Rerun`을 태우는 모양은 "신선한
Effect는 `canExecute`가 항상 거짓"이라 실패를 못 만들어 발견으로 안 올림.
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|---|---|---|---|---|---|
| **`H-168`** | `Ref<T>(T)` vs 무인자 `Ref()` 관용구 | (a) 시그니처 유지, 문서의 빈 호출 관용구를 전부 `Ref<<T?>>()`(nil-able 파라미터는 생략 가능)로 고쳐 씀 / (b) `default: T?`, `.Value: T?`(H-167 이전 모양 — `Ref(5).Value`까지 nil 검사 강요) / (c) 두 오버로드 `Ref<T>(T)` `Ref<T>() -> Ref<T?>` | **(a)** | "제네릭 시그니처" 확정(단일 파라미터, 명시 확장으로 넓힘)을 그대로 두고 관용구만 그 규칙에 맞추는 것 — (b)는 문서가 기각한 모양, (c)는 그 절이 기각한 2-파라미터 솔버 문제의 재개방 | (b)가 그렇다 |
| **`H-169`** | 재진입 `:Set` 뒤 남은 콜백의 인자 | (a) 블록을 `k(self.Value, self)`로(항상 최신 값, 문서 불변식 그대로) / (b) 문서에 "재진입 시 바깥 파동의 남은 콜백은 자기 파동의 값을 받는다"로 계약화(코드 유지) / (c) 재진입 자체를 금지(error) | **(a)** | 문서 불변식(*"옛 값이 보이는 창이 없다"*)이 이미 (a)를 말하고 있고 한 토큰 차이 — (b)는 인자와 `.Value`가 다른 창을 계약으로 열고, (c)는 새 가드 | 아니오 |
| **`H-170`** | resume이 삼키는 에러 | (a) `local ok, err = coroutine.resume(k, self); if not ok then error(err, 0) end` — 대기자 에러를 `:Set` 호출부로 다시 올림 / (b) UB로 문서화(대기자 에러는 사라진다) / (c) 대기자 소진을 `task.spawn`류 주입 op로(Roblox `task.spawn`은 에러를 콘솔로 보냄) | **(a)** | `architecture.md` "예외 안전성 계약"(감싸지 않는다 = 에러는 전파)과 같은 결. 다만 **새 코드 두 줄(re-raise)** 이라 사용자 결정 자리. (c)는 새 주입 op | 아니오 |
| **`H-182`** (`/code-review`, 단위 3) | Destroy 파동 안 cleanup 뒤의 재실행 창 | (a) `Destroying` 콜백이 `_dying = true`를 세우고 `rawRerun``canExecute`와 함께 본다(재바인드 `_bindDestroying`이 내림) / (b) `_destroyConn == nil`이면서 gcconn이 살아 있는 상태를 "죽는 중"으로 판정(새 필드 없음, 단 `Subscribe`로 묶인 핸들과 구분 못 함) / (c) UB 문서화(`fn` 안 `inst:Destroy()` UB의 이웃) | **(a)** | `H-160`이 "cleanup 중"을 홀드로 닫은 것과 같은 결 — 파동 후반은 같은 상태의 연장. 새 플래그 하나 | 아니오 |
| **`H-183`** (`/code-review`, 단위 3) | Observer 설치 발화 재진입 | (a) Observer에도 `_running` 가드 — 네 진입점·`bindLifetime` 훅 첫 줄 `error(…, 2)`(`H-147` 대칭) / (b) 생성자 순서만 바꿔(플래그 내림 → fn) 중첩 재생을 없애고 나머지는 UB / (c) UB 문서화 | **(a)** | `H-147`의 근거("유저 함수가 본인을 죽이고 살린다는 점 자체가 모순")가 Observer에도 그대로 — Effect만 막을 이유가 없다 | 아니오 |
| **`H-184`** (`/code-review`, 단위 1·3) | `bindLifetime` 부기 커밋 vs 훅 가드 순서 | (a) `bindLifetime``isEffect(value)`면 훅 가드를 **먼저** 묻고(예: `value:_assertBindable()` 훅 하나) 통과 후 커밋 / (b) 훅이 던지면 부기를 되감음 / (c) UB(`fn` 안 bind는 `H-147`로 이미 금지) | **(a)** | (b)는 pcall 없이는 못 하고 예외 안전성 계약과 충돌; (c)는 반쯤 묶인 핸들이 남아 이후 bind가 전부 "already bound"로 막히는 실측이라 UB로 두기엔 무겁다 | 아니오 |
| **`H-185`** (`/code-review`, 단위 3) | cleanup 팩 vs 첫 반환만 | (a) 런타임이 반환 전부를 cleanup 목록으로 소진(역순) / (b) 타입을 `-> (() -> ())?`류로 좁힘(`H-95`가 기각한 모양 — 무반환 `fn`이 막힘) / (c) 문서·타입 주석에 "첫 반환만" 명시 | **(a)** | 타입이 이미 팩을 광고하고 다중 자원 정리는 자연스러운 용례 — 소진 순서만 정하면 됨(`_consumeCleanup` 한 곳) | 아니오 |
| **`H-186`** (`/code-review`, 단위 3) | 교차 인스턴스 dep | (a) `Effect`/`Observer`/`Compute` 생성 시 dep의 인스턴스가 다르면 `error(…, 2)`(임플이 `module`을 아니 판정 가능) / (b) UB 문서화 / (c) 지원(dep의 백엔드가 게이팅 — 지금 동작) | **(a)** | 지금 동작은 두 백엔드가 한 핸들을 나눠 판정해 에러 귀속이 틀린다; `architecture.md` 13번의 다중 `New()`는 "같은 인스턴스 안"이 전제 | 아니오 |
| **`H-187`** (`/code-review`, 단위 3·4) | 타입 별칭 이름 넷 | (a) 그대로 승인(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`) / (b) 인라인으로 풀어 이름을 없앰 / (c) 다른 이름 | **(a)** | 시그니처는 문서 그대로이고 이름이 없으면 `quad-types` 선언이 세 배로 길어진다 | 아니오 |
| **`H-198`** (`/code-review`, 단위 2) 🔴 | `fn` 도중 닫힌 게이트 상류의 `Set`이 영구 stale 캐시(§4 확정 의사코드의 구멍 — `_track`이 라이브 리비전을 찍어 안 본 변경을 "본 것"으로 기록) | (a) `_recompute`**`fn` 직전에 dep 리비전을 스냅샷**하고 성공 시 그 스냅샷을 찍음 — `fn` 도중 변경은 "안 본 것"으로 남아 flush가 규칙 1(재계산)로 떨어짐 / (b) UB 문서화(게이트 너머 재계산 중 `Set`) / (c) `Get` 시점마다 읽은 리비전을 그때그때 기록 | **(a)** | `H-85`의 해법(*"fn 직전 스냅샷"* — `gen` 카운터)과 정확히 같은 결을 에포크 맵에 적용하는 것. 문서의 "전부 갱신"이 값을 하는 자리(게이트 유보 중 앞당겨 읽기 → 통지만)는 스냅샷도 재계산 **시작 시점** 리비전이라 그대로 성립. (b)는 `H-85` 근거(*"재계산 중 상류 쓰기는 흔하다"*)와 정면 충돌, (c)는 읽기 훅이 필요한 새 구조 | 아니오 |
| **`H-200`** (`/code-review`, 단위 4) | `Gate` `setup`**던질 때** 반쯤 만든 노드가 `_subs`에 좀비로 남음 | (a) 예외 계약대로 UB 유지 + `spec.gate` 1 단언 문구를 반환 경로로 좁힘(코드 무변경) / (b) `newNode` 직후 `self._subs[node] = nil`로 떼고 `setup` 성공 후 재등록 — throw면 자연히 미등록(pcall 없음, 두 줄) / (c) `pcall`로 감싸 되감기(예외 계약 위반) | **(b)** | `H-188`이 이미 "우리가 통제하는 검증 경로는 닫는다"를 택한 전례고, (b)는 pcall 없이 spec의 단언(*"아무것도 안 남긴다"*)을 두 실패 경로 모두에서 참으로 만듦. 부수 효과는 setup 실행 중 emit을 게이트가 못 받는 창 하나인데 그 구간은 유보 집합이 항상 비어 있어 관측 불가 | 아니오 |
| **`H-203`** (`/code-review`, 단위 4) | `Off()` 순회 중 하류의 `b:On()` 재차단이 남은 핸들 flush를 못 막음(`IsOn() == true`인데 통지가 샘) | (a) 순회가 핸들마다 `IsBlocked`를 다시 읽고 참이면 중단 — 남은 배치는 유보 유지, 다음 `Off`가 마저 flush / (b) 스냅샷 의미론을 계약으로 문서화("한 번 시작된 `Off`는 끝까지 flush") — 탐사자가 §5에서 미지원 연장으로 본 그 판정 / (c) 순회 중 `On()`을 error로 | **(a)** | `IsOn()`이 참인데 통지가 나가는 창은 하류가 합리적으로 기대할 불변식 위반이고, 비용이 핸들당 불린 비교 하나. `blocker-plan.md` 미지원 선언의 근거는 카운팅 네스팅("`On()` 여러 번")이라 이 경우를 안 다룸. 다만 (b)도 탐사자 판정과 일치하는 방어 가능한 자리라 사용자 결정 | 아니오 |
| **`H-205`** (`/code-review`, 단위 2) | `_recompute`의 Modifier 가드 level — 지금 level 2는 항상 `Impl.Get` 내부 줄을 지목 | (a) level 3으로 — 직접 `Get`이면 유저 호출부, 체인 경유면 지금과 같은 내부 프레임(엄격히 우세, 더 나빠지는 경우 없음) / (b) 정직한 level 1 + 메시지에 맥락 의존(사용자 입력인데 내부 표기라 계약과 어긋남) / (c) 현행 유지 + 한계를 문서화 | **(a)** | lazy 체인이라 어떤 고정 level도 항상 유저 코드에 닿진 못하지만, (a)는 닿을 수 있는 유일한 경우(직접 `Get`)를 살리고 나머지에서 현행과 동일 — 유일하게 손해 없는 개선 | 아니오 |
| **`H-174`** (탐사자, **단위 2 착수 전 필요**) | 반응형 모듈이 자기 `quad` 인스턴스의 `canExecute`/`canBound`에 닿는 법 | (a) `Source.luau`/`State.luau`/`Observer.luau`/`Effect.luau`를 `InitSource(module)`**팩토리**로 — 클래스와 `Subscribed`/`WeakSubscribed` 레지스트리를 `module`을 닫은 클로저 안에서 만들고 `module.Source = …`로 심음(`InitDispatch`와 같은 모양, `EpochMap`/`Brand`/`Ref`는 그대로 잎). 공개 필드가 없는 `State`/`Observer` 클래스는 `Init`의 반환값이나 `module.RunInit` 뒤의 비공개 필드로 형제 `Init`에 넘김 / (b) 잎 모듈 유지 + 값마다 역참조 필드(`observer._quad`)를 두고 `self._quad.canExecute(self)` / (c) 잎 모듈 유지 + 모듈 로컬 슬롯 하나를 백엔드가 채움(인스턴스 격리 포기) | **(a)** | `architecture.md` 13번(*"모듈 인스턴스를 인자로 받도록"*)과 `module-lifecycle-plan.md` "New()의 내부 구성"이 이미 이 모양이고, `Observer`의 두 레지스트리가 인스턴스별이 되어 *"완전히 별도의 새 Quad 네임스페이스"*와 맞음. (b)는 새 필드 + 값이 자기 모듈을 강참조(`Relate` 되참조 계열 위험), (c)는 `spec.lifetime` 8·`New()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 |
**[2026-08-28 회신 — 단위 1 배치 전량 확정]** 사용자 원문: *"174 는 module.canExecute 로
lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복문을 자신 epoch 를 보며
달라져버렸다면 놓으면 될듯. 후행 Set 이 전부 호출하도록. (진짜 항상 콜백이 받는건
최신임) / ref 쪽에서는 `local function A<T>(a: T)end A<<{}?>>()` 로 쓰면 없어도 호출
가능해짐. 시그니처 유지 권고를 따르고자 해. / coroutine.resume 은 애초에 에러가 날지 안
날지 후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과 false 은
확인 해줄 수 있는듯. a 권고사항 따르기로"*. `H-169`만 권고 (a)(`k(self.Value, self)`)가
아니라 **사용자 안**(리비전 비교로 파동을 놓음)으로 — 권고안은 같은 파동에서 콜백이 두
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
**[2026-08-31 회신 2 — 단위 2~4 배치]** 확정 일곱(`H-182`~`H-185`/`H-187`/`H-200`/`H-203`),
재질문 둘(`H-186`/`H-198`), 보류 하나(`H-205` — *"H205 부터는 이후에 결정할게"*). 앞
라운드(`H-168`~`H-170`)도 재서술로 재확인됐다 — `H-168`: *"처음에 생각했던 부분은 유저가
직접 T 를 nilable 하게 보내거나 아니거나 골라야했어. … 생략은 오직 nilable 할 때만"* /
`H-169`: *"callback 도중에 set 된다면 처음부터 다시 수행. 후행의 요소는 set으로 인해
과거의 값을 받지는 않음"*(기존 확정과 동일 의미 — 안쪽 파동이 전부 돌고 바깥 순회는
리비전 비교로 놓음) / `H-170`: (a) 재확인 + **한계 문서화 지시**(원문은 `ref-plan.md`
`:Wait` 절, 등록은 `documentation-content-map.md` §4의 `H-170` 항목). 확정 근거 인용:
- **`H-182`** (a) + 네이밍: *"slot 에서 _destroyed 이라는 단어를 썼었고 그거와 다른 점은
이건 '죽는 도중만' 확인한다는것 — slot 은 죽으면 재바운딩 못 하지만, Effect 는 다시
bind 가능해지므로 네이밍의 다른 이유가 확실함"* → 이름은 `_dying`.
- **`H-183`** (a): *"isRunning 유사하게 두어도 되어보임. error 로 죽으면 유사하게 running
가드가 안 내려갈 수 있다는 문제는 있지만, 이건 quad 프로젝트가 오류가 날 때 구조가
깨짐은 설계 상 인정한 부분이라 괜찮음"*.
- **`H-184`** (a): *"지금 에러가 모두 같은 형식으로 통일되는데, assertBindable 을 두면
내부적으로 canBound 를 확인 가능한 부분"* — 구현은 `canBound` 게이트를 `bindLifetime`
남기고(단일 변경점) `_assertBindable`은 running 판정만.
- **`H-185`** 권고 (a) **기각**, 단일 cleanup 확정: *"그건 전체 표면을 넓힘. 클린 업 중에
재진입이 걸린다던가, 하는 부분이 _rerunRequired, _pending 등으로 엮이기에 확인할 표면이
잠재적으로 늘고, … function() CleanUpA(A) CleanUpB(B) end 형태를 가하는데 아무 문제가
없기 때문에 지원해야할 이유가 불명확함. cleanup 은 하나만 허용하고자 하고
문서화/타입주석에만 명시하는게 맞다고 봄"*.
- **`H-187`** (a): *"부분부분 타입을 뽑아 재사용성을 높이겠다는것 같은데, 이의 없음. 이름
그대로 사용해도 좋음"*.
- **`H-200`** (b): *"권고 동의"*.
- **`H-203`** (a): *"그렇다면 Off 시점 이후 모두 emit 된 상태가 아니라 절반만 emit 된
어정쩡한 상태가 남는다는것. 다만 그 이외의 재진입을 말지 말아야할 이유는 안 보임.
여전히 중간에서 멈춰도 quad 정의 상 문제는 없는 상태이므로 (a) 로 가도 될것이라 생각함.
권장될만한 패턴은 아니여보이지만, 막아야할 이유도 없음"*.
재질문 원문(다음 회신에서 닫을 것):
- **`H-186`**: *"impl 부분에서 module 을 안다고 해도 그 비교가 어찌 가능한지 모르겠는
부분. 하위 시스템인 Observer 의 module 과 상위 Effect 의 module 을 비교하려면 어떻게든
그걸 노출시키는 표면이 있어야하는데, 새로운 메커니즘의 추가가 불가피하지 않은지?"* —
메인 세션 답변: 맞다, (a)는 새 표면 없이는 불가. 최소 모양은 **메타테이블 신원 비교**
(dep의 `getmetatable`이 그 인스턴스의 State/Source/Gate 임플 테이블과 일치하는지 —
임플들은 이미 `module._impl`에 모여 있어 per-노드 등록 없이 판정 가능)이지만, §6의
이웃(교차 `bindLifetime`)은 그걸로도 못 막는다(주입 op 시그니처 변경 필요). 대안 권고는
**(b) UB 문서화**(드문 오용에 구조를 안 늘리는 원칙; M5에서 실 백엔드가 둘이 되면 재검토).
- **`H-198`**: *"상류 Set 이 Effect와 유사하게 flag 로써 재진입 해야함을 요구하는게
맞아보임. slot의 offset순회처럼, 그런데 처음부터 진행. 그럼 항상 최신 값만 읽게되어
문제가 없다고 보는데 어떻게 생각하는지?"* — 메인 세션 분석: 재시작 루프는 성립하고
(a)보다 강한 보장(그 `Get` 안에서 최신 수렴)을 주지만, **게이트 너머의 움직임을 알
채널이 여전히 필요하다**(유보된 emit은 `_receive`에 안 오므로 플래그를 세울 자가 없음 —
탐지는 결국 "fn 직전 리비전 스냅샷과 비교"가 됨). 즉 사용자 안 = (a)의 스냅샷 탐지 +
`Get`의 재시작 루프. 캐비엇 하나: `fn`이 자기 (전이적) dep을 매번 `Set`하면 지금은
"Get마다 1회 재계산"으로 수렴하던 것이 무한 재시작 루프가 된다 — 그 모양을 UB로 접을지
같이 결정 필요. 상세는 세션 보고, 결정 대기.
**[2026-08-31 회신 3 — 잔여 셋 + 코드 검토 둘, §4 전량 종결]** 사용자 원문: *"H186: 확인.
UB로 놓는게 맞아보여. 그렇게 닫아두고, 추후 생각해볼 점으로 두면 돼. H198: 확인. epoch
자체에 peek 해볼 수도 있고, 이미 있는 표면들로 충분히 구현 가능해서 동의함. 재시작
모델에서 조건 없이 무한 루프 되는건 UB로 접는거 그대로 두면 될것 같아. 어차피
스텍오버플로우로 터지거든. H205 는 권고대로 가고, H174 는 이미 a로 결정했던거 같아."*
(`H-174`는 맞다 — 2026-08-28 회신에서 이미 (a) 확정, 표도 그렇게 기록돼 있다.) `H-198`
구현 각주 하나: `Get` 재시작을 재귀가 아니라 **루프**로 짰으므로 무한 케이스의 관측은
스택오버플로우가 아니라 무한 재계산(프리즈)이다 — UB 분류는 동일. 같은 회신의 코드 검토
둘은 `H-208`(`table.clone` 스냅샷)/`H-209`(generalized iteration)로 등록, 둘 다 ① 반영.
**이로써 §4의 열린 문항은 0, 코드 마커도 0.**
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개**였다가, **[2026-08-31 회신 2] 일곱이 확정 반영되어 셋**(`H-186`/`H-198`/`H-205`)이 남았고, **[2026-08-31 회신 3] 그 셋도 전부 확정 반영되어 지금 마커는 0개**다(`grep -rn "TODO(H-" quad-base quad-types`). 마커 없이 답변된 §4 행 중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
## §5 이상 없다고 확인한 것
(탐사자가 실제로 돌려보고 계약대로였던 자리 — 다음 탐사자가 다시 파지 않게.)
**단위 1 (메인 세션, 2026-08-28)**:
- `Relate.luau`(M1) ↔ `relate-plan.md` "API"/"실제 구조": 4 메서드, lazy 서브테이블,
공유 `{__mode="v"}` 메타테이블, `inst` weak — 전부 일치(`spec.relate.luau`가 고정).
- `lifecycle-pattern.md` (0)/(1) 스케치는 mock 시그널 위에 그대로 돌아간다 — `Destroy`
`gcconn.Connected=false``canExecute` false/`canBound` true, gchold 강참조, 조기
해제, 이중 바인드 게이트 모양. 스케치의 한국어 에러 문구는 같은 문서가 이미
*"실제 문구는 영어"*라 밝힌 자리표시자라 문서 결함 아님(코드는 영어 + `level 2`).
- `ref-plan.md` `:Set` 블록을 한 줄씩 옮겼고 계약 9개가 테스트로 고정됐다. 함수키
dedup(강+약 동시 등록 시 1회)과 순회 중 해제 skip이 실제로 성립한다.
- `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or
isPostRef or RefBrand`)와 weak-key 멤버십 — 성립.
**단위 2 (메인 세션, 2026-08-28)**:
- `EpochMap` 6 연산 ↔ `state-epoch-plan.md` §3 일치(`spec.epochmap`). `Update`의 "한 번 다름을
찾으면 나머지는 쓰기만" 최적화 포함. 키 weak 실측.
- `State:_receive` 규칙 1~3, 시딩(`Sync`/`TrackFrom` 분기), 카운터 쌍(`curr = nil` 시작, 재계산
도중 무효화·`fn` 예외 케이스), `Get``Refresh` 순회(값만, 통지 없음), 다이아몬드 접힘 —
전부 `spec.state`가 고정. `_hold` 불변식(하류→상류 강, 상류→하류 weak) GC 실측 통과 —
`luau-test/STATUS.md`의 "만들어야 할 스파이크"(중간 State GC)는 이걸로 대신한다.
- `Source:Set` 동일값 갱신(`H-68`), `Emit`, `isModifier` 가드 셋(생성자/`Set`/`Compute` 캐싱).
- `Store`: `H-122`/`H-153`/`H-83` 전부 `spec.store`. `Names()``pairs(self)` — 메소드가
`__index`라 안 센다는 `H-153` 전제가 실측으로 성립.
- **조립 세부**(`H-174`의 구현, 새 표면 아님): `State.luau``Init(module)`이 인스턴스별 임플을
만들고 `implFor(module)``Source` Init에 건넨다(`Store` Init은 이 임플을 직접 안 받고 `module.Source`를 통해서만 State에 닿는다; 각 Init이 `module:RunInit(dep)`
의존성을 직접 당겨오므로 `New()` 순서 무관 — `H-177`; `implFor`의 level 1 error는 그래도 남겨둔
불변식 검사). `Source``__index` 체인이 그 임플을 가리켜 `With`/`Compute`/
`Apply`가 위임된다. 그 시점(단위 2 끝)의 인스턴스 간 공유 잎은 `Ref`/`Void`/`Relate`/`Brand`/`EpochMap` — 이후의 소스는 각 파일 헤더 주석(`module-lifecycle-plan.md` `H-174` 배너), 이 줄은 스냅샷.
- **입력 검증 하나 추가**(`architecture.md` error 계약의 예 *"dep #3 is not a State/Source/Ref"*
그대로): `newNode`가 dep이 `isState`가 아니면 `error(…, 3)` — 없으면 `dep._subs` nil 인덱스로
quad 내부 줄에서 죽는다. 새 메커니즘이 아니라 계약이 이미 요구하는 자리라 ①로 둔다.
- `Apply`의 팩토리 파라미터가 유니온(함수 | `__apply` 객체)이라 **콜백 파라미터 무주석
추론이 안 된다** — `typing-limits.md` §1②가 이미 캐비엇으로 적어둔 자리(`:Apply`의 factory는
주석 필요). 테스트는 주석으로.
**단위 3 (메인 세션, 2026-08-29)**:
- `Observer` — 생성자 순서(fn 1회 → 플래그 내림 → `_subs`), `_receive``canExecute` 게이팅과
홀드, `_catchUp`이 유일한 재생 자리(출처 `nil`), 네 진입점(Weak 프리미티브·인라인 게이트·
관대/엄격·양쪽 테이블 해제), 강/약 GC, 파동 중 구독자 스냅샷 — `spec.observer.luau` 8절.
- `Effect` — deps 검증 셋(`H-70`), dep 종류별 클로저 둘(`H-107`), `fire``from == nil` 가드,
`_epochs` 갱신은 `fire`뿐(`H-151`), 다이아몬드 1회, cleanup 세 자리(루프 머리/`Unsubscribe`/
`Destroying`), cleanup 없는 `fn`의 재바인드 무재실행(`H-58` 회귀), 재진입 지연(`_pending`),
네 진입점의 `isRunning` 가드가 `fn`·cleanup 안 전부 막음(`H-147`), 에러 시 사망 계약, 강한
주인 GC — `spec.effect.luau` 9절.
- **조립 세부**: `Observer.luau`가 인스턴스별 레지스트리 둘(`_Subscribed`/`_WeakSubscribed`)을
임플에 매달고 `Effect.luau``implFor`로 받는다(`H-99`의 "모듈 내부 export, 이름은 구현 시").
`State.Init``Observer.Init`을 당겨 `state:Observer``ObserverImpl.new`로 위임.
- **`onDestroying` 스텁의 자리**: `LifetimeHandle.luau``Init`이 생명주기 4종과 같이 에러
스텁을 설치(주입 op 목록의 소스는 `architecture.md` EngineOps 줄 — 스텁 파일 배치는 코드
배치). mock은 `installLifetime``inst.Destroying:Connect(fn)`으로 채운다.
- 색인이 짚은 문서 긴장(`base/source-state-plan.md` "`state:Observer(fn)`" 절의 *"Effect의 내부 Observer가 이 설치 발화를 `from == nil`로 거르는 이유이기도 하다"* — 한때 여기 `ss:1197`이라는 색인 약호로 적혀 있었다 —
vs `H-164`)은 코드에서 모순 없음 — Observer 계약은 `nil` = 출처 없음이고, Effect의
`fire`가 그걸 `Update` 못 하는 값으로 **자기 사정**으로 거를 뿐이다.
- 테스트 작성 함정: `table.insert(t, nil)`은 길이를 안 늘린다 — `emitFrom == nil` 발화를 셀 땐
래퍼로 기록할 것(한 번 오진했다).
**단위 4 (메인 세션, 2026-08-29)**:
- `GateNode``State.luau` 안(`architecture.md` 소스 트리가 `:Gate``State.luau` 소속으로
둠) — `newNode({self}, passThrough, GateImpl)`로 만들어 `StateBrand` 등록·시딩·`_hold`를
그대로 받고, `_receive`(emit 맵은 `Peek`, unfold 합류, 정책 호출)와 `_flush`(빈 배치
false → 스왑 → `Sync(batch)``_emitDown(batch)`; `emit(false)`는 버리기)만 덮어쓴다.
`Get`은 상속(pass-through + 카운터). 값은 안 가린다(3번) — `spec.gate.luau` 9절.
- `Blocker.luau`는 잎 모듈(게이트 표면을 안 만짐 — §6의 상속 우려는 해당 없음): `On`/`Off`/
`OffWithoutEmit`/`IsOn`/`Policy`/`__apply`, 핸들 weak-key 셋 + 강한 주인은 `onUpstreamEmit`
클로저(그 클로저가 `handle(true)`로 통과시켜 업밸류를 산 채로), `Off`류는 스냅샷 순회 —
`spec.blocker.luau` 7절(GC 실측 포함).
- 단위 3 감사 1라운드 잔여: `Observer`/`Effect` 생성자의 `fn` 타입 검사(`error(…, 2)`)는
문서에 없고 코드가 더한 입력 검증 — `newNode` dep 검증과 같은 급(`architecture.md` 계약의
예), 기록만. 스파이크 `05``spec.state`/`spec.effect` 3번이 대체 → `done/`.
- `Impl.Gate``setup` 타입 검사와 `setup(emit)` 반환값 검사(`error(…, 2)`) 둘도 문서에
없고 코드가 더한 입력 검증 — `newNode` dep 검증·단위 3의 `fn` 검사와 같은 급, 기록만.
- `Debounce`/`Throttle`·`setTimeout`/`clearTimeout` 주입 op는 백로그 — 이번 단위에 없음.
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
- `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.
- GC 테스트 함정 둘: 같은 프레임의 죽은 레지스터가 임시값을 붙잡는다(별도 함수 안에서
만들 것) / **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬인 클로저는 Luau가
프로토에 캐시해 영영 GC되지 않는다**(**[`H-175` 정정]** 함수 인자·지역을 잡는 클로저는
정상 GC됨 — 테스트의 탑레벨 클로저는 업밸류를 직접 변경하게 할 것,
`lifecycle-pattern.md` (0)의 `false or` 트릭이 막는 것과 같은 최적화).
**단위 1 (탐사자, 2026-08-28)** — 커밋 `d9898d6`..`325f3f0`, 신선한 컨텍스트, `git stash` 안 씀:
- **실행**: `./scripts/test.sh` 전체 — relink 30 갱신, `luau-analyze quad-base/src + spec.* +
mock.luau` 진단 0, 9파일 전부 `ALL PASS`. `grep -rn "TODO(H-" quad-base/src` 0건(§4 표와
1:1 — 표의 넷은 전부 코드 밖 문항).
- **한 줄 대조(코드 ↔ `base/` 절), 어긋난 곳 없음**: `Brand.luau``brand-plan.md` "구현 —
인스턴스 브랜드"/"`isX` wrapper"(생성자·15 인스턴스·11 술어·합성 방향·소문자 메소드·`None`
무의존) / `Relate.luau` ↔ "API (확정)"/"실제 구조 (확정, 2026-08-08 세션)"(범위 안 diff는
타입 재export뿐, 본문 무변경 확인) / `LifetimeHandle.luau``lifecycle-pattern.md` "탑레벨
평범한 함수로 확정"의 2026-08-28 정정 + `architecture.md` error 계약(영어, `level 2`, 스텁은
조용한 no-op 아님) / `Ref.luau``ref-plan.md` "`:Set(value)`의 순서" 블록(토큰 단위 동일 —
`H-169`/`H-170`은 블록 자체의 문제), "API 모양"(self 반환, 등록 즉시 1회, `fn(value, ref)`),
"`.Callbacks`는 배열이 아니라", "`:WeakCallback(fn)`"(weak-key 별도 테이블, `Uncallback`
양쪽), "`Ref`는 `Epoch`를 만족한다"(`Revision` 공개, `EpochBrand` 다중 태깅),
`state-epoch-plan.md` §2(`bit32.bnot(-rev)`) / `mock.luau` `installLifetime` ↔ (0)/(1)/(2)
스케치(`isBoundAlive` 두 경로, 게이트 모양, `gchold[value]`, `BindData` 둘 다 weak, Observer
`_catchUp`/Effect `_bindDestroying`·`_unbindDestroying`, `unbindLifetime`이 cleanup·콜백을 안
뗌) — mock만의 추가는 `isMockInstance` 검사와 `H-171` 분기뿐 / `init.luau`·`quad-types`
`Quad` ↔ brief §6 표(재export 목록·술어 11개 이름 일치).
- **다음 단위가 쓸 표면, 타입 레벨 실측(`luau-analyze`, strict, 진단 0)**: `Ref<T>`
`QuadTypes.Epoch`에 그대로 들어감(`sync(e: Epoch)`에 `Ref(5)`), `Ref<number>``Ref<any>`
인자 전달 OK(`Effect(fn, ...deps)`의 dep 합집합에 필요), `{ [Epoch]: number }` weak-key 맵에
`Ref`를 키로 OK(`EpochMap` 내부 모양), `Epoch | Ref<any>` 합집합 OK, 콜백 인자 생략
(`function(v)`/`function()`) OK, 두 번째 인자를 `Epoch`로 넘기기 OK. **`H-168`의 전제와
(a) 둘 다 실측**: `Quad.Ref()`*"Function expects 1 argument"*, `Quad.Ref<<number?>>()`
**인자 없이도 진단 0**이고 `.Value: number?` — 권고 (a)의 관용구가 실제로 성립한다.
- **런타임 실측(스크래치, 인라인)**: mock의 `.Subscribed` 경로는 지금 이미 돈다(`ObserverBrand`
등록 + `Subscribed = true``canExecute` true/`canBound` false, `bindLifetime` → *"already
subscribed"*) / `ObserverBrand` 등록값을 `_catchUp` 없이 바인드하면 `attempt to call missing
method '_catchUp'` — 단위 2의 `Observer`가 이 메소드를 반드시 갖는다는 뜻(`EffectBrand`는
`_bindDestroying`/`_unbindDestroying`, 단위 3) / `Ref``bindLifetime`의 value로 — 정상(바인드
`canExecute` true, `Destroy` 후 false) / `Effect` 모양(`handle`을 잡는 클로저를
`WeakCallback`)의 항목은 `handle`을 놓으면 GC 뒤 0개(`H-175`) / thread 키를 **양쪽** 테이블에
넣으면 첫 `:Set``.Callbacks`만 비우고 둘째 `:Set`이 죽은 코루틴을 **조용히** resume —
`ref-plan.md`가 적은 불변식(*"대기자는 `.Callbacks`에만"*)은 가드가 아니라 관례이고 M8
`:Wait`가 유일한 쓰기 지점이므로 지금은 문제 아님.
- **mock의 인스턴스 불멸성(툴링 사실)**: `claim`된 mock 인스턴스는 참조를 놓아도 `Destroy`
전엔 GC되지 않는다(실측: inst를 떨어뜨리고 GC 2회 뒤에도 묶인 값 생존). 이유는 의도가
아니라 **`dataOf`가 weak-key·강한 값(Luau엔 ephemeron 없음)**이라 `data → changed 시그널 →
gcconn 클로저 → inst`로 키가 자기 값을 통해 살아남기 때문. 결과적으로 `lifecycle-pattern.md`
(0)의 *"quad가 만든 Instance는 … 반드시 `Destroy`로 회수된다"*와 같은 관측을 주지만 근거가
다르다 — 다음 단위 GC 테스트는 만든 인스턴스를 **반드시 `Destroy`**할 것(안 하면 그
인스턴스에 묶인 값 전부가 뒤 테스트의 GC 단언에 남는다).
**단위 1 끝 절차 기록**: 감사 루프 8라운드(발견 3→4→1→2→4→4→1→0, 5·6라운드 절반은
각도를 넓혀 잡힌 옛 같은-파일 절 인용 부채) → `/code-review high` 1회 10건: ② 셋(`H-168`~
`H-170`), ① 셋(`H-171`~`H-173`), 잔손질 셋(`smoke.init` 5절 → typechecked `spec.init.luau` /
`conventions.md`의 단위 나열 제거 / 코드 주석의 절 인용을 제목 앞부분으로), 그리고
기각 셋(gcconn 슬롯 중복·mock claim 불멸·공유 본문 빌더 — 전부 `lifecycle-pattern.md`
(0)/(1) 그대로이거나 `claim-plan.md` §7-9가 이미 기각한 모양).
**단위 3·4 (탐사자, 2026-08-29)** — 커밋 `dca789e`..`3670e88`, 신선한 컨텍스트, `git stash` 안 씀:
- **실행**: `./scripts/test.sh` 전체 — relink 39 갱신, `luau-analyze quad-base/src + spec.* +
mock.luau` 진단 0, 20파일 전부 `ALL PASS`. `grep -rn "TODO(H-" quad-base quad-types`
추적 파일에 여섯(`H-182`~`H-187`), §4 표와 1:1(`roblox_packages/` 안의 `quad_types` 사본에
`H-187`이 한 번 더 보이는데 relink 복사본이고 무시 대상).
- **한 줄 대조(코드 ↔ `base/` 절), 코드가 어긋난 곳 없음** — 어긋난 건 전부 문서 쪽 옛 문장
(`H-191`~`H-196`): `Observer.luau``source-state-plan.md` "전파 루프 — 확정 의사코드"
(`_receive` 게이팅·홀드, `_catchUp` 유일 재생 자리, 생성자 순서 fn → 플래그 → `_subs`,
`alwaysObserve``targetState:Get()`), `lifecycle-pattern.md` "(2) 전역 경로"(네 진입점
토큰 단위 동일 — 인라인 게이트, `H-133` 관대/엄격, 양쪽 테이블 해제, `error(…, 2)`가 본문에) /
`Effect.luau``effect-plan.md` "의사코드 — 생성자"(`select("#")` 검증 셋, dep 종류별 클로저
둘, `fire``from == nil``Update``Rerun`, `isEpoch` 시딩 — `TrackFrom(d.valueEpochMap)`
자리만 단위 2의 `d:_track(map)` 훅), `_bindDestroying`/`_unbindDestroying`/`_consumeCleanup`/
`rawRerun`/`Rerun`(토큰 단위 동일, `local function` 선언 순서까지), "`EffectHandle:Subscribe()`"
(네 진입점 + `resubscribeTail` + `isRunning` 술어) / `State.luau``GateImpl``gate-plan.md`
"`GateNode` 조립"(`_receive`: `Update`/`Peek`/invalidate/규칙 3 리턴/unfold/정책; `_flush`:
빈 배치 false → 스왑 → `Sync(batch)``_emitDown(batch)`, `emit(false)` 버리기, 스왑 테이블도
weak `H-9`), 2번(`emit(commit) -> boolean`)·3번(값 안 가림)·8번(빈 배치 no-op) /
`Blocker.luau``blocker-plan.md` "메커니즘 (확정)"(`On`/`Off` 순서/`OffWithoutEmit`/`IsOn`/
`Policy`/`__apply` 전부, `H-63` 셋 — weak-key 셋, 강한 주인은 `onUpstreamEmit` 클로저의
업밸류 `handle`, 스냅샷 순회) / `quad-types` ↔ 각 절의 시그니처(`ObserverFn` 세 자리·`emitFrom?`,
`EffectFn` 팩, `GateEmit`/`GateSetup` 2단, `Blocker` 여섯, `Quad``Effect`/`Blocker`/
`onDestroying`). 코드가 문서보다 더 가진 것은 이미 기록된 입력 검증(`fn`/`setup` 타입,
`setup` 반환 검사, `Apply` 객체 검사)과 `H-188`의 detach뿐.
- **런타임 실측(스크래치 프로브 21건, 전부 계약대로)**: (a) `gate-plan.md` "계약 — 게이트는
emit 경로만 미룬다"의 셋 — 유보 중 형제 plain dep이 깨우면 `fn``gated:Get()`이 최신 /
`Unsubscribe`(소진) → `Subscribe`가 유보 중이어도 재설치 1회 / 풀린 배치로 1회 더, 빈 flush는
무. (b) `H-65` — leaf `Destroy` → cleanup → `canBound` 참 → 다른 inst에 재바인드 → 재설치
1회 → 새 inst에서 발화. (c) 죽은 바인딩 재사용 — Destroy 뒤 `Observer:Subscribe()` 통과·발화.
(d) 한 번도 안 묶인 `Effect`/`Observer`/plain에 `unbindLifetime` — 전부 no-op(단위 1 §6의
의심 닫힘 — `_unbindDestroying``_destroyConn == nil`이면 아무것도 안 한다). (e) leaf-bound
Effect에 `WeakUnsubscribe` — 조용히 통과하고 gcconn 경로는 그대로(`canExecute` 참, 계속 발화) /
`Unsubscribe``E-11` 그대로 error, cleanup 무손. (f) 다이아몬드 — `Effect(fn, a, a:Compute)`
1회 / `Effect(fn, a, a:Apply(b))`는 유보 중 직접 dep로 1회, `b:Off()`의 배치는 같은 리비전이라
**추가 실행 없음**(`_epochs` 키 dedup이 게이트 배치에도 성립). (g) `Ref` dep 재진입(`fn` 안
`r:Set`) — `_pending` 지연 1회. (h) `H-192` 실측(위). (i) Observer `fn` 예외 — `Set` 밖으로
전파, Observer는 죽지 않음(당시 재진입 플래그 없음 — 다음 `Set`에 정상 발화; Effect와 다른 계약이고
문서도 Observer엔 사망을 안 정했다. **[2026-08-31 `H-183`]** 이후 `_running` 가드가 생겼다 —
발화는 여전히 안 막히지만 error 후 네 진입점·bind는 막힌다, `spec.observer` 9). (j) `H-160``Unsubscribe` 경로 cleanup 안 `dep:Set`
홀드, 재구독 꼬리 1회. (k) `setup` 안에서 `emit()` — 빈 배치 false, `_onUpstreamEmit = Void`
덕에 크래시 없음. (l) `GateNode``Compute` dep·`Effect` dep로 — `Get`/`_track` 상속으로
유보 중 값 가시, 풀리면 하류 1회. (m) unbind → 변경 둘(홀드) → 다른 inst → 정확히 1회. (n)
`WeakSubscribe`된 Observer에 `bindLifetime` — error 뒤 상태 무변경. (o) 설치 `fn`이 자기
dep을 `Set``_pending` 1회, 플래그 전부 내려감. (p) deps 0개 `Effect`(`slot-plan.md`의
`_detachCleanup` 모양) — bind/unbind/rebind 무재실행, Destroy 소진, 죽은 뒤 `unbindLifetime`
안전.
- **M3(디스패치)가 이 코어를 부르는 모양 — 막히는 자리 없음**: `dispatch-core-plan.md`
`len:Observer(gatedRecompute)` + `bindLifetime(anchor, observer)`(콜백이 인자를 안 받아도
됨 — `fn(targetState, self, from)`은 여분 인자), `unbindLifetime(oldObserver)` 뒤 재등록,
store-bind의 `state:Observer(fn)` + `bindLifetime(inst, observer)` + 클로저의
`unbindLifetime`; `slot-plan.md``Effect(function() return function() … end end)` deps 0개 +
`bindLifetime(physicalTarget, …)`/언마운트 `unbindLifetime`/재마운트 재바인드(무재실행)/파괴
`unbindLifetime`(cleanup 미소진 — 손으로 비웠으니 의도); `getBlocker``Blocker()` 직접
사용(`On`/`IsOn`/`OffWithoutEmit`, gated state 없음 — 핸들 0개라 `Off`류가 no-op). 전부
지금 표면에서 그대로 돈다(위 프로브). **M3가 옮길 때 바꿀 것은 이름 해석 하나** — 문서의
자유 이름 `bindLifetime`/`canExecute`/`Effect`/`Blocker`는 `InitDispatch(module)`이 쥔
`module.bindLifetime`…이고 생명주기 넷은 발화 시점에 늦게 읽는다(`H-174` (a)). `quad-types`
`Quad`엔 M3가 읽을 값이 전부 있다(`Effect`/`Blocker`/`onDestroying`/생명주기 4/술어 11).
Slot 재마운트 캐치업(`H-163`)은 `bindLifetime` 안의 `_catchUp()` 한 줄이 맡는다 — mock이
그렇게 하고 있고 quad-roblox도 같은 줄이 필요하다(`lifecycle-pattern.md` (1)).
- **백엔드 없는 인스턴스(스텁만)의 실제 동작**(툴링 사실): `s:Observer(fn)` 생성과 deps 0개
`Effect(fn)`은 되지만, `Effect(fn, dep)`은 내부 Observer의 `WeakSubscribe`에서, Observer가
달린 State의 첫 `Set``_receive`에서 **스텁 에러**가 난다(*"canBound/canExecute is not
available"*, 위치는 `Observer.luau` 안 — 스텁의 `level 2`가 quad 내부를 가리킨다). 설계대로
"조용한 no-op 아님"이고, M5가 기본 인스턴스를 감싸기 전엔 반응형을 못 쓴다는 뜻.
- **관측만(발견 아님)**: `b:Off()` 순회 중 하류 Observer가 `b:On()`을 다시 켜도 스냅샷의
남은 핸들은 그대로 flush된다(`blocker-plan.md` "재진입(네스팅)" 미지원 그대로).
## §6 남은 의심 / 못 본 것
**단위 1 (탐사자, 2026-08-28)**:
- **`H-174`의 결정이 `EpochMap`엔 안 걸리지만 `GateNode`/`Blocker`(단위 4)엔 걸릴 수 있다** —
`GateNode`는 State 노드라 `canExecute`를 안 보지만(`H-56`), `Blocker`가 Observer/Effect의
구독 표면을 만지면 같은 인스턴스 문제를 상속한다. 단위 4 전에 다시 볼 것.
- **`unbindLifetime`을 한 번도 안 묶인 `EffectBrand` 값에 부르면 `value:_unbindDestroying()`
먼저 돈다**(mock·스케치 동일) — 단위 3의 `_unbindDestroying`은 미연결 상태에서 no-op이어야
한다(`unbindLifetime`의 *"안 걸려있던 값에 불러도 안전한 no-op"* 계약을 지키려면). 문서엔
그 요구가 명시돼 있지 않다 — 단위 3 탐사자 몫.
- **quad-roblox 쪽 마일스톤 표기가 둘로 갈린다**: `ROADMAP.md` M5의 `RobloxFactory.luau`
`bindLifetime`/`canExecute`를 주입한다고 하고(`architecture.md` 소스 트리 300행도 같음),
실 구현 체크박스는 M8(1523행)이다. `LifetimeHandle.luau` 도크스트링은 M8을 따랐다. 이
단위 범위 밖(코드는 어느 쪽이든 같음)이라 발견으로 세지 않고 여기만 적는다.
- **못 본 것**: `Relate` 핫패스 비용(`relate-plan.md` "M2 착수 시 실측 확인")은 측정하지
않았다 — 아직 핫패스를 부르는 코드가 없다. `ref-plan.md` "API 모양"의 *"`.Value`(읽기
전용 필드)"*는 타입 레벨(`read` 프로퍼티)로는 강제하지 않는다 — 문서가 타입 강제를
요구하지 않으므로 발견으로 안 올림.
**단위 3·4 (탐사자, 2026-08-29)**:
- **`H-186`의 이웃(같은 문항에 실어 답할 것, 새 번호 없음)**: dep뿐 아니라 **생명주기
진입점도** 교차한다 — 실측: `quad`의 Observer `o``other.bindLifetime(inst, o)`를 부르면
**조용히 성공**하고 `other.canExecute(o) == true`인데 `o``_receive`는 자기 `quad.canExecute(o)`
(거짓)를 읽어 **영영 안 울린다**. `H-186` (a)를 택한다면 판정 자리가 하나 더 있다(`bindLifetime`
쪽은 백엔드 코드라 base가 못 막고, 핸들 쪽에서 막으려면 핸들이 자기 `module`을 알아야 한다 —
임플이 `module`을 닫고 있으니 가능).
- **M3가 Observer의 사적 필드를 직접 쓴다**: `slot-plan.md``materializeSlotTree`
`slot._baseObserver._rerunRequired = false`를 대입한다(`H-163` 결정). 접근자는 없고 Luau가
막지도 않지만 계층 경계를 넘는 쓰기라, M3 탐사자가 다시 볼 자리(이 단위 밖).
- **못 본 것**: `lifecycle-pattern.md` (4)의 *"실측 필요(M0/M2)"*`canExecute`가 매 발화마다
`BindData:GetWeak` 2단 조회를 하는 비용은 측정하지 않았다(핫패스 코드가 아직 없음).
`Debounce`/`Throttle`(백로그)이 `GateNode` 위에 실제로 얹히는지는 정책 스텁(`switchPolicy`)으로만
확인했다 — 타이머 경로(`setTimeout` 주입 op)는 없다.
- **`H-184`의 마커가 mock에만 있다** — quad-roblox의 `bindLifetime`도 같은 순서라(`lifecycle-pattern.md`
(1)) 결정이 나면 두 곳(스케치·M8 구현)에 같이 반영해야 한다.