feat: 단위 2 /code-review high 반영 — ① 여섯(H-199/H-201/H-202/H-204/H-206/H-207) + ② 넷(H-198/H-200/H-203/H-205) §4 합류

체크포인트 재개 첫 항목. 8각도 후보 22 → 검증 생존 10(정확성 8건 실측 재현).

- H-199: With/Compute nil dep 조용한 탈락 → collectDeps + "dep #N is nil"(level 3)
- H-201: store:Of 이름 문자열 검증(할당 전, level 2)
- H-202: Compute fn 함수 검증(형제 표면과 같은 급)
- H-204: Store defaults 평범한 테이블 검증(clone 전)
- H-206: implsOf 세 벌 → ImplRegistry.luau 신설(잎, 내부 전용)
- H-207: Source.Set이 Impl.Emit 직접 호출로 위임(꼬리 한 벌)
- ② 넷은 코드에 TODO 마커만, 문항은 round11.md §4(마커 10 = 문항 10, 1:1)
- H-198(🔴 닫힌 게이트 너머 fn 도중 Set → 영구 stale)은 state-epoch-plan §4
  확정 의사코드 자체의 구멍 — 그 절에 ⚠️ 결정 대기 배너, README 색인 갱신
- architecture.md: ImplRegistry 소스 트리 등재, error 계약 "도착지가 계약" 명료화
- spec.state 13절·spec.store 2·3절 신설/확장, 감사 2라운드(6건 → 니트 2) 반영

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
This commit is contained in:
qwreey-agent-selene 2026-08-31 11:39:42 +09:00
parent 3cb45646d4
commit dc81dd26db
No known key found for this signature in database
16 changed files with 363 additions and 61 deletions

View file

@ -73,7 +73,7 @@
| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. |
| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker``state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 — **[2026-08-29 완료]** `State.luau``:Gate`/`GateNode` + `Blocker.luau`(단위 4) | | `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker``state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 — **[2026-08-29 완료]** `State.luau``:Gate`/`GateNode` + `Blocker.luau`(단위 4) |
| `claim-plan.md` | **[2026-08-28 신설·같은 날 확정, `research/`에서 승격]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper.<Class>(key) {…}) -> inst` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 `drive` 위의 한 겹(DFS 이름 해석 → bottom-up `drive`)으로 닫는다. 계약은 claim-once·own-all(부기 대상 자식 전부 매핑, 디스크립터 순서가 정본, 이름 중복·부재 UB + debug 검사, 같은 `inst` 이중 claim error, 다중 quad UB). 루트 키는 센티널 `D.Mapper.Root`, props 타입은 `D.<Class>`와 `type <Class>Param` 공유, `Claim`은 타입 인자 없음(추론). **루트의 `.Parent`는 부기 밖이라 밖에서 대입 허용**(`H-146` 예외를 좁혀 복원 — 여러 스크립트가 한 `PlayerGui`를 쓰는 흔한 경우의 답, PlayerGui 직하 Slot 공유는 중간 모듈). `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님). 프로바이더 op `nativeFindChild` + **`nativeClaim`**(gcconn/gchold 셋업의 유일한 자리 — `New`도 호출; 이중 claim은 그 셋업 유무로 error). **`PlayerGui`류 공동 소유 컨테이너는 claim 대상 아님**(루트는 `ScreenGui`·`SurfaceGui`). `type <Class>Param<E>` 원소 타입 파라미터. **M5 스코프**(`H-161`) | | `claim-plan.md` | **[2026-08-28 신설·같은 날 확정, `research/`에서 승격]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper.<Class>(key) {…}) -> inst` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 `drive` 위의 한 겹(DFS 이름 해석 → bottom-up `drive`)으로 닫는다. 계약은 claim-once·own-all(부기 대상 자식 전부 매핑, 디스크립터 순서가 정본, 이름 중복·부재 UB + debug 검사, 같은 `inst` 이중 claim error, 다중 quad UB). 루트 키는 센티널 `D.Mapper.Root`, props 타입은 `D.<Class>`와 `type <Class>Param` 공유, `Claim`은 타입 인자 없음(추론). **루트의 `.Parent`는 부기 밖이라 밖에서 대입 허용**(`H-146` 예외를 좁혀 복원 — 여러 스크립트가 한 `PlayerGui`를 쓰는 흔한 경우의 답, PlayerGui 직하 Slot 공유는 중간 모듈). `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님). 프로바이더 op `nativeFindChild` + **`nativeClaim`**(gcconn/gchold 셋업의 유일한 자리 — `New`도 호출; 이중 claim은 그 셋업 유무로 error). **`PlayerGui`류 공동 소유 컨테이너는 claim 대상 아님**(루트는 `ScreenGui`·`SurfaceGui`). `type <Class>Param<E>` 원소 타입 파라미터. **M5 스코프**(`H-161`) |
| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`**배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) | | `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`**배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **[2026-08-31 `H-198`]** §4 "재계산이 끝나면"의 라이브 리비전 "전부 갱신"에 실측 구멍 발견(닫힌 게이트 너머 `fn` 도중 `Set` → 영구 stale — `qa-request/pre-implementation-handtrace-round11.md` §4 회신 대기, 그 절에 ⚠️ 배너). 그 외 열린 설계 항목 없음. **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) |
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)

View file

@ -288,6 +288,7 @@ quad/
│ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require │ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require
│ ├── Brand.luau # **[2026-08-28 M2 첫 단위]** `Brand()` 생성자 + **브랜드 인스턴스 전부**(`EpochBrand`를 `Source`/`Ref`/`GateNode`가 공유하므로 타입 모듈마다 두면 순환 require) + `is*` 술어(타입이 생길 때 그 술어를 여기 추가, 최상위 `init.luau`가 재export). 의존 없는 잎(`base/brand-plan.md`) │ ├── Brand.luau # **[2026-08-28 M2 첫 단위]** `Brand()` 생성자 + **브랜드 인스턴스 전부**(`EpochBrand`를 `Source`/`Ref`/`GateNode`가 공유하므로 타입 모듈마다 두면 순환 require) + `is*` 술어(타입이 생길 때 그 술어를 여기 추가, 최상위 `init.luau`가 재export). 의존 없는 잎(`base/brand-plan.md`)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
│ ├── ImplRegistry.luau # **[2026-08-31 `H-206`]** 인스턴스별 임플 저장 접근 `implsOf(module)`(`module._impl`, `H-181`) 한 벌 — State/Observer/Effect에 verbatim 세 벌이던 것을 잎으로 추출(순수 데이터 접근, 내부 전용 — `init.luau` 재export 없음)
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만) — **[2026-08-28 M2 첫 단위]** `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종을 설치하고 백엔드가 덮어쓴다, `Relate`는 안 쓴다(그건 아래 quad-roblox 실 구현 몫 — `base/lifecycle-pattern.md`) │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만) — **[2026-08-28 M2 첫 단위]** `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종을 설치하고 백엔드가 덮어쓴다, `Relate`는 안 쓴다(그건 아래 quad-roblox 실 구현 몫 — `base/lifecycle-pattern.md`)
│ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
@ -339,7 +340,10 @@ quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전
``` ```
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를 내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
가리켜야 리포트가 쓸모 있다. 가리켜야 리포트가 쓸모 있다. **[2026-08-31 명료화]** 표의 `2`는 리터럴이
아니라 **"사용자 호출부를 가리킨다"의 기본형**이다 — 검증이 내부 헬퍼
프레임을 하나 거치면(`newNode`의 dep 검증, `collectDeps`의 nil 검증) 같은
뜻을 지키기 위해 `3`을 쓴다. 프레임 수가 아니라 도착지가 계약이다.
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금 - **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데 코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은 정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은

View file

@ -420,6 +420,14 @@ if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
### 재계산이 끝나면 ### 재계산이 끝나면
> **⚠️ [2026-08-31, `H-198` 결정 대기]** 이 절의 "전부 갱신"(라이브 리비전)에
> 실측 구멍이 발견됐다 — `fn` **도중** 닫힌 게이트 상류에서 난 `Set`은 emit이
> 유보돼 카운터 쌍(`H-85`)이 안 움직이는데, 꼬리의 `_track`이 라이브 리비전을
> 찍어 "본 것"으로 기록하므로 나중 flush가 `_invalidate` 없이 통지만 하고
> 캐시가 영구 stale이 된다(재현 실측). 문항과 선택지는
> `qa-request/pre-implementation-handtrace-round11.md` §4의 `H-198` — 회신
> 전까지 이 절을 무결한 확정으로 읽지 말 것(코드는 이 절 그대로 + `TODO(H-198)`).
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류 - **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이 전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해 `Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해

View file

@ -47,6 +47,16 @@
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(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-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-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 확정 의사코드 자체의 구멍 | §4 대기 (`-- TODO(H-198)`) |
| `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의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | §4 대기 (`-- TODO(H-200)`) |
| `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` 카운팅)만 다룸 | §4 대기 (`-- TODO(H-203)`) |
| `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도 유저 코드에 못 닿음 | §4 대기 (`-- TODO(H-205)`) |
| `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)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) |
## 상세 ## 상세
@ -364,6 +374,114 @@
(`spec.init` 2가 `q.Effect`를 부르긴 하지만 그건 `H-181` GC 검사다). (`spec.init` 2가 `q.Effect`를 부르긴 하지만 그건 `H-181` GC 검사다).
- **처리**: ✅ 반영. 1절에 세 줄. - **처리**: ✅ 반영. 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 ⭐ 사용자 결정이 필요한 것 (배치 회신용) ## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? | | 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
@ -377,6 +495,10 @@
| **`H-185`** (`/code-review`, 단위 3) | cleanup 팩 vs 첫 반환만 | (a) 런타임이 반환 전부를 cleanup 목록으로 소진(역순) / (b) 타입을 `-> (() -> ())?`류로 좁힘(`H-95`가 기각한 모양 — 무반환 `fn`이 막힘) / (c) 문서·타입 주석에 "첫 반환만" 명시 | **(a)** | 타입이 이미 팩을 광고하고 다중 자원 정리는 자연스러운 용례 — 소진 순서만 정하면 됨(`_consumeCleanup` 한 곳) | 아니오 | | **`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-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-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()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 | | **`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 로 **[2026-08-28 회신 — 단위 1 배치 전량 확정]** 사용자 원문: *"174 는 module.canExecute 로
@ -390,7 +512,7 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열. 번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야 코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개 마커**가 코드에 있다(`grep -rn "TODO(H-" quad-base quad-types`) — 셋(`H-168`~`H-170`)은 단위 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` 넷이 더해져 열 개 마커**가 코드에 있다(`grep -rn "TODO(H-" quad-base quad-types`) — 마커 없이 답변된 §4 행은 넷(`H-168`~`H-170`, `H-174`)이고, 그중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제). 블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
## §5 이상 없다고 확인한 것 ## §5 이상 없다고 확인한 것

View file

@ -2041,3 +2041,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
`module._impl`)·`H-188`~`H-190` 반영, ② `H-182`~`H-187` §4. 탐사자 ① 7건(`H-191`~`H-197`, `module._impl`)·`H-188`~`H-190` 반영, ② `H-182`~`H-187` §4. 탐사자 ① 7건(`H-191`~`H-197`,
전부 옛 문장) 반영, 전 코퍼스 스윕으로 `HasBlockedEmit`·`LifetimeHandle` 표기 정리. 전부 옛 문장) 반영, 전 코퍼스 스윕으로 `HasBlockedEmit`·`LifetimeHandle` 표기 정리.
**M2 단위 넷 구현·감사·리뷰·탐사 완료** — 남은 건 §4 회신. **M2 단위 넷 구현·감사·리뷰·탐사 완료** — 남은 건 §4 회신.
- **`session/2026-08-31-01-unit2-code-review.md`** — 체크포인트 재개 첫 항목: 단위 2 파일
`/code-review high` 완주(포크 조기 반환을 `SendMessage` 재개로 해결 — 재실행보다 이
방법 먼저). 8각도 22후보 → 검증 생존 10: ① 여섯 반영(`H-199` nil dep `collectDeps` /
`H-201`·`H-202`·`H-204` 입력 검증 셋 / `H-206` `ImplRegistry.luau` 신설 / `H-207`
`Set`→`Emit` 위임), ② 넷 §4 합류(`H-198` 🔴 닫힌 게이트 너머 `fn` 도중 `Set` 영구
stale — §4 확정 의사코드 자체의 구멍 / `H-200` setup throw 좀비 / `H-203` `Off`
재차단 / `H-205` Modifier 가드 level). 문서 이름 변경(pre-implementation 아님)은
사용자 지시로 §4 회신 뒤로 미룸. 남은 재개 지점은 §4 문항 열 개 회신뿐.

View file

@ -0,0 +1,50 @@
# 2026-08-31-01 — 체크포인트 재개: 단위 2 `/code-review high` 완주와 반영
## 맥락
2026-08-29 아침 체크포인트(`session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절)의
재개 지점 둘 중, 사용자가 *"미완인 것 먼저 처리해줘"*로 (2) 단위 2 파일 리뷰를 먼저
지시했다. §4 문항 여섯(`H-182`~`H-187`)의 회신은 이 세션 시작 시점에 아직 없음.
부수 결정 하나 — 사용자: *"이건 pre-implementation 은 아닌듯 하네, 이름은 나중에
바꾸자"*. `pre-implementation-handtrace-round11*.md`는 구현 **중** 발견 문서라 이름이
안 맞는다는 것. **이름 변경은 §4 회신 처리 뒤로 미룸**(문항지·brief와 인용처를 한꺼번에
옮겨야 해서) — 아직 안 했고, 잊지 말 것.
## 포크 조기 반환과 재개
`/code-review high` + 경로 넷(`EpochMap`/`Source`/`State`/`Store`)으로 실행. 체크포인트가
기록한 실패 모드(하위 탐색자 완료 전 포크 조기 반환)가 **이번에도 재현**됐다 — 첫
notification 시점에 파인더 4개가 미완인 채 멈춤. 이번엔 opus 대체가 아니라
**`SendMessage`로 포크를 재개**(나머지 파인더 수거 → dedup → 검증 → `ReportFindings`까지
끝내고 턴을 닫으라고 명시)했고, 그걸로 완주했다. 다음에 같은 패턴이 나오면 이 방법을
먼저 쓸 것 — 재실행보다 싸고(이미 돈 파인더 결과를 버리지 않음) 잘 듣는다.
## 결과 — 8각도, 후보 22 → 검증 생존 10
정확성 8건 전부 실제 코드에 재현 스크립트로 확인. 효율 배치는 리뷰 스스로 "관측된
병목에만 구조" 원칙 위반으로 기각, 약한 정리 항목들은 심각도 미달 기각. 발견 상세와
처리의 소스는 `qa-request/pre-implementation-handtrace-round11.md`(요약 표
`H-198`~`H-207`, 상세 절 "단위 2 — `/code-review high`") — 여기선 갈래만:
- **① 여섯**(같은 커밋에서 반영): `H-199`(nil dep 조용한 탈락 — `collectDeps` + error),
`H-201`(`Of` 이름 문자열 검증), `H-202`(`Compute` fn 검증), `H-204`(`defaults` 평범한
테이블 검증), `H-206`(`implsOf` 세 벌 → `ImplRegistry.luau` 신설), `H-207`(`Source.Set`이
`Emit` 꼬리 위임). 검증 셋은 `H-190`·단위 3 `fn` 검사와 같은 급(형제 검증과 동급),
중복 제거 둘은 conventions 설계 원칙의 공유 허용 범위(데이터/순수 접근, 같은 타입).
- **② 넷**(§4 문항 + `TODO` 마커): `H-198` 🔴(닫힌 게이트 너머 `fn` 도중 `Set` → 영구
stale 캐시 — **`state-epoch-plan.md` §4 확정 의사코드 자체의 구멍**, `H-85`와 같은 실패
계열의 게이트 경유 변형), `H-200`(`Gate` setup throw 좀비 노드 — 예외 계약 UB냐
`H-188` 연장이냐), `H-203`(`Blocker:Off` 순회 중 재차단 무시 — 탐사자는 §5에서 미지원
연장으로 봤고 리뷰는 `IsOn` 불변식 위반으로 봄), `H-205`(Modifier 가드 level — lazy
체인이라 어떤 고정 level도 항상은 유저 코드에 못 닿음).
리뷰가 확인한 "이상 없음" 목록과 기각 중 기록할 것 하나(Effect `Ref` dep의 생성 중
`onRefFire` 즉시 발화 — 신선한 Effect는 `canExecute` 거짓이라 실패 불가)는 round11.md의
"단위 2 리뷰가 이상 없다고 확인한 것" 절로.
## 이 시점 상태
`./scripts/test.sh` 전부 통과(`spec.state` 13절·`spec.store` 2·3절에 새 가드 테스트 추가),
`luau-analyze` 진단 0, 코드 마커 `TODO(H-)` 열 개 = §4 문항 열 개와 1:1. **재개 지점은
§4 배치 회신 하나로 줄었다**(여섯 + 이번 넷). M2 종료 보고는 그 회신 처리 뒤.

View file

@ -10,9 +10,14 @@
단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가 단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가
읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서 읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서
세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴), 세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴),
단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료 — **[2026-08-29 아침 체크포인트]** 재개 지점은 (1) `round11.md` §4 문항 여섯(`H-182`~`H-187`) 사용자 회신, (2) 단위 2 파일 `/code-review`(미완, 포크 조기 반환) — 단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료. **[2026-08-31]** 체크포인트에
진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. 밤샘 자율 미완으로 남았던 단위 2 파일 `/code-review high`도 완료(포크 재개 지시로 완주) —
구간(사용자 허용, 병렬 2)이라 이 줄은 자주 낡는다 — `ROADMAP.md` 체크박스가 소스. 발견 10건 중 ① 여섯 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`)이 §4 합류.
**남은 재개 지점은 `round11.md` §4 문항 열 개(`H-182`~`H-187` + 위 넷) 사용자
회신뿐** — 진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과
`session/2026-08-31-01-unit2-code-review.md`. §4 회신 처리 뒤 할 일 하나:
`pre-implementation-handtrace-round11*` 파일명 변경(사용자 2026-08-31 — 구현 중
문서라 pre-implementation이 안 맞음; 새 이름은 그때 사용자와 정할 것).
아래는 착수 전(2026-08-26) 서술: 아래는 착수 전(2026-08-26) 서술:
**[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.** **[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.**

View file

@ -55,6 +55,10 @@ local function runHandles(self: any, doEmit: boolean)
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
snapshot[#snapshot + 1] = handle snapshot[#snapshot + 1] = handle
end end
-- TODO(H-203): the walk never re-reads `IsBlocked` — a subscriber fired by an
-- earlier handle's flush that calls `blocker:On()` cannot stop the remaining
-- handles: their batches propagate while `IsOn() == true`. (The line-comment in
-- `Off` covers only the open-direction re-entry; the doc disclaims nesting only.)
for _, handle in ipairs(snapshot) do for _, handle in ipairs(snapshot) do
handle(doEmit) handle(doEmit)
end end

View file

@ -34,6 +34,7 @@
local Brand = require("./Brand") local Brand = require("./Brand")
local EpochMap = require("./EpochMap") local EpochMap = require("./EpochMap")
local ImplRegistry = require("./ImplRegistry")
local Observer = require("./Observer") local Observer = require("./Observer")
local EffectBrand = Brand.EffectBrand local EffectBrand = Brand.EffectBrand
@ -42,18 +43,8 @@ local isSource = Brand.isSource
local isRef = Brand.isRef local isRef = Brand.isRef
local isEpoch = Brand.isEpoch local isEpoch = Brand.isEpoch
-- Per-instance impls live ON the module instance (`module._impl`, private — the -- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map local implsOf = ImplRegistry.implsOf
-- keyed by `module` would never collect: the impl's closures capture `module`, and
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
local function implsOf(module: any): { [string]: any }
local impls = rawget(module, "_impl")
if impls == nil then
impls = {}
rawset(module, "_impl", impls)
end
return impls
end
local function createImpl(module: any) local function createImpl(module: any)
module:RunInit(Observer.Init) -- registries live there (`H-99`) module:RunInit(Observer.Init) -- registries live there (`H-99`)

View file

@ -0,0 +1,28 @@
--[[
Per-instance implementation registry access (`H-174`/`H-181`) — the one
copy of `implsOf`, shared by `State.luau`/`Observer.luau`/`Effect.luau`
(`H-206`: three verbatim copies risked forking the registry scheme).
Dependency-free LEAF module, shared across quad instances like `Ref`: it
holds no state of its own — pure access to the `module` it is handed (the
sharing `conventions.md`'s heterogeneous-bodies principle explicitly
permits; bodies with per-type tails stay separate).
Impls live ON the module instance (`module._impl`, private — the `H-174`
(a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
keyed by `module` would never collect: the impl's closures capture
`module`, and Luau has no ephemerons (`relate-plan.md`), so every
`Quad.New()` would be pinned (`H-181`).
]]
local function implsOf(module: any): { [string]: any }
local impls = rawget(module, "_impl")
if impls == nil then
impls = {}
rawset(module, "_impl", impls)
end
return impls
end
return {
implsOf = implsOf,
}

View file

@ -34,23 +34,15 @@
]] ]]
local Brand = require("./Brand") local Brand = require("./Brand")
local ImplRegistry = require("./ImplRegistry")
local ObserverBrand = Brand.ObserverBrand local ObserverBrand = Brand.ObserverBrand
local WEAK_KEY_MT = { __mode = "k" } local WEAK_KEY_MT = { __mode = "k" }
-- Per-instance impls live ON the module instance (`module._impl`, private — the -- One implementation (and registry pair) per quad instance (`H-174`);
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map -- storage scheme in `ImplRegistry.luau`.
-- keyed by `module` would never collect: the impl's closures capture `module`, and local implsOf = ImplRegistry.implsOf
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
local function implsOf(module: any): { [string]: any }
local impls = rawget(module, "_impl")
if impls == nil then
impls = {}
rawset(module, "_impl", impls)
end
return impls
end
local function createImpl(module: any) local function createImpl(module: any)
local Impl = {} local Impl = {}

View file

@ -50,6 +50,14 @@ local function Init(module: any)
return self._value return self._value
end end
-- In-place mutation was done by the caller; only the signal is sent.
-- Root Sources only — derived States have no such concept ("하드 경계").
function Impl.Emit(self: any): any
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self) -- payload = the source epoch itself
return self
end
-- `H-68`: same value still bumps the revision and emits — dedup is the -- `H-68`: same value still bumps the revision and emits — dedup is the
-- downstream's job (EpochMap judgement, gates), and `==` would silently -- downstream's job (EpochMap judgement, gates), and `==` would silently
-- drop in-place table mutations. -- drop in-place table mutations.
@ -58,17 +66,9 @@ local function Init(module: any)
error("Source: cannot Set a Modifier as a Source value", 2) error("Source: cannot Set a Modifier as a Source value", 2)
end end
self._value = value self._value = value
self.Revision = bit32.bnot(-self.Revision) -- `H-207`: one copy of the bump-and-emit tail. Direct call, not colon
emitDown(self, self) -- payload = the source epoch itself -- delegation — same type, same impl table, no subtype override to hit.
return self return Impl.Emit(self)
end
-- In-place mutation was done by the caller; only the signal is sent.
-- Root Sources only — derived States have no such concept ("하드 경계").
function Impl.Emit(self: any): any
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self)
return self
end end
function Impl._track(self: any, map: any) function Impl._track(self: any, map: any)

View file

@ -45,6 +45,7 @@
local Brand = require("./Brand") local Brand = require("./Brand")
local EpochMap = require("./EpochMap") local EpochMap = require("./EpochMap")
local ImplRegistry = require("./ImplRegistry")
local Observer = require("./Observer") local Observer = require("./Observer")
local Void = require("./Void") local Void = require("./Void")
local QuadTypes = require("../roblox_packages/quad_types") local QuadTypes = require("../roblox_packages/quad_types")
@ -60,19 +61,8 @@ local isModifier = Brand.isModifier
local WEAK_KEY_MT = { __mode = "k" } local WEAK_KEY_MT = { __mode = "k" }
-- One implementation per quad instance (`H-174`). -- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
-- Per-instance impls live ON the module instance (`module._impl`, private — the local implsOf = ImplRegistry.implsOf
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
-- keyed by `module` would never collect: the impl's closures capture `module`, and
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
local function implsOf(module: any): { [string]: any }
local impls = rawget(module, "_impl")
if impls == nil then
impls = {}
rawset(module, "_impl", impls)
end
return impls
end
local function createImpl(module: any) local function createImpl(module: any)
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`) module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
@ -155,10 +145,15 @@ local function createImpl(module: any)
-- (`hold[1]`), never this result node. -- (`hold[1]`), never this result node.
local result = self._fn(hold[1], self._cache, table.unpack(hold, 2)) local result = self._fn(hold[1], self._cache, table.unpack(hold, 2))
if isModifier(result) then if isModifier(result) then
-- TODO(H-205): level 2 here always blames Impl.Get's own line (its only
-- callers are internal) — the contract wants user-code blame; level TBD.
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2) error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2)
end end
self._cache = result self._cache = result
for _, dep in ipairs(hold) do for _, dep in ipairs(hold) do
-- TODO(H-198): tracking LIVE revisions here marks as seen an upstream Set
-- that landed DURING fn but whose emit a closed gate withheld — the later
-- flush then forwards downstream without `_invalidate` (permanent stale cache).
dep:_track(self._valueEpochMap) -- fresh value is current w.r.t. EVERY upstream dep:_track(self._valueEpochMap) -- fresh value is current w.r.t. EVERY upstream
end end
self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid
@ -181,20 +176,32 @@ local function createImpl(module: any)
return s:Get() return s:Get()
end end
function Impl.With(self: any, ...: any): any -- `H-199`: a nil dep must error, not vanish — `deps[#deps + 1]` would drop it
-- silently AND shift later deps left, so `fn`'s positional lazy-dep args land
-- in the wrong slots. Index numbering matches `newNode`'s (self is dep #1).
local function collectDeps(self: any, ...: any): { any }
local deps = { self } local deps = { self }
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
deps[#deps + 1] = (select(i, ...)) local dep = (select(i, ...))
if dep == nil then
error(`State: dep #{i + 1} is nil`, 3) -- 3: past With/Compute to the user's call
end end
return newNode(deps, passThrough) -- value is `self`'s, subscriptions are wider deps[i + 1] = dep
end
return deps
end
function Impl.With(self: any, ...: any): any
return newNode(collectDeps(self, ...), passThrough) -- value is `self`'s, subscriptions are wider
end end
function Impl.Compute(self: any, fn: any, ...: any): any function Impl.Compute(self: any, fn: any, ...: any): any
local deps = { self } if type(fn) ~= "function" then
for i = 1, select("#", ...) do -- `H-202`: without this the crash lands at the first `Get` deep inside
deps[#deps + 1] = (select(i, ...)) -- `_recompute` — sibling surfaces (Gate/Observer/Apply) all validate here.
error("State: Compute fn must be a function", 2)
end end
return newNode(deps, fn) -- one node: edges only, no combining node return newNode(collectDeps(self, ...), fn) -- one node: edges only, no combining node
end end
-- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ── -- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ──
@ -252,6 +259,9 @@ local function createImpl(module: any)
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
node._withheld = newWithheld() node._withheld = newWithheld()
node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee
-- TODO(H-200): if `setup` THROWS here, the node stays in `self._subs` as a
-- zombie subscriber (the detach below only covers the non-function return) —
-- spec.gate §1's "nothing left in the subscriber set" only holds for that path.
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
return node:_flush(commit) return node:_flush(commit)
end) end)

View file

@ -50,6 +50,12 @@ local function Init(module: any)
-- Dynamic keys only: the type cannot stop a computed name, so the runtime does. -- Dynamic keys only: the type cannot stop a computed name, so the runtime does.
function Impl.Of(self: any, name: string): any function Impl.Of(self: any, name: string): any
if type(name) ~= "string" then
-- `H-201`: `Of` exists precisely for computed names — a nil/number name
-- must not pass (nil died at `self[name] =` with an internal frame after
-- already allocating a Source; a number silently broke `Names(): { string }`).
error(`Store: Of name must be a string (got {typeof(name)})`, 2)
end
if RESERVED[name] then if RESERVED[name] then
error(`Store: "{name}" is a reserved store key`, 2) error(`Store: "{name}" is a reserved store key`, 2)
end end
@ -70,6 +76,17 @@ local function Init(module: any)
end end
local function Store(defaults: any?): any local function Store(defaults: any?): any
-- `H-204`: validate the bag itself before cloning it — a non-table died
-- inside `table.clone`, and a bare Source (forgot the wrapping table) got
-- pairs-walked into an error naming a Source-internal field.
if defaults ~= nil then
if type(defaults) ~= "table" then
error(`Store: defaults must be a table of Sources (got {typeof(defaults)})`, 2)
end
if getmetatable(defaults) ~= nil then
error("Store: defaults must be a plain table without a metatable (a bare Source instead of { name = Source }?)", 2)
end
end
local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`) local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`)
for name, value in pairs(self) do for name, value in pairs(self) do
if RESERVED[name :: any] then if RESERVED[name :: any] then

View file

@ -329,5 +329,38 @@ do
print("PASS") print("PASS")
end end
print()
print("=== 13. 가드 (H-199/H-202) — nil dep은 조용히 사라지지 않고 error, Compute fn은 함수여야 ===")
do
local s = Source(1)
-- H-199: a nil mid-list used to shift later deps left (fn's positional args
-- landed in the wrong slots); a single trailing nil used to vanish entirely.
local ok, err = pcall(function()
s:Compute(function(x)
return x:Get()
end, nil :: any)
end)
assert(not ok and string.find(tostring(err), "dep #2 is nil", 1, true) ~= nil, "trailing nil dep: " .. tostring(err))
assert(string.find(tostring(err), "spec.state.luau", 1, true) ~= nil, "points at the caller")
local other = Source(2)
local ok2, err2 = pcall(function()
s:Compute(function(x)
return x:Get()
end, other, nil :: any, other)
end)
assert(not ok2 and string.find(tostring(err2), "dep #3 is nil", 1, true) ~= nil, "mid-list nil dep: " .. tostring(err2))
local ok3 = pcall(function()
s:With(nil :: any)
end)
assert(not ok3, "With rejects nil deps through the same collector")
-- H-202: non-function fn errors at Compute, not at the first Get deep in _recompute.
local ok4, err4 = pcall(function()
(s :: any):Compute(42)
end)
assert(not ok4 and string.find(tostring(err4), "Compute fn must be a function", 1, true) ~= nil, "non-function fn: " .. tostring(err4))
assert(string.find(tostring(err4), "spec.state.luau", 1, true) ~= nil, "points at the caller")
print("PASS")
end
print() print()
print("=== ALL PASS ===") print("=== ALL PASS ===")

View file

@ -43,6 +43,21 @@ do
Store({ ok = Source(1), bad = Quad.Ref(1) } :: any) Store({ ok = Source(1), bad = Quad.Ref(1) } :: any)
end) end)
assert(not ok2, "a Ref is not a Source") assert(not ok2, "a Ref is not a Source")
-- H-204: the bag itself is validated before clone — a non-table used to die
-- inside table.clone, a bare Source got an error naming a Source-internal field.
local ok3, err3 = pcall(function()
Store(5 :: any)
end)
assert(not ok3 and string.find(tostring(err3), "must be a table", 1, true) ~= nil, "non-table defaults: " .. tostring(err3))
assert(string.find(tostring(err3), "spec.store.luau", 1, true) ~= nil, "level 2 points at the caller")
local ok4, err4 = pcall(function()
Store(Source(100) :: any)
end)
assert(not ok4 and string.find(tostring(err4), "plain table", 1, true) ~= nil, "bare Source as defaults: " .. tostring(err4))
local ok5 = pcall(function()
Store(setmetatable({}, { __metatable = "locked" }) :: any)
end)
assert(not ok5, "protected metatable rejected before clone")
print("PASS") print("PASS")
end end
@ -61,6 +76,20 @@ do
assert(not ok2 and string.find(tostring(err2), "reserved", 1, true) ~= nil, name .. " via Of: " .. tostring(err2)) assert(not ok2 and string.find(tostring(err2), "reserved", 1, true) ~= nil, name .. " via Of: " .. tostring(err2))
assert(string.find(tostring(err2), "spec.store.luau", 1, true) ~= nil, "level 2") assert(string.find(tostring(err2), "spec.store.luau", 1, true) ~= nil, "level 2")
end end
-- H-201: Of exists for computed names, so a computed nil/number must error
-- here (nil used to allocate a Source then die at an internal frame; a number
-- silently broke `Names(): { string }`).
local s = Store({} :: {})
local okNil, errNil = pcall(function()
s:Of(nil :: any)
end)
assert(not okNil and string.find(tostring(errNil), "Of name must be a string", 1, true) ~= nil, "nil Of name: " .. tostring(errNil))
assert(string.find(tostring(errNil), "spec.store.luau", 1, true) ~= nil, "level 2 points at the caller")
local okNum, errNum = pcall(function()
s:Of(123 :: any)
end)
assert(not okNum and string.find(tostring(errNum), "Of name must be a string", 1, true) ~= nil, "number Of name: " .. tostring(errNum))
assert(#s:Names() == 0, "nothing was allocated by the rejected names")
print("PASS") print("PASS")
end end