qa: 10라운드 후속 H-158~H-162 반영 — :Block 폐기(__apply), _rerunRequired 홀드(_installed 흡수), Claim M5, Void export

- H-158 state:Block → state:Apply(blocker), Blocker:__apply 메소드형(호출 규약 명시)
- H-159 사용자 제안 _rerunRequired 홀드: fire=Update→Rerun, rawRerun이 실행 불가 상태의 요청을 홀드,
  _installed 폐기, Observer 대칭(전파 루프 else + bind/subscribe 1회 발화, 생성자 순서 fn→_subs)
- H-160 홀드로 정정 + "error 나면 그 Effect는 죽는다" 계약 / H-161 Claim M5 스코프 / H-162 Void.luau 잎 모듈
- 감사 7→6→1→0, /code-review high 10건 중 8 반영, 둘(H-163 Slot 내부 Observer×홀드, H-164 emitFrom nil)은 문항으로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
This commit is contained in:
qwreey 2026-08-28 14:43:56 +09:00
parent ae34cfa316
commit ecc6b0e538
Signed by: qwreey
GPG key ID: D28DB79297A214BD
27 changed files with 415 additions and 189 deletions

File diff suppressed because one or more lines are too long

View file

@ -278,20 +278,21 @@ quad/
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive``None` 스킵 분기 폐기) │ │ ├── None.luau # (**[2026-08-28 `H-162`]** 센티널 `None`과 같은 급으로 quad-base가 export하는 단일 no-op 함수 **`Void`**는 의존 없는 잎 모듈 `Void.luau`(아래)에 정의하고 최상위 `init.luau`가 재export한다 — `Dispatch/init.luau`가 파일 스코프 `local NOOP = Void`로 쓰므로 최상위에 두면 순환 require(`/code-review` 지적); 핸들러 retractor·cleanup 자리의 `function() end`를 전부 대체) NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive``None` 스킵 분기 폐기)
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) │ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동)
│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**) │ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**)
│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈 │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈
│ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`) │ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`)
│ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`) │ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`)
│ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require
│ ├── 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 대체
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`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 여섯 번째 세션에서 분리)
│ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정) │ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정)
│ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음
│ └── init.luau │ └── init.luau # 패키지 최상위 export — `Quad` 값 테이블(`New`/`Source`/…/`None`/**`Void`**(재export — 정의는 위 `Void.luau`, `H-162`))
└── quad-roblox/ └── quad-roblox/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/ └── src/

View file

@ -342,7 +342,7 @@ end
상위에 있고 거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안 상위에 있고 거기에 GUI 를 여럿 바운딩 해야해서 `Slot { Shop{} … }` 하는게 안
될것 같은 느낌이 듦. 이건 Parent 이상의 문제인것 같아."* 루트는 밖에서 될것 같은 느낌이 듦. 이건 Parent 이상의 문제인것 같아."* 루트는 밖에서
`Parent`를 만지는 게 아니라 **quad가 `Claim`으로 소유**한다(PlayerGui·`Clone()` `Parent`를 만지는 게 아니라 **quad가 `Claim`으로 소유**한다(PlayerGui·`Clone()`
사본·Studio GUI — 그 research 문서가 소스, M5 이후). 그러면 `.Parent =` 사본·Studio GUI — 그 research 문서가 소스, **M5 스코프**(`H-161`)). 그러면 `.Parent =`
사용자가 쓸 자리 자체가 없어진다. 아래는 폐기 전 서술: 사용자가 쓸 자리 자체가 없어진다. 아래는 폐기 전 서술:
**[2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 — **[2026-08-27 확정, 9라운드 `H-146`] 루트는 이 금지의 범위 밖이다 —
quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =` quad 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서 `.Parent =`

View file

@ -16,7 +16,20 @@
문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이 문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이
위험을 구조적으로 우회한다. 위험을 구조적으로 우회한다.
**store 개발(M2)과 밀접하게 연관됨** — `state:Block(blocker)`가 State **⭐ [2026-08-28 확정, 10라운드 `H-158`] `state:Block(blocker)` 동사는 폐기 — 배선은
`state:Apply(blocker)`다.** 사용자: *"이제 Compute 와 유사하게 Gate 만 놓일 뿐, Apply 로
Blocker 처리가 가능함. 왜냐면 펑터와 적용성펑터를 전부 허용하니까 ( map|{...map} ) 키는
`__apply` 로 하기로 했던거로 기억중임."* — `source-state-plan.md`의 "`state:Apply(factory)`"
절이 애플리커티브 팩토리를 **지정된 필드**로 받기로 확정해뒀고(필드 이름은 "구현 시"로
열려 있었다 — 이 발언으로 **`__apply`**), `Blocker`가 그 필드를 **메소드**로 노출한다 — `function Blocker:__apply(state)
return state:Gate(function(emit) return self:Policy(emit) end) end`(`self` = 이 blocker
인스턴스; 호출 규약은 `source-state-plan.md`의 "`state:Apply(factory)`" 절). 아래 본문의
`state:Apply(blocker)`는 전부 옛 `state:Block(blocker)`를 기계 치환한 것 — 의미는 같다
(2026-08-21에 *"`Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다"*고
예고한 그것). 이 문서는 **`Blocker` = `Policy`를 주는 프리미티브 + `__apply` 슈가**를
서술한다.
**store 개발(M2)과 밀접하게 연관됨** — `state:Apply(blocker)`가 State
위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State 위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함.
**[2026-08-24 재확정] 구현 마일스톤은 다시 M2다 — "State와 같은 **[2026-08-24 재확정] 구현 마일스톤은 다시 M2다 — "State와 같은
@ -57,14 +70,15 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를
-- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래 -- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래
-- "이름 확정" 참고), 호출부 가독성만을 위한 추가. -- "이름 확정" 참고), 호출부 가독성만을 위한 추가.
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 state:Apply(blocker) -> state -- [2026-08-28 `H-158`] `Blocker.__apply`를 통한 `state:Apply`
-- 옛 `state:Block(blocker)`. 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을 -- 처음 블록될 때가 아니라) onunblock 핸들을
-- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관"). -- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관").
blocker:Policy(emit) -> onUpstreamEmit blocker:Policy(emit) -> onUpstreamEmit
-- [2026-08-24 신설] 이 blocker의 게이트 정책을 -- [2026-08-24 신설] 이 blocker의 게이트 정책을
-- **값으로** 돌려준다. `state:Block(b)`가 -- **값으로** 돌려준다. `state:Apply(b)`가
-- 내부에서 쓰는 바로 그것: -- 내부에서 쓰는 바로 그것:
-- state:Block(b) -- state:Apply(b)
-- == state:Gate(function(emit) return b:Policy(emit) end) -- == state:Gate(function(emit) return b:Policy(emit) end)
-- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)` -- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)`
-- 적었었는데 그건 언바운드 메소드라 `emit``self` -- 적었었는데 그건 언바운드 메소드라 `emit``self`
@ -91,10 +105,10 @@ gated state의 동작:
안 한다"**로 일반화된다(`base/gate-plan.md`의 8번). 구현 시 두 개를 따로 안 한다"**로 일반화된다(`base/gate-plan.md`의 8번). 구현 시 두 개를 따로
들지 말 것. 들지 말 것.
**⭐ [2026-08-21 신설] `state:Block(blocker)`는 `state:Gate(setup)` 위에 **⭐ [2026-08-21 신설] `state:Apply(blocker)`는 `state:Gate(setup)` 위에
얹힌다.** 위 "gated state의 동작"은 `Blocker`만의 특수 노드가 아니라 얹힌다.** 위 "gated state의 동작"은 `Blocker`만의 특수 노드가 아니라
`base/gate-plan.md`가 확정한 **`GateNode`**(`ComputeNode`와 같은 층위)의 `base/gate-plan.md`가 확정한 **`GateNode`**(`ComputeNode`와 같은 층위)의
정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy` 정책 하나다 — `Blocker.__apply`(옛 `Block`)가 내부에서 `state:Gate(policy)`를 부르고, 그 `policy`
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지 `blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다. 정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
@ -118,20 +132,20 @@ gated state의 동작:
걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List` 걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List`
항목마다 게이트를 무는 패턴에서 직행 누수). 항목마다 게이트를 무는 패턴에서 직행 누수).
3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중 3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중
새 등록(핸들 → flush → 하류 Observer가 `state:Block(b)`를 새로 만듦)이 새 등록(핸들 → flush → 하류 Observer가 `state:Apply(b)`를 새로 만듦)이
`pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은 `pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은
처방(`base/source-state-plan.md`의 `H-23` 확정). 처방(`base/source-state-plan.md`의 `H-23` 확정).
**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로 **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로
꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고, 꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고,
`Blocker()` 생성자와 `state:Block`은 그대로다. `Blocker()` 생성자와 배선(`state:Apply(blocker)` — 옛 `state:Block`)은 그대로다.
- **왜 필요한가**: `Debounce`/`Throttle`이 "언제 통과시킬지"를 정하면서 - **왜 필요한가**: `Debounce`/`Throttle`이 "언제 통과시킬지"를 정하면서
실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수 실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수
있으면 그게 그냥 함수 합성이 된다 — `setup`이 곧 있으면 그게 그냥 함수 합성이 된다 — `setup`이 곧
`(emit) -> onUpstreamEmit`이라 타입이 이미 맞는다. `(emit) -> onUpstreamEmit`이라 타입이 이미 맞는다.
- **정책이 하는 일은 안 바뀐다**`Policy(emit)`을 부르는 시점에 onunblock - **정책이 하는 일은 안 바뀐다**`Policy(emit)`을 부르는 시점에 onunblock
핸들이 등록되므로(지금 `state:Block`이 하던 것과 같은 자리), `Off()` 핸들이 등록되므로(지금 `state:Apply(blocker)`가 하던 것과 같은 자리), `Off()`
풀 때 그 `emit`이 정확히 1회 불린다. 풀 때 그 `emit`이 정확히 1회 불린다.
- **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당 - **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당
하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성). 하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성).
@ -160,7 +174,7 @@ gated state의 동작:
```lua ```lua
local blocker = Blocker() local blocker = Blocker()
local gated3 = state3:Block(blocker) -- 소비자는 gated3를 구독 local gated3 = state3:Apply(blocker) -- 소비자는 gated3를 구독
blocker:On() blocker:On()
state1:Set(1) -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true state1:Set(1) -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true
@ -173,9 +187,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번
바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.
## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설) ## `state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설)
지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를 지금까지 위 예시는 전부 `state:Apply(blocker)`로 만든 **gated state**를
경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md` 경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`
"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 "Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
@ -186,7 +200,7 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서 배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서
`blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는
방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` 방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end`
형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 형태로 자기 자신을 게이팅). 이 용례는 `state:Apply(blocker)`를 전혀 호출하지
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — 않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —
`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두 `blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두
메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기 메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기
@ -204,13 +218,12 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
- 클래스: `Blocker``Observer`/`Modifier`/`Ref`와 같은 명사-행위자 - 클래스: `Blocker``Observer`/`Modifier`/`Ref`와 같은 명사-행위자
네이밍 관례와 일치. 네이밍 관례와 일치.
- Blocker 자신의 토글: **`On()`/`Off()` -> self** (`Block()`/`Unblock()` - Blocker 자신의 토글: **`On()`/`Off()` -> self** (`Block()`/`Unblock()`
아님) — `state:Block(blocker)`가 이미 "배선(wiring)" 동작의 동사로 아님) — 원래 근거는 배선 동사 `state:Block(blocker)`와의 충돌이었고,
"Block"을 쓰고 있어서, Blocker 자신의 토글까지 같은 단어를 쓰면 **[2026-08-28 `H-158`]** 그 동사가 `state:Apply(blocker)`로 바뀐 뒤에도 이름은
`blocker:Block()`(블로커를 켠다)과 `state:Block(blocker)`(state를 이 유지한다(`On`/`Off`가 `IsOn`/`IsBlocked`와 이미 짝이고, 바꿀 이유가 없다).
블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨.
- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** - 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`**
(gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌).
- 메소드: `state:Block(blocker) -> state`. - 메소드: `state:Apply(blocker) -> state`(**[2026-08-28 `H-158`]** 옛 `state:Block(blocker)` — 별도 메소드가 아니라 `Blocker.__apply`).
- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는 - **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는
얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들" 얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들"
참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함. 참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함.
@ -253,7 +266,7 @@ Blocker 자신은 여전히 단순 불리언이고 두 번 켜지지 않는다
아니라 소유권 판정이다. 아니라 소유권 판정이다.
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset 위 "`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot` 배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`
재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의 재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의
`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다) `materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다)
@ -267,7 +280,7 @@ Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식
## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐 ## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐
**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과 **[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과
`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API `state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API
확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과 확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과
메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신. 메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신.

View file

@ -932,7 +932,8 @@ local function makeGate(reset: boolean, opts)
-- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/ -- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/
-- `Blocker`가 전부 같은 계약을 만족한다 — -- `Blocker`가 전부 같은 계약을 만족한다 —
-- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스 -- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스
-- (필드 이름과 정확한 시그니처는 구현 시 정한다). -- (필드 이름은 `__apply`, **메소드형** `obj:__apply(state) -> State` — [2026-08-28 `H-158`],
-- 호출 규약은 그 절이 소스; 아래 `__call = function(_, self)` 자리 수는 자리표시자일 뿐).
-- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만 -- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만
-- 볼 것 — 위 7절 배너와 같은 취급이다. -- 볼 것 — 위 7절 배너와 같은 취급이다.
local factory = setmetatable({}, { local factory = setmetatable({}, {
@ -1138,7 +1139,7 @@ Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름
평소처럼 무효화를 받아 `:Get()`할 뿐. 평소처럼 무효화를 받아 `:Get()`할 뿐.
- **`Tween`**: 직교. `debounced:Apply(Animate{...})`처럼 겹쳐 쓸 수 있고, - **`Tween`**: 직교. `debounced:Apply(Animate{...})`처럼 겹쳐 쓸 수 있고,
둘 다 시간을 다루지만 층이 다름(하나는 값 보간, 하나는 전파 타이밍). 둘 다 시간을 다루지만 층이 다름(하나는 값 보간, 하나는 전파 타이밍).
- **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`). - **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Apply(b)`).
실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음. 실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음.
- **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된 - **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된
빈도로 재실행됨 — 별도 장치 불필요. **[2026-08-28 `H-151`]** 단 게이트는 빈도로 재실행됨 — 별도 장치 불필요. **[2026-08-28 `H-151`]** 단 게이트는

View file

@ -66,7 +66,11 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
전반에서 이 인자를 `hintValue`라고 부르는데 이는 타입이 보장되지 않던 전반에서 이 인자를 `hintValue`라고 부르는데 이는 타입이 보장되지 않던
옛 모델에서 온 이름이고, 지금은 "힌트"가 아니라 보장된 값임에 유의 옛 모델에서 온 이름이고, 지금은 "힌트"가 아니라 보장된 값임에 유의
(이름 자체는 `question.md` 용어 정리 대기열). **반환값 생략 불가 — (이름 자체는 `question.md` 용어 정리 대기열). **반환값 생략 불가 —
정리할 게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** — 정리할 게 없는 핸들러도 항상 no-op 클로저를 반환할 것 — [2026-08-28 `H-162`]
새 클로저 `function() end`가 아니라 quad-base가 export하는 단일 `Void`
반환한다**(사용자: *"의도적으로 클린업이 없는 Void 함수를 많이 만들게 될 것 …
quad-base 에서 Void = function()end 를 제공하는게 편해보임"* — 할당 없음, 신원
비교 가능) —
`Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가 `Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가
나중에 정확히 이 클로저 하나만 호출해서 정리하므로(예전 "retract 필드 나중에 정확히 이 클로저 하나만 호출해서 정리하므로(예전 "retract 필드
생략 불가" 규칙과 같은 이유, 자리만 옮겨옴). **생략했을 때 실제로 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴). **생략했을 때 실제로
@ -278,8 +282,8 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의 Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의
"quad는 자신이 만든 Instance의 라이프사이클" 절의 원칙 참고). "quad는 자신이 만든 Instance의 라이프사이클" 절의 원칙 참고).
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만 동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`Void`, **[2026-08-28
반환하면 됨. `H-162`]**)만 반환하면 됨.
- **[정정 이력, 2026-08-12 열한 번째 → 2026-08-13 다섯 번째 → 열네 번째 - **[정정 이력, 2026-08-12 열한 번째 → 2026-08-13 다섯 번째 → 열네 번째
세션] 이 클로저는 "핸들러 타입이 바뀔 때만" 불리는 게 아니라, store 세션] 이 클로저는 "핸들러 타입이 바뀔 때만" 불리는 게 아니라, store
바인드가 재발행될 때마다(값이 뭐로 바뀌든) 항상 불림** — 다만 **누가 바인드가 재발행될 때마다(값이 뭐로 바뀌든) 항상 불림** — 다만 **누가
@ -478,7 +482,7 @@ NoneHandler.priority = <매우 높음>
NoneHandler.isHandlable(inst, k, v) = (v == None) NoneHandler.isHandlable(inst, k, v) = (v == None)
function NoneHandler.process(inst, k, v, index) function NoneHandler.process(inst, k, v, index)
Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스 Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스
return function() end -- 자기 자신은 아무 상태도 없어 no-op return Void -- 자기 자신은 아무 상태도 없어 no-op ([2026-08-28 `H-162`] 단일 `Void`)
end end
``` ```
@ -638,7 +642,7 @@ end
- **반환하는 retractor는 여기서 할 일이 없음**`NoneHandler``v==None` - **반환하는 retractor는 여기서 할 일이 없음**`NoneHandler``v==None`
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)` 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`
부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등
전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티 전혀 안 씀) `Void`(no-op, **[2026-08-28 `H-162`]**)만 반환하면 됨 — 일반 프로퍼티
핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에 핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에
쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위 쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위
"핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음. "핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음.
@ -669,7 +673,7 @@ function NilHandler.process(inst, k, v, index)
-- [2026-08-18 감사에서 순서 정정] -- [2026-08-18 감사에서 순서 정정]
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0) Dispatch.setLength(inst, k, 0)
return function() end return Void -- [2026-08-28 `H-162`] no-op은 단일 `Void`
end end
``` ```
@ -949,7 +953,7 @@ Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **
```lua ```lua
-- Dispatch/init.luau -- Dispatch/init.luau
local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = {handler, retractor}}(strong)}} local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = {handler, retractor}}(strong)}}
local NOOP = function() end local NOOP = Void -- [2026-08-28 `H-162`] 새 클로저가 아니라 export된 단일 no-op
function Dispatch.process(inst, k, v, index) function Dispatch.process(inst, k, v, index)
-- [순서 주의] list 확보 + chains 등록은 반드시 h.process 호출 *전에* 끝나야 함 — -- [순서 주의] list 확보 + chains 등록은 반드시 h.process 호출 *전에* 끝나야 함 —
@ -1110,7 +1114,7 @@ end
Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함 Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함
end end
-- v.enabled가 false면 아무것도 안 함 ← 여기가 문제 -- v.enabled가 false면 아무것도 안 함 ← 여기가 문제
return function() end return Void -- [2026-08-28 `H-162`] no-op은 단일 `Void`
end end
``` ```
@ -1306,7 +1310,7 @@ end
`index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를
`index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음. `index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음.
**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil` **7. 반환 생략 금지.** 정리할 게 없어도 `Void`(**[2026-08-28 `H-162`]** 단일 no-op export). `nil`
반환하면 그 자리 슬롯이 완성되지 못해 `#list`가 정의되지 않게 되고 반환하면 그 자리 슬롯이 완성되지 못해 `#list`가 정의되지 않게 되고
(`retractFrom` 순회 시작점이 어긋남) 체인 추적 자체가 깨짐 — (`retractFrom` 순회 시작점이 어긋남) 체인 추적 자체가 깨짐 —
`Dispatch.process`가 (A)/(B) 양쪽에서 즉시 error를 냄. **[정정, 2026-08-13 `Dispatch.process`가 (A)/(B) 양쪽에서 즉시 error를 냄. **[정정, 2026-08-13
@ -2275,8 +2279,8 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그 자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작 owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다**(`Dispatch.drive`는 배열 파트가 있을 때만 — 위 `H-17` 전에 `:On()`한다**(`Dispatch.drive`는 배열 파트가 있을 때만 — 위 `H-17`
절의 2026-08-27 가드). 이 Blocker는 `state:Block()`을 거치지 않고 절의 2026-08-27 가드). 이 Blocker는 `state:Apply(blocker)`를 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Apply(blocker)` 없이
직접 쓰는 두 번째 용례" 절 참고. 직접 쓰는 두 번째 용례" 절 참고.
2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는
`gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉 `gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉

View file

@ -153,9 +153,9 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그
`H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는 `H-114`]** 위 배너대로 `H-58`이 뒤집었다. 포탈이 성립하는 실제 근거는
**dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다**는 것 — 재마운트는 **dep 등록이 생성자에 고정돼 언바인드가 아무것도 안 뗀다**는 것 — 재마운트는
`Destroying` 연결만 다시 건다(`_bindDestroying`). 포탈 사이에 놓친 emit의 `Destroying` 연결만 다시 건다(`_bindDestroying`). 포탈 사이에 놓친 emit의
캐치업은 없다(**[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()` 폐기 — `_installed` 캐치업은 **홀드 플래그로**(**[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`
참인 채 재마운트되므로 `not _installed → Rerun`도 안 걸린다; 다음 emit이 폐기 — 대신 언마운트~재마운트 사이에 온 emit은 `rawRerun``_rerunRequired`
리비전 차이로 잡는다). 잡아 두고 재마운트의 `_bindDestroying`이 1회 돌린다).
3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
`Rerun`이 같은 자리를 읽게 된다. `Rerun`이 같은 자리를 읽게 된다.
@ -220,7 +220,8 @@ function Effect(fn, ...)
if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님) if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님)
end end
-- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다. -- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출(설치 발화)은 `fire``from == nil`
-- 가드가 거른다(**[2026-08-28]** 옛 `Blocker`/`canExecute` 억제 서술은 폐기).
-- ⭐⭐ [2026-08-26 재확정, 8라운드 `H-107`] dep 종류마다 **클로저를 -- ⭐⭐ [2026-08-26 재확정, 8라운드 `H-107`] dep 종류마다 **클로저를
-- 따로** 단다. 여기 한때 "클로저는 하나로 통일한다"고 적혀 있었으나, -- 따로** 단다. 여기 한때 "클로저는 하나로 통일한다"고 적혀 있었으나,
-- 그 통일을 시도할 근거 자체가 없었다 — **사용자 확정**: -- 그 통일을 시도할 근거 자체가 없었다 — **사용자 확정**:
@ -235,18 +236,21 @@ function Effect(fn, ...)
-- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다 -- "공통 상류를 공유해도 한 파동에 fn은 한 번만"이 그대로 성립한다
-- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 -- (그게 아니었으면 `A → b`, `A → c`, `Effect(fn, b, c)`에서
-- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). -- `A:Set()` 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
-- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 등록 즉시 1회 발화(내부 Observer·`Ref` -- ⭐ [2026-08-28 확정, 10라운드 `H-150`] 사적 `Blocker`(`_blocker:On()` …
-- 콜백의 설치 발화는 **그대로 일어난다**)는 아래 `fire`의 첫 줄 — **Effect -- `OffWithoutEmit()`)는 **제거** — 실측(10라운드 `t18`)상 어떤 경로에서도 판정에
-- 핸들의** `canExecute` — 이 흡수한다: 생성자 안에선 아직 어디에도 안 묶여 -- 닿지 않는 죽은 부품이었다(사용자 확정: *"Effect 의 canExecute 를 보겠다는거지?
-- 있어 항상 거짓이다. 한때 여기 사적 `Blocker`(`_blocker:On()` … `OffWithoutEmit()`) -- 그럼 그건 맞는것 같아"*). 설치 발화의 억제는 아래 `from == nil` 가드가, 실행
-- 가 같은 억제를 한 번 더 하려고 있었는데, 실측(10라운드 `t18`)상 어떤 경로에서도 -- 가능 여부는 `rawRerun`이 한 곳에서 본다(**[같은 날 `H-159`]** `fire` 자신은
-- 판정에 닿지 않는 죽은 부품이라 **제거**(사용자 확정: *"Effect 의 canExecute 를 -- 상태를 판정하지 않는다 — 사용자: *"fire 는 그냥 rerun 을 호출해도 될것"*).
-- 보겠다는거지? 그럼 그건 맞는것 같아"*). `H-147``fn`이 생성자 안에서 자기를
-- 묶을 수도 없으므로 "생성자 구간 = 안 묶임 = `canExecute` 거짓"은 불변식이다.
local function fire(from) -- 공통 본문 local function fire(from) -- 공통 본문
if not canExecute(self) then return end -- 발화 게이트 — `Update`보다 먼저(아래 ⚠️) if from == nil then return end -- 내부 Observer의 **설치 발화**(등록 즉시 1회,
if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 -- `emitFrom == nil``source-state-plan.md`)는
self:Rerun() -- 출처가 없어 `Update(nil)`을 못 한다. ⚠️ `Ref`
-- 값이 `nil`인 것과 무관 — `Ref` 경로의 `from`
-- 항상 그 `ref` 객체다(`onRefFire`).
if self._epochs:Update(from) then -- ⭐ [`H-151`] `_epochs`가 갱신되는 **유일한** 자리 —
self:Rerun() -- 묶여 있든 아니든 항상. 실행 불가면 `rawRerun`
-- `_rerunRequired`로 홀드한다(`H-159`).
end end
end end
local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처 local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처
@ -275,9 +279,12 @@ function Effect(fn, ...)
end end
-- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇). -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
-- ⭐ [2026-08-28 `H-147`] 공개 `Rerun()`이 아니라 본체를 `force`로 부른다 — -- ⭐ [2026-08-28 `H-147`/`H-159`] "한 번도 안 돌았다"는 `_rerunRequired`
-- 초기 설치는 "re"-run이 아니고, 아직 안 묶여 있어 공개 진입의 -- 표시하고(초기 실행과 "실행 못 하던 중의 변경"은 같은 요구 — 사용자),
-- `canExecute` 게이트를 통과하지 못한다. -- 본체를 `force`로 부른다. `force`의 뜻은 **하나** — *"canExecute 를 무시하고도
-- 호출할 수 있냐. 오직 그게 전부야."*(사용자). 초기 실행을 바인드로 미룰 수
-- 없다는 결정(순차 처리) 때문에 이 시점 예외 하나가 남는다.
self._rerunRequired = true
rawRerun(self, true) rawRerun(self, true)
return self return self
end end
@ -285,8 +292,8 @@ end
- **`_installing` 플래그도, 그 뒤를 이은 사적 `_blocker`도 폐기됐다** — - **`_installing` 플래그도, 그 뒤를 이은 사적 `_blocker`도 폐기됐다** —
`_installing`은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 `H-58`), `_installing`은 생성자 구간만 덮어 바인드 구간을 놓쳤고(7라운드 `H-58`),
`_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `fire`의 `_blocker`는 그 자리에 들어왔지만 **[2026-08-28 10라운드 `H-150`]** `canExecute`(당시
첫 줄 `canExecute`가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다 `fire` 첫 줄, `H-159` 뒤엔 `rawRerun` 진입)가 이미 같은 억제를 하고 있어 한 번도 판정에 닿지 않았다
(실측 `t18`: `drop:canExecute` 3 / `drop:blocker` 0). `H-58`의 사용자 지시 (실측 `t18`: `drop:canExecute` 3 / `drop:blocker` 0). `H-58`의 사용자 지시
(*"해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서 (*"해당 맥락의 도구인 Blocker 가 존재함 … 모든 옵저버와 callback 등록에 있어서
이를 수행해야할 것임."*)는 그 전제("등록 즉시 1회가 `Rerun`에 닿는다")가 이를 수행해야할 것임."*)는 그 전제("등록 즉시 1회가 `Rerun`에 닿는다")가
@ -313,17 +320,17 @@ function EffectHandle:_bindDestroying(inst)
self:_consumeCleanup() self:_consumeCleanup()
end) end)
-- (2) 캐치업 — **재설치 1회뿐**. dep 등록은 이미 생성자에서 끝났다. -- (2) 캐치업 — **`_rerunRequired`가 서 있으면 1회**. dep 등록은 이미 생성자에서
-- ⭐ [2026-08-28 확정, 10라운드 `H-151`] 여기 한때 `_epochs:Refresh()` -- 끝났다. ⭐ [2026-08-28 `H-151`→`H-159`] `_epochs:Refresh()`는 폐기(`_epochs`는
-- "dep이 변했으면 재실행"까지 했는데 **폐기**`_epochs``fire` -- `fire``Update(from)`에서만 갱신 — 사용자: *"우린 애초에 Refersh 를 할
-- `Update(from)`에서만, 즉 **emit을 받을 때만** 갱신한다(Observer·중간 -- 필요가 없는거야"*), 대신 **실행 불가 상태(안 묶임·죽음·cleanup 중)에 온
-- State와 같다). 재바인드는 초기 설치와 같은 뜻이라 소진돼 있으면 다시 -- 변경은 `rawRerun``_rerunRequired`로 홀드**해 두고 여기서 한 번 돌린다
-- 설치할 뿐이고, 죽어 있는 동안 떨어뜨린 emit은 다음 emit의 리비전 차이로 -- (Gate의 유보와 같은 그림 — Effect는 "한 번 다시 돌면 된다"라 불리언 하나).
-- 잡힌다(Observer와 같은 정도의 캐치업 없음 — 계약). 사용자: *"우린 애초에 -- 소진된 뒤의 재바인드도 같은 플래그(`_consumeCleanup`이 세운다). 사용자:
-- Refersh 를 할 필요가 없는거야. 재진입은 초기 설정해주는 요소이고, 그건 -- *"'초기실행' 과 '실행 안하던 중에 바뀐것' 이 사실 같은 요소"* — 옛
-- 처음 생성할때랑 같은거야."* gcconn 연결 **뒤**라 공개 `Rerun`의 게이트를 -- `_installed`는 이 플래그의 부정형이라 통합했다. gcconn 연결 **뒤**라 공개
-- 통과한다. -- `Rerun`의 게이트를 통과한다.
if not self._installed then if self._rerunRequired then
self:Rerun() self:Rerun()
end end
end end
@ -350,7 +357,7 @@ end
function EffectHandle:_consumeCleanup() function EffectHandle:_consumeCleanup()
local c = self._cleanup local c = self._cleanup
self._cleanup = nil self._cleanup = nil
self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다 self._rerunRequired = true -- ⭐ 소진됐다 = 다음 기회에 다시 설치해야 한다(아래 캐비엇)
if c then if c then
-- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 — -- ⭐ [2026-08-28 확정, 10라운드 감사 2라운드] cleanup은 **세 자리**에서 돈다 —
-- `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은 -- `rawRerun` 루프 머리 / `Unsubscribe()` / leaf `Destroying` 콜백. 뒤의 둘은
@ -366,16 +373,17 @@ function EffectHandle:_consumeCleanup()
end end
``` ```
**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup` **⚠️ [2026-08-25 `/code-review high` 정정; 2026-08-28 `H-159`로 플래그 통합] "설치돼
유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때 있는가"를 `_cleanup`유무로 판정하면 안 된다 — 별도 플래그가 필요하다.** 여기
`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은 한때 `if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은
선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는 선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는
게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면 게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면
바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가 바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가
없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** 없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** 그 플래그는
`_installed``rawRerun``fn`을 돌리고 끝날 때 참, `_consumeCleanup`에서 2026-08-25~28엔 `_installed`(설치됨)였고, **[2026-08-28 `H-159`]** 지금은
거짓이 된다(**[2026-08-28 `H-147`]** `fn` 실행 중에 핸들이 죽는 경로는 더 이상 **`_rerunRequired`**("`fn`이 돌아야 하는데 아직 안 돌았다") 하나다 — 세워지는 곳은
없다 — 아래 `Rerun` 정의). 생성자·`_consumeCleanup`·`rawRerun`의 홀드 셋, 내려가는 곳은 `rawRerun``fn`
실제로 돌리는 자리 하나. `_installed`는 이 플래그의 부정형이라 통합했다.
**⭐ [2026-08-25 신설, 7라운드 `H-60`; 2026-08-28 10라운드 `H-147`로 재정의] **⭐ [2026-08-25 신설, 7라운드 `H-60`; 2026-08-28 10라운드 `H-147`로 재정의]
`rawRerun(self, force)` 본체 + 공개 `EffectHandle:Rerun()`.** `rawRerun(self, force)` 본체 + 공개 `EffectHandle:Rerun()`.**
@ -396,18 +404,22 @@ local function rawRerun(self, force: boolean)
self._pending = true -- 실행 중 재진입 → 지연 self._pending = true -- 실행 중 재진입 → 지연
return return
end end
if not force and not canExecute(self) then if self._cleanupRunning or (not force and not canExecute(self)) then
return -- ⭐ 죽은 핸들·안 묶인 핸들의 재실행 요청은 **정의된 self._rerunRequired = true -- ⭐ [2026-08-28 `H-159`/`H-160`] 실행 불가 상태(cleanup 중 /
end -- no-op** — `fire`가 죽은 핸들의 emit을 버리는 것과 return -- 안 묶임 / 죽음)에 온 요청은 **버리지 않고 홀드** — 다음에
-- 같은 규칙(`H-147`: `Unsubscribe` 뒤 늦게 오는 end -- 묶이는 순간 1회 돈다. 버리면 "변경을 아예 보고 안 함" 경로가
-- 타이머의 `Rerun()`, 해제 뒤 cleanup의 재요청 등). -- 생긴다(사용자: `State<Effect>` 포탈의 언마운트 cleanup 도중
-- dep이 바뀌면 재마운트가 최신값을 못 본다). leaf `Destroying`
-- cleanup 안의 `self:Rerun()`/`dep:Set()`도 여기 — gcconn이 아직
-- 연결돼 `canExecute`만으론 못 막는다(`H-160`). `Unsubscribe`
-- 늦게 오는 타이머의 `Rerun()`도 홀드(재구독하면 돈다).
self._running = true self._running = true
repeat repeat
self._pending = false self._pending = false
self:_consumeCleanup() self:_consumeCleanup() -- 안에서 `_rerunRequired = true`
self._rerunRequired = false -- ⭐ 실제로 돈다 — 이 플래그가 내려가는 **유일한** 자리
self._cleanup = self.fn(self) self._cleanup = self.fn(self)
self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" until not self._pending -- 재요청이 또 오면 또 돈다(`_pending` = 실행 **중**에 온 요청)
until not self._pending -- 재요청이 또 오면 또 돈다
self._running = false self._running = false
end end
@ -437,18 +449,21 @@ end
뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행
하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는
상황은 react 등지에서 흔함."* 상황은 react 등지에서 흔함."*
- **`canExecute` 확인은 진입에서 한 번** — `fire`(`Ref` 콜백·전파 루프 경유)가 - **`canExecute` 확인은 `rawRerun` 진입에서 한 번** — **[2026-08-28 `H-159`]** `fire`
첫 줄에서 보고, 공개 `Rerun()`**[2026-08-28 `H-147`]** 진입에서 본다(죽은·안 판정하지 않고 `Update → Rerun`만 하며, 실행 불가(안 묶임·죽음·cleanup 중)면
묶인 핸들은 no-op). 그래서 `rawRerun` 루프 안엔 판정이 없다. (한때 "사용자가 `rawRerun``_rerunRequired`로 **홀드**한다(no-op이 아니다 — 다음 바인드에서 1회).
`fn` 안에서 직접 부르는 경로는 게이트하지 않는다"였는데, 그 문장은 `Rerun` 그래서 루프 안엔 판정이 없다. (한때 "`fire` 첫 줄에서 보고 죽은 핸들은 no-op"
게이트가 없던 시절 것.) 이었는데 그 문장은 `H-159` 이전 것.)
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`/`_cleanupRunning`이 참으로 - **error 시 UB — 그 Effect는 죽는다.** 전파되고 복구하지 않는다: `fn`이 error하면
남는 것 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 `_running`이, cleanup이 error하면 `_cleanupRunning`이 참으로 남아 **이후 모든
재진입(`Rerun`·네 진입점·재바인드)이 막힌다**. **[2026-08-28 `H-160` 사용자 확정]**
*"한번 죽는게 나오면 Effect 가 전부 죽는다가 계약으로 상향되어도 문제는 없는듯.
이미 _running 도 그러한 제약을 받으니까."* — 계약으로 명문화. *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의
일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다. 일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다.
**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리 **⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리
패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의 패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의
`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는 `_rerunRequired`가 참이라(**[2026-08-28 `H-159`]** 옛 `not _installed`) **재설치**된다. 죽음을 표시하는 별도 부기는
만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에 만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에
이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도
알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시
@ -484,9 +499,11 @@ end
`base/architecture.md``EngineOps.luau` 줄이다. `base/architecture.md``EngineOps.luau` 줄이다.
- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건 - **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건
`fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref``Epoch`라 균일), `fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref``Epoch`라 균일),
`_cleanup`, **`_installed`**(설치 여부 — `_cleanup`, **`_rerunRequired`**(`fn`이 돌아야 하는데 아직 안 돌았다 — 생성 직후 /
cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다), 소진 뒤 / 실행 불가 상태에 온 변경. cleanup 반환이 선택이라 `_cleanup`으로는 판정
`_running`/`_pending`(재진입), **`_cleanupRunning`**(cleanup 실행 중 — `_running` 못 한다; **[2026-08-28 `H-159`]** 옛 `_installed`의 부정형을 흡수),
`_running`/`_pending`(재진입 — `_pending`은 실행 **중**에 온 요청, `_rerunRequired`
실행 **불가 상태**에 온 요청), **`_cleanupRunning`**(cleanup 실행 중 — `_running`
별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute` 별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute`
읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절). 읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절).
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로 **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로
@ -589,7 +606,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
쓴 것이고, **순서는 이 블록이 정본**이다. 쓴 것이고, **순서는 이 블록이 정본**이다.
산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에 산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에
`:Unsubscribe()`를 불렀을 때 **cleanup을 소진한 뒤에야 error**가 나서 `E-11` `:Unsubscribe()`를 불렀을 때 **cleanup을 소진한 뒤에야 error**가 나서 `E-11`
막으려던 피해(cleanup 앞당김, `_installed = false`)가 이미 일어난 뒤다 — 막으려던 피해(cleanup 앞당김, `_rerunRequired = true`)가 이미 일어난 뒤다 —
Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다. Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.
```lua ```lua
@ -624,8 +641,8 @@ Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.
-- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시 -- 있어. 그 경우도 그냥 재실행 해주지."*). Blocker는 안 쓴다 — dep을 다시
-- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다. -- 등록하지 않으므로(생성자에서 한 번, `_deps` 강참조 유지) 억제할 발화가 없다.
local function resubscribeTail(self) local function resubscribeTail(self)
if not self._installed then -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다 if self._rerunRequired then -- 소진됐거나 죽어 있는 동안 변경이 홀드됐으면 1회(`H-159`).
self:Rerun() self:Rerun() -- 등록 뒤라 공개 `Rerun`의 게이트를 통과한다
end end
end end
@ -685,7 +702,7 @@ function EffectHandle:Unsubscribe()
Subscribed[self] = nil -- 안 댄다(`E-11`). Subscribed[self] = nil -- 안 댄다(`E-11`).
WeakSubscribed[self] = nil WeakSubscribed[self] = nil
self.Subscribed = false -- 향후 재실행 차단 self.Subscribed = false -- 향후 재실행 차단
self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_installed = false` self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_rerunRequired = true`
return self return self
end end
``` ```
@ -720,11 +737,10 @@ end
- **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref` - **`:Subscribe()`가 등록하는 것은 그것 하나뿐이다** — 내부 Observer와 `Ref`
콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절). 콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절).
`Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그 `Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그
경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `not _installed → 경로들이 살아난다. **[2026-08-27 `H-144`]** 등록 뒤 꼬리로 `_rerunRequired →
Rerun`이 붙는다(위 의사코드) — 첫 구독은 설치돼 있으니 no-op, **소진된 뒤의 Rerun`이 붙는다(위 의사코드) — 소진된 뒤의 재구독은 재설치, **[2026-08-28
재구독**만 재설치. **[2026-08-28 `H-151`]** 생성과 `Subscribe()` 사이에 온 `H-159`]** 생성과 `Subscribe()` 사이에 온 변경도 `rawRerun`이 홀드해 뒀다가
emit은 `fire`가 버렸고 여기서 따라잡지 않는다 — 다음 emit의 리비전 차이로 여기서 1회 따라잡는다(첫 구독이라도 그 사이 변경이 없었으면 no-op).
잡힌다(Observer와 같은 정도의 캐치업 없음).
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst` 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
@ -900,7 +916,7 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
핸들의 `canExecute`가 한다.** 여기 한때 *"[2026-08-21 확정] 이건 `Effect` 내부 핸들의 `canExecute`가 한다.** 여기 한때 *"[2026-08-21 확정] 이건 `Effect` 내부
플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을 플래그로 한다"*고 적혀 있었고 그 플래그는 생성자 구간만 덮어 바인드 구간을
놓쳤다(`H-58`). 그 자리에 `self._blocker:On()``:OffWithoutEmit()`이 들어왔는데, 놓쳤다(`H-58`). 그 자리에 `self._blocker:On()``:OffWithoutEmit()`이 들어왔는데,
생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `fire`의 첫 줄 `canExecute`가 생성자 안의 핸들은 아직 어디에도 안 묶여 있어 `canExecute`(**[`H-159`]** 지금은 `fire`가 아니라 `rawRerun` 진입에서 본다)
설치 발화를 전부 떨어뜨리므로 `_blocker`는 **한 번도 판정에 닿지 않았다** 설치 발화를 전부 떨어뜨리므로 `_blocker`는 **한 번도 판정에 닿지 않았다**
(실측 `t18`). 위 생성자 의사코드가 소스다. **`_installing``_blocker`도 폐기된 (실측 `t18`). 위 생성자 의사코드가 소스다. **`_installing``_blocker`도 폐기된
필드다.** 필드다.**
@ -943,8 +959,8 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
(2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가 (2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가
`_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로였고, **[2026-08-28 `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로였고, **[2026-08-28
`H-150`]** 그 억제 주체가 `canExecute`가 된 지금도 같다 — `canExecute` `H-150`→`H-159`]** 지금은 `fire``Update`**먼저** 하므로 설치 발화는
`fire`의 첫 줄, `Update`는 그 뒤. 명시적 `from == nil` 가드가 `Update` 앞에서 거른다(같은 제약, 자리만 바뀜).
- **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** — - **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** —
여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from` 여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`
없다"*고 적혀 있었는데, **`Ref``Epoch`로 승격**되며(`base/ref-plan.md`) 없다"*고 적혀 있었는데, **`Ref``Epoch`로 승격**되며(`base/ref-plan.md`)

View file

@ -128,7 +128,7 @@ end)
`pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는 `pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는
여전히 안 닫힌다). 여전히 안 닫힌다).
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가 `Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Apply(blocker)`가
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.** 내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로 `:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
*"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로, *"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로,
@ -149,7 +149,9 @@ end)
- **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미 - **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미
**`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로 **`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로
확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는 확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는
것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다. 것으로 끝난다 — **[2026-08-28 `H-158` 정정]** 그 메소드는 폐기됐고 지금은
정확히 반대로 `blocker` 객체를 `state:Apply(blocker)`에 넘긴다(`blocker:__apply(state)`가
`state:Gate(...)`를 감싼다 — 메소드형, `self`는 blocker). 결과 노드가 `GateNode`인 건 같다.
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고, - **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U` 타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력). 함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
@ -281,10 +283,10 @@ end)
했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. 했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
- **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가 - **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가
틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md` 틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md`
*"이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도 … *"이 용례는 `state:Apply(blocker)`를 전혀 호출하지 않으므로 gated state도 …
생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.** 생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.**
결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다** 결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다**
`state:Block(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해 `state:Apply(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해
거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가 거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가
실제로 집합이 단조 증가하는 자리다. 실제로 집합이 단조 증가하는 자리다.
- **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의 - **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의
@ -375,14 +377,14 @@ end
방향이 반대로 잡혔다: 방향이 반대로 잡혔다:
- **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.** - **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.**
`state:Block(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다. `state:Apply(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다.
**⚠️ [2026-08-24 표기 정정, `/code-review high` 지적]** 여기 한때 **⚠️ [2026-08-24 표기 정정, `/code-review high` 지적]** 여기 한때
`state:Gate(b:Policy)`라고 적었는데 **그건 문법 오류다**(인자 목록 없는 `state:Gate(b:Policy)`라고 적었는데 **그건 문법 오류다**(인자 목록 없는
`b:Policy`). `b.Policy`로 고쳐도 **언바운드 메소드**라 `Gate` `b:Policy`). `b.Policy`로 고쳐도 **언바운드 메소드**라 `Gate`
`setup(emit)`으로 부르면 `emit``self` 자리에 들어가 정책의 `emit` `setup(emit)`으로 부르면 `emit``self` 자리에 들어가 정책의 `emit`
`nil`이 된다. 정확한 형태는 클로저로 묶는 것이다: `nil`이 된다. 정확한 형태는 클로저로 묶는 것이다:
```lua ```lua
state:Block(b) == state:Gate(function(emit) return b:Policy(emit) end) state:Apply(b) == state:Gate(function(emit) return b:Policy(emit) end)
``` ```
- **`Debounce`/`Throttle`은 보류 판정·`pending` 부기를 직접 구현하지 - **`Debounce`/`Throttle`은 보류 판정·`pending` 부기를 직접 구현하지
않는다 — `Blocker`에 위임한다.** 않는다 — `Blocker`에 위임한다.**
@ -503,7 +505,7 @@ end
## 계약 — 게이트는 emit 경로만 미룬다 (2026-08-28 확정, 10라운드 `H-151`) ## 계약 — 게이트는 emit 경로만 미룬다 (2026-08-28 확정, 10라운드 `H-151`)
게이트가 하는 일은 **다운스트림 통지의 유보**뿐이고, 값은 안 가린다 게이트가 하는 일은 **다운스트림 통지의 유보**뿐이고, 값은 안 가린다
(`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**: (`base/debounce-throttle-plan.md` 4절이 확정한 (A) emit-gate). 그래서 통지가 emit이 아닌 경로로 오면 게이트를 **거치지 않는다**(**[같은 날 `H-159`]** 그 캐치업의 메커니즘은 `_epochs:Refresh()`가 아니라 `rawRerun`이 세우는 `_rerunRequired` 홀드 플래그 — `base/effect-plan.md`. 재구독 시 소진돼 있었으면(`_consumeCleanup`이 플래그를 세운다) 게이트 상태와 무관하게 재설치로 한 번 돌고, 유보분은 flush 때 `Update`로 또 한 번 — 둘 다 정상):
- **`Effect`의 재바인드/재구독 캐치업** — 소진된 핸들이 다시 묶이면 초기 설치와 - **`Effect`의 재바인드/재구독 캐치업** — 소진된 핸들이 다시 묶이면 초기 설치와
같은 뜻으로 `fn`이 돈다(`base/effect-plan.md` `_bindDestroying`). 유보 중이어도 같은 뜻으로 `fn`이 돈다(`base/effect-plan.md` `_bindDestroying`). 유보 중이어도
돈다. 돈다.

View file

@ -352,10 +352,19 @@ function bindLifetime(inst, value)
-- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58` -- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`
-- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 — -- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 —
-- `base/effect-plan.md`의 "확정 구조" 절이 소스. -- `base/effect-plan.md`의 "확정 구조" 절이 소스.
if isObserver(value) and value._rerunRequired then
-- ⭐ [2026-08-28 `H-159`] Observer도 대칭 — 묶이기 전(생성~바인드 사이)에 온 emit은
-- 전파 루프가 `_rerunRequired`로 홀드해 두고(`source-state-plan.md` 전파 루프),
-- 묶이는 순간 1회 발화(출처 없음 — 설치 발화와 같은 모양). Observer엔 epoch가
-- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다.
value._rerunRequired = false
value.fn(value._state, value, nil)
end
if isEffect(value) then if isEffect(value) then
value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회** value:_bindDestroying(inst) -- Destroying 연결 + **홀드된 변경 캐치업 1회**
-- (`if not self._installed then self:Rerun() end` — -- (`if self._rerunRequired then self:Rerun() end` —
-- [2026-08-28 `H-151`] 옛 `_epochs:Refresh()` 캐치업은 폐기) -- [2026-08-28 `H-151`/`H-159`] 옛 `_epochs:Refresh()`는 폐기,
-- 실행 불가 상태에 온 변경은 홀드됐다가 여기서 1회)
-- 의사코드는 `base/effect-plan.md`가 소스. -- 의사코드는 `base/effect-plan.md`가 소스.
-- 그 안에서 주입 op `onDestroying(inst, fn)` -- 그 안에서 주입 op `onDestroying(inst, fn)`
-- 부른다(base는 Instance를 모른다). -- 부른다(base는 Instance를 모른다).
@ -454,7 +463,7 @@ end
확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두 확정**: *"observer 랑 effect 랑 헤테로지니어스한 타입인데 … '하나의 무언가가 두
일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect` 일을 동작하지 않는가에 유의하자'"* — Observer 본문은 Observer만 쓴다. `Effect`
쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는 쪽 넷(`Unsubscribe`는 cleanup 소진, `Subscribe`/`WeakSubscribe`는
**[2026-08-27 `H-144`]** 등록 끝에 `not _installed → Rerun`, 넷 다 첫 줄에 **[2026-08-27 `H-144`]** 등록 끝에 `_rerunRequired → Rerun`, 넷 다 첫 줄에
**[2026-08-28 `H-147`]** `_running`/`_cleanupRunning` 가드 — `fn`/cleanup은 자기 구독을 **[2026-08-28 `H-147`]** `_running`/`_cleanupRunning` 가드 — `fn`/cleanup은 자기 구독을
못 바꾼다)은 못 바꾼다)은
`base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스: `base/effect-plan.md`의 "`EffectHandle:Subscribe()`" 절이 소스:
@ -473,6 +482,10 @@ function Observer:WeakSubscribe()
end end
self.Subscribed = true -- ⭐ [H-111] 약한 쪽도 세운다 — 구독 경로 공용 플래그 self.Subscribed = true -- ⭐ [H-111] 약한 쪽도 세운다 — 구독 경로 공용 플래그
WeakSubscribed[self] = true WeakSubscribed[self] = true
if self._rerunRequired then -- [2026-08-28 `H-159`] 구독 전에 홀드된 변경 1회(바인드와 대칭)
self._rerunRequired = false
self.fn(self._state, self, nil)
end
return self return self
end end
@ -510,6 +523,10 @@ function Observer:Subscribe()
self.Subscribed = true self.Subscribed = true
WeakSubscribed[self] = true WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵 하나만 더 Subscribed[self] = true -- 강한 킵 하나만 더
if self._rerunRequired then -- [2026-08-28 `H-159`] 위 `WeakSubscribe`와 같은 꼬리
self._rerunRequired = false
self.fn(self._state, self, nil)
end
return self return self
end end
@ -595,6 +612,16 @@ leaf냐"를 가르는 판별자.
| `:WeakUnsubscribe()` | `false` | — | 제거 | | `:WeakUnsubscribe()` | `false` | — | 제거 |
| `:Unsubscribe()` | `false` | 제거 | 제거 | | `:Unsubscribe()` | `false` | 제거 | 제거 |
**Observer 인스턴스 필드 목록 (2026-08-28 명문화)** — `fn`(콜백, `fn(targetState, self,
emitFrom)`), `_state`(리시버 State — `_hold`로 강참조, `source-state-plan.md`),
**`.Subscribed`**(공개 플래그, 위 표), **`_rerunRequired`**(**[2026-08-28 10라운드
`H-159`]** 묶이기 전에 온 emit을 전파 루프가 홀드 — `bindLifetime`/`Subscribe`/
`WeakSubscribe`가 1회 발화. **거짓으로 시작**한다: `state:Observer(fn)`의 "등록 시점
즉시 1회 실행"(`source-state-plan.md`)은 생성자가 **무조건** 하는 것이라 이 플래그와
무관하고, 플래그는 그 뒤 ~ 묶이기 전 사이에 온 변경만 기록한다 — 감사 3라운드가
"초기화가 없어 한 번도 안 돈다"로 오독할 수 있음을 짚어 명시). 레지스트리 두 테이블은 인스턴스 필드가 아니라
`Observer.luau`의 모듈 로컬. Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다.
**여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()` **여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`
강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만 강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만
내리면) 그게 반쪽짜리 해제다. 내리면) 그게 반쪽짜리 해제다.
@ -666,7 +693,8 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를
담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다. 담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다.
- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)` - 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`
확인하고, 거짓이면 **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst` 확인하고, 거짓이면 **그 구독자에게 `_rerunRequired`만 세우고 건너뜀**(**[2026-08-28
`H-159`]** 옛 "조용히 건너뜀" — 이제 묶일 때 1회 따라잡는다) — 죽은 `inst`
건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의
실제 구현 지점. 실제 구현 지점.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를 **⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를

View file

@ -118,7 +118,7 @@ end
-- 순서는 늘 offsetSource 먼저(setLength가 끝에서 recompute를 태우므로) -- 순서는 늘 offsetSource 먼저(setLength가 끝에서 recompute를 태우므로)
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst) Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음 return Void -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음 ([2026-08-28 `H-162`] 단일 `Void`)
end end
``` ```

View file

@ -288,7 +288,7 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
`priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13 `priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13
다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종 다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종
계약이었으나, `process`가 자기 retract 클로저 `(nextValue: any?) -> ()` 계약이었으나, `process`가 자기 retract 클로저 `(nextValue: any?) -> ()`
반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end` 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `Void`(**[2026-08-28 `H-162`]** 단일 no-op)
반환 생략 불가까지 확정. `base/dispatch-core-plan.md` "핸들러 계약" 절. 반환 생략 불가까지 확정. `base/dispatch-core-plan.md` "핸들러 계약" 절.
- ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 - ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히
뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~

View file

@ -803,7 +803,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
-- (`base/dispatch-core-plan.md`의 해제 순서 계약) -- (`base/dispatch-core-plan.md`의 해제 순서 계약)
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst) Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract, 이 자리는 fire가 끝나 return Void -- no-op retract([2026-08-28 `H-162`]), 이 자리는 fire가 끝나
-- 되돌릴 상태 자체가 없음 -- 되돌릴 상태 자체가 없음
end end
``` ```
@ -1080,7 +1080,7 @@ function ProcessedPostRefHandler.process(inst, k, v, index)
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유) -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유)
Dispatch.setOffsetSource(inst, k, None) Dispatch.setOffsetSource(inst, k, None)
Dispatch.setLength(inst, k, 0, inst) Dispatch.setLength(inst, k, 0, inst)
return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음) return Void -- no-op retract([2026-08-28 `H-162`]), PreRef와 같은 이유(되돌릴 상태가 없음)
end end
``` ```

View file

@ -2148,7 +2148,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-28 10라운드 조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. **[2026-08-28 10라운드
`H-148`]** 루트(`PlayerGui` 등 quad 밖 부모)는 사용자가 `.Parent =`로 붙이는 게 `H-148`]** 루트(`PlayerGui` 등 quad 밖 부모)는 사용자가 `.Parent =`로 붙이는 게
아니라 **quad가 `Claim`으로 소유**하는 쪽으로 방향이 확정됐다 아니라 **quad가 `Claim`으로 소유**하는 쪽으로 방향이 확정됐다
(`research/existing-mount-plan.md`, M5 이후) — 그래서 이 금지에 예외가 없어진다. (`research/existing-mount-plan.md`, M5 스코프 — `H-161`) — 그래서 이 금지에 예외가 없어진다.
(2026-08-27에 하루 있었던 "루트는 밖에서" 예외는 폐기.) (2026-08-27에 하루 있었던 "루트는 밖에서" 예외는 폐기.)
## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar) ## `Slot:Single(state, updateFn?, opts?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar)

View file

@ -255,7 +255,11 @@ function State:_emitDown(from)
sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3 sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3
elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다) elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다)
sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처)
end -- 거짓이면 조용히 건너뜀 else
sub._rerunRequired = true -- ⭐ [2026-08-28 `H-159`] 묶이기 전의 변경은 버리지 않고 홀드 —
end -- `bindLifetime`/`Subscribe`가 1회 발화(`lifecycle-pattern.md`).
-- (Effect의 내부 Observer는 `WeakSubscribe`돼 있어 여기 안 옴 —
-- Effect 쪽 홀드는 `rawRerun`이 한다)
end end
end end
``` ```
@ -1029,7 +1033,13 @@ Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processo
State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 —
`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 `state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로
쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한
정의: `function(self, factory) return factory(self) end`). 정의: `function(self, factory) return factory(self) end`). **[2026-08-28 `H-158`
호출 규약 — `/code-review` 지적으로 명시, 에이전트 배선]** 애플리커티브 팩토리는
**메소드형** `__apply`다: `function(self, factory) if type(factory) == "function"
then return factory(self) else return factory:__apply(self) end end` — 즉
`Blocker`/`Debounce`는 `function Blocker:__apply(state) … end`로 정의하고 `self`
그 인스턴스다(클래스 테이블의 `__apply``self` 없이 부르면 첫 emit에서 `nil`
인덱스로 죽는다). 세 소비자(`Blocker`·`Debounce`·`Throttle`)가 같은 자리 수.
- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 - **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서
안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면,
@ -1064,8 +1074,9 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
- **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 — - **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 —
`Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다. `Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다.
- **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면 - **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면
유니온도 캐스트도 필요 없다. 필드 이름과 정확한 시그니처는 구현 시 유니온도 캐스트도 필요 없다. 필드 이름은 **`__apply`**(**[2026-08-28 10라운드
정한다. `H-158` 사용자 확정]** *"키는 __apply 로 하기로 했던거로 기억중임"*
`base/blocker-plan.md` 배너), 정확한 시그니처는 구현 시 정한다.
- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index` - **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`
필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만
존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌
@ -1075,7 +1086,7 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
**함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체" **함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체"
형태를 **거부한다**`state:Apply(Debounce{...})`가 그대로 타입에러다. 형태를 **거부한다**`state:Apply(Debounce{...})`가 그대로 타입에러다.
파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다 파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다
(정확한 필드 이름과 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에 (필드 이름은 `__apply`**[2026-08-28 `H-158`]**; 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에
대한 것이라 그대로 유효하다 — Modifier의 대한 것이라 그대로 유효하다 — Modifier의
`Apply``factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 `Apply``factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이
이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종
@ -1131,7 +1142,13 @@ Frame {
retract/Destroy되면 자동으로 정리됨. retract/Destroy되면 자동으로 정리됨.
- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, - **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션,
사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 사용자 확정 — 이전까지 미명시였던 항목).** (**[2026-08-28 `H-159`]** 이 1회는
`state:Observer(fn)` 생성자가 무조건 하는 것이고, 같은 날 신설된 `_rerunRequired`
홀드는 그 **뒤** ~ 묶이기 전 사이의 변경만 다룬다 — 별개다. **생성자 순서는 `fn`
1회 실행 → `_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기
State를 `Set`할 때 그 emit이 아직 안 묶인 자신에게 와 `_rerunRequired`가 서고 첫
바인드에서 `fn`이 한 번 더 돈다. Effect의 내부
Observer가 이 설치 발화를 `from == nil`로 거르는 이유이기도 하다.) 근거: (1) 이미 채워진
State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안
일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회
실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에
@ -1162,7 +1179,9 @@ retract/Destroy되면 자동으로 정리됨.
그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom` 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom`
**옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high` **옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high`
발견 — 한때 non-optional로 적혀 있었다). `fn``emitFrom`을 실제로 쓰는 발견 — 한때 non-optional로 적혀 있었다). `fn``emitFrom`을 실제로 쓰는
소비자라면 `nil`을 "설치 발화"로 분기해야 한다. 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. **⚠️ [2026-08-28 판단 대기,
`H-164`]** `H-159`의 Observer 홀드 발화(묶일 때 1회)도 지금 `nil`을 넘겨 이
분기와 구분이 안 된다 — 갈래는 `-round10.md` §4.
- **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라 - **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라
**핸들과 메타데이터**뿐이다. **핸들과 메타데이터**뿐이다.
- 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄 - 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄

View file

@ -69,7 +69,7 @@ Lua 문법상 `Tween{Value=target, Time=0.3}`처럼 괄호를 생략해 호출.
**PropertyHandler.process(inst,k,realv,index)의 새 로직** — `realv`는 이미 **PropertyHandler.process(inst,k,realv,index)의 새 로직** — `realv`는 이미
StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값(그리고 이 함수는 StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값(그리고 이 함수는
계약대로 마지막에 no-op 클로저 `function() end`을 반환 — 아래 "왜 계약대로 마지막에 no-op 클로저 `Void`(**[2026-08-28 `H-162`]**)를 반환 — 아래 "왜
`retract`가 더 이상 필요 없는가" 절): `retract`가 더 이상 필요 없는가" 절):
1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고, 1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고,

View file

@ -150,7 +150,7 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면
바뀌어도, `dispatch-core-plan.md` 일반 retract 계약 절 정정분 참고), 이 바뀌어도, `dispatch-core-plan.md` 일반 retract 계약 절 정정분 참고), 이
Handler는 `process(inst,k,v,index)` 자체가 `v``nil`이든 숫자든 Handler는 `process(inst,k,v,index)` 자체가 `v``nil`이든 숫자든
전부 완결적으로 처리하므로(있으면 지우거나 만들거나) 반환 클로저가 할 일이 전부 완결적으로 처리하므로(있으면 지우거나 만들거나) 반환 클로저가 할 일이
없어 `function() end`이면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를 없어 `Void`(**[2026-08-28 `H-162`]**)면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를
반환하는 것과 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 반환하는 것과 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼)
바뀌면 `process`가 다시 자식을 만들면 그만이라 클로저 쪽에 별도로 바뀌면 `process`가 다시 자식을 만들면 그만이라 클로저 쪽에 별도로
구현할 게 없음. 구현할 게 없음.
@ -178,15 +178,15 @@ function UICornerHandler.process(inst, k, v, index)
if v == nil then if v == nil then
-- 기존 규칙 그대로: 만들어둔 자식이 있으면 지움(아래 "v가 nil인 경우" 절) -- 기존 규칙 그대로: 만들어둔 자식이 있으면 지움(아래 "v가 nil인 경우" 절)
destroyManagedChild(inst, k) destroyManagedChild(inst, k)
return function() end return Void -- [2026-08-28 `H-162`]
end end
local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용 local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용
Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1) Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1)
return function() end -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고 return Void -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고. [2026-08-28 `H-162`] `Void` export
end end
``` ```
- **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 `function() end`다 — - **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 no-op(`Void`, **[2026-08-28 `H-162`]** — 옛 표기 `function() end`)다 —
여기 한때 `function(hint) if hint == nil then destroyManagedChild(inst, k) end 여기 한때 `function(hint) if hint == nil then destroyManagedChild(inst, k) end
end`가 적혀 있었는데, 그건 위 "`v`가 `nil`인 경우" 절의 `v == nil`(값) 규칙을 end`가 적혀 있었는데, 그건 위 "`v`가 `nil`인 경우" 절의 `v == nil`(값) 규칙을
`hint == nil`(retractor 인자)로 잘못 옮긴 **복사 오류**였다.** 이 절의 주제는 `hint == nil`(retractor 인자)로 잘못 옮긴 **복사 오류**였다.** 이 절의 주제는

View file

@ -27,7 +27,7 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선
(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`), (소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`),
`question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋 `question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋
`9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/` `9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`
반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 **[2026-08-28] 10라운드**(`-round10.md`, 광범위 탐사 `H-150`~`H-157` 포함)도 같은 날 전량 결정·반영(소스 `-round10-followup.md`) — 둘이 뒤집혔다(`fn`은 자기 구독을 못 바꿈 / 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절(게이트 아님). 저장소 루트에 반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 새 메커니즘 넷(`H-143`~`H-146`)도 **같은 날 `session/2026-08-27-03-handtrace-round9-h143-h146.md`에서 전부 권고 (a)로 확정·반영**돼 **[2026-08-28] 10라운드**(`-round10.md`, 광범위 탐사 `H-150`~`H-157` 포함)도 같은 날 전량 결정·반영(소스 `-round10-followup.md`) — 둘이 뒤집혔다(`fn`은 자기 구독을 못 바꿈 / 루트는 `Claim`으로 quad 소유, `research/existing-mount-plan.md`). 같은 날 후속 `H-158`~`H-162`까지 반영. 남은 미결은 `question.md` 최우선 절`Claim` 갈래 하나(게이트 아님). 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md` 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -14,13 +14,17 @@
| `H-149` | Observer `Subscribe` 위임과 `level 2` | ✅ **확정 (a)**`Subscribe`/`Unsubscribe`도 게이트·등록을 인라인, 위임 없음 | | `H-149` | Observer `Subscribe` 위임과 `level 2` | ✅ **확정 (a)**`Subscribe`/`Unsubscribe`도 게이트·등록을 인라인, 위임 없음 |
| `H-150` | `Effect._blocker` 죽은 부품 | ✅ **확정 (a)** — 제거, 억제는 Effect 핸들의 `canExecute` | | `H-150` | `Effect._blocker` 죽은 부품 | ✅ **확정 (a)** — 제거, 억제는 Effect 핸들의 `canExecute` |
| `H-151` | 게이트 우회 계약 | ✅ **확정 — (a) 문서화 + `Refresh` 캐치업 폐기**: Effect의 `_epochs`는 emit 수신 때만 갱신, 재바인드/재구독은 초기 설치와 같다 | | `H-151` | 게이트 우회 계약 | ✅ **확정 — (a) 문서화 + `Refresh` 캐치업 폐기**: Effect의 `_epochs`는 emit 수신 때만 갱신, 재바인드/재구독은 초기 설치와 같다 |
| `H-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ⏳ 권고: 폐기 → `state:Apply(blocker)` | | `H-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ✅ **확정 폐기**`state:Apply(blocker)`, 필드 `__apply` |
| `H-159`~`H-161` | 반영 뒤 `/code-review high`가 낸 새 메커니즘 셋 (`-round10.md` §4 하단) | ⏳ 판단 대기 — 바인드 전 emit 캐치업 / `Destroying` 경로 cleanup `Rerun` / M5 루트 부착·다중 스크립트 `Claim` | | `H-159` | 바인드 전 emit 캐치업 | ✅ **확정 — 사용자 제안 `_rerunRequired`(Gate식 홀드)**, `_installed` 흡수, Observer 대칭, `fire``Update → Rerun`만 |
| `H-162` | `Void` no-op export (이 대화에서 나옴) | ✅ **확정** — quad-base export(잎 모듈 `Void.luau`), no-op 클로저 자리는 전부 `Void` |
| `H-163`/`H-164` | `H-159` 반영분에 `/code-review high`가 낸 둘 | ⏳ 판단 대기 — Slot 내부 Observer × 홀드 발화 / 홀드 발화의 `emitFrom == nil` |
| `H-160` | `Destroying` 경로 cleanup `Rerun` | ✅ **확정 (a) → `H-159`로 정정**: `rawRerun``_cleanupRunning`이면 **버리지 않고 `_rerunRequired`로 홀드** + "error 나면 그 Effect는 죽는다" 계약 |
| `H-161` | M5 루트 부착·다중 스크립트 `Claim` | ✅ **확정 (a)** `Claim`을 M5 스코프로; §5-7 다중 스크립트는 미결 |
| `H-153` | Store 예약 이름 런타임 가드 | ✅ **확정 (a)** — 생성자·`Of(name)`에 예약 이름 검사(level 2), 그림자 = store 자신 (I) | | `H-153` | Store 예약 이름 런타임 가드 | ✅ **확정 (a)** — 생성자·`Of(name)`에 예약 이름 검사(level 2), 그림자 = store 자신 (I) |
| `H-154` | `InstanceChildHandler` dedup | ✅ **확정 (a)** — retractor 첫 줄 `if nextValue == v then return end` | | `H-154` | `InstanceChildHandler` dedup | ✅ **확정 (a)** — retractor 첫 줄 `if nextValue == v then return end` |
| `H-152`/`H-155`~`H-157` | 갈래 없음 | ✅ 반영(`gate-plan.md` 조립 첫 줄 `StateBrand:register` / `ROADMAP.md` M6×3·M11 / `debounce-throttle-plan.md` 7절 `H-32` 문단 / `store-plan.md` 빈 Store 실측 완료) | | `H-152`/`H-155`~`H-157` | 갈래 없음 | ✅ 반영(`gate-plan.md` 조립 첫 줄 `StateBrand:register` / `ROADMAP.md` M6×3·M11 / `debounce-throttle-plan.md` 7절 `H-32` 문단 / `store-plan.md` 빈 Store 실측 완료) |
## `H-147` — 전제 정정: `fn`/cleanup은 자기 구독을 바꿀 수 없다 (A) — `H-143` 소멸 ## `H-147` — 전제 정정: `fn`/cleanup은 자기 구독을 바꿀 수 없다 (A) — `H-143` 소멸 (`Rerun` 모양은 이후 `H-159`로 다시 바뀜 — 아래)
문항은 "(a) UB / (b) `_everAlive` / (c) `wasAlive` 위치"였는데, 대화 중에 **문제의 문항은 "(a) UB / (b) `_everAlive` / (c) `wasAlive` 위치"였는데, 대화 중에 **문제의
뿌리가 `H-143`의 허용 자체**라는 것이 드러났다. 뿌리가 `H-143`의 허용 자체**라는 것이 드러났다.
@ -93,10 +97,10 @@
/ `Claim` DFS(내려가며 해석 → 자식부터 `drive`)는 derive 위의 한 겹 / 부기 대상 / `Claim` DFS(내려가며 해석 → 자식부터 `drive`)는 derive 위의 한 겹 / 부기 대상
자식은 전부 매핑, 숏핸드(`UI*`)는 부기 밖 — quad가 직접 쓰거나 실제 객체로 자식은 전부 매핑, 숏핸드(`UI*`)는 부기 밖 — quad가 직접 쓰거나 실제 객체로
매핑하거나 / 이름 중복·부재는 UB + debug 모드 `seen` 검사 / `nativeFindChild` 매핑하거나 / 이름 중복·부재는 UB + debug 모드 `seen` 검사 / `nativeFindChild`
프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 이후. 프로바이더 op, 순회는 quad-base / 다중 quad 한 트리 UB / M5 스코프(`H-161`로 당김).
**따름**: 전용 문구 **철회**(일반 매치 실패 그대로), `H-146` (a)의 "루트는 밖에서 **따름**: 전용 문구 **철회**(일반 매치 실패 그대로), `H-146` (a)의 "루트는 밖에서
`.Parent =`" **폐기**(루트가 quad 소유). `H-142` 키 금지는 그대로. `.Parent =`" **폐기**(루트가 quad 소유). `H-142` 키 금지는 그대로.
**미결 6개**는 그 research 문서 §5 — 다음 배치 문항. **미결**(개수는 그 research 문서 §5가 소스)은 다음 배치 문항.
## `H-149` — Observer `Subscribe`/`Unsubscribe`도 인라인 (a) ## `H-149` — Observer `Subscribe`/`Unsubscribe`도 인라인 (a)
@ -134,7 +138,7 @@
체크박스의 "선행: `Blocker` 기본 메커니즘" 문구(Effect 자체는 이제 Blocker 불필요 체크박스의 "선행: `Blocker` 기본 메커니즘" 문구(Effect 자체는 이제 Blocker 불필요
`Blocker.luau` 선행 요구는 `GateNode`/Slot 쪽만). `Blocker.luau` 선행 요구는 `GateNode`/Slot 쪽만).
## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기) ## `H-151` — 게이트는 유보만 한다; Effect는 emit 받을 때만 `_epochs`를 갱신 (`Refresh` 캐치업 폐기 — "캐치업 없음"은 이후 `H-159`의 홀드로 대체, 아래)
**사용자 확정**: *"해당 우회는 더 크게 보면, 처음부터 Effect 가 Rerun 되는 **사용자 확정**: *"해당 우회는 더 크게 보면, 처음부터 Effect 가 Rerun 되는
경로라서 그건 맞아. 그리고 Observer 에서 받은 emit 의 epoch|{epoch:boolean} 를 경로라서 그건 맞아. 그리고 Observer 에서 받은 emit 의 epoch|{epoch:boolean} 를
@ -216,7 +220,7 @@ then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil
`-round9-followup.md`/`-round9.md`의 `H-143`/`H-144`/`H-146` 소멸·정정 배너. `-round9-followup.md`/`-round9.md`의 `H-143`/`H-144`/`H-146` 소멸·정정 배너.
**남은 미결**: `H-158`(`state:Block` 슈가 폐기 → `state:Apply(blocker)`, 권고만) — **남은 미결**: `H-158`(`state:Block` 슈가 폐기 → `state:Apply(blocker)`, 권고만) —
`question.md`에; `research/existing-mount-plan.md` §5 갈래 6개 — 다음 배치. `question.md`에; `research/existing-mount-plan.md` §5 갈래 — 다음 배치. (이후 `H-158`은 확정, 아래 절.)
## 감사 루프 (2026-08-28, 10라운드 반영분) ## 감사 루프 (2026-08-28, 10라운드 반영분)
@ -255,3 +259,109 @@ then return end`(`SlotHandler` 동형). 같은 값 재발행에 `Parent = nil
7. stale 문장 — `effect-plan.md`*게이트가 아니라 `Blocker`를 쓰는 이유* / 포탈 7. stale 문장 — `effect-plan.md`*게이트가 아니라 `Blocker`를 쓰는 이유* / 포탈
근거를 *`H-64` 캐치업*으로 적은 것(재마운트는 `_installed` 참이라 캐치업이 아예 근거를 *`H-64` 캐치업*으로 적은 것(재마운트는 `_installed` 참이라 캐치업이 아예
없다) / `source-state-plan.md`*구현이 한 벌*(위임 아님으로 정정). 없다) / `source-state-plan.md`*구현이 한 벌*(위임 아님으로 정정).
## `H-158``state:Block(blocker)` 폐기 → `state:Apply(blocker)` (확정)
**사용자 확정**: *"H158은 폐기로 하자. 내가 이미 그렇게 정했었는데, 전파가 안 된
부분이라서. 이제 Compute 와 유사하게 Gate 만 놓일 뿐, Apply 로 Blocker 처리가 가능함.
왜냐면 펑터와 적용성펑터를 전부 허용하니까 ( map|{...map} ) 키는 __apply 로 하기로
했던거로 기억중임."* — `source-state-plan.md`의 "`state:Apply(factory)`" 절은
애플리커티브 팩토리를 "지정된 필드"로 받되 이름은 "구현 시"로 열어뒀었다 → 이
발언으로 **`__apply`**. `Blocker.__apply = function(state) return state:Gate(function(emit)
return self:Policy(emit) end) end`. 반영: `blocker-plan.md` 배너·API·이름 절,
`gate-plan.md`·`dispatch-core-plan.md`·`debounce-throttle-plan.md`·`ROADMAP.md`·
`README.md``state:Block` 전부 치환, `qa-round2.md`의 절 인용.
## `H-160``rawRerun``_cleanupRunning`이면 버린다 (a) + error 계약 상향 (→ `H-159`로 "홀드"로 정정. 문별 동작: `Rerun`/`fire` 경로는 조용히 홀드, 네 진입점·`_bindDestroying`은 error)
**사용자 확정**: *"H-160 는 a 동의. 그런데 이로 인해 cleanup 이 error 를 내면
cleanupRunning 플래그가 풀리지 않는 문제가 날듯. 모든 재 진입이 막히는건데, 문제 될
것은 없어보이나 문서화가 필요한것 같아. 한번 죽는게 나오면 Effect 가 전부 죽는다가
계약으로 상향되어도 문제는 없는듯. 이미 _running 도 그러한 제약을 받으니까."* —
`effect-plan.md` "error 시 UB" bullet을 **"그 Effect는 죽는다"** 계약으로.
## `H-161``Claim`을 M5 스코프로 (a); §5-7은 미결
**사용자 확정**: *"H-161 는 확인. M5 스코프로 올라가도 될것으로 보임."*`ROADMAP.md`
M5 체크박스·`research/existing-mount-plan.md` 헤더·§5-6. 다중 스크립트/루트 컨테이너
(§5-7)는 답이 없어 미결 유지.
## `H-159``_rerunRequired` 홀드 플래그 (사용자 제안, 확정) — `_installed` 흡수, `H-160` 정정
`/code-review`의 권고 (a)("묶이는 시점 1회 `Refresh` 복원")를 올렸더니 사용자가
다른 모양을 냈다: *"observrer 는 생성과 동시에 바운딩 처리가 되는게 일반적임. 그리고
이건 또한 observer 에도 유사한 문제가 있는 부분으로 보임 - 바운딩 전에 바뀌다가,
바운딩 되면 그대로 다 씹힘. 따라서 Gate 와 유사하게 _rerunRequired 정도가 필요한듯.
언바운딩 상태에서 이것을 true 로 만들지 관리. canExecute 가 거짓이면 모두 홀드하는게
맞아보임. 각 둘의 정의는 '초기에 한번은 불러주고, 각 변경에 불러주겠다' 인데(바인드
빼고 보면) 바인드가 들어오면서 각 변경에 아에 스킵하는 경우가 생겨났다는 의미."*
**메인 세션 판단**: `Refresh` 복원보다 낫다 — 갱신 경로는 `fire``Update` 하나로
유지(`H-151` 계약 그대로)하고, 실행 불가일 때 *버리는 대신 홀드*한다. Gate의 유보와
같은 그림이되 Effect는 "한 번 다시 돌면 된다"라 불리언 하나.
**대화로 다듬어진 것 넷**:
1. **`_cleanupRunning` 중의 변경도 홀드** — 처음 메인 세션이 "cleanup 중 `dep:Set()`
`_epochs`에 반영되니 버려도 된다"고 했는데 사용자가 정정: *"변경을 '아에 보고
안함' 이라는 경로가 생김. State<Effect> 형태로 포탈을 만들었다고 가정하면, cleanup
도중에도 여전히 _rerunRequired 는 셋업되어야함. 안 그러면 다음 바운딩에 최신값
측정이라는 목표를 잃거든."* → `H-160`의 "버림"도 홀드로 정정. 세 상황이 갈린다:
`fn` 실행 중 → `_pending`(같은 루프) / 실행 불가(안 묶임·죽음·cleanup 중) →
`_rerunRequired`(다음 바인드) / 그 외 → 즉시. `_pending``_rerunRequired`의 분리는
사용자 *"완전 동의"*.
2. **`fire``Update → Rerun`만** — 사용자: *"self._cleanupRunning 확인이 아래에
있다면 … fire 는 그냥 rerun 을 호출해도 될것"*. 상태 판정은 `rawRerun` 한 곳.
3. **`_installed` 폐기 → `_rerunRequired`로 통합** — 사용자: *"rerun 의 force 가
self._rerunRequired = true 하는것과 같은 동작을 낼것으로 보임. … '초기실행' 과
'실행 안하던 중에 바뀐것' 이 사실 같은 요소"*. `_installed`는 그 플래그의
부정형이었다. 세워지는 곳: 생성자·`_consumeCleanup`·`rawRerun` 홀드 / 내려가는 곳:
`rawRerun``fn`을 실제로 돌리는 자리 하나. **`force`는 시점 예외 하나만** —
사용자: *"force 는 딱 하나의 역할을 해. canExecute 를 무시하고도 호출할 수 있냐.
오직 그게 전부야."* 초기 실행을 바인드로 미룰 수 없다는 결정(순차 처리)은 유지.
4. **`fire``from == nil` 가드는 유지** — 사용자가 *"ref<...> 가 Frame->nil 가는
경로를 막을 이유가 없지 않나?"*라 물었는데 그건 오해: `from`은 값이 아니라 출처
`Epoch``Ref` 경로에선 항상 `ref` 객체이고, `nil`은 내부 Observer의 설치 발화
(`emitFrom == nil`)뿐 — `Update(nil)`이 정의돼 있지 않아 걸러야 한다(2026-08-21
`/code-review`가 잡았던 자리). 주석에 그 구분을 명시.
**Observer 대칭**: 전파 루프가 `canExecute(observer)` 거짓이면 `_rerunRequired`를 세우고,
`bindLifetime`·`Subscribe`·`WeakSubscribe`가 그 플래그를 보면 1회 발화(`emitFrom = nil`,
설치 발화와 같은 모양). 실효 범위는 "구독자 집합엔 있지만 아직 안 묶인" 창.
**[반영 뒤 감사 3라운드]** Observer의 "등록 시점 즉시 1회 실행"(2026-08-07 확정)은
생성자가 무조건 하는 것이라 이 플래그와 **별개** — Observer의 `_rerunRequired`
거짓으로 시작한다(Effect는 생성자가 참으로 세우고 `force`로 즉시 돌리는 것과 대비).
감사자가 "초기화가 없어 한 번도 안 돈다"로 읽을 수 있음을 짚어 두 문서에 명시.
**반영**: `effect-plan.md`(`fire`·생성자·`rawRerun`·`_consumeCleanup`·`_bindDestroying`·
`resubscribeTail`·필드 목록·캐비엇·포탈 근거·`H-65` 문단), `lifecycle-pattern.md`
(bindLifetime Observer 분기·Observer `Subscribe`/`WeakSubscribe` 꼬리·주석),
`source-state-plan.md` 전파 루프, `ROADMAP.md` M2 넷·M6, `README.md`.
## `H-162``Void` no-op export (확정)
사용자: *"지금 상황에서 의도적으로 클린업이 없는 Void 함수를 많이 만들게 될 것으로
보이는데, 이걸 quad-base 에서 Void = function()end 를 제공하는게 편해보임."* —
quad-base가 단일 no-op 함수 `Void`를 export(quad-roblox 핸들러도 쓰므로 공개),
no-op 클로저를 돌려주는 자리(숏핸드 retractor 등)는 새 클로저 대신 `Void`. 사용자
Effect `fn``return Void`로 "cleanup 없음"을 명시하는 것도 자연히 허용(반환 안 하는
것과 동일 취급). 반영: `dispatch-core-plan.md` 반환값 규칙, `ui-shorthand-plan.md` 둘,
`ROADMAP.md` M2 공통 기반.
## 감사 루프 (2026-08-28, `H-158`~`H-162` 반영분)
새 발견 7→6→1→**0**(4라운드에서 수렴). 1 문구 잔존(`Block` 현재형 / `__apply` 전파 /
`Void` 9곳 / "`fire`의 첫 줄 `canExecute`" stale 4곳 / `architecture.md` `Void` 자리 /
session 후속 절) · 2 의미론(**`_rerunRequired` 상태 기계 자체는 모순 없음**; debounce
`__apply` / `Void` 5곳 더 / session-summary 후속 / `init.luau` 모호 / gate 계약 절
포인터 / Observer 필드 목록 신설) · 3 수정분 재검토(Observer 생성자 1회 실행이
플래그와 별개임을 명시 — 감사자의 오독 가능성) · 4 수렴 **0건**.
## `/code-review high` (2026-08-28, `H-158`~`H-162` 반영분 + 감사 4라운드 뒤)
10건. **여덟 반영**, **둘 문항**(`H-163`/`H-164`, `-round10.md` §4).
반영: `__apply` 호출 규약(메소드형 `factory:__apply(state)`, 에이전트 배선) /
`Void`는 잎 모듈 `Void.luau`(최상위 `init.luau`에 두면 순환 require) / Observer 생성자
순서 `fn` 1회 → `_subs` 삽입 / `gate-plan.md` 계약 절 괄호 정정 / `effect-plan.md`
stale 셋(`Blocker` 억제·"`fire` 첫 줄"·"죽은 핸들은 no-op") / `ROADMAP.md` M2 `Rerun`
no-op → 홀드 / `lifecycle-pattern.md`*조용히 건너뜀* → 홀드 / "M5 이후" 잔존 셋 /
followup 절 제목 포인터·개수.

View file

@ -8,7 +8,7 @@
> 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의 > 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의
> "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것. > "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것.
> >
> 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 미결로 남은 것은 거기서 새로 생긴 `H-158`(`:Block` 슈가)과 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는 > 상태: **[2026-08-28] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) → 같은 날 사용자와 대화형으로 전량 결정·반영 — 결정의 소스는 `-round10-followup.md`.** 후속 `H-158`~`H-162`도 같은 날 확정(`H-159`는 `/code-review` 권고 (a) `Refresh` 복원이 아니라 사용자 제안 **`_rerunRequired` 홀드**로). 미결은 `research/existing-mount-plan.md` §5의 갈래들뿐(개수는 거기가 소스). `H-147`~`H-149`는
> `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존 > `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존
> 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md` > 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`
> 마지막 code-review 절). > 마지막 code-review 절).
@ -350,6 +350,8 @@ spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18`
| **`H-159`** | **[2026-08-28 `/code-review`, 반영 뒤]** `H-151`이 잃은 캐치업 — 바인드 **전**에 온 emit(특히 `Ref`)은 다시 안 온다 | (a) `_bindDestroying`/`resubscribeTail`에 **"묶이는 시점 1회 `Refresh`"**만 되살림(emit 경로의 `_epochs` 갱신은 `H-151`대로 `Update`만) / (b) `Ref` dep만 바인드 시 `.Revision` 대조 / (c) 계약으로 두고 사용자에게 "`Ref`를 dep으로 쓰는 Effect는 그 leaf 뒤에 두라" 문서화 | **(a)** — `H-151`의 근거("다음 emit이 잡는다")가 `Ref`엔 성립하지 않는다; (a)는 `H-151`을 되돌리는 게 아니라 "emit 경로만 미룬다"는 계약과 양립(바인드는 emit 경로가 아님) | | **`H-159`** | **[2026-08-28 `/code-review`, 반영 뒤]** `H-151`이 잃은 캐치업 — 바인드 **전**에 온 emit(특히 `Ref`)은 다시 안 온다 | (a) `_bindDestroying`/`resubscribeTail`에 **"묶이는 시점 1회 `Refresh`"**만 되살림(emit 경로의 `_epochs` 갱신은 `H-151`대로 `Update`만) / (b) `Ref` dep만 바인드 시 `.Revision` 대조 / (c) 계약으로 두고 사용자에게 "`Ref`를 dep으로 쓰는 Effect는 그 leaf 뒤에 두라" 문서화 | **(a)** — `H-151`의 근거("다음 emit이 잡는다")가 `Ref`엔 성립하지 않는다; (a)는 `H-151`을 되돌리는 게 아니라 "emit 경로만 미룬다"는 계약과 양립(바인드는 emit 경로가 아님) |
| **`H-160`** | leaf `Destroying` 콜백이 도는 cleanup 안의 `self:Rerun()`/`dep:Set()` — `canExecute`가 아직 참이라 죽는 inst에서 `fn`이 돌고 새 cleanup이 영구 고아 | (a) `rawRerun` 진입에서 `_cleanupRunning`이면 **버린다**(no-op) — "cleanup은 자기 생명주기를 못 바꾼다"의 `Rerun`판 / (b) `Destroying` 콜백이 `_consumeCleanup` **전에** `.Subscribed`류 표식으로 죽음을 먼저 세움(새 상태) / (c) UB 문서화 | **(a)** — 새 상태 없이 기존 플래그 하나로, `Unsubscribe` 경로와 같은 결과 | | **`H-160`** | leaf `Destroying` 콜백이 도는 cleanup 안의 `self:Rerun()`/`dep:Set()` — `canExecute`가 아직 참이라 죽는 inst에서 `fn`이 돌고 새 cleanup이 영구 고아 | (a) `rawRerun` 진입에서 `_cleanupRunning`이면 **버린다**(no-op) — "cleanup은 자기 생명주기를 못 바꾼다"의 `Rerun`판 / (b) `Destroying` 콜백이 `_consumeCleanup` **전에** `.Subscribed`류 표식으로 죽음을 먼저 세움(새 상태) / (c) UB 문서화 | **(a)** — 새 상태 없이 기존 플래그 하나로, `Unsubscribe` 경로와 같은 결과 |
| **`H-161`** | `H-148` 이후 **M5에 승인된 루트 부착 경로가 없다** + 여러 스크립트가 같은 `PlayerGui``Claim`하면 이중 claim error / 다중 quad UB라 `Claim`이 자기 동기 사례를 막는다 | (a) `Claim`**M5 스코프**로 당기고(프로바이더 마일스톤이라 자연스러움) `research/existing-mount-plan.md` §5-7·8 갈래를 같이 정한다 / (b) `Claim` 전까지 임시로 `H-146` 루트 예외(밖에서 `.Parent =`)를 M5 한정으로 되살림 / (c) 루트 컨테이너(부기 대상 아님)는 claim 없이 자식만 붙이는 얇은 표면 신설 | **(a)** — 임시 예외는 하루 만에 뒤집힌 것을 되살리는 것이고, (c)는 `Mount` 기각의 재개방. §5-7(다중 스크립트)은 `Claim`의 "전부 매핑" 계약이 **루트 컨테이너에는 안 맞는다**는 신호라 갈래를 그 문서에 적었다 | | **`H-161`** | `H-148` 이후 **M5에 승인된 루트 부착 경로가 없다** + 여러 스크립트가 같은 `PlayerGui``Claim`하면 이중 claim error / 다중 quad UB라 `Claim`이 자기 동기 사례를 막는다 | (a) `Claim`**M5 스코프**로 당기고(프로바이더 마일스톤이라 자연스러움) `research/existing-mount-plan.md` §5-7·8 갈래를 같이 정한다 / (b) `Claim` 전까지 임시로 `H-146` 루트 예외(밖에서 `.Parent =`)를 M5 한정으로 되살림 / (c) 루트 컨테이너(부기 대상 아님)는 claim 없이 자식만 붙이는 얇은 표면 신설 | **(a)** — 임시 예외는 하루 만에 뒤집힌 것을 되살리는 것이고, (c)는 `Mount` 기각의 재개방. §5-7(다중 스크립트)은 `Claim`의 "전부 매핑" 계약이 **루트 컨테이너에는 안 맞는다**는 신호라 갈래를 그 문서에 적었다 |
| **`H-163`** | **[2026-08-28 `/code-review`, `H-159` 반영 뒤]** Slot 내부 Observer(`_listObserver`·`_baseObserver`)에도 홀드 발화가 걸려 재마운트의 `bindLifetime``materializeSlotTree` **도중** `reconcile`을 동기 실행 → 자리 이중 등록, 중첩 Slot이면 `canBound` error | (a) Slot이 자기 내부 Observer를 다시 묶기 전에 `_rerunRequired`**지운다**(재마운트 캐치업은 `activateList`가 이미 명시적으로 한다 — 이중) / (b) 홀드 발화를 사용자 Observer에만(내부 Observer는 브랜드로 구분 — 새 구분) / (c) 홀드 발화를 `bindLifetime` 안이 아니라 `materializeSlotTree` 끝(`blocker:OffWithoutEmit()` 뒤)으로 미룸 | **(a)** — 새 구분 없이 한 줄, "재마운트 캐치업의 주체는 Slot"이라는 기존 계약 그대로 |
| **`H-164`** | Observer 홀드 발화가 `emitFrom = nil`로 오면 계약("`nil` = 설치 발화")과 구분 불가 — `if emitFrom == nil then initOnly()`로 짠 소비자가 변경을 놓침 | (a) 홀드 시 **마지막 `from`을 보관**(`_rerunRequired = from`, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(`HeldEmit`) / (c) 계약 문구만 "`nil` = 설치 **또는** 묶일 때 캐치업" | **(a)** — Observer는 dedup이 없어 "마지막 출처"가 곧 홀드의 내용; 단 필드가 불리언과 출처를 겸하는 게 원칙(한 필드 두 뜻)에 걸리면 (b) |
갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), 갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷),
`H-156`(`H-32` 문단), `H-157`(실측 완료 표기). `H-156`(`H-32` 문단), `H-157`(실측 완료 표기).

View file

@ -24,7 +24,7 @@
설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제 설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제
반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는
Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" `base/blocker-plan.md`의 "`state:Apply(blocker)` 없이 직접 쓰는 두 번째 용례"(2026-08-28 `H-158`로 개명 — 당시 이름은 `state:Block()`)
절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다. 절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다.
### 발견한 크래시 경로 ### 발견한 크래시 경로

View file

@ -12,23 +12,22 @@
--- ---
## ⭐ 최우선 — 10라운드 후속 둘 (2026-08-28 갱신) ## ⭐ 최우선 — `Claim` 갈래 (2026-08-28 갱신)
**[2026-08-28] 10라운드 §4 문항 7건은 사용자와 대화형으로 전량 결정·반영됐습니다** **[2026-08-28] 10라운드 §4 문항 7건과 그 반영분의 후속(`H-158`~`H-162`)까지
(소스는 `qa-request/pre-implementation-handtrace-round10-followup.md`). 그 대화에서 사용자와 대화형으로 전량 결정·반영됐습니다**(소스는
새로 생긴 것만 남아 있습니다 — **둘 다 M2 게이트 아님**: `qa-request/pre-implementation-handtrace-round10-followup.md`). 남은 건 하나 —
- **`H-158`** `state:Block(blocker)` 슈가가 `blocker-plan.md`에 아직 있다 — 사용자 **M2 게이트 아님**:
언급(*"Apply(Blocker) 이긴 할꺼야 (표면은 :Gate 만 남아 …)"*)대로 **폐기하고 - **`H-163`/`H-164`** (`-round10.md` §4 표 마지막 둘, `H-159` 반영분에
`state:Apply(blocker)`로** 갈지 확인(권고 (a) 폐기). `/code-review high`가 낸 것) — Slot 내부 Observer × 홀드 발화(권고 (a) Slot이
- **`H-159`~`H-161`** (`-round10.md` §4 표 아래 셋, 10라운드 반영분에 재바인드 전에 플래그를 지움) / Observer 홀드 발화의 `emitFrom == nil` 모호(권고
`/code-review high`가 낸 것) — `H-151`이 잃은 "바인드 전 emit(특히 `Ref`)" (a) 마지막 `from`을 넘김, 한 필드 두 뜻이 걸리면 (b) 센티널).
캐치업(권고 (a) 묶이는 시점 1회 `Refresh`만 복원) / leaf `Destroying` 경로의
cleanup 안 `Rerun`(권고 (a) `_cleanupRunning`이면 `rawRerun` no-op) / **M5
루트 부착 경로 부재 + 다중 스크립트 `Claim`**(권고 (a) `Claim`을 M5로, 다중
스크립트는 권고 없음).
- **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는 - **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는
`Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트 `Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트
사용·debug 검사 범위·표면 이름·마일스톤 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5 이후. 사용·debug 검사 범위·표면 이름 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5
스코프(`H-161`). **특히 §5-7**(여러 스크립트/여러 quad가 같은 `PlayerGui`
쓰는 경우 — "전부 매핑" 계약이 루트 컨테이너엔 안 맞는다는 신호, 권고 없음)이
`Claim` 설계의 핵심 미결입니다.
**[2026-08-27] 9라운드**(Q1~Q10·`H-138`·`H-139`·`H-142`·`H-143`~`H-146`)는 **[2026-08-27] 9라운드**(Q1~Q10·`H-138`·`H-139`·`H-142`·`H-143`~`H-146`)는
전량 처리·반영됐습니다 — 소스는 `-round9-followup.md`. 전량 처리·반영됐습니다 — 소스는 `-round9-followup.md`.

View file

@ -2,8 +2,10 @@
> **[2026-08-28 신설, 사용자 발의]** 10라운드 `H-148`(`Parent` 거부 문구)을 > **[2026-08-28 신설, 사용자 발의]** 10라운드 `H-148`(`Parent` 거부 문구)을
> 논의하다 **더 큰 표면의 공백**이 드러나 만든 문서. 상태: **설계 논의 중 — > 논의하다 **더 큰 표면의 공백**이 드러나 만든 문서. 상태: **설계 논의 중 —
> 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 이후** > 방향은 사용자 확정, 갈래 몇 개 미결(§5)**. M2 착수 게이트 아님, **M5 스코프**
> (프로바이더 op가 필요). 결정이 나면 `base/`로 승격한다. > (**[2026-08-28 `H-161`]** "M5 이후"에서 당김 — 루트 예외 폐기 뒤 M5의 유일한 루트
> 부착 경로; 프로바이더 op가 필요하니 M5가 자연스러운 자리). 결정이 나면 `base/`
> 승격한다.
> >
> **`archive/existing-instance-bind-rejected.md`(2026-08-14 기각)와의 관계**: > **`archive/existing-instance-bind-rejected.md`(2026-08-14 기각)와의 관계**:
> 그 기각은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*였고 사유는 > 그 기각은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*였고 사유는
@ -131,7 +133,8 @@ local cloned = Claim(template:Clone(), M.Frame "root" { -- ← 루트 이름
백로그에 포인터만. **[2026-08-28 `/code-review`, `H-161`]** 단 `H-146` 루트 백로그에 포인터만. **[2026-08-28 `/code-review`, `H-161`]** 단 `H-146` 루트
예외를 폐기한 지금 **M5에 승인된 루트 부착 경로가 없다** — (a) `Claim`을 M5 예외를 폐기한 지금 **M5에 승인된 루트 부착 경로가 없다** — (a) `Claim`을 M5
스코프로 당김 / (b) `Claim` 전까지 M5 한정 임시 예외 / (c) 루트 컨테이너용 스코프로 당김 / (b) `Claim` 전까지 M5 한정 임시 예외 / (c) 루트 컨테이너용
얇은 표면. 권고 (a). 얇은 표면. **사용자 확정 (a)**(*"M5 스코프로 올라가도 될것으로 보임"*) — 헤더와
`ROADMAP.md` M5 체크박스에 반영. §5-7(다중 스크립트)은 여전히 미결.
7. **[2026-08-28 `/code-review`, `H-161`] 여러 스크립트/여러 quad가 같은 루트 7. **[2026-08-28 `/code-review`, `H-161`] 여러 스크립트/여러 quad가 같은 루트
컨테이너를 쓰는 경우** — 위 "이중 claim error / 다중 quad UB / 부기 대상 자식 컨테이너를 쓰는 경우** — 위 "이중 claim error / 다중 quad UB / 부기 대상 자식
전부 매핑"을 그대로 두면 `Shop.client.luau``Inventory.client.luau`가 각각 전부 매핑"을 그대로 두면 `Shop.client.luau``Inventory.client.luau`가 각각

View file

@ -1988,5 +1988,8 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
`_epochs`는 emit 때만 갱신, `Refresh` 캐치업 폐기 + "게이트는 emit 경로만 미룬다" `_epochs`는 emit 때만 갱신, `Refresh` 캐치업 폐기 + "게이트는 emit 경로만 미룬다"
계약(`H-151`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 런타임 계약(`H-151`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 런타임
가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`) / ROADMAP·debounce·store 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`) / ROADMAP·debounce·store
stale(`H-155`~`H-157`). 미결 `H-158`(`:Block` 슈가). 소스는 stale(`H-155`~`H-157`). **같은 세션 후속**으로 `/code-review` 3건 + `H-158`~`H-162`도
확정 — `:Block` 폐기(`__apply`) / **`_rerunRequired` 홀드 플래그**(사용자 제안,
`_installed` 흡수, Observer 대칭) / `Claim` M5 스코프 / `Void` export. 미결은
`Claim` 갈래(특히 다중 스크립트)뿐. 소스는
`qa-request/pre-implementation-handtrace-round10-followup.md`. `qa-request/pre-implementation-handtrace-round10-followup.md`.

View file

@ -34,3 +34,23 @@
- 사용자가 결정 직전에 전제를 묻는 패턴(*"그게 진짜 날 수 있어?"*, *"canExecute 가 - 사용자가 결정 직전에 전제를 묻는 패턴(*"그게 진짜 날 수 있어?"*, *"canExecute 가
막는다가 말이 맞아?"*)이 두 번 다 유효한 정정으로 이어졌다 — 그때 "맞다"로 막는다가 말이 맞아?"*)이 두 번 다 유효한 정정으로 이어졌다 — 그때 "맞다"로
넘기지 말고 층을 갈라 답할 것. 넘기지 말고 층을 갈라 답할 것.
## 후속 (같은 세션) — `/code-review` 3건과 `H-158`~`H-162`
반영 뒤 감사 6라운드 수렴 → `/code-review high` 10건 중 셋이 판단 필요라 커밋
`ae34cfa` 뒤 사용자와 이어서 처리:
- **`H-158`** `:Block` 폐기 확정(*"내가 이미 그렇게 정했었는데, 전파가 안 된
부분"*) + `__apply` 필드명(코퍼스는 "구현 시"로 열어뒀었다).
- **`H-160`** (a) + *"한번 죽는게 나오면 Effect 가 전부 죽는다"* 계약 상향.
- **`H-161`** `Claim` M5 스코프.
- **`H-159`** — 리뷰 권고((a) `Refresh` 복원)를 사용자가 **다른 모양**으로 뒤집음:
`_rerunRequired` 홀드. 대화 네 턴: (1) 홀드 제안 → 메인이 `fire` 분기 모양 제시
→ (2) 사용자: cleanup 중 변경도 홀드해야(포탈), `fire`는 그냥 `Rerun`, `force`
`_rerunRequired` → (3) 메인: `_installed``_rerunRequired`로 통합 제안 → (4)
사용자: `from == nil` 가드 의문(→ 출처 `Epoch`와 값 `nil`의 혼동을 갈라 답함),
*"force 는 딱 하나의 역할"*. 결정의 소스는 followup `H-159` 절.
- **`H-162`** `Void` export(사용자 발의).
교훈: **리뷰 권고 (a)가 "옛 메커니즘 복원"일 때는 사용자가 더 단순한 새 모양을
갖고 있을 가능성이 높다** — `Refresh`(두 번째 갱신 경로) 대신 홀드 플래그(갱신
경로는 하나, 실행만 미룸). 이번 세션에서 `_installed`가 사라진 것처럼 통합 기회도
같이 온다.

View file

@ -56,8 +56,10 @@
와 함께 **같은 날 대화형으로 전량 결정·반영**(소스 `-round10-followup.md`). 와 함께 **같은 날 대화형으로 전량 결정·반영**(소스 `-round10-followup.md`).
**어제 결정 중 뒤집힌 것 셋**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸) / **어제 결정 중 뒤집힌 것 셋**: `fn`/cleanup은 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸) /
재구독·재바인드의 `Refresh` 캐치업 폐기(`H-151`) / 루트는 밖에서 `.Parent =` 재구독·재바인드의 `Refresh` 캐치업 폐기(`H-151`) / 루트는 밖에서 `.Parent =`
아니라 quad가 `Claim`으로 소유(`H-148`, `research/existing-mount-plan.md`). 남은 미결은 `question.md` 최우선 절의 둘 아니라 quad가 `Claim`으로 소유(`H-148`, `research/existing-mount-plan.md`). **같은 날 후속** `H-158`~`H-162`(`:Block` 폐기 → `state:Apply(blocker)` /
(`H-158` `:Block` 슈가, `Claim` 갈래 — 개수는 `research/existing-mount-plan.md` §5가 소스) — 게이트 아님. 남은 액션: M2 착수.** `_rerunRequired` 홀드 플래그가 `_installed`를 흡수 / `Claim` M5 스코프 / `Void` export)도
확정·반영. 남은 미결은 `question.md` 최우선 절의 `Claim` 갈래(특히 §5-7 다중
스크립트) 하나 — 게이트 아님. 남은 액션: M2 착수.**
아래는 돌리기 전(2026-08-26) 서술: 아래는 돌리기 전(2026-08-26) 서술:
지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는 지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는
**커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤 **커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤

View file

@ -17,8 +17,8 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
`H-143`~`H-146`까지 전량 반영 완료**; **[2026-08-28] 10라운드 몫은 `H-143`~`H-146`까지 전량 반영 완료**; **[2026-08-28] 10라운드 몫은
`-round10-followup.md`** — 광범위 탐사 `H-150`~`H-157`까지 전량 결정·반영, 그중 `-round10-followup.md`** — 광범위 탐사 `H-150`~`H-157`까지 전량 결정·반영, 그중
`H-143``H-146` 루트 예외는 하루 만에 뒤집힘)이고, `.claude/question.md` 최우선 `H-143``H-146` 루트 예외는 하루 만에 뒤집힘)이고, `.claude/question.md` 최우선
절엔 그 대화에서 새로 생긴 둘(`H-158` `:Block` 슈가 / `Claim` 갈래 — 절엔 `Claim` 갈래(`research/existing-mount-plan.md` §5, 특히 다중 스크립트)만
`research/existing-mount-plan.md` §5)만 남아 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md` 남아 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`. 항상 루트 `ROADMAP.md`.

View file

@ -32,7 +32,7 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> (`-round10-followup.md`가 소스) — 그중 둘은 하루 만에 다시 뒤집혔다: `fn`/cleanup은 > (`-round10-followup.md`가 소스) — 그중 둘은 하루 만에 다시 뒤집혔다: `fn`/cleanup은
> 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸 · `rawRerun(force)`/`Rerun` 분리) / > 자기 구독을 못 바꾼다(`H-147`, `H-143` 소멸 · `rawRerun(force)`/`Rerun` 분리) /
> 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148`, > 루트는 밖에서 `.Parent =`가 아니라 **quad가 `Claim`으로 소유**(`H-148`,
> `research/existing-mount-plan.md`, M5 이후). 그 밖에 `_epochs`는 emit 때만 갱신 > `research/existing-mount-plan.md`, **M5 스코프**`H-161`). 그 밖에 `_epochs`는 emit 때만 갱신
> (`Refresh` 캐치업 폐기, `H-151`) / `Effect._blocker` 제거(`H-150`) / Observer > (`Refresh` 캐치업 폐기, `H-151`) / `Effect._blocker` 제거(`H-150`) / Observer
> 진입점 인라인(`H-149`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름 > 진입점 인라인(`H-149`) / `GateNode` 브랜드 등록(`H-152`) / Store 예약 이름
> 런타임 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`). > 런타임 가드(`H-153`) / `InstanceChildHandler` dedup(`H-154`).
@ -430,6 +430,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음
elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다) elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다)
sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처) sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처)
else sub._rerunRequired = true -- [2026-08-28 `H-159`] 묶이기 전의 변경은 홀드 → 바인드/구독 시 1회
end end
``` ```
`canExecute``inst`를 인자로 받을 수 없는 이유는 그대로다(State는 `canExecute``inst`를 인자로 받을 수 없는 이유는 그대로다(State는
@ -489,7 +490,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~ - [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
(**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적 (**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적
`Blocker``fire` 첫 줄의 `canExecute`가 이미 같은 억제를 해서 한 번도 `Blocker``canExecute`(지금은 `rawRerun` 진입, `H-159`)가 이미 같은 억제를 해서 한 번도
판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/ 판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/
Slot 쪽 요구뿐.) (`base/effect-plan.md`, **[2026-08-21 5라운드 Slot 쪽 요구뿐.) (`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
@ -505,14 +506,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
**[2026-08-28 10라운드 `H-147`]** `rawRerun(self, force)` 본체 + 공개 **[2026-08-28 10라운드 `H-147`]** `rawRerun(self, force)` 본체 + 공개
`Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들은 정의된 no-op), `Rerun()`(진입에서 `canExecute` 게이트 — 죽은·안 묶인 핸들의 요청은 **[`H-159`]** `_rerunRequired`로 홀드),
생성자는 `rawRerun(self, true)`. **`fn`/cleanup은 자기 구독을 못 바꾼다** — 생성자는 `rawRerun(self, true)`. **`fn`/cleanup은 자기 구독을 못 바꾼다** —
네 진입점 첫 줄에 `_running` 가드(2026-08-27의 "`fn` 안 `Unsubscribe` 지원" 네 진입점 첫 줄에 `_running` 가드(2026-08-27의 "`fn` 안 `Unsubscribe` 지원"
`H-143``wasAlive` 꼬리는 소멸). 네 진입점은 **`EffectHandle` 자기 것** `H-143``wasAlive` 꼬리는 소멸). 네 진입점은 **`EffectHandle` 자기 것**
(`H-144` (b) — 공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐), (`H-144` (b) — 공유는 `Observer.luau`의 레지스트리 둘과 `canBound`뿐),
`Subscribe`/`WeakSubscribe`는 등록 끝에 `not _installed → Rerun`(재구독 `Subscribe`/`WeakSubscribe`는 등록 끝에 `_rerunRequired → Rerun`(재구독
재설치; **[`H-151`]** `_epochs:Refresh()` 캐치업은 폐기 — `_epochs` 재설치 + 홀드된 변경; **[`H-151`/`H-159`]** `_epochs:Refresh()` 캐치업은 폐기 —
`fire``Update`에서만 갱신) — 의사코드는 `base/effect-plan.md`. `_epochs``fire``Update`에서만 갱신하고, 실행 불가 상태에 온 변경은
`rawRerun``_rerunRequired`로 홀드) — 의사코드는 `base/effect-plan.md`.
**동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md` **동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md`
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션) "동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
@ -531,11 +533,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은 Observer엔 자기 epoch가 없지만 `Ref`는 그 자체가 epoch다). dedup은
클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 · 클로저 identity가 아니라 `_deps`/`_epochs` 맵이 한다 ·
~~**`handle._blocker`**~~(**[2026-08-28 `H-150`]** 제거 — 등록 구간의 ~~**`handle._blocker`**~~(**[2026-08-28 `H-150`]** 제거 — 등록 구간의
즉시-1회 호출은 별도 필드 없이 `fire` 첫 줄의 `canExecute`가 억제한다; 옛 즉시-1회 호출은 별도 필드 없이 `fire``from == nil` 가드와 `rawRerun``canExecute`가 억제한다(`H-159`); 옛
`_installing` 플래그도 생성자 구간만 덮어 폐기됐었다) · `_installing` 플래그도 생성자 구간만 덮어 폐기됐었다) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가 **`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup 같은 자리를 읽는다) · **`handle._rerunRequired`**(`fn`이 돌아야 하는데 아직 안 돌았다 — 생성 직후 /
반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · 소진 뒤 / 실행 불가 상태에 온 변경; **[2026-08-28 `H-159`]** 옛 `_installed`를 흡수.
`fn`의 cleanup 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) ·
**`handle._running`/`_pending`**(`Rerun` 재진입 **`handle._running`/`_pending`**(`Rerun` 재진입
지연) · **`handle._destroyConn`** · 지연) · **`handle._destroyConn`** ·
**`:_bindDestroying(inst)`/`:_unbindDestroying()`** **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
@ -549,9 +552,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/ **⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/
`:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 `:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다
(`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다 (`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다
(`H-7`). 캐치업은 바인드 직후 **재설치 1회뿐**(`if not self._installed then (`H-7`). 캐치업은 바인드 직후 **`_rerunRequired`면 1회**(`if self._rerunRequired then
self:Rerun() end` — **[2026-08-28 `H-151`]** 옛 `_epochs:Refresh()`는 폐기, self:Rerun() end` — **[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`는 폐기,
`_epochs`는 emit 받을 때만 갱신 — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 `_epochs`는 emit 받을 때만 갱신하되 실행 불가 상태의 변경은 홀드`H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 - [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
@ -616,7 +619,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** - [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Ref`**(최소형, `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
2026-08-27 `H-128`) / 2026-08-27 `H-128`) /
`is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/ `is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/
`isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`· `isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`·
@ -643,7 +646,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`luau-test``15-type-compute-trailing-deps-typepack.luau` `luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정) 되면 동종 타입 dep 1개로 한정)
- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` / - [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` /
`Source:Emit()``:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 `Source:Emit()``:Compute`/`:Apply`/`:Observer`는 각각 체크박스가
있는데 이 셋만 빠져 있었다 있는데 이 셋만 빠져 있었다
- [ ] **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]** - [ ] **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]**
@ -844,7 +847,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다. `base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `Void`(**[2026-08-28 `H-162`]** 단일 no-op)를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션 호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션
/ **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process` / **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process`
반환값으로 합쳐지며 대상만 바뀜**) 반환값으로 합쳐지며 대상만 바뀜**)
@ -970,7 +973,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
문단. 빠뜨리면 문단. 빠뜨리면
`Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 — `Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 —
`base/dispatch-core-plan.md``H-39` 블록(그 다섯째 항목)이 소스. `base/dispatch-core-plan.md``H-39` 블록(그 다섯째 항목)이 소스.
- [ ] **[2026-08-28 백로그, M5 이후]** `Claim(inst, D.Mapper.<Class> "Name" {…})` — 이미 있는 트리(PlayerGui·`Clone()` 사본)를 quad가 소유. 프로바이더 op `nativeFindChild` 필요. 갈래 미결 — `research/existing-mount-plan.md` §5가 소스(개수도), 다음 배치 문항. - [ ] **[2026-08-28 M5 스코프, `H-161`]** `Claim(inst, D.Mapper.<Class> "Name" {…})` — 이미 있는 트리(PlayerGui·`Clone()` 사본)를 quad가 소유. `H-146` 루트 예외를 폐기한 뒤 M5에 승인된 루트 부착 경로가 이것뿐이라 **백로그가 아니라 M5 안**(사용자 확정). 프로바이더 op `nativeFindChild` 필요. 갈래 미결(특히 §5-7 다중 스크립트/루트 컨테이너)`research/existing-mount-plan.md` §5가 소스(개수도), 다음 배치 문항.
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션
확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/
lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에
@ -1514,8 +1517,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고 않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고
`_bindDestroying`**`Ref` dep 콜백을 (재)등록하지 않는다** — `_bindDestroying`**`Ref` dep 콜백을 (재)등록하지 않는다** —
dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과 dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과
**재설치 캐치업 한 줄**(`if not self._installed then self:Rerun() end` — **홀드 캐치업 한 줄**(`if self._rerunRequired then self:Rerun() end` —
**[2026-08-28 `H-151`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 **[2026-08-28 `H-151`/`H-159`]** 옛 `Refresh()` 판정은 폐기)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다
(그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md` (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`
`base/effect-plan.md`가 소스. **[2026-08-14 `base/effect-plan.md`가 소스. **[2026-08-14