feat: round11 §4 배치 회신 1차 반영 — 확정 일곱(H-182~H-185/H-187/H-200/H-203), 재질문 둘(H-186/H-198)·보류(H-205)

- H-182 (a): Effect `_dying` — Destroying 콜백이 세우고 재바인드·Subscribe류가
  내림, rawRerun 홀드 조건 합류(Slot `_destroyed`와 다른 이름은 의도 — 재바인드 가능)
- H-183 (a): Observer `_running` — 모든 fn 실행 둘레 + 네 진입점 첫 줄 가드
  (H-147 대칭; error 시 잔류는 설계상 인정)
- H-184 (a): `_assertBindable` 훅 — bindLifetime이 부기 커밋 전에 문의(level 3),
  H-147 가드는 _bindDestroying에서 이 훅으로 이동. mock + lifecycle-pattern (1)
- H-185: 권고 기각 — cleanup은 하나만(목록 소진은 표면 확대), 문서·타입 주석 명시
- H-187 (a): 타입 별칭 이름 넷 승인(quad-types-plan 기록, 마커 해소)
- H-200 (b): Gate 생성이 setup 동안 상류 _subs에서 떼고 성공 후 재등록(pcall 없음)
- H-203 (a): Blocker 순회가 핸들마다 IsBlocked 재확인 — 재차단 시 잔여는 다음 Off로
- H-168~H-170 재확인 반영: H-170 한계 공개 문서화(ref-plan + content-map 22번)
- 스펙: spec.effect 10 / spec.observer 9 / spec.gate 1 확장 / spec.blocker 8
- 감사 3라운드(3+2 → 3 → 0 수렴): 필드 목록 _running/_dying, CLAUDE.md·
  project-context.md M2 배너(목록은 todos.md 00번 단일 소스), session-summary
- 남은 코드 마커 셋: H-186/H-198(재질문 — §4 회신 2 블록에 메인 답변), H-205(보류)

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
This commit is contained in:
qwreey-agent-selene 2026-08-31 12:56:31 +09:00
parent dc81dd26db
commit 0d66e03875
No known key found for this signature in database
24 changed files with 455 additions and 60 deletions

View file

@ -135,6 +135,15 @@ gated state의 동작:
새 등록(핸들 → flush → 하류 Observer가 `state:Apply(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-31 `H-203`, 사용자 확정] 순회는 핸들마다 `IsBlocked`를 다시
읽고, 참이면 그 자리에서 멈춘다** — 앞선 핸들의 flush가 깨운 하류가
`b:On()`으로 재차단하면 남은 핸들은 유보를 그대로 쥔 채 다음 `Off()`
기다린다. 안 멈추면 `IsOn() == true`인데 통지가 새는 창이 생긴다(실측).
그 결과로 남는 "절반만 emit된" 상태는 quad 정의상 문제가 없다는 게 사용자
판정(*"여전히 중간에서 멈춰도 quad 정의 상 문제는 없는 상태"*) — 재차단
자체를 막지는 않는다(권장 패턴은 아니지만 막을 이유도 없음). 기존
`Off`의 "IsBlocked = false 먼저" 주석은 **여는 방향** 재진입만 다루는
별개 항목이다.
**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로 **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로
꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고, 꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고,

View file

@ -306,16 +306,33 @@ end
`Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다. `Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
```lua ```lua
-- ⭐ [2026-08-31 `H-184`, 사용자 확정] `bindLifetime`이 부기를 커밋하기 **전에**
-- 이 훅을 먼저 묻는다 — 가드가 거부해도 반쯤 묶인 핸들(묶였는데 `Destroying`
-- 연결 없음)이 남지 않는다. `H-147` (A)의 가드는 `_bindDestroying` 첫 줄에서
-- 여기로 이동했다(그쪽의 유일한 호출자인 `bindLifetime`이 이미 물었으므로).
-- level 3: 이 메소드와 `bindLifetime`을 지나 사용자 호출부. Observer도 같은
-- 이름의 훅을 가진다(`H-183` — 자기 `_running`을 본다). 값이 훅을 안 가지면
-- (평범한 클로저 등) `bindLifetime`은 물을 것이 없다.
function EffectHandle:_assertBindable()
if isRunning(self) then
error("cannot bind an Effect from inside its own fn or cleanup", 3)
end
end
function EffectHandle:_bindDestroying(inst) function EffectHandle:_bindDestroying(inst)
if isRunning(self) then -- ⭐ [2026-08-28 `/code-review`] (A)의 강제 — `fn` 안에서
error("cannot bind an Effect from inside its own fn or cleanup", 2)
end --
-- `New "Frame" { self }`로 자기를 leaf에 묶는 경로도 막는다
-- (`bindLifetime`은 범용이라 Effect 훅인 여기서 건다)
self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등 self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등
self._dying = false -- ⭐ [2026-08-31 `H-182`] Destroy 파동의 생존자를 재무장
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점. -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
self._destroyConn = onDestroying(inst, function() self._destroyConn = onDestroying(inst, function()
-- ⭐ [2026-08-31 `H-182`, 사용자 확정] 이 콜백 뒤에도 같은 Destroy 파동
-- 안에선 `canExecute`가 참(gcconn은 마지막에 끊김) — `_dying`이 그 창을
-- 닫아 파동 후반의 dep 변경이 죽는 leaf 위에서 `fn`을 다시 돌리는 대신
-- **홀드**된다(`rawRerun`이 `canExecute`와 같이 본다). 이름이 Slot의
-- `_destroyed`와 다른 건 의도다 — Slot은 죽으면 재바인딩 못 하지만 Effect
-- 핸들은 다시 bind될 수 있어 "죽는 도중"만 뜻한다(사용자: *"네이밍의 다른
-- 이유가 확실함"*). 재무장 자리는 위 bind와 `Subscribe`/`WeakSubscribe`.
self._dying = true
self:_unbindDestroying() self:_unbindDestroying()
self:_consumeCleanup() self:_consumeCleanup()
end) end)
@ -404,7 +421,7 @@ local function rawRerun(self, force: boolean)
self._pending = true -- 실행 중 재진입 → 지연 self._pending = true -- 실행 중 재진입 → 지연
return return
end end
if self._cleanupRunning or (not force and not canExecute(self)) then if self._cleanupRunning or self._dying or (not force and not canExecute(self)) then
self._rerunRequired = true -- ⭐ [2026-08-28 `H-159`/`H-160`] 실행 불가 상태(cleanup 중 / self._rerunRequired = true -- ⭐ [2026-08-28 `H-159`/`H-160`] 실행 불가 상태(cleanup 중 /
return -- 안 묶임 / 죽음)에 온 요청은 **버리지 않고 홀드** — 다음에 return -- 안 묶임 / 죽음)에 온 요청은 **버리지 않고 홀드** — 다음에
end -- 묶이는 순간 1회 돈다. 버리면 "변경을 아예 보고 안 함" 경로가 end -- 묶이는 순간 1회 돈다. 버리면 "변경을 아예 보고 안 함" 경로가
@ -413,12 +430,18 @@ local function rawRerun(self, force: boolean)
-- cleanup 안의 `self:Rerun()`/`dep:Set()`도 여기 — gcconn이 아직 -- cleanup 안의 `self:Rerun()`/`dep:Set()`도 여기 — gcconn이 아직
-- 연결돼 `canExecute`만으론 못 막는다(`H-160`). `Unsubscribe` -- 연결돼 `canExecute`만으론 못 막는다(`H-160`). `Unsubscribe`
-- 늦게 오는 타이머의 `Rerun()`도 홀드(재구독하면 돈다). -- 늦게 오는 타이머의 `Rerun()`도 홀드(재구독하면 돈다).
-- ⭐ [2026-08-31 `H-182`] `_dying`(자기 `Destroying` 콜백이 소진한
-- 뒤, 같은 파동의 잔여 구간)도 같은 홀드 — `H-160`의 연장이다.
self._running = true self._running = true
repeat repeat
self._pending = false self._pending = false
self:_consumeCleanup() -- 안에서 `_rerunRequired = true` self:_consumeCleanup() -- 안에서 `_rerunRequired = true`
self._rerunRequired = false -- ⭐ 실제로 돈다 — 이 플래그가 내려가는 **유일한** 자리 self._rerunRequired = false -- ⭐ 실제로 돈다 — 이 플래그가 내려가는 **유일한** 자리
self._cleanup = self.fn(self) self._cleanup = self.fn(self) -- ⭐ [2026-08-31 `H-185`, 사용자 확정] cleanup은 **첫 반환 하나뿐**
-- 여러 정리는 `function() a() b() end`로 묶는 게 계약. 반환 전부를
-- 목록으로 소진하는 안은 기각(재진입·`_rerunRequired`·`_pending`과
-- 엮이는 표면만 늘고, 클로저로 묶는 데 아무 문제가 없음). 타입의
-- 팩 표기는 "반환 안 해도 됨"(`H-95`)을 위한 것이지 목록 계약이 아니다.
until not self._pending -- 재요청이 또 오면 또 돈다(`_pending` = 실행 **중**에 온 요청) until not self._pending -- 재요청이 또 오면 또 돈다(`_pending` = 실행 **중**에 온 요청)
self._running = false self._running = false
end end
@ -504,7 +527,10 @@ end
못 한다; **[2026-08-28 `H-159`]** 옛 `_installed`의 부정형을 흡수), 못 한다; **[2026-08-28 `H-159`]** 옛 `_installed`의 부정형을 흡수),
`_running`/`_pending`(재진입 — `_pending`은 실행 **중**에 온 요청, `_rerunRequired` `_running`/`_pending`(재진입 — `_pending`은 실행 **중**에 온 요청, `_rerunRequired`
실행 **불가 상태**에 온 요청), **`_cleanupRunning`**(cleanup 실행 중 — `_running` 실행 **불가 상태**에 온 요청), **`_cleanupRunning`**(cleanup 실행 중 — `_running`
별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`.Subscribed`**(공개 플래그 — `canExecute` 별개, 네 진입점 가드가 둘 다 본다, **[2026-08-28]**), **`_dying`**(**[2026-08-31
`H-182`]** Destroy 파동에서 자기 `Destroying` 콜백이 소진한 뒤의 잔여 구간 —
`rawRerun`이 홀드 조건으로 보고, 재바인드·`Subscribe`류가 내린다),
**`.Subscribed`**(공개 플래그 — `canExecute`
읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절). 읽는 그것, 네 진입점이 세우고 내린다, 아래 "`EffectHandle:Subscribe()`" 절).
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로 **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나로
대체됐고, `_installing` 자리에 잠깐 있던 `_blocker`도 [2026-08-28 `H-150`] 대체됐고, `_installing` 자리에 잠깐 있던 `_blocker`도 [2026-08-28 `H-150`]
@ -678,6 +704,7 @@ function EffectHandle:WeakSubscribe()
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end end
self.Subscribed = true self.Subscribed = true
self._dying = false -- ⭐ [2026-08-31 `H-182`] 실행 가능성을 재무장하는 모든 자리가 내린다
WeakSubscribed[self] = true WeakSubscribed[self] = true
resubscribeTail(self) resubscribeTail(self)
return self return self
@ -689,6 +716,7 @@ function EffectHandle:Subscribe()
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end end
self.Subscribed = true self.Subscribed = true
self._dying = false -- `H-182`
WeakSubscribed[self] = true WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 한 번 Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 한 번
resubscribeTail(self) resubscribeTail(self)

View file

@ -361,6 +361,13 @@ function GateNode:_flush(commit: boolean?): boolean -- 이게 정책이 받는
end end
``` ```
- **⭐ [2026-08-31 `H-200`, 사용자 확정] 생성 배선 — `setup`이 도는 동안 노드를
상류 `_subs`에서 떼어둔다.** `state:Gate(setup)``newNode`로 노드를 만든 직후
`_subs`에서 떼고(`setup` 전 `_onUpstreamEmit``Void`), 유저 코드인 `setup`
**던지든 비함수를 돌려주든** 좀비 구독자가 남지 않게 한 뒤(`pcall` 없음 —
"예외 안전성 계약"과 충돌하지 않는 순서 재배치), 정책이 돌아온 뒤에야
재등록한다 — 게이트는 그때부터 emit을 듣는다. 떼어둔 창은 관측 불가다:
유보 집합이 그동안 항상 비어 있다.
- **반환값이 곧 위 2번의 `emit(commit) -> boolean`**이다 — "실제로 내보내거나 - **반환값이 곧 위 2번의 `emit(commit) -> boolean`**이다 — "실제로 내보내거나
버릴 게 있었는가"(`H-55`/`H-86`). 버릴 게 있었는가"(`H-55`/`H-86`).
- **`valueEpochMap`도 있다는 걸 여기서 명시한다** — 지금까지 *"규칙 1~3을 - **`valueEpochMap`도 있다는 걸 여기서 명시한다** — 지금까지 *"규칙 1~3을

View file

@ -347,6 +347,15 @@ function bindLifetime(inst, value)
else "이미 다른 Instance에 바인딩된 값") else "이미 다른 Instance에 바인딩된 값")
end end
-- ⭐ [2026-08-31 `H-184`, 사용자 확정] 값이 자기 훅 가드를 가지면(`Effect`/`Observer`의
-- `_assertBindable``H-147`/`H-183`의 "fn 안에서 bind 금지") 부기를 커밋하기
-- **전에** 먼저 묻는다 — 안 그러면 가드가 던질 때 이미 묶인 채(canExecute 참)
-- `Destroying` 연결 없는 반쯤 묶인 핸들이 남는다(실측). 평범한 클로저처럼 훅이
-- 없는 값은 물을 것이 없다. mock(installLifetime)과 M8 실 구현 둘 다 이 순서.
if type(value) == "table" and value._assertBindable ~= nil then
value:_assertBindable()
end
local gchold = InstData:GetWeak(inst, "gchold") local gchold = InstData:GetWeak(inst, "gchold")
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1) gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). -- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2).
@ -494,6 +503,12 @@ end
-- ⭐ [2026-08-29 `H-174`/`H-194`] 이 블록 전체는 `Observer.Init(module)`이 만드는 **인스턴스별 임플 -- ⭐ [2026-08-29 `H-174`/`H-194`] 이 블록 전체는 `Observer.Init(module)`이 만드는 **인스턴스별 임플
-- 팩토리 안**이라고 읽을 것 — 두 레지스트리는 그 클로저 로컬(인스턴스마다 한 벌)이고, -- 팩토리 안**이라고 읽을 것 — 두 레지스트리는 그 클로저 로컬(인스턴스마다 한 벌)이고,
-- `canBound`는 탑레벨 함수가 아니라 `module.canBound`(발화 시점에 읽는 인스턴스 필드)다. -- `canBound`는 탑레벨 함수가 아니라 `module.canBound`(발화 시점에 읽는 인스턴스 필드)다.
-- ⭐ [2026-08-31 `H-183`, 사용자 확정] Observer도 `fn`이 자기 생명주기를 못 바꾼다
-- (`H-147` 대칭) — `_running` 플래그를 모든 `fn` 실행(설치 발화·`_receive`·`_catchUp`)
-- 둘레에 세우고, 네 진입점 첫 줄이 이를 거부하며, `bindLifetime`은 커밋 전
-- `_assertBindable`(같은 판정, level 3)로 묻는다(`H-184`). `fn`이 error로 죽으면
-- 플래그가 선 채 남는 건 인정된 설계다(사용자: *"오류가 날 때 구조가 깨짐은 설계 상
-- 인정한 부분"*). 아래 블록들의 첫 줄 가드는 지면상 생략 — 실물은 `Observer.luau`.
local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적) local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적)
local WeakSubscribed = setmetatable({}, {__mode = "k"}) -- 약한 레지스트리 local WeakSubscribed = setmetatable({}, {__mode = "k"}) -- 약한 레지스트리
@ -637,11 +652,14 @@ emitFrom)`), `_state`(리시버 State — `_hold`로 강참조, `source-state-pl
`H-159`]** 묶이기 전에 온 emit을 자기 `_receive`가 홀드 — `bindLifetime`/`Subscribe`/ `H-159`]** 묶이기 전에 온 emit을 자기 `_receive`가 홀드 — `bindLifetime`/`Subscribe`/
`WeakSubscribe`가 1회 발화. Effect와 같은 뜻("`fn`이 돌아야 하는데 아직 안 돌았다"): `WeakSubscribe`가 1회 발화. Effect와 같은 뜻("`fn`이 돌아야 하는데 아직 안 돌았다"):
생성 시 참 → `state:Observer(fn)` 생성자의 "등록 시점 즉시 1회 실행"이 돌면서 거짓 → 생성 시 참 → `state:Observer(fn)` 생성자의 "등록 시점 즉시 1회 실행"이 돌면서 거짓 →
그 뒤 묶이기 전 사이에 온 변경이 다시 세운다), **`_receive(from)`**(`EmitReceive` — 그 뒤 묶이기 전 사이에 온 변경이 다시 세운다), **`_running`**(**[2026-08-31 `H-183`]**
모든 `fn` 실행(설치 발화·`_receive`·`_catchUp`) 둘레에 서는 재진입 플래그 — 네 진입점과
`_assertBindable`이 거부에 쓴다, 위 (2) 배너), **`_receive(from)`**(`EmitReceive` —
`source-state-plan.md``_emitDown` 아래), **`_catchUp()`**(홀드가 있었으면 출처 없이 1회 — `source-state-plan.md``_emitDown` 아래), **`_catchUp()`**(홀드가 있었으면 출처 없이 1회 —
`bindLifetime`·`Subscribe`·`WeakSubscribe`가 부름, 내부 메소드). 레지스트리 두 테이블은 Observer 인스턴스의 `bindLifetime`·`Subscribe`·`WeakSubscribe`가 부름, 내부 메소드). 레지스트리 두 테이블은 Observer 인스턴스의
필드가 아니라 **quad 인스턴스별 임플의 클로저 로컬**(`Observer.Init(module)`이 만든다, `H-174`; 필드가 아니라 **quad 인스턴스별 임플의 클로저 로컬**(`Observer.Init(module)`이 만든다, `H-174`;
**[2026-08-29 `H-194`]** 한때 "`Observer.luau`의 모듈 로컬"). Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다. **[2026-08-29 `H-194`]** 한때 "`Observer.luau`의 모듈 로컬"). Effect와 달리 epoch 맵·cleanup은 없다
(**[2026-08-31 정정]** 재진입 플래그는 `H-183`으로 생겼다 — 위 `_running`).
**여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()` **여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`
강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만 강한 테이블만 비우고 `WeakUnsubscribe`에 위임하지 않으면(또는 필드만

View file

@ -76,6 +76,12 @@ type-version-check/ # 워크스페이스 네 번째 멤버, quad에 종속
발견). 마일스톤별로 무엇이 추가돼야 하는지는 `ROADMAP.md``H-80` 체크박스가 발견). 마일스톤별로 무엇이 추가돼야 하는지는 `ROADMAP.md``H-80` 체크박스가
소스이고, 이 문서는 아래처럼 **왜 그 모양인지**만 적는다. 소스이고, 이 문서는 아래처럼 **왜 그 모양인지**만 적는다.
**[2026-08-31 `H-187`, 사용자 승인]** M2 단위 3·4가 재사용성 목적으로 붙인 타입
별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)은 그 이름 그대로
확정 — 시그니처는 각 `base/` 문서(`source-state-plan.md`/`effect-plan.md`/
`gate-plan.md`)가 소스이고 이름은 `quad-types/src/init.luau`가 소스다(사용자:
*"부분부분 타입을 뽑아 재사용성을 높이겠다는것 같은데, 이의 없음"*).
**⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25` — 실측] 이 레코드는 **닫혀 **⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25` — 실측] 이 레코드는 **닫혀
있고**, 마일스톤마다 서브시스템 필드를 여기 추가해야 한다.** 있고**, 마일스톤마다 서브시스템 필드를 여기 추가해야 한다.**

View file

@ -293,7 +293,13 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
즉시 돌아온 실패는 `error(err, 0)`으로 올린다(`architecture.md` "예외 안전성 계약 — 즉시 돌아온 실패는 `error(err, 0)`으로 올린다(`architecture.md` "예외 안전성 계약 —
감싸지 않는다"와 같은 결). 대기자가 다시 yield한 뒤 나는 에러는 우리 손 밖이다 감싸지 않는다"와 같은 결). 대기자가 다시 yield한 뒤 나는 에러는 우리 손 밖이다
(사용자: *"후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과 (사용자: *"후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과
false 은 확인 해줄 수 있는듯"*). false 은 확인 해줄 수 있는듯"*). **[2026-08-31 재확인 — 이 한계는 공개 문서화
대상이다**(`research/documentation-content-map.md` §4)**.** 사용자 원문: *"중간에
yield 되어버린 다음 다른곳에서 resume 되는건 우리가 처리해줄 수 없음. 그러나 그
부분은 우리의 처리 관할이 아님, 필요한 경우 루프 resume 으로 감싸거나, 안에서
spawn 을 하도록, 단일 resume 에서만 생긴 에러만 throw 해줄 뿐임"* — 다단
yield가 필요한 대기자는 사용자가 스스로 루프 resume으로 감싸거나 안에서
`task.spawn`류를 쓰는 게 계약이다.
- **왜 `k(value, self)`인가(`H-107`, 사용자 확정 2026-08-26)**`Effect` - **왜 `k(value, self)`인가(`H-107`, 사용자 확정 2026-08-26)**`Effect`
`Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch` `Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch`
필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다 필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다

View file

@ -264,6 +264,10 @@ function State:_emitDown(from)
end end
-- Observer 쪽 `EmitReceive` 구현(`Observer.luau`). 판정과 홀드가 **여기** 산다. -- Observer 쪽 `EmitReceive` 구현(`Observer.luau`). 판정과 홀드가 **여기** 산다.
-- ⭐ [2026-08-31 `H-183`] 아래 세 자리의 `fn` 호출은 실제 코드에선 전부
-- `_running` 플래그로 둘러싼다(설치 발화 포함) — `fn`이 자기 생명주기를 못
-- 바꾼다는 `H-147`의 Observer 대칭. 근거·가드 목록은 `lifecycle-pattern.md`
-- (2) 배너, 실제 코드는 `Observer.luau`.
function Observer:_receive(from) function Observer:_receive(from)
if canExecute(self) then if canExecute(self) then
self.fn(self._state, self, from) -- ⭐ (리시버 State, Observer 자신, 출처) self.fn(self._state, self, from) -- ⭐ (리시버 State, Observer 자신, 출처)

View file

@ -13,8 +13,10 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
**[2026-08-24 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(반응형 **[2026-08-24 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(반응형
코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수, 자율 구현 구간 진행 코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수, 자율 구현 구간 진행
중**(규약 `qa-request/pre-implementation-handtrace-round11-brief.md`, 발견 중**(규약 `qa-request/pre-implementation-handtrace-round11-brief.md`, 발견
`-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료, 단위 3·4 끝 절차 진행 중 — 진행의 `-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료·단위 3·4 끝 절차 완료,
소스는 `ROADMAP.md`)(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 **[2026-08-31]** 단위 2 `/code-review`·§4 배치 회신 1차 처리 완료 — 남은 문항
셋(재질문·보류)의 목록은 `.claude/todos.md` 00번이 소스)(마일스톤이
넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ [2026-08-24] M2와 같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ [2026-08-24] M2와
M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 문제가 (a) 순서 M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 문제가 (a) 순서
교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계" 교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계"

View file

@ -27,12 +27,12 @@
| `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 | | `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 |
| `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) | | `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) |
| `H-181` | ① | 2~4 | 🔴 | 인스턴스별 임플을 `module` 키의 weak-key 맵에 뒀는데 값(임플 클로저)이 키(`module`)를 캡처 — Luau엔 ephemeron이 없어 `Quad.New()`마다 영영 안 죽고 그 강한 레지스트리의 Observer/Effect 그래프까지 핀됨 | ✅ 임플을 `module._impl`(비공개 필드 — `H-174` (a)안 원문 모양)에, `spec.init` 2 | | `H-181` | ① | 2~4 | 🔴 | 인스턴스별 임플을 `module` 키의 weak-key 맵에 뒀는데 값(임플 클로저)이 키(`module`)를 캡처 — Luau엔 ephemeron이 없어 `Quad.New()`마다 영영 안 죽고 그 강한 레지스트리의 Observer/Effect 그래프까지 핀됨 | ✅ 임플을 `module._impl`(비공개 필드 — `H-174` (a)안 원문 모양)에, `spec.init` 2 |
| `H-182` | **②** | 3 | 🟡 | leaf `Destroying` 콜백이 cleanup을 소진한 뒤에도 같은 파동 안에서는 `canExecute`가 참(gcconn은 마지막에 끊김) → 파동 후반의 dep 변경이 죽는 leaf 위에서 `fn`을 다시 돌리고 아무도 소진 안 할 cleanup을 저장 | §4 대기 (`-- TODO(H-182)`) | | `H-182` | **②** | 3 | 🟡 | leaf `Destroying` 콜백이 cleanup을 소진한 뒤에도 같은 파동 안에서는 `canExecute`가 참(gcconn은 마지막에 끊김) → 파동 후반의 dep 변경이 죽는 leaf 위에서 `fn`을 다시 돌리고 아무도 소진 안 할 cleanup을 저장 | ✅ (a) 사용자 확정(2026-08-31) — `_dying` 플래그(`Destroying` 콜백이 세우고 재바인드·`Subscribe`류가 내림; Slot `_destroyed`와 다른 이름인 건 의도 — 재바인드 가능), `spec.effect` 10 |
| `H-183` | **②** | 3 | 🟡 | Observer 설치 발화 안에서 `self:Subscribe()`/`bindLifetime(inst, self)`를 부르면 `_catchUp``fn`을 중첩 재생하고 생성자가 "already subscribed"로 죽음 — Effect의 `isRunning` 가드(`H-147`)에 해당하는 것이 Observer엔 없음 | §4 대기 (`-- TODO(H-183)`) | | `H-183` | **②** | 3 | 🟡 | Observer 설치 발화 안에서 `self:Subscribe()`/`bindLifetime(inst, self)`를 부르면 `_catchUp``fn`을 중첩 재생하고 생성자가 "already subscribed"로 죽음 — Effect의 `isRunning` 가드(`H-147`)에 해당하는 것이 Observer엔 없음 | ✅ (a) 사용자 확정(2026-08-31) — Observer `_running` 가드(네 진입점 + `_assertBindable`; fn error 시 플래그 잔류는 설계상 인정), `spec.observer` 9 |
| `H-184` | **②** | 1·3 | 🟡 | `bindLifetime`이 부기를 커밋한 **뒤** `_bindDestroying``isRunning` 가드가 던지면 Effect가 묶인 채(`canExecute` 참) `Destroying` 연결 없이 남음 — 순서는 `lifecycle-pattern.md` (1) 그대로라 quad-roblox도 상속 | §4 대기 (`-- TODO(H-184)`) | | `H-184` | **②** | 1·3 | 🟡 | `bindLifetime`이 부기를 커밋한 **뒤** `_bindDestroying``isRunning` 가드가 던지면 Effect가 묶인 채(`canExecute` 참) `Destroying` 연결 없이 남음 — 순서는 `lifecycle-pattern.md` (1) 그대로라 quad-roblox도 상속 | ✅ (a) 사용자 확정(2026-08-31) — `bindLifetime`이 커밋 전 `value:_assertBindable()` 문의(Effect·Observer 공통 훅), `lifecycle-pattern.md` (1)·mock 동시 반영, `spec.effect` 10·`spec.observer` 9 |
| `H-185` | **②** | 3 | 🟢 | `EffectFn``-> ...(() -> ())` 팩(H-95)인데 런타임은 첫 반환만 cleanup으로 저장 — `return stopA, stopB`가 타입은 통과하고 `stopB`는 조용히 버려짐 | §4 대기 (`-- TODO(H-185)`) | | `H-185` | **②** | 3 | 🟢 | `EffectFn``-> ...(() -> ())` 팩(H-95)인데 런타임은 첫 반환만 cleanup으로 저장 — `return stopA, stopB`가 타입은 통과하고 `stopB`는 조용히 버려짐 | ✅ 사용자 확정(2026-08-31, (a) 아님) — cleanup은 **하나만**: 목록 소진은 표면만 넓혀 기각, 다중 정리는 클로저로 묶는 게 계약(문서·타입 주석 명시, 런타임 무변경) |
| `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | §4 대기 (`-- TODO(H-186)`) | | `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | 🔁 재질문 중(2026-08-31) — 사용자: 임플이 `module`을 알아도 dep의 소속 비교엔 노출 표면이 필요, 새 메커니즘이 불가피하지 않은가? → §4 회신 블록의 답변 참고, 결정 대기 (`-- TODO(H-186)`) |
| `H-187` | **②** | 3·4 | 🟢 | `quad-types`의 새 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)이 `base/`에 없는 이름 — 시그니처는 문서 그대로, 이름은 구현이 붙임 | §4 대기 (`-- TODO(H-187)`) | | `H-187` | **②** | 3·4 | 🟢 | `quad-types`의 새 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)이 `base/`에 없는 이름 — 시그니처는 문서 그대로, 이름은 구현이 붙임 | ✅ (a) 사용자 확정(2026-08-31) — 이름 그대로 승인("부분부분 타입을 뽑아 재사용성 … 이의 없음"), `quad-types-plan.md`에 기록 |
| `H-188` | ① | 4 | 🟡 | `state:Gate(setup)`가 검증 실패로 error할 때 반쯤 만든 노드가 상류 `_subs`에 남아 다음 `Set``nil` 호출로 죽음(GC 타이밍 의존) | ✅ 실패 시 detach + setup 전 `_onUpstreamEmit = Void`, `spec.gate` 1 | | `H-188` | ① | 4 | 🟡 | `state:Gate(setup)`가 검증 실패로 error할 때 반쯤 만든 노드가 상류 `_subs`에 남아 다음 `Set``nil` 호출로 죽음(GC 타이밍 의존) | ✅ 실패 시 detach + setup 전 `_onUpstreamEmit = Void`, `spec.gate` 1 |
| `H-189` | ① | 3 | 🟢 | `Observer.Subscribed`가 초기화되지 않아 `nil`(타입은 `boolean`, Effect는 `false`) | ✅ `false`로, `spec.observer` 1 | | `H-189` | ① | 3 | 🟢 | `Observer.Subscribed`가 초기화되지 않아 `nil`(타입은 `boolean`, Effect는 `false`) | ✅ `false`로, `spec.observer` 1 |
| `H-190` | ① | 2 | 🟢 | `Apply`의 비함수 분기가 검증 없이 `factory:__apply`를 불러 quad 내부 줄을 가리키는 raw 에러 | ✅ `level 2` 검증(형제 생성자들과 같은 급), `spec.state` 10 | | `H-190` | ① | 2 | 🟢 | `Apply`의 비함수 분기가 검증 없이 `factory:__apply`를 불러 quad 내부 줄을 가리키는 raw 에러 | ✅ `level 2` 검증(형제 생성자들과 같은 급), `spec.state` 10 |
@ -47,14 +47,14 @@
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)| | `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)| | `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)| | `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`**라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | §4 대기 (`-- TODO(H-198)`) | | `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`**라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | 🔁 재질문 중(2026-08-31) — 사용자 대안: 상류 Set을 플래그로 알리고 재계산을 처음부터 재시작(항상 최신 읽기) → §4 회신 블록의 분석 참고, 결정 대기 (`-- TODO(H-198)`) |
| `H-199` | ① | 2 | 🟡 | `With`/`Compute`의 vararg 수집(`deps[#deps + 1]`)이 nil dep을 조용히 버림 — 중간 nil은 뒤 dep들을 왼쪽으로 밀어 `fn`의 positional lazy 인자가 엉뚱한 자리에 감(실측) | ✅ `collectDeps` 공유 + `dep #N is nil` error(level 3), `spec.state` 13 | | `H-199` | ① | 2 | 🟡 | `With`/`Compute`의 vararg 수집(`deps[#deps + 1]`)이 nil dep을 조용히 버림 — 중간 nil은 뒤 dep들을 왼쪽으로 밀어 `fn`의 positional lazy 인자가 엉뚱한 자리에 감(실측) | ✅ `collectDeps` 공유 + `dep #N is nil` error(level 3), `spec.state` 13 |
| `H-200` | **②** | 4 | 🟡 | `Impl.Gate`의 detach(`H-188`)는 "비함수 반환" 경로만 — `setup`**던지면** 반쯤 만든 노드가 상류 `_subs`에 좀비로 남음(`_onUpstreamEmit = Void`라 크래시는 없지만 유보 집합을 영영 못 비움). `spec.gate` 1의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | §4 대기 (`-- TODO(H-200)`) | | `H-200` | **②** | 4 | 🟡 | `Impl.Gate`의 detach(`H-188`)는 "비함수 반환" 경로만 — `setup`**던지면** 반쯤 만든 노드가 상류 `_subs`에 좀비로 남음(`_onUpstreamEmit = Void`라 크래시는 없지만 유보 집합을 영영 못 비움). `spec.gate` 1의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | ✅ (b) 사용자 확정(2026-08-31) — `setup`이 도는 동안 `_subs`에서 떼어두고 성공 후 재등록(pcall 없음), `gate-plan.md` 조립 절·`spec.gate` 1 |
| `H-201` | ① | 2 | 🟡 | `store:Of(nil)`이 RESERVED·rawget을 통과해 Source를 할당한 **뒤** `self[name] =`에서 Luau 내부 에러로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`이 숫자를 반환 | ✅ `type(name) ~= "string"` → error level 2, `spec.store` 3 | | `H-201` | ① | 2 | 🟡 | `store:Of(nil)`이 RESERVED·rawget을 통과해 Source를 할당한 **뒤** `self[name] =`에서 Luau 내부 에러로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`이 숫자를 반환 | ✅ `type(name) ~= "string"` → error level 2, `spec.store` 3 |
| `H-202` | ① | 2 | 🟢 | `Compute``fn`이 함수인지 안 봄(형제 표면 Gate/Observer/Apply는 전부 검증) — 첫 `Get``_recompute` 안 내부 프레임에서 죽음 | ✅ level 2 검증(형제와 같은 급), `spec.state` 13 | | `H-202` | ① | 2 | 🟢 | `Compute``fn`이 함수인지 안 봄(형제 표면 Gate/Observer/Apply는 전부 검증) — 첫 `Get``_recompute` 안 내부 프레임에서 죽음 | ✅ level 2 검증(형제와 같은 급), `spec.state` 13 |
| `H-203` | **②** | 4 | 🟡 | `Off()`의 스냅샷 순회가 `IsBlocked`를 다시 안 읽음 — 첫 게이트 flush의 하류가 `b:On()`을 다시 켜도 남은 핸들이 전부 flush돼 `IsOn() == true`인데 통지가 새어나감(실측). 문서의 미지원 선언은 네스팅(`On`/`On`/`Off` 카운팅)만 다룸 | §4 대기 (`-- TODO(H-203)`) | | `H-203` | **②** | 4 | 🟡 | `Off()`의 스냅샷 순회가 `IsBlocked`를 다시 안 읽음 — 첫 게이트 flush의 하류가 `b:On()`을 다시 켜도 남은 핸들이 전부 flush돼 `IsOn() == true`인데 통지가 새어나감(실측). 문서의 미지원 선언은 네스팅(`On`/`On`/`Off` 카운팅)만 다룸 | ✅ (a) 사용자 확정(2026-08-31) — 순회가 핸들마다 `IsBlocked` 재확인, 멈추면 잔여는 다음 `Off`로(절반 emit 상태는 quad 정의상 무문제), `blocker-plan.md`·`spec.blocker` 8 |
| `H-204` | ① | 2 | 🟢 | `Store(defaults)``defaults`가 평범한 테이블인지 안 봄 — 비테이블은 `table.clone` 내부 프레임에서, 맨 Source는 내부 필드(`Revision`)를 지목하는 에러로 죽음 | ✅ 테이블·메타테이블 검증(level 2), `spec.store` 2 | | `H-204` | ① | 2 | 🟢 | `Store(defaults)``defaults`가 평범한 테이블인지 안 봄 — 비테이블은 `table.clone` 내부 프레임에서, 맨 Source는 내부 필드(`Revision`)를 지목하는 에러로 죽음 | ✅ 테이블·메타테이블 검증(level 2), `spec.store` 2 |
| `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | §4 대기 (`-- TODO(H-205)`) | | `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | ⏸ 보류(2026-08-31) — 사용자: "H205 부터는 이후에 결정할게" (`-- TODO(H-205)`) |
| `H-206` | ① | 2·3 | 🟢 | `implsOf` 헬퍼 + 4줄 ephemeron 근거 주석이 세 파일(`State`/`Observer`/`Effect`)에 verbatim 중복 — 저장 방식이 바뀌면 세 곳을 다 고쳐야 하고, 미묘하게 다른 네 번째 사본이 레지스트리를 조용히 가름 | ✅ `ImplRegistry.luau` 신설(순수 데이터 접근 — 공유 허용 범위) | | `H-206` | ① | 2·3 | 🟢 | `implsOf` 헬퍼 + 4줄 ephemeron 근거 주석이 세 파일(`State`/`Observer`/`Effect`)에 verbatim 중복 — 저장 방식이 바뀌면 세 곳을 다 고쳐야 하고, 미묘하게 다른 네 번째 사본이 레지스트리를 조용히 가름 | ✅ `ImplRegistry.luau` 신설(순수 데이터 접근 — 공유 허용 범위) |
| `H-207` | ① | 2 | 🟢 | `Source.Set``Emit`의 꼬리(리비전 범프 + `emitDown` + self 반환) 전체를 복제 — 한 파일 안에 같아야 하는 계약 두 벌 | ✅ `Set``Impl.Emit(self)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) | | `H-207` | ① | 2 | 🟢 | `Source.Set``Emit`의 꼬리(리비전 범프 + `emitDown` + self 반환) 전체를 복제 — 한 파일 안에 같아야 하는 계약 두 벌 | ✅ `Set``Impl.Emit(self)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) |
@ -511,8 +511,58 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
아니라 **사용자 안**(리비전 비교로 파동을 놓음)으로 — 권고안은 같은 파동에서 콜백이 두 아니라 **사용자 안**(리비전 비교로 파동을 놓음)으로 — 권고안은 같은 파동에서 콜백이 두
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열. 번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
**[2026-08-31 회신 2 — 단위 2~4 배치]** 확정 일곱(`H-182`~`H-185`/`H-187`/`H-200`/`H-203`),
재질문 둘(`H-186`/`H-198`), 보류 하나(`H-205` — *"H205 부터는 이후에 결정할게"*). 앞
라운드(`H-168`~`H-170`)도 재서술로 재확인됐다 — `H-168`: *"처음에 생각했던 부분은 유저가
직접 T 를 nilable 하게 보내거나 아니거나 골라야했어. … 생략은 오직 nilable 할 때만"* /
`H-169`: *"callback 도중에 set 된다면 처음부터 다시 수행. 후행의 요소는 set으로 인해
과거의 값을 받지는 않음"*(기존 확정과 동일 의미 — 안쪽 파동이 전부 돌고 바깥 순회는
리비전 비교로 놓음) / `H-170`: (a) 재확인 + **한계 문서화 지시**(원문은 `ref-plan.md`
`:Wait` 절, 등록은 `documentation-content-map.md` §4의 22번). 확정 근거 인용:
- **`H-182`** (a) + 네이밍: *"slot 에서 _destroyed 이라는 단어를 썼었고 그거와 다른 점은
이건 '죽는 도중만' 확인한다는것 — slot 은 죽으면 재바운딩 못 하지만, Effect 는 다시
bind 가능해지므로 네이밍의 다른 이유가 확실함"* → 이름은 `_dying`.
- **`H-183`** (a): *"isRunning 유사하게 두어도 되어보임. error 로 죽으면 유사하게 running
가드가 안 내려갈 수 있다는 문제는 있지만, 이건 quad 프로젝트가 오류가 날 때 구조가
깨짐은 설계 상 인정한 부분이라 괜찮음"*.
- **`H-184`** (a): *"지금 에러가 모두 같은 형식으로 통일되는데, assertBindable 을 두면
내부적으로 canBound 를 확인 가능한 부분"* — 구현은 `canBound` 게이트를 `bindLifetime`
남기고(단일 변경점) `_assertBindable`은 running 판정만.
- **`H-185`** 권고 (a) **기각**, 단일 cleanup 확정: *"그건 전체 표면을 넓힘. 클린 업 중에
재진입이 걸린다던가, 하는 부분이 _rerunRequired, _pending 등으로 엮이기에 확인할 표면이
잠재적으로 늘고, … function() CleanUpA(A) CleanUpB(B) end 형태를 가하는데 아무 문제가
없기 때문에 지원해야할 이유가 불명확함. cleanup 은 하나만 허용하고자 하고
문서화/타입주석에만 명시하는게 맞다고 봄"*.
- **`H-187`** (a): *"부분부분 타입을 뽑아 재사용성을 높이겠다는것 같은데, 이의 없음. 이름
그대로 사용해도 좋음"*.
- **`H-200`** (b): *"권고 동의"*.
- **`H-203`** (a): *"그렇다면 Off 시점 이후 모두 emit 된 상태가 아니라 절반만 emit 된
어정쩡한 상태가 남는다는것. 다만 그 이외의 재진입을 말지 말아야할 이유는 안 보임.
여전히 중간에서 멈춰도 quad 정의 상 문제는 없는 상태이므로 (a) 로 가도 될것이라 생각함.
권장될만한 패턴은 아니여보이지만, 막아야할 이유도 없음"*.
재질문 원문(다음 회신에서 닫을 것):
- **`H-186`**: *"impl 부분에서 module 을 안다고 해도 그 비교가 어찌 가능한지 모르겠는
부분. 하위 시스템인 Observer 의 module 과 상위 Effect 의 module 을 비교하려면 어떻게든
그걸 노출시키는 표면이 있어야하는데, 새로운 메커니즘의 추가가 불가피하지 않은지?"* —
메인 세션 답변: 맞다, (a)는 새 표면 없이는 불가. 최소 모양은 **메타테이블 신원 비교**
(dep의 `getmetatable`이 그 인스턴스의 State/Source/Gate 임플 테이블과 일치하는지 —
임플들은 이미 `module._impl`에 모여 있어 per-노드 등록 없이 판정 가능)이지만, §6의
이웃(교차 `bindLifetime`)은 그걸로도 못 막는다(주입 op 시그니처 변경 필요). 대안 권고는
**(b) UB 문서화**(드문 오용에 구조를 안 늘리는 원칙; M5에서 실 백엔드가 둘이 되면 재검토).
- **`H-198`**: *"상류 Set 이 Effect와 유사하게 flag 로써 재진입 해야함을 요구하는게
맞아보임. slot의 offset순회처럼, 그런데 처음부터 진행. 그럼 항상 최신 값만 읽게되어
문제가 없다고 보는데 어떻게 생각하는지?"* — 메인 세션 분석: 재시작 루프는 성립하고
(a)보다 강한 보장(그 `Get` 안에서 최신 수렴)을 주지만, **게이트 너머의 움직임을 알
채널이 여전히 필요하다**(유보된 emit은 `_receive`에 안 오므로 플래그를 세울 자가 없음 —
탐지는 결국 "fn 직전 리비전 스냅샷과 비교"가 됨). 즉 사용자 안 = (a)의 스냅샷 탐지 +
`Get`의 재시작 루프. 캐비엇 하나: `fn`이 자기 (전이적) dep을 매번 `Set`하면 지금은
"Get마다 1회 재계산"으로 수렴하던 것이 무한 재시작 루프가 된다 — 그 모양을 UB로 접을지
같이 결정 필요. 상세는 세션 보고, 결정 대기.
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야 코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개 마커**가 코드에 있다(`grep -rn "TODO(H-" quad-base quad-types`) — 마커 없이 답변된 §4 행은 넷(`H-168`~`H-170`, `H-174`)이고, 그중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서 한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개**였다가, **[2026-08-31 회신 2] 일곱이 확정 반영되어 남은 마커는 셋**(`H-186`/`H-198`/`H-205` — 앞 둘은 재질문, 뒤는 사용자 보류)이다(`grep -rn "TODO(H-" quad-base quad-types`). 마커 없이 답변된 §4 행 중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제). 블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
## §5 이상 없다고 확인한 것 ## §5 이상 없다고 확인한 것
@ -686,8 +736,9 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
1회 / `Effect(fn, a, a:Apply(b))`는 유보 중 직접 dep로 1회, `b:Off()`의 배치는 같은 리비전이라 1회 / `Effect(fn, a, a:Apply(b))`는 유보 중 직접 dep로 1회, `b:Off()`의 배치는 같은 리비전이라
**추가 실행 없음**(`_epochs` 키 dedup이 게이트 배치에도 성립). (g) `Ref` dep 재진입(`fn` 안 **추가 실행 없음**(`_epochs` 키 dedup이 게이트 배치에도 성립). (g) `Ref` dep 재진입(`fn` 안
`r:Set`) — `_pending` 지연 1회. (h) `H-192` 실측(위). (i) Observer `fn` 예외 — `Set` 밖으로 `r:Set`) — `_pending` 지연 1회. (h) `H-192` 실측(위). (i) Observer `fn` 예외 — `Set` 밖으로
전파, Observer는 죽지 않음(재진입 플래그 없음 — 다음 `Set`에 정상 발화; Effect와 다른 계약이고 전파, Observer는 죽지 않음(당시 재진입 플래그 없음 — 다음 `Set`에 정상 발화; Effect와 다른 계약이고
문서도 Observer엔 사망을 안 정했다). (j) `H-160``Unsubscribe` 경로 cleanup 안 `dep:Set` 문서도 Observer엔 사망을 안 정했다. **[2026-08-31 `H-183`]** 이후 `_running` 가드가 생겼다 —
발화는 여전히 안 막히지만 error 후 네 진입점·bind는 막힌다, `spec.observer` 9). (j) `H-160``Unsubscribe` 경로 cleanup 안 `dep:Set`
홀드, 재구독 꼬리 1회. (k) `setup` 안에서 `emit()` — 빈 배치 false, `_onUpstreamEmit = Void` 홀드, 재구독 꼬리 1회. (k) `setup` 안에서 `emit()` — 빈 배치 false, `_onUpstreamEmit = Void`
덕에 크래시 없음. (l) `GateNode``Compute` dep·`Effect` dep로 — `Get`/`_track` 상속으로 덕에 크래시 없음. (l) `GateNode``Compute` dep·`Effect` dep로 — `Get`/`_track` 상속으로
유보 중 값 가시, 풀리면 하류 1회. (m) unbind → 변경 둘(홀드) → 다른 inst → 정확히 1회. (n) 유보 중 값 가시, 풀리면 하류 1회. (m) unbind → 변경 둘(홀드) → 다른 inst → 정확히 1회. (n)

View file

@ -187,6 +187,12 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부
하나를 만들거나 claim하고 Slot을 반환하는 중간 모듈 패턴; **`PlayerGui`·`CoreGui` 하나를 만들거나 claim하고 Slot을 반환하는 중간 모듈 패턴; **`PlayerGui`·`CoreGui`
같은 공동 소유 컨테이너는 claim 대상이 아니다**(`base/claim-plan.md` §7-11) — 같은 공동 소유 컨테이너는 claim 대상이 아니다**(`base/claim-plan.md` §7-11) —
"왜 PlayerGui를 claim하면 안 되는가"를 설명할 것 "왜 PlayerGui를 claim하면 안 되는가"를 설명할 것
22. **[2026-08-31 신설, `H-170` 재확인] `ref:Wait`의 에러 전파 한계** —
`:Set`이 올려주는 건 **단일 resume에서 즉시 돌아온 실패뿐**이다. 대기자가
다시 yield한 뒤(다른 곳에서 resume되어) 나는 에러는 quad의 처리 관할이
아니다 — 다단 yield가 필요하면 사용자가 루프 resume으로 감싸거나 안에서
`task.spawn`류를 쓸 것(사용자 확정 원문은 `base/ref-plan.md` `:Wait` 절의
`H-170` 항목)
(13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번 (13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번
`quadnomicon`으로 이동) `quadnomicon`으로 이동)

View file

@ -2049,4 +2049,8 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
`Set`→`Emit` 위임), ② 넷 §4 합류(`H-198` 🔴 닫힌 게이트 너머 `fn` 도중 `Set` 영구 `Set`→`Emit` 위임), ② 넷 §4 합류(`H-198` 🔴 닫힌 게이트 너머 `fn` 도중 `Set` 영구
stale — §4 확정 의사코드 자체의 구멍 / `H-200` setup throw 좀비 / `H-203` `Off` stale — §4 확정 의사코드 자체의 구멍 / `H-200` setup throw 좀비 / `H-203` `Off`
재차단 / `H-205` Modifier 가드 level). 문서 이름 변경(pre-implementation 아님)은 재차단 / `H-205` Modifier 가드 level). 문서 이름 변경(pre-implementation 아님)은
사용자 지시로 §4 회신 뒤로 미룸. 남은 재개 지점은 §4 문항 열 개 회신뿐. 사용자 지시로 §4 회신 뒤로 미룸. **같은 날 §4 배치 회신 1차 처리** — 확정 일곱
반영(`H-182` `_dying` / `H-183` Observer `_running` / `H-184` `_assertBindable` 커밋 전
문의 / `H-185` 단일 cleanup 문서화(권고 기각) / `H-187` / `H-200` detach-중-setup /
`H-203` 순회 중 `IsBlocked` 재확인), 재질문 둘(`H-186`/`H-198` — 메인 답변은
그 §4 회신 2 블록), 보류 하나(`H-205`). 남은 코드 마커 셋.

View file

@ -48,3 +48,31 @@ notification 시점에 파인더 4개가 미완인 채 멈춤. 이번엔 opus
`./scripts/test.sh` 전부 통과(`spec.state` 13절·`spec.store` 2·3절에 새 가드 테스트 추가), `./scripts/test.sh` 전부 통과(`spec.state` 13절·`spec.store` 2·3절에 새 가드 테스트 추가),
`luau-analyze` 진단 0, 코드 마커 `TODO(H-)` 열 개 = §4 문항 열 개와 1:1. **재개 지점은 `luau-analyze` 진단 0, 코드 마커 `TODO(H-)` 열 개 = §4 문항 열 개와 1:1. **재개 지점은
§4 배치 회신 하나로 줄었다**(여섯 + 이번 넷). M2 종료 보고는 그 회신 처리 뒤. §4 배치 회신 하나로 줄었다**(여섯 + 이번 넷). M2 종료 보고는 그 회신 처리 뒤.
## 같은 날 후속 — §4 배치 회신 1차 처리 (2026-08-31)
사용자가 §4 열 문항(+ 앞 라운드 셋 재확인)을 자유서술로 회신. 갈래: **확정 일곱**
(`H-182` (a)+`_dying` 네이밍 / `H-183` (a) Observer `_running` / `H-184` (a)
`_assertBindable` 커밋 전 문의 / `H-185` 권고 기각 — 단일 cleanup 문서화 /
`H-187` (a) / `H-200` (b) detach-중-setup / `H-203` (a) 순회 중 `IsBlocked` 재확인),
**재질문 둘**(`H-186` — "새 메커니즘 불가피하지 않나?", `H-198` — 재시작 루프 대안),
**보류 하나**(`H-205`). 인용 원문과 반영 위치는 `round11.md` §4의 "[2026-08-31 회신 2]"
블록이 소스.
구현 요지: `Effect._dying`(Destroying 콜백이 세움, 재바인드·`Subscribe`류가 내림 —
죽은 바인딩 재사용 계약과의 충돌 때문에 재무장 자리가 셋), Observer `_running`
(모든 fn 실행 둘레 + 네 진입점 첫 줄, error 시 잔류는 설계상 인정), 공통 훅
`_assertBindable`(mock bindLifetime이 커밋 전 문의 — `H-147` 가드는 `_bindDestroying`
첫 줄에서 이 훅으로 이동, level 3), Gate 생성이 setup 동안 `_subs`에서 떼었다 성공 후
재등록, `Blocker.runHandles`가 핸들마다 `IsBlocked` 재확인. 문서는 `effect-plan`/
`lifecycle-pattern`/`source-state-plan`/`gate-plan`/`blocker-plan`/`ref-plan`(+
`documentation-content-map` 22번, `quad-types-plan` `H-187`)에 반영. 스펙 넷 추가
(`spec.effect` 10 / `spec.observer` 9 / `spec.gate` 1 확장 / `spec.blocker` 8).
코드 마커는 셋 남음(`H-186`/`H-198`/`H-205`).
`H-186` 답변(메인): 비교 자체는 메타테이블 신원(임플 테이블 = `module._impl` 경유)으로
새 per-노드 등록 없이 가능하나 어쨌든 형제 임플 노출이 필요하고, §6 이웃(교차
`bindLifetime`)은 그걸로도 못 막음 — (b) UB 문서화를 권고로 되돌림. `H-198` 답변(메인):
재시작 루프는 성립하되 게이트 너머 움직임의 탐지는 결국 fn 직전 스냅샷 비교로 귀결 —
사용자 안 = 스냅샷 탐지 + `Get` 재시작 루프, 캐비엇은 자기-dep `Set`의 무한 재시작
(UB로 접을지 함께 결정 필요).

View file

@ -13,9 +13,11 @@
단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료. **[2026-08-31]** 체크포인트에 단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료. **[2026-08-31]** 체크포인트에
미완으로 남았던 단위 2 파일 `/code-review high`도 완료(포크 재개 지시로 완주) — 미완으로 남았던 단위 2 파일 `/code-review high`도 완료(포크 재개 지시로 완주) —
발견 10건 중 ① 여섯 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`)이 §4 합류. 발견 10건 중 ① 여섯 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`)이 §4 합류.
**남은 재개 지점은 `round11.md` §4 문항 열 개(`H-182`~`H-187` + 위 넷) 사용자 **같은 날 §4 배치 회신 1차 처리 완료** — 확정 일곱 반영(`H-182`~`H-185`/`H-187`/
회신뿐** — 진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과 `H-200`/`H-203`), **남은 재개 지점은 셋: `H-186`·`H-198`(재질문 회신 대기, 메인
`session/2026-08-31-01-unit2-code-review.md`. §4 회신 처리 뒤 할 일 하나: 답변은 `round11.md` §4 "[2026-08-31 회신 2]" 블록)과 `H-205`(사용자 보류)** —
진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과
`session/2026-08-31-01-unit2-code-review.md`. 그 뒤 할 일 하나:
`pre-implementation-handtrace-round11*` 파일명 변경(사용자 2026-08-31 — 구현 중 `pre-implementation-handtrace-round11*` 파일명 변경(사용자 2026-08-31 — 구현 중
문서라 pre-implementation이 안 맞음; 새 이름은 그때 사용자와 정할 것). 문서라 pre-implementation이 안 맞음; 새 이름은 그때 사용자와 정할 것).
아래는 착수 전(2026-08-26) 서술: 아래는 착수 전(2026-08-26) 서술:

View file

@ -5,7 +5,9 @@ Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프
M2(반응형 코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수, 자율 구현 M2(반응형 코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수, 자율 구현
구간 진행 중**(규약은 `.claude/qa-request/pre-implementation-handtrace-round11-brief.md`, 구간 진행 중**(규약은 `.claude/qa-request/pre-implementation-handtrace-round11-brief.md`,
발견·배치 문항은 `-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료 — `ROADMAP.md` M2 발견·배치 문항은 `-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료 — `ROADMAP.md` M2
체크박스 전부 `[x]`, 단위 3·4의 끝 절차(감사·`/code-review`·탐사자) 진행 중). **⚠️ [2026-08-24] M2와 M3의 체크박스 전부 `[x]`, 단위 3·4 끝 절차 완료; **[2026-08-31]** 단위 2 `/code-review`
§4 배치 회신 1차 처리까지 완료 — 남은 문항 셋(재질문·보류)의 목록은
`.claude/todos.md` 00번이 소스). **⚠️ [2026-08-24] M2와 M3의
번호·순서가 맞바뀌었다** — 예전엔 M2=디스패치, M3=반응형이었는데 의존이 번호·순서가 맞바뀌었다** — 예전엔 M2=디스패치, M3=반응형이었는데 의존이
한 방향(디스패치 → 반응형)이라 반응형을 먼저 짓기로 확정했다. 그래서 한 방향(디스패치 → 반응형)이라 반응형을 먼저 짓기로 확정했다. 그래서
**2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의 **2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의

View file

@ -55,11 +55,14 @@ local function runHandles(self: any, doEmit: boolean)
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
snapshot[#snapshot + 1] = handle snapshot[#snapshot + 1] = handle
end end
-- TODO(H-203): the walk never re-reads `IsBlocked` — a subscriber fired by an
-- earlier handle's flush that calls `blocker:On()` cannot stop the remaining
-- handles: their batches propagate while `IsOn() == true`. (The line-comment in
-- `Off` covers only the open-direction re-entry; the doc disclaims nesting only.)
for _, handle in ipairs(snapshot) do for _, handle in ipairs(snapshot) do
if self.IsBlocked then
-- `H-203` (a): a downstream fired by an earlier handle's flush re-blocked
-- this blocker — stop; the remaining handles keep their withheld batches
-- for the next Off. A half-flushed stop is fine by quad's contract
-- (user, 2026-08-31: "중간에서 멈춰도 quad 정의 상 문제는 없는 상태").
return
end
handle(doEmit) handle(doEmit)
end end
end end

View file

@ -67,8 +67,8 @@ local function createImpl(module: any)
self._pending = true -- re-entered while running → deferred re-run self._pending = true -- re-entered while running → deferred re-run
return return
end end
if self._cleanupRunning or (not force and not module.canExecute(self)) then if self._cleanupRunning or self._dying or (not force and not module.canExecute(self)) then
self._rerunRequired = true -- `H-159`/`H-160`: unexecutable (cleanup running / unbound / dead) self._rerunRequired = true -- `H-159`/`H-160`/`H-182`: unexecutable (cleanup running / dying / unbound / dead)
return -- → HOLD, replayed once on the next bind/subscribe return -- → HOLD, replayed once on the next bind/subscribe
end end
self._running = true self._running = true
@ -76,8 +76,9 @@ local function createImpl(module: any)
self._pending = false self._pending = false
self:_consumeCleanup() -- sets `_rerunRequired = true` self:_consumeCleanup() -- sets `_rerunRequired = true`
self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs
-- TODO(H-185): `EffectFn` is typed `-> ...(() -> ())` (H-95 pack) but only the first -- `H-185` (user, 2026-08-31): the cleanup is ONE function — wrap several
-- return is kept; extra cleanups are silently dropped. -- teardowns in one closure (`function() a() b() end`); extra returns are
-- deliberately not collected (a list would widen the re-entry surface).
self._cleanup = self.fn(self) self._cleanup = self.fn(self)
until not self._pending until not self._pending
self._running = false self._running = false
@ -91,16 +92,28 @@ local function createImpl(module: any)
end end
-- ── hooks called by `bindLifetime` / `unbindLifetime` ───────────── -- ── hooks called by `bindLifetime` / `unbindLifetime` ─────────────
function Impl._bindDestroying(self: any, inst: any)
-- `H-184`: `bindLifetime` asks this BEFORE committing the binding, so a refusal
-- cannot leave a half-bound handle (bound, `canExecute` true, no Destroying
-- connection). Level 3: past this method and `bindLifetime` to the user's call.
function Impl._assertBindable(self: any)
if isRunning(self) then if isRunning(self) then
error("Effect: cannot bind an Effect from inside its own fn or cleanup", 2) -- `H-147` (A) error("Effect: cannot bind an Effect from inside its own fn or cleanup", 3) -- `H-147` (A)
end end
end
function Impl._bindDestroying(self: any, inst: any)
-- `H-147`'s guard moved to `_assertBindable` (`H-184`) — `bindLifetime` is the
-- only caller and has already asked it before committing the binding.
self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent
self._dying = false -- `H-182`: rebinding a survivor of a Destroy wave re-arms it
-- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`). -- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`).
-- TODO(H-182): after this callback, `canExecute` stays true for the rest of the Destroy
-- wave (gcconn is cut last), so a dep change later in the wave re-runs fn on the
-- dying leaf and stores a cleanup nothing consumes.
self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time
-- `H-182`: `canExecute` stays true for the rest of the Destroy wave (gcconn
-- is cut last) — the flag closes that window so a dep change later in the
-- wave HOLDS instead of re-running fn on the dying leaf. NOT `_destroyed`
-- (Slot's word): a Slot never rebinds, this handle may — the bind above re-arms.
self._dying = true
self:_unbindDestroying() self:_unbindDestroying()
self:_consumeCleanup() self:_consumeCleanup()
end) end)
@ -146,6 +159,7 @@ local function createImpl(module: any)
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2) error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end end
self.Subscribed = true self.Subscribed = true
self._dying = false -- `H-182`: every path that re-arms executability lowers it
WeakSubscribed[self] = true WeakSubscribed[self] = true
resubscribeTail(self) resubscribeTail(self)
return self return self
@ -159,6 +173,7 @@ local function createImpl(module: any)
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2) error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end end
self.Subscribed = true self.Subscribed = true
self._dying = false -- `H-182`
WeakSubscribed[self] = true WeakSubscribed[self] = true
Subscribed[self] = true -- the strong keep is up BEFORE the tail runs Subscribed[self] = true -- the strong keep is up BEFORE the tail runs
resubscribeTail(self) resubscribeTail(self)
@ -205,6 +220,7 @@ local function createImpl(module: any)
_running = false, _running = false,
_pending = false, _pending = false,
_cleanupRunning = false, _cleanupRunning = false,
_dying = false, -- `H-182`: inside a Destroy wave, after our Destroying ran
_destroyConn = nil, _destroyConn = nil,
Subscribed = false, Subscribed = false,
}, Impl) }, Impl)

View file

@ -58,7 +58,9 @@ local function createImpl(module: any)
-- ── EmitReceive ────────────────────────────────────────────────── -- ── EmitReceive ──────────────────────────────────────────────────
function Impl._receive(self: any, from: any) function Impl._receive(self: any, from: any)
if module.canExecute(self) then -- read at fire time (`H-174`) if module.canExecute(self) then -- read at fire time (`H-174`)
self.fn(self._state, self, from) -- (receiver State, Observer itself, source) self._running = true -- `H-183`: fn may not change its own lifecycle (`H-147` symmetric);
self.fn(self._state, self, from) -- an fn that throws leaves it set — dead by contract
self._running = false
else else
self._rerunRequired = true -- `H-159`: change before binding is held — replayed once when bound self._rerunRequired = true -- `H-159`: change before binding is held — replayed once when bound
end end
@ -69,12 +71,25 @@ local function createImpl(module: any)
function Impl._catchUp(self: any) function Impl._catchUp(self: any)
if self._rerunRequired then if self._rerunRequired then
self._rerunRequired = false self._rerunRequired = false
self._running = true -- `H-183`
self.fn(self._state, self, nil) self.fn(self._state, self, nil)
self._running = false
end
end
-- `H-184`: `bindLifetime` asks this BEFORE committing the binding — a refusal
-- must not leave a half-bound handle. Level 3: past this and `bindLifetime`.
function Impl._assertBindable(self: any)
if self._running then
error("Observer: cannot bind an Observer from inside its own fn", 3) -- `H-183`
end end
end end
-- ── four entry points (`lifecycle-pattern.md` (2)) ─────────────── -- ── four entry points (`lifecycle-pattern.md` (2)) ───────────────
function Impl.WeakSubscribe(self: any): any function Impl.WeakSubscribe(self: any): any
if self._running then
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183` (`H-147` symmetric)
end
if not module.canBound(self) then -- same gate as bindLifetime (shared isBoundAlive) if not module.canBound(self) then -- same gate as bindLifetime (shared isBoundAlive)
error( error(
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance", if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
@ -88,6 +103,9 @@ local function createImpl(module: any)
end end
function Impl.WeakUnsubscribe(self: any): any function Impl.WeakUnsubscribe(self: any): any
if self._running then
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
end
-- A strong keep left behind would make a half-released, never-GC'd handle: fail fast. -- A strong keep left behind would make a half-released, never-GC'd handle: fail fast.
if Subscribed[self] ~= nil then if Subscribed[self] ~= nil then
error("Observer: subscribed strongly; use :Unsubscribe()", 2) error("Observer: subscribed strongly; use :Unsubscribe()", 2)
@ -99,6 +117,9 @@ local function createImpl(module: any)
end end
function Impl.Subscribe(self: any): any function Impl.Subscribe(self: any): any
if self._running then
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
end
-- `H-149`: NOT delegated to WeakSubscribe — level 2 must point at the user's call, -- `H-149`: NOT delegated to WeakSubscribe — level 2 must point at the user's call,
-- and colon delegation would resolve to a subtype's override. -- and colon delegation would resolve to a subtype's override.
if not module.canBound(self) then if not module.canBound(self) then
@ -115,6 +136,9 @@ local function createImpl(module: any)
end end
function Impl.Unsubscribe(self: any): any function Impl.Unsubscribe(self: any): any
if self._running then
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
end
-- Symmetric with WeakUnsubscribe's guard: release through the path you subscribed by. -- Symmetric with WeakUnsubscribe's guard: release through the path you subscribed by.
if Subscribed[self] == nil then if Subscribed[self] == nil then
error("Observer: not subscribed strongly; use :WeakUnsubscribe()", 2) error("Observer: not subscribed strongly; use :WeakUnsubscribe()", 2)
@ -135,12 +159,13 @@ local function createImpl(module: any)
fn = fn or alwaysObserve, fn = fn or alwaysObserve,
_state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`) _state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`)
_rerunRequired = true, _rerunRequired = true,
_running = false, -- `H-183`: raised around every fn run; entry points refuse while up
Subscribed = false, -- the shared flag both handle types carry (`H-111`) Subscribed = false, -- the shared flag both handle types carry (`H-111`)
}, Impl) }, Impl)
ObserverBrand:register(o) ObserverBrand:register(o)
-- TODO(H-183): no re-entry guard — `fn` calling Subscribe/bindLifetime on `self` during o._running = true -- `H-183`: the install fire is an fn run like any other
-- this install fire replays through `_catchUp` (Effect has `isRunning`, H-147).
o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`) o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`)
o._running = false
o._rerunRequired = false -- (2) the install fire lowers the flag o._rerunRequired = false -- (2) the install fire lowers the flag
state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own
return o -- State would land in this Observer's `_receive` and raise the flag return o -- State would land in this Observer's `_receive` and raise the flag

View file

@ -259,17 +259,18 @@ local function createImpl(module: any)
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
node._withheld = newWithheld() node._withheld = newWithheld()
node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee
-- TODO(H-200): if `setup` THROWS here, the node stays in `self._subs` as a -- `H-200` (b): detach while user code (`setup`) runs — whether it THROWS or
-- zombie subscriber (the detach below only covers the non-function return) — -- returns garbage, no zombie subscriber is left behind (no pcall needed). The
-- spec.gate §1's "nothing left in the subscriber set" only holds for that path. -- window is unobservable: the withheld set stays empty while detached.
self._subs[node] = nil
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
return node:_flush(commit) return node:_flush(commit)
end) end)
if type(onUpstreamEmit) ~= "function" then if type(onUpstreamEmit) ~= "function" then
self._subs[node] = nil -- detach the half-built node before reporting (no orphan in `_subs`)
error("State: Gate setup must return the onUpstreamEmit function", 2) error("State: Gate setup must return the onUpstreamEmit function", 2)
end end
node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle) node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle)
self._subs[node] = true -- re-attach: the gate starts hearing emits only now
return node return node
end end

View file

@ -346,11 +346,14 @@ local function installLifetime(quad: any)
2 2
) )
end end
-- [H-184] 훅 가드를 커밋 **전에** 묻는다 — 가드가 거부해도 반쯤 묶인 핸들
-- (묶였는데 Destroying 연결 없음)이 남지 않는다. 값이 훅을 안 가지면(평범한
-- 클로저 등) 물을 것이 없다. quad-roblox의 bindLifetime도 같은 순서여야 한다.
if type(value) == "table" and (value :: any)._assertBindable ~= nil then
(value :: any):_assertBindable()
end
claim(inst) claim(inst)
-- TODO(H-184): the binding is committed below BEFORE `_bindDestroying` runs its `isRunning`
-- guard; if that guard throws, the Effect stays bound with no Destroying connection
-- (order transcribed from lifecycle-pattern.md (1) — quad-roblox inherits it).
local gchold = InstData:GetWeak(inst, "gchold") local gchold = InstData:GetWeak(inst, "gchold")
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1) gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak. -- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak.

View file

@ -151,5 +151,40 @@ do
print("PASS") print("PASS")
end end
print()
print("=== 8. H-203 — Off 순회 중 재차단: 남은 핸들은 유보 유지, IsOn true인 채 통지가 새지 않는다 ===")
do
local b = Blocker()
local s, t = Source(0), Source(0)
local gs: State<number> = s:Apply(b)
local gt: State<number> = t:Apply(b)
-- 어느 게이트가 먼저 flush되든(스냅샷 순서는 비결정), 먼저 깨어난 쪽이 재차단한다
local delivered = 0
local function reblock()
delivered += 1
b:On()
end
local ps, pt = probe(gs), probe(gt)
local origPs, origPt = ps._receive, pt._receive
ps._receive = function(self: any, from: any)
origPs(self, from)
reblock()
end
pt._receive = function(self: any, from: any)
origPt(self, from)
reblock()
end
b:On()
s:Set(1)
t:Set(1)
assert(delivered == 0, "both withheld")
b:Off()
assert(delivered == 1, "exactly one gate flushed — the re-block stopped the walk")
assert(b:IsOn() == true, "the mid-walk On() sticks")
b:Off()
assert(delivered == 2, "the survivor's withheld batch flushes on the next Off")
print("PASS")
end
print() print()
print("=== ALL PASS ===") print("=== ALL PASS ===")

View file

@ -1,7 +1,7 @@
--[[ --[[
Effect 계약 — `.claude/base/effect-plan.md` "확정 구조" / "의사코드 — 생성자" / `_bindDestroying` / Effect 계약 — `.claude/base/effect-plan.md` "확정 구조" / "의사코드 — 생성자" / `_bindDestroying` /
`_unbindDestroying` / `_consumeCleanup` / `rawRerun`+`Rerun` / "`EffectHandle:Subscribe()`" / `_unbindDestroying` / `_consumeCleanup` / `rawRerun`+`Rerun` / "`EffectHandle:Subscribe()`" /
"`Effect(fn, ...deps)`". `H-70`/`H-107`/`H-144`/`H-147`/`H-150`/`H-151`/`H-159`/`H-160`. "`Effect(fn, ...deps)`". `H-70`/`H-107`/`H-144`/`H-147`/`H-150`/`H-151`/`H-159`/`H-160`/`H-182`/`H-184`.
]] ]]
local Quad = require("../src") local Quad = require("../src")
@ -277,5 +277,65 @@ do
print("PASS") print("PASS")
end end
print()
print("=== 10. H-182 — Destroy 파동 안 cleanup 소진 뒤의 dep 변경은 홀드(_dying); H-184 — 거부된 bind는 아무것도 커밋 안 함 ===")
do
-- H-182 재현(리뷰 실측 모양): parent의 Destroying(A cleanup)이 먼저, child의
-- Destroying(B cleanup)이 그 파동 후반에 dep을 Set — gcconn은 파동 끝에 끊기므로
-- canExecute만으론 A가 죽는 중임을 못 본다.
local count = Source(0)
local parent = Instance.new("Frame")
local child = Instance.new("Frame")
child.Parent = parent
local runsA = 0
local cleansA = 0
local A: any = Effect(function()
runsA += 1
return function()
cleansA += 1
end
end, count)
quad.bindLifetime(parent, A)
local B = Effect(function()
return function()
count:Set(-1) -- fires A's internal observer mid-wave, after A's Destroying ran
end
end)
quad.bindLifetime(child, B)
assert(runsA == 1 and cleansA == 0, "setup: one install run")
parent:Destroy()
assert(cleansA == 1, "A's cleanup ran exactly once in the wave")
assert(runsA == 1, "the mid-wave dep change did NOT re-run fn on the dying leaf (_dying holds)")
assert(A._cleanup == nil, "no cleanup was stored that nothing would consume")
assert(A._rerunRequired == true, "the change was held, not dropped")
-- 재무장: 재바인드가 _dying을 내리고 홀드를 1회 재생
local fresh = Instance.new("Frame")
quad.bindLifetime(fresh, A)
assert(runsA == 2, "rebinding replays the held change once")
assert(A._dying == false, "rebind re-armed the handle")
fresh:Destroy()
-- Subscribe 경로도 재무장한다
assert(A._dying == true, "its own Destroying raised the flag again")
A:Subscribe()
assert(A._dying == false and runsA == 3, "Subscribe re-arms and replays the hold")
A:Unsubscribe()
-- H-184: fn 안의 bindLifetime은 커밋 전에 거부된다 — 반쯤 묶인 핸들이 없다
local inst2 = Instance.new("Frame")
local bindErr: any = nil
local C: any = Effect(function(self)
local ok, err = pcall(function()
quad.bindLifetime(inst2, self)
end)
if not ok then
bindErr = tostring(err)
end
end)
assert(bindErr ~= nil and string.find(bindErr, "inside its own fn or cleanup", 1, true) ~= nil, "bind inside fn refused: " .. tostring(bindErr))
assert(quad.canBound(C), "nothing was committed — not half-bound")
quad.bindLifetime(inst2, C)
assert(quad.canExecute(C), "binds cleanly afterwards")
print("PASS")
end
print() print()
print("=== ALL PASS ===") print("=== ALL PASS ===")

View file

@ -76,14 +76,22 @@ do
end) end)
end) end)
assert(not bad2, "setup must return the onUpstreamEmit function") assert(not bad2, "setup must return the onUpstreamEmit function")
s:Set(3) -- [H-188] the half-built node was detached — this must not crash on a nil callee -- [H-200 (b)] the node is DETACHED while setup runs — a THROWING setup must not
-- leave a zombie subscriber either (H-188 covered only the non-function return).
local bad3, err3 = pcall(function()
s:Gate(function()
error("setup boom")
end)
end)
assert(not bad3 and string.find(tostring(err3), "setup boom", 1, true) ~= nil, "a throwing setup propagates")
s:Set(3) -- [H-188/H-200] the half-built nodes were never attached — no nil callee, no zombie
local left = 0 local left = 0
for sub in pairs((s :: any)._subs) do for sub in pairs((s :: any)._subs) do
if sub ~= g then if sub ~= g then
left += 1 left += 1
end end
end end
assert(left == 0, "a failed Gate left nothing in the upstream's subscriber set") assert(left == 0, "a failed Gate (throw or bad return) left nothing in the upstream's subscriber set")
print("PASS") print("PASS")
end end

View file

@ -1,7 +1,8 @@
--[[ --[[
Observer 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`_receive`/`_catchUp`/ Observer 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`_receive`/`_catchUp`/
생성자 순서) / "`state:Observer(fn)`" 절(등록 즉시 1회, `fn(targetState, self, emitFrom?)`, `nil` 계약), 생성자 순서) / "`state:Observer(fn)`" 절(등록 즉시 1회, `fn(targetState, self, emitFrom?)`, `nil` 계약),
`.claude/base/lifecycle-pattern.md` "(2) 전역 경로"(네 진입점) / "(4) 실제 호출부"(`canExecute` 게이팅). `.claude/base/lifecycle-pattern.md` "(2) 전역 경로"(네 진입점, `H-183` `_running`) / "(1)"(`bindLifetime`의
커밋 전 `_assertBindable` 문의 — `H-184`) / "(4) 실제 호출부"(`canExecute` 게이팅).
]] ]]
local Quad = require("../src") local Quad = require("../src")
@ -207,5 +208,73 @@ do
print("PASS") print("PASS")
end end
print()
print("=== 9. H-183 — fn은 자기 생명주기를 못 바꾼다(_running 가드, H-147 대칭); H-184 — 거부돼도 반쯤 묶이지 않음 ===")
do
local s = Source(1)
local inst = Instance.new("Frame")
local subErr: any, bindErr: any = nil, nil
local o = s:Observer(function(_, self)
local ok, err = pcall(function()
(self :: any):Subscribe()
end)
if not ok then
subErr = tostring(err)
end
local ok2, err2 = pcall(function()
quad.bindLifetime(inst, self)
end)
if not ok2 then
bindErr = tostring(err2)
end
end)
assert(subErr ~= nil and string.find(subErr, "inside its own fn", 1, true) ~= nil, "Subscribe inside the install fire errors: " .. tostring(subErr))
assert(bindErr ~= nil and string.find(bindErr, "inside its own fn", 1, true) ~= nil, "bindLifetime inside the install fire errors: " .. tostring(bindErr))
-- H-184: the refused bind committed NOTHING — the handle binds cleanly afterwards
assert(quad.canBound(o), "not half-bound after the refusal")
quad.bindLifetime(inst, o)
assert(quad.canExecute(o), "binds cleanly after construction")
-- _receive-time fn runs are guarded the same way
local recvErr: any = nil
local s3 = Source(0)
local o3: any
o3 = s3:Observer(function(_, self, from)
if from ~= nil then
local ok, err = pcall(function()
(self :: any):WeakSubscribe()
end)
if not ok then
recvErr = tostring(err)
end
end
end)
quad.bindLifetime(Instance.new("Frame"), o3)
s3:Set(1)
assert(recvErr ~= nil and string.find(recvErr, "inside its own fn", 1, true) ~= nil, "entry point inside a _receive fn errors: " .. tostring(recvErr))
-- fn이 error로 죽으면 _running이 선 채 남는다 — 진입점은 막히지만 발화는 계속
-- (오류 시 구조 깨짐은 설계상 인정 — 사용자 확정 2026-08-31)
local s4 = Source(0)
local fires = 0
local o4: any = s4:Observer(function(_, _, from)
fires += 1
if from ~= nil then
error("observer fn boom")
end
end)
quad.bindLifetime(Instance.new("Frame"), o4)
assert(not pcall(function()
s4:Set(1)
end), "the fn error propagates out of Set")
assert(o4._running == true, "the flag stays up after an error (dead by contract)")
assert(not pcall(function()
o4:Subscribe()
end), "entry points refuse while the flag is up")
local okAfter = pcall(function()
s4:Set(2)
end)
assert(not okAfter and fires == 3, "firing itself is not gated by the flag (next Set still runs fn)")
print("PASS")
end
print() print()
print("=== ALL PASS ===") print("=== ALL PASS ===")

View file

@ -56,8 +56,8 @@ export type Ref<T> = {
-- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일"). -- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일").
export type StateData<T> = { Get: (self: StateData<T>) -> T } export type StateData<T> = { Get: (self: StateData<T>) -> T }
-- TODO(H-187): 아래 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)은 `base/`에 없는 -- 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)은 구현이 붙였고
-- 이름이다 — 시그니처는 문서 그대로이나 이름은 구현이 붙였다(§4 문항). -- `H-187` (a)로 사용자 승인(2026-08-31, "이름 그대로 사용해도 좋음").
-- `Observer` — 값을 안 실어주는 leaf 구독(`source-state-plan.md` "`state:Observer(fn)`"). -- `Observer` — 값을 안 실어주는 leaf 구독(`source-state-plan.md` "`state:Observer(fn)`").
-- `fn(targetState, self, emitFrom?)`: 3번째가 출처(`Epoch` | 집합), 설치·캐치업 발화는 `nil`. -- `fn(targetState, self, emitFrom?)`: 3번째가 출처(`Epoch` | 집합), 설치·캐치업 발화는 `nil`.
-- 네 진입점은 `lifecycle-pattern.md` (2) — Weak가 프리미티브, `.Subscribed`는 강·약 공용. -- 네 진입점은 `lifecycle-pattern.md` (2) — Weak가 프리미티브, `.Subscribed`는 강·약 공용.
@ -80,6 +80,8 @@ export type EffectHandle = {
Unsubscribe: (self: EffectHandle) -> EffectHandle, Unsubscribe: (self: EffectHandle) -> EffectHandle,
WeakUnsubscribe: (self: EffectHandle) -> EffectHandle, WeakUnsubscribe: (self: EffectHandle) -> EffectHandle,
} }
-- ⚠️ 팩 표기는 "반환 안 해도 됨"을 위한 것(`H-95`) — 런타임이 소진하는 cleanup은
-- **첫 반환 하나뿐**이다(`H-185` 확정: 여러 정리는 `function() a() b() end`로 묶을 것).
export type EffectFn = (self: EffectHandle) -> ...(() -> ()) export type EffectFn = (self: EffectHandle) -> ...(() -> ())
-- `state:Gate(setup)`의 정책 계약(`gate-plan.md` 2번): 바깥은 생성 시 1회, 반환 클로저는 상류 -- `state:Gate(setup)`의 정책 계약(`gate-plan.md` 2번): 바깥은 생성 시 1회, 반환 클로저는 상류