qa: 10라운드 후속 H-163/H-164 + EmitReceive — 전파 루프 계층 분리, Slot 재마운트 캐치업, emitFrom nil 계약

- EmitReceive: State:_emitDown은 sub:_receive(from)만, Observer:_receive가 canExecute 판정·_rerunRequired 홀드
  (사용자 지시 — 계층간 지식 분리), State:Observer(fn) 생성자 순서(플래그 참 → fn 1회 → 내림 → _subs)
- H-163 (a′): _listObserver 재마운트 바인드는 materializeSlotTree 꼬리(트리 확정 뒤)에서 재마운트일 때만,
  홀드가 있었으면 reconcile 1회 (감사가 잡은 첫 마운트 이중 bind·reconcile 미실행 결함 정정)
- H-164 (c): emitFrom == nil = 출처 없음(설치 또는 캐치업), from 보관 기각
- 감사 4→3→2→2(각도 교체, 마지막 둘은 표현), /code-review 8 반영분 포함

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
This commit is contained in:
qwreey 2026-08-28 15:52:15 +09:00
parent ecc6b0e538
commit 5e96cd6258
Signed by: qwreey
GPG key ID: D28DB79297A214BD
10 changed files with 143 additions and 54 deletions

File diff suppressed because one or more lines are too long

View file

@ -2601,8 +2601,9 @@ end
캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가
나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위
"핸들러 계약"/"핸들러 내부 상태 저장" 절 참고).
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — State의
전파 루프가 발화 때마다 `canExecute(observer)`로 각 구독자를 게이팅하고,
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — 발화 때마다
Observer 자신의 `_receive``canExecute(observer)`로 게이팅하고(**[2026-08-28
`EmitReceive`]** 옛 표현 "State의 전파 루프가"),
그 판정 근거(`inst` 생존)는 `bindLifetime``observer` 쪽에 복사해둔
gcconn 참조가 제공함(`base/lifecycle-pattern.md`의
"`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime`" 절).

View file

@ -539,8 +539,9 @@ end
있는 것은 "`Ref`에 콜백 해제 경로(`:Uncallback`)를 둔다"는 결론뿐이다).
**⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도
발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가
구독자마다 `canExecute(observer)`를 보고 죽은 것을 건너뛰는데(`base/source-state-plan.md`),
발화 시점에 `canExecute`를 확인한다.** State/Source dep은 Observer 자신의 `_receive`
`canExecute(observer)`를 보고 죽은 것을 건너뛰는데(**[2026-08-28 `EmitReceive`]** 옛 표현은
"전파 루프가" — `base/source-state-plan.md`),
`Ref` 경로엔 그 게이트가 아예 없었다. 해제가 늦거나 누락되는 창이 실재한다 —
예컨대 `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`
안 도는데도 `canExecute`가 거짓이다. **확정된 형태**: `Effect`가 거는 `Ref`

View file

@ -354,7 +354,8 @@ function bindLifetime(inst, value)
-- `base/effect-plan.md`의 "확정 구조" 절이 소스.
if isObserver(value) and value._rerunRequired then
-- ⭐ [2026-08-28 `H-159`] Observer도 대칭 — 묶이기 전(생성~바인드 사이)에 온 emit은
-- 전파 루프가 `_rerunRequired`로 홀드해 두고(`source-state-plan.md` 전파 루프),
-- Observer 자신의 `_receive``_rerunRequired`로 홀드해 두고(`source-state-plan.md`의
-- `Observer:_receive` — 전파 루프는 `EmitReceive`로만 본다),
-- 묶이는 순간 1회 발화(출처 없음 — 설치 발화와 같은 모양). Observer엔 epoch가
-- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다.
value._rerunRequired = false
@ -615,11 +616,11 @@ leaf냐"를 가르는 판별자.
**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라운드가
"초기화가 없어 한 번도 안 돈다"로 오독할 수 있음을 짚어 명시). 레지스트리 두 테이블은 인스턴스 필드가 아니라
`H-159`]** 묶이기 전에 온 emit을 자기 `_receive`가 홀드 — `bindLifetime`/`Subscribe`/
`WeakSubscribe`가 1회 발화. Effect와 같은 뜻("`fn`이 돌아야 하는데 아직 안 돌았다"):
생성 시 참 → `state:Observer(fn)` 생성자의 "등록 시점 즉시 1회 실행"이 돌면서 거짓 →
그 뒤 묶이기 전 사이에 온 변경이 다시 세운다), **`_receive(from)`**(`EmitReceive` —
`source-state-plan.md``_emitDown` 아래). 레지스트리 두 테이블은 인스턴스 필드가 아니라
`Observer.luau`의 모듈 로컬. Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다.
**여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`
@ -680,10 +681,12 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는
게 이 게이트의 의도.
#### (4) 실제 호출부 — State 전파(`emit`)`canExecute`로 게이팅한다
#### (4) 실제 호출부 — Observer의 `_receive``canExecute`로 게이팅한다
`canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위
정정 배너 참고). 확정된 위치는 **State의 전파 루프**:
정정 배너 참고). 확정된 위치는 **`Observer:_receive(from)`**(**[2026-08-28]** State의
전파 루프는 구독자를 `EmitReceive`로만 보고 `sub:_receive(from)`을 부른다 —
`base/source-state-plan.md``_emitDown`; 판정·홀드는 Observer 자신의 몫):
- State는 자기 구독자를 **weak-키로** 담는다 — 살려두는 책임은 State가
아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있고, 어디에도
@ -692,9 +695,9 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
클로저"가 아니라 **Observer 값**이다.** `bindLifetime(inst, observer)`
Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를
담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다.
- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`
확인하고, 거짓이면 **그 구독자에게 `_rerunRequired`만 세우고 건너뜀**(**[2026-08-28
`H-159`]** 옛 "조용히 건너뜀" — 이제 묶일 때 1회 따라잡는다) — 죽은 `inst`
- 발화 시 Observer 자신의 `_receive``canExecute(self)`를 확인하고, 거짓이면
**`_rerunRequired`만 세우고 건너뜀**(**[2026-08-28 `H-159`]** 옛 "조용히 건너뜀" —
이제 묶일 때 1회 따라잡는다) — 죽은 `inst`
건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의
실제 구현 지점.
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를

View file

@ -1418,7 +1418,14 @@ function activateList(self, physicalTarget)
-- 죽는다(`base/lifecycle-pattern.md`의 `bindLifetime` 계약).
-- 언마운트 쪽(`unmountSlotTree`)은 이미 `if slot._listObserver then`
-- 방어돼 있었는데 이 재마운트 분기만 빠져 있었다.
if self._listObserver then bindLifetime(physicalTarget, self._listObserver) end
-- ⭐ [2026-08-28 확정, 10라운드 `H-163` (a)] `_listObserver`**여기서 묶지 않는다**
-- `materializeSlotTree`의 꼬리(트리 확정 뒤)가 묶는다. 언마운트 사이의 emit은
-- `_receive``_rerunRequired`로 홀드해 뒀는데, 트리가 미확정인 이 시점에 묶으면
-- `bindLifetime`의 홀드 발화가 reconcile을 동기 실행해 자리 이중 등록·중첩 Slot
-- `canBound` error가 난다. 사용자: *"슬롯트리가 미확정 상황에는 bind 안하고,
-- 확정될 때 bind 전에 rerun 끈다"*, 유실 대신 확정 뒤 1회 reconcile은 *"권고가
-- 맞는듯. get 자체가 안 나니까"*(재마운트 분기 자체는 reconcile을 안 돌린다 —
-- 감사 2라운드 지적). plain table `data``_listObserver``nil`이라 꼬리도 건너뛴다.
bindLifetime(physicalTarget, self._detachCleanup)
return
end
@ -2344,6 +2351,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- 완주하면, 그 시점 `bk`는 언마운트 전 옛 부기(`Relate(slot)` 위에
-- 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다. 이 Blocker는 이 Slot의 것이고
-- `setOffsetSource`**부모 owner의** blocker를 보므로 간섭이 없다.
slot._baseObserver._rerunRequired = false -- [2026-08-28 `H-163`] 위 `_listObserver`와 같은 이유
bindLifetime(physicalTarget, slot._baseObserver)
-- offset — activateList가 updateFn에 이 값을 넘겨야 하므로(C1). 여기서
-- `slot.Offset`이 새 베이스로 `Set`되고, 위 관측자가 두 필드를 0으로 내린 뒤
@ -2381,8 +2389,10 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- `_elements` 안의 중첩 Slot들이 **다시 실체화되지 않는다**
-- `_physicalTarget``nil`인 채 `_mounted`만 켜지고, `_baseObserver`
-- **죽은 옛 target**에 매달린 채 남는다(그 가드 자신이 경고하는 실패 모드).
local remountingList = slot._listed and slot._listActivated -- [2026-08-28 `H-163` (a)] 꼬리가 본다 —
-- 아래 activateList가 플래그를 켜기 **전**에 잡는다
if slot._listed and not slot._listActivated then
activateList(slot, physicalTarget) -- 최초 population — rawAdd가 자리마다 등록
activateList(slot, physicalTarget) -- 최초 population — rawAdd가 자리마다 등록(observer도 여기서 묶는다)
else
if slot._listed then
activateList(slot, physicalTarget) -- 재마운트: 앵커만 새 target으로
@ -2417,6 +2427,24 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
if not bk.recomputeBlocker:IsOn() then
recompute(slot, bk) -- 여기서 slot.Length가 최종값으로 확정
end
-- ⭐ [2026-08-28 확정, 10라운드 `H-163` (a)] 재마운트의 `_listObserver` 바인드는
-- **트리가 확정된 여기서**`activateList` 재마운트 분기가 넘겨둔 일. 언마운트
-- 사이에 `data`가 바뀌었으면(`_receive`가 세운 `_rerunRequired`) 끄고 묶은 뒤
-- reconcile을 **정확히 1회** — 상류 `data`는 epoch가 최신이라 `Get` 한 번이면 된다.
-- `bindLifetime` 자신의 홀드 발화에 맡기지 않는 이유: 그건 gcconn 연결 직후
-- 묶이는 자리에서 돌아 순서를 이 꼬리에 못 맞춘다(`_baseObserver`는 위에서 끄고
-- 묶었다 — 그쪽 캐치업은 `setOffsetSource` 경로가 한다).
-- ⚠️ **재마운트일 때만** — 최초 population은 위 `activateList`의 fresh 경로가 이미
-- observer를 만들고 묶었다(감사 3라운드: 여기서 또 묶으면 `canBound` error). 늦은
-- `:List()`(이미 마운트된 Slot에 설치, `Slot:List`)는 이 함수를 안 거치므로 fresh
-- 경로의 bind가 유일 — 그래서 fresh 경로의 bind를 여기로 옮길 수도 없다.
if remountingList and slot._listObserver then
local listObserver = slot._listObserver
local held = listObserver._rerunRequired
listObserver._rerunRequired = false
bindLifetime(physicalTarget, listObserver)
if held then listObserver.fn(slot._listData, listObserver, nil) end -- == reconcile(data:Get())
end
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
-- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에

View file

@ -251,16 +251,39 @@ function State:_emitDown(from)
local snap = {}
for sub in self._subs do snap[#snap + 1] = sub end
for _, sub in ipairs(snap) do
if isState(sub) then -- 자식 노드
sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3
elseif canExecute(sub) then -- Observer (Effect는 자기 내부 Observer로 여기 온다)
sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처)
sub:_receive(from) -- ⭐ [2026-08-28 확정, `H-163` 대화] 구독자는 전부 **`EmitReceive`**
end -- (`:_receive(from)` 하나짜리 인터페이스 — `Epoch`과 같은 급).
-- State 노드(`ComputeNode`/`GateNode`)는 §4 규칙, Observer는 아래
-- `Observer:_receive`. 한때 여기서 `isState`/`canExecute`로 갈라
-- Observer의 `fn`을 직접 부르고 `_rerunRequired`까지 세웠는데
-- **계층 간 지식이 섞여** 있었다 — 사용자: *"State:_emitDown 에
-- … 계층간 확인 구조가 있거든? 그냥 Epoch 처럼 EmitReceive 를
-- 만들고, 각 state 나 observer 측에서 해당 emit 을 처리하는 함수를
-- 만들어주는게 맞는듯. _rerunRequired 를 여기서 설정하는게
-- 문제가 되어보여(계층간 지식이 분리 안되어있음)."*
end
-- Observer 쪽 `EmitReceive` 구현(`Observer.luau`). 판정과 홀드가 **여기** 산다.
function Observer:_receive(from)
if canExecute(self) then
self.fn(self._state, self, from) -- ⭐ (리시버 State, Observer 자신, 출처)
else
sub._rerunRequired = true -- ⭐ [2026-08-28 `H-159`] 묶이기 전의 변경은 버리지 않고 홀드 —
end -- `bindLifetime`/`Subscribe`가 1회 발화(`lifecycle-pattern.md`).
-- (Effect의 내부 Observer는 `WeakSubscribe`돼 있어 여기 안 옴 —
-- Effect 쪽 홀드는 `rawRerun`이 한다)
end
self._rerunRequired = true -- [2026-08-28 `H-159`] 묶이기 전의 변경은 홀드 — 묶일 때 1회
end -- (Effect의 내부 Observer도 이 경로 — `fire``fn`이다)
end
-- `EmitReceive` — 구독자 집합 `_subs`의 원소가 만족하는 인터페이스(`Epoch`처럼 구조적).
type EmitReceive = { _receive: (self: any, from: Epoch | EpochSet) -> () }
-- 구현: `ComputeNode`/`GateNode`(state-epoch-plan.md §4 규칙 1~3 / gate-plan.md 조립 절), `Observer`(위).
-- `state:Observer(fn)` 생성자 — 순서가 계약이다(**[2026-08-28 `H-159`/`H-164`]**).
function State:Observer(fn)
local o = setmetatable({ fn = fn, _state = self, _rerunRequired = true }, Observer)
ObserverBrand:register(o)
o.fn(self, o, nil) -- (1) 등록 시점 즉시 1회 — 출처 없음(`nil`)
o._rerunRequired = false -- (2) 설치 발화가 플래그를 내린다(사용자 확인)
self._subs[o] = true -- (3) 그다음 구독자 집합에 — 순서를 뒤집으면 (1)이 자기
return o -- State를 Set할 때 그 emit이 자신의 _receive에 와 플래그가 선다
end
```
@ -1143,10 +1166,11 @@ retract/Destroy되면 자동으로 정리됨.
- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션,
사용자 확정 — 이전까지 미명시였던 항목).** (**[2026-08-28 `H-159`]** 이 1회는
`state:Observer(fn)` 생성자가 무조건 하는 것이고, 같은 날 신설된 `_rerunRequired`
홀드는 그 **뒤** ~ 묶이기 전 사이의 변경만 다룬다 — 별개다. **생성자 순서는 `fn`
1회 실행 → `_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기
State를 `Set`할 때 그 emit이 아직 안 묶인 자신에게 와 `_rerunRequired`가 서고 첫
`state:Observer(fn)` 생성자가 무조건 하는 것(Effect 생성자와 같은 모양 —
`_rerunRequired = true`로 시작해 이 1회가 내린다)이고, 같은 날 신설된 홀드는
**뒤** ~ 묶이기 전 사이의 변경만 다룬다. **생성자 순서는 `fn` 1회 실행 →
`_subs` 삽입**(`/code-review` 지적으로 고정): 반대면 설치 발화가 자기 State를
`Set`할 때 그 emit이 아직 안 묶인 자신의 `_receive`에 와 플래그가 서고 첫
바인드에서 `fn`이 한 번 더 돈다. Effect의 내부
Observer가 이 설치 발화를 `from == nil`로 거르는 이유이기도 하다.) 근거: (1) 이미 채워진
State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안
@ -1179,9 +1203,13 @@ retract/Destroy되면 자동으로 정리됨.
그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `emitFrom`
**옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high`
발견 — 한때 non-optional로 적혀 있었다). `fn``emitFrom`을 실제로 쓰는
소비자라면 `nil`을 "설치 발화"로 분기해야 한다. **⚠️ [2026-08-28 판단 대기,
`H-164`]** `H-159`의 Observer 홀드 발화(묶일 때 1회)도 지금 `nil`을 넘겨 이
분기와 구분이 안 된다 — 갈래는 `-round10.md` §4.
소비자라면 `nil`**"출처 없음 — 값을 읽어라"**로 다뤄야 한다. **[2026-08-28
`H-164` 확정]** `nil`은 설치 발화 **또는** 묶일 때의 캐치업(`H-159` 홀드 발화)
둘 다다 — 둘 다 "특정 출처의 통지"가 아니라 "지금 값을 반영하라"라 같은 종류.
홀드된 출처를 보관해 넘기는 안은 기각(사용자: *"_rerunRequired 를 from 으로
저장하면 여러 홀드 변경이 오면 from 이 날아가지 않아? 애초에 from 을 저장할
이유가 왜 있어?"*). `nil`을 "초기화 전용"으로 분기하는 소비자 패턴은 계약이
아니다.
- **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라
**핸들과 메타데이터**뿐이다.
- 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄
@ -1262,8 +1290,9 @@ override할 자리를 구조적으로 열어두는 것.)
동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님)
— 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op.
**[명시화, 2026-08-14 다섯 번째 세션] 이 게이팅이 일어나는 자리는 State의
전파 루프**다 — State는 구독자를 **weak로** 담고, 발화 시 각 구독자마다
`canExecute(observer)`를 확인해 거짓이면 그 구독자만 건너뜀. 여기에
전파 루프**다 — State는 구독자를 **weak로** 담고 `sub:_receive(from)`을 부르며, 발화
시 Observer 자신의 `_receive``canExecute(observer)`를 확인해 거짓이면 홀드
(**[2026-08-28 `EmitReceive`]**). 여기에
`inst`가 없다는 사실이 `canExecute``value` 하나만 받아야 하는
이유(`base/lifecycle-pattern.md`의 "실제 호출부" 절, 옛 2-인자
시그니처의 역전 경위는 `archive/canexecute-inst-arg-reversed.md`).
@ -1357,7 +1386,7 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
세운다.** 즉 갈라지는 지점은 **레지스트리를 강하게 잡느냐뿐**이고,
`.Subscribed` 플래그는 **강·약 구독 경로 공용**이다. 이게 정해져 있지
않아서 두 해석이 각자 다른 확정 문장에 뿌리를 두고 있었고, 안 세우는
쪽으로 읽으면 전파 루프의 `canExecute(sub)` 게이트가 항상 거짓이 되어
쪽으로 읽으면 `Observer:_receive``canExecute(sub)` 게이트(**[2026-08-28 `EmitReceive`]** 옛 표현 "전파 루프의")가 항상 거짓이 되어
**`Effect`의 State dep 전량이 조용히 침묵**한다(`Effect`의 내부 Observer는
`WeakSubscribe`로만 등록되고, gcconn 경로는 핸들에만 있으므로 남는 판정
근거가 `.Subscribed`뿐이다). 사용자 원문 *"구현이 한 벌"*과도 이쪽이

View file

@ -17,7 +17,8 @@
| `H-158` | `state:Block(blocker)` 슈가 잔존 (이 대화에서 나옴) | ✅ **확정 폐기**`state:Apply(blocker)`, 필드 `__apply` |
| `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-163` | Slot 내부 Observer × 홀드 발화 | ✅ **확정 (a)** `_listObserver`는 트리 확정 뒤(`materializeSlotTree` 꼬리)에 끄고 묶고, 홀드가 있었으면 reconcile 1회; `_baseObserver`는 끄고 묶음 + **`EmitReceive` 인터페이스**(전파 루프 계층 분리) |
| `H-164` | 홀드 발화의 `emitFrom == nil` | ✅ **전제 정정 (c)**`nil` = 출처 없음(설치 또는 캐치업), `from` 보관 기각 |
| `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) |
@ -365,3 +366,35 @@ session 후속 절) · 2 의미론(**`_rerunRequired` 상태 기계 자체는
stale 셋(`Blocker` 억제·"`fire` 첫 줄"·"죽은 핸들은 no-op") / `ROADMAP.md` M2 `Rerun`
no-op → 홀드 / `lifecycle-pattern.md`*조용히 건너뜀* → 홀드 / "M5 이후" 잔존 셋 /
followup 절 제목 포인터·개수.
## `H-163` — Slot은 재바인드 전에 내부 Observer의 홀드를 끈다 (a) + `EmitReceive` 인터페이스
**사용자 확정**: *"slot 의 canExecute 를 따라 이것도 쌓아두는 등의 작업을 할 필요는 안
보이는듯. 이건 단일 대상에 대해 observe 하는거라서, 상류 state 의 온전한 epochmap
처리를 받거든. _rerunRequired 를 직접 false 로 바꿔두고 bind 하는건 확실히 맞아보여.
… 그냥 슬롯트리가 미확정 상황에는 bind 안하고, 확정될 때 bind 전에 rerun 끈다는
괜찮은 생각."* — 처음엔 `activateList` 재마운트 분기에서 끄고 묶었는데 **감사
2라운드가 그 분기는 reconcile을 다시 돌리지 않는다**(앵커만 옮기고 return)고 잡아,
그대로면 언마운트 중 `data` 변경이 다음 `Set`까지 유실됐다. 갈래 (a) 트리 확정 뒤
(`materializeSlotTree` 꼬리, `OffWithoutEmit`·`recompute` 뒤) 끄고 묶고 홀드가 있었으면
`reconcile(data:Get())` 1회 / (b) 유실을 계약으로 → **사용자 확정 (a)**: *"권고가
맞는듯. get 자체가 안 나니까"*. `_baseObserver``materializeSlotTree` 머리에서 끄고
묶는다(그쪽 캐치업은 `setOffsetSource` 경로).
**같은 발언에서 나온 구조 결정 — `EmitReceive`**: *"State:_emitDown 에 관한 이야기인데,
여기 계층간 확인 구조가 있거든? 그냥 Epoch 처럼 EmitReceive 를 만들고, 각 state 나
observer 측에서 해당 emit 을 처리하는 함수를 만들어주는게 맞는듯. _rerunRequired 를
여기서 설정하는게 문제가 되어보여(계층간 지식이 분리 안되어있음)."* → 전파 루프는
`sub:_receive(from)` 한 줄, `Observer:_receive``canExecute` 판정과 홀드를 자기 안에
둔다(`source-state-plan.md` `_emitDown` 아래, `lifecycle-pattern.md` (4)절, `ROADMAP.md`).
## `H-164` — 문항 전제 정정: `emitFrom == nil` = 출처 없음(설치 또는 캐치업), `from` 보관 기각 (c)
**사용자**: *"H164는 뭔가 이상함. 설치 발화 후 홀드된 변경이 무슨말인지 모르겠음. 아에
별도의 이야기일텐데? … _rerunRequired 를 from 으로 저장하면 여러 홀드 변경이 오면 from 이
날아가지 않아? 애초에 from 을 저장할 이유가 왜 있어?"* — 맞다. 홀드 발화는 출처 있는
통지가 아니라 "묶였으니 값을 읽어라"라 설치 발화와 같은 종류이고, `nil`을 "초기화
전용"으로 분기하는 소비자 패턴은 계약이 아니다. Observer의 `_rerunRequired`는 Effect와
같은 뜻으로 **생성 시 참 → 설치 발화가 내림 → 묶이기 전 변경이 다시 세움**(사용자
확인: *"설치 발화 이후엔 rerunRequired = false 되긴 해."*). 반영: `source-state-plan.md`
1171행 계약 문구.

View file

@ -350,8 +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-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-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-163`** ✅ (a) → **(a)** | **[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) → (a)** — (a)의 전제("재마운트 캐치업은 `activateList`가 이미 한다")는 감사 2라운드가 반증(그 분기는 앵커만 옮긴다) → 트리 확정 뒤 끄고 묶고 홀드가 있었으면 reconcile 1회. 소스는 `-round10-followup.md` |
| **`H-164`** ✅ (c) — 문항 전제 정정 | Observer 홀드 발화가 `emitFrom = nil`로 오면 계약("`nil` = 설치 발화")과 구분 불가 — `if emitFrom == nil then initOnly()`로 짠 소비자가 변경을 놓침 | (a) 홀드 시 **마지막 `from`을 보관**(`_rerunRequired = from`, 진리값으로 플래그 겸용)해 그것을 넘김 / (b) 전용 센티널(`HeldEmit`) / (c) 계약 문구만 "`nil` = 설치 **또는** 묶일 때 캐치업" | **(c) — 문항 전제 정정**: 홀드 발화는 출처 있는 통지가 아니라 "묶였으니 값을 읽어라"라 설치 발화와 같은 종류 — `nil` = 출처 없음(설치 또는 캐치업). (a)의 `from` 보관은 사용자 기각(여러 홀드가 오면 앞 것이 날아감, 보관할 이유 없음). 소스는 `-round10-followup.md` |
갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷),
`H-156`(`H-32` 문단), `H-157`(실측 완료 표기).

View file

@ -18,10 +18,6 @@
사용자와 대화형으로 전량 결정·반영됐습니다**(소스는
`qa-request/pre-implementation-handtrace-round10-followup.md`). 남은 건 하나 —
**M2 게이트 아님**:
- **`H-163`/`H-164`** (`-round10.md` §4 표 마지막 둘, `H-159` 반영분에
`/code-review high`가 낸 것) — Slot 내부 Observer × 홀드 발화(권고 (a) Slot이
재바인드 전에 플래그를 지움) / Observer 홀드 발화의 `emitFrom == nil` 모호(권고
(a) 마지막 `from`을 넘김, 한 필드 두 뜻이 걸리면 (b) 센티널).
- **`research/existing-mount-plan.md` §5** — 루트/템플릿을 quad가 소유하는
`Claim` + `D.Mapper`의 갈래들(루트 디스크립터 이름·물리 순서 계약·비루트
사용·debug 검사 범위·표면 이름 … — 개수는 그 문서 §5가 소스). 방향은 확정, M5

View file

@ -410,8 +410,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`
곧 선언 키 집합이고 `Names()`가 성립한다
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부" 절) —
State는 구독자를 **weak-키로만** 담고, 살려두는
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐).
@ -427,12 +426,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold`
불변식**(하류 → 상류 강함)이 책임진다.
```lua
if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음
elseif canExecute(sub) then -- Observer만 (Effect는 자기 내부 Observer로 온다)
sub.fn(sub._state, sub, from) -- [H-109] (리시버 State, Observer 자신, 출처)
else sub._rerunRequired = true -- [2026-08-28 `H-159`] 묶이기 전의 변경은 홀드 → 바인드/구독 시 1회
end
sub:_receive(from) -- [2026-08-28 `EmitReceive`] 구독자 전부 같은 인터페이스 — State 노드는 §4 규칙,
-- Observer:_receive가 canExecute 판정·홀드(`_rerunRequired`)를 자기 안에서
```
(한때 여기서 `isState`/`canExecute`로 갈라 Observer의 `fn`을 직접 불렀다 —
계층 지식이 섞여 사용자 지시로 인터페이스화, `base/source-state-plan.md`)
`canExecute``inst`를 인자로 받을 수 없는 이유는 그대로다(State는
자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`
"등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로
@ -1526,7 +1524,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는
`.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/
`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit
전파 루프만 `canExecute`.
`Observer:_receive``canExecute`(**[2026-08-28 `EmitReceive`]** 옛 표현 "전파 루프만").
**저장은 전부 `SetWeak`**(`SetStrong` 아님 — gchold/gcconn은 아래 M5
클로저↔`gchold[1]` 상호 참조로 이미 안전하게 살아있고, "다른 곳에서
안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙).