사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.
**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).
## 구조가 바뀐 것 넷
- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
`getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다
요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).
## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿
`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.
## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건
**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.
**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).
**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.
## 백로그 하나
`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.
**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
17 KiB
Blocker — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게
상태: base — research/additional-primitives-plan.md(다른 프레임워크
대비 갭 분석)에서 갈라져 나온 확정 프리미티브. lexical Batch(fn)으로
풀려던 대안은 기각되어 archive/batch-rejected.md로 분리됨 — 이 문서는
확정된 Blocker만 다룬다. base/effect-plan.md(같은 조사에서 나온
다른 확정 프리미티브)와는 서로 무관 — Blocker는 State/Store 작업과
밀접히 얽혀 있고 Effect는 완전히 독립된 요소라 원래도 별개 파일이었어야
했음(2026-08-07 문서 정리에서 한 파일로 합쳤던 걸 다시 분리).
왜 필요한가: state1, state2 -> state3처럼 여러 소스가 한 파생값에
합류할 때, 둘을 한 번에 바꾸면 소비자에게 두 번 전파(재계산+재대입)되는
문제. lexical Batch(fn)(Solid batch()/MobX runInAction()류)으로
풀려던 접근은 코루틴 yield 위에서 구조적으로 위험해 기각됨 — 상세 근거는
archive/batch-rejected.md 참고, 여기서 반복하지 않음. Blocker는 그
문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현해서 이
위험을 구조적으로 우회한다.
store 개발(M3)과 밀접하게 연관됨 — state:Block(blocker)가 State
위에 얹히는 메소드이므로 base/source-state-plan.md의 Source/State
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(base/source-state-plan.md "전파 모델 확정" 절)을 전제로 함.
⚠️ [2026-08-22 정정] 구현 마일스톤은 M3가 아니라 M2다 — 여기 "State와
같은 마일스톤(ROADMAP.md M3)에서 함께 구현할 것"이라고 적혀 있었으나,
Dispatch.drive의 배치 등록이 Blocker를 호출하므로 "게이팅 먼저" 결정에
따라 Blocker.luau 체크박스가 M2로 이동했다. 별도 파일로 두는 것은 그대로.
그리고 이제 Blocker는 바닥부터 짜는 게 아니라 공용 GateNode
(base/gate-plan.md) 위에 얹는 정책이다 — 노드를 다시 만들지 말 것.
[2026-08-14 위치 명문화] Blocker는 quad에서 emit(무효화 신호) 전파를
지연시킬 수 있는 유일한 요소임. 평범한 State는 신호를 받으면 자기
invalid 상태를 이유로는 절대 전파를 접지 않고(base/source-state-plan.md
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 Blocker가 유일하고, 시간 기반 게이트(base/ debounce-throttle-plan.md, 설계 확정·구현은 아직)가 추가되면 같은 자리에
들어옴. 이걸 못 박아
두는 이유: 과거에 "이미 invalid면 전파를 멈춘다"는 서술이 base에
있었고, 그건 사실상 Blocker가 하는 일을 모든 State에 암묵적으로 심는
것이라 Blocker의 존재 의의를 반쯤 지워버렸음(역전 경위는
archive/invalidate-dedup-propagation-reversed.md).
메커니즘 (확정)
Blocker() -> blocker -- 생성자
blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함
blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된
-- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent)
blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음
-- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이
-- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀.
-- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고),
-- 차이는 넘기는 emit 플래그 하나뿐.
blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는
-- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래
-- "이름 확정" 참고), 호출부 가독성만을 위한 추가.
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
-- 처음 블록될 때가 아니라) onunblock 핸들을
-- blocker의 weak 배열에 등록.
blocker:Policy(emit) -> onUpstreamEmit
-- [2026-08-24 신설] 이 blocker의 게이트 정책을
-- **값으로** 돌려준다. `state:Block(b)`가
-- 내부에서 쓰는 바로 그것:
-- state:Block(b)
-- == state:Gate(function(emit) return b:Policy(emit) end)
-- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)`라
-- 적었었는데 그건 언바운드 메소드라 `emit`이 `self`
-- 자리에 들어가 게이트가 영원히 안 열린다.
gated state의 동작:
- 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도.
blocker.IsBlocked이면: 전파 안 하고HasBlockedEmit = true만 세팅.blocker.IsBlocked가 아니면: 평소처럼 그냥 전파(투명하게 통과).- onunblock 핸들은 이제
emit: boolean인자를 받는다(blocker:Off()/:OffWithoutEmit()가 공유하는 내부 실행 경로, 2026-08-18 신설) —HasBlockedEmit을 확인해 true면emit이 참일 때만 그제서야 정확히 1회 전파(emit)하고,emit이 거짓이면 전파 없이 플래그만 리셋. 이미HasBlockedEmit이 false면emit값과 무관하게 아무 것도 안 함 (idempotent). 즉Off()는 "밀린 전파를 흘려보내며 끈다",OffWithoutEmit()은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기 상태(HasBlockedEmit)는 항상 깨끗하게 리셋됨.
⭐ [2026-08-21] HasBlockedEmit은 게이트 흡수 집합의 특수형이다.
GateNode가 드는 withheld(이번에 유보한 소스들)에 대해
HasBlockedEmit == (next(withheld) ~= nil)이고, 아래 "밀린 전파가 없으면
아무 것도 안 함(idempotent)"이 게이트 층위에서는 **"빈 배치면 통지 자체를
안 한다"**로 일반화된다(base/gate-plan.md의 8번). 구현 시 두 개를 따로
들지 말 것.
⭐ [2026-08-21 신설] state:Block(blocker)는 state:Gate(setup) 위에
얹힌다. 위 "gated state의 동작"은 Blocker만의 특수 노드가 아니라
base/gate-plan.md가 확정한 GateNode(ComputeNode와 같은 층위)의
정책 하나다 — Block이 내부에서 self:Gate(policy)를 부르고, 그 policy가
blocker.IsBlocked를 보고 emit()을 부를지 HasBlockedEmit만 세울지
정한다. Debounce/Throttle도 같은 자리에 다른 정책으로 들어간다.
⭐ [2026-08-24 신설, 6라운드 손 트레이싱 H-33/H-49] 그 정책을 값으로
꺼내는 표면 blocker:Policy(emit)을 추가한다 — 표면이 하나 늘고,
Blocker() 생성자와 state:Block은 그대로다.
- 왜 필요한가:
Debounce/Throttle이 "언제 통과시킬지"를 정하면서 실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수 있으면 그게 그냥 함수 합성이 된다 —setup이 곧(emit) -> onUpstreamEmit이라 타입이 이미 맞는다. - 정책이 하는 일은 안 바뀐다 —
Policy(emit)을 부르는 시점에 onunblock 핸들이 등록되므로(지금state:Block이 하던 것과 같은 자리),Off()가 풀 때 그emit이 정확히 1회 불린다. Debounce/Throttle은 Blocker를 사적으로 하나 갖는다 — 적용 핸들당 하나(커링 결과가 여러 곳에 적용될 수 있으므로Apply시점 생성).pending같은 상태는 별도로 안 들고HasBlockedEmit으로 흡수한다. 상세와 의사코드는base/gate-plan.md의 5번 항목이 소스.
:Get()엔 영향 없음 — 블록은 emit 전파만 지연시킨다. 블록 중이라도
누군가 명시적으로 :Get()하면 그 순간의 실제 값을 정상적으로 계산해서
준다 — base/source-state-plan.md의 "Source 값을 직접 mutate한 뒤 전파 — :Emit()" 절("Get()은 라이브 레퍼런스를 준다" 캐비엇)과 일치.
[2026-08-21] 이 계약은 base/state-epoch-plan.md가 의존하는 전제다 — 그
문서 §5의 3번이 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히
이걸 뒤집지 않기 위해서다. 바꾸려면 그쪽도 같이 봐야 한다.
사용 예시
state1/state2 각각이 아니라 결합된 결과(state3) 하나에만 :Block을
건다:
local blocker = Blocker()
local gated3 = state3:Block(blocker) -- 소비자는 gated3를 구독
blocker:On()
state1:Set(1) -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true
state2:Set(2) -- state3 무효화 → gated3로 전파 시도 → 이미 true, 그대로
blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 번 emit
일반 사용 가이드(확정, 문서화 필수): Block은 파이프라인의 최종 연산 지점(실제로 무거운 계산이 일어나는 derived state, eager 소비자에 가장 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.
state:Block() 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설)
지금까지 위 예시는 전부 state:Block(blocker)로 만든 gated state를
경유하는 사용자 대상 패턴이었다. base/dispatch-core-plan.md의
"Length/Offset" 절이 recompute의 크래시(RC-1, 배열 위치가 하나씩
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
고치며 Blocker를 gated state 없이 직접 쓰는 두 번째 용례를 만들었다
— [정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 이후
bk.N(순회 상한)의 정의를 고치며 사라졌지만(base/dispatch-core-plan.md
"저장 위치" 절), 이 용례는 그대로 유효하다 — 이유가 크래시 방지에서
배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서
blocker:IsOn()을 직접 확인하고 스스로 전파를 건너뛰는
방식(Length State의 Observer가 if not blocker:IsOn() then recompute(...) end
형태로 자기 자신을 게이팅). 이 용례는 state:Block()을 전혀 호출하지
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —
blocker:Off()/:OffWithoutEmit()을 불러도 실행할 핸들이 없어 두
메소드가 이 용례에서는 사실상 동일하게 동작하지만, 의도를 코드에 남기기
위해 OffWithoutEmit()을 쓴다("이 배치가 끝나면 무엇이든 자동으로
흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도
표현). 상세 메커니즘·Dispatch.setLength/setOffsetSource가 이 Blocker를
어떻게 만들고 어디에 저장하는지는 base/dispatch-core-plan.md의 "배치
등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음.
재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용 — 중첩된 owner
(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를
새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절).
이름 확정
- 클래스:
Blocker—Observer/Modifier/Ref와 같은 명사-행위자 네이밍 관례와 일치. - Blocker 자신의 토글:
On()/Off()-> self (Block()/Unblock()아님) —state:Block(blocker)가 이미 "배선(wiring)" 동작의 동사로 "Block"을 쓰고 있어서, Blocker 자신의 토글까지 같은 단어를 쓰면blocker:Block()(블로커를 켠다)과state:Block(blocker)(state를 이 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨. - 필드:
IsBlocked(Blocker 자신의 On/Off 상태),HasBlockedEmit(gated state의 대기 플래그,Is/Has접두어로 불리언임을 바로 알려줌). - 메소드:
state:Block(blocker) -> state. - [2026-08-18 신설]
IsOn() -> boolean(IsBlocked필드를 그대로 읽는 얇은 조회 메소드),OffWithoutEmit() -> self(위 "onunblock 핸들" 참고) — 사용자 확정: "IsBlocked가 있다면 그냥 두어도 될듯 함. HasBlockedEmit 만 처리된다면 괜찮다 생각" — 즉IsBlocked/HasBlockedEmit필드는 그대로 유지하고, 별도HasBlocked(Blocker 자신의 새 최상위 플래그)는 신설하지 않는다 —OffWithoutEmit()이 각 gated state의 기존HasBlockedEmit을 그대로 리셋해주는 것으로 충분하다고 판단됐기 때문(처음 제안됐던 "HasBlocked"는 이 논의 과정에서 자연스럽게 불필요해짐 —qa-request/pre-implementation-qa-round2.md"RC-1" 절에 논의 경위 기록). [보강, 2026-08-20 구현 전 QA 4라운드BK-9] "영원히 안 만든다"로 못박은 건 아니다 — 지금 사용 케이스가 없을 뿐인 백로그다. 사용자 판정: "있는게 어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임 … 나중에 사용 필요 요구가 나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가 신설될 가능성이 존재하지 않는다고 못 박기는 이름." 즉 (a) 구현 난이도가 낮고, (b) 신설된다면 이름은HasBlocked가 아니라HasBlockedState쪽이 될 가능성이 높으며(그 Blocker에 걸린 gated state 중 대기 중인 게 있는가 = "블록된 state가 있는가"라 이름이 더 정직함), (c) 실제 요구가 나오기 전엔 안 만든다 —conventions.md의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙 그대로.
재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
IsBlocked는 카운터가 아니라 단순 불리언이고, 의도적으로 그렇게 둔다.
레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 "On() 여러
번, Off() 실수로 적게" 같은 버그가 영구 블록으로 조용히 새는 더
위험한 실패 모드를 만든다("poisoned mutex" 트래킹류 해키함도 만들지
않기로 함).
대신 확정된 규칙: 겹치는 배치가 필요하면 각자 새 Blocker 인스턴스를
만들 것 — 하나의 Blocker를 여러 컨텍스트에서 재사용/중첩하지 않는다.
Off()는 스태킹 없이 즉시 그 자리에서 꺼진다. 이 제약은 반드시 사용자
문서(API 레퍼런스 수준)에 명시적으로 강조할 것 — 네스팅을 시도하면
조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.
base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18) —
위 "state:Block() 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 attachSlot을
재귀할 때마다([2026-08-21] 분해 후 정확히는 그 안의
materializeSlotTree — 물리 마운트 쪽은 Blocker가 필요 없다)
그 자식 Slot 자신의 owner 키로 새 Blocker를 만든다 —
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: "중첩마다 별도
Blocker (권장)"). 부모/자식이 같은 Blocker를 공유했다면, 자식의
OffWithoutEmit()이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이
경고하는 실패 모드의 구체 사례.
상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐
[2026-08-18 갱신] IsOn()/OffWithoutEmit()(위 "메커니즘" 절)과
state:Block() 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API
확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과
메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신.
quadnomicon에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는
게 좋은 소재(archive/batch-rejected.md와 나란히 인용).