quad/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md
qwreey 168d3d8dcc
docs: 핸드오버 — Epoch/Brand 제안 승격을 다음 세션 첫 작업으로 지정
사용자 지시로 승격은 다음 세션에("이 세션은 너무 길어요"), 감사는 승격
이후에 돌린다. 이번 세션에서 알게 된 것 중 base/에 안 남아 있던 것을
전부 채웠다.

- base/ 네 문서 상단에 research/epoch-brand-composition.md를 가리키는 ⚠️
  배너 신설: state-epoch-plan(두 맵 -> EpochMap 둘), source-state-plan
  (Source가 Epoch를 구조적으로 만족 + Observer 클로저 fn(self, from)),
  brand-plan(인스턴스 브랜드 전면 재작성), gate-plan(emit 페이로드 타입).
  넷 다 "기제는 그대로 유효, 표현만 바뀐다 / 승격 전엔 이 문서가 정본"을
  명시.
-  base/effect-plan.md에 다중 의존성 Effect 갭을 미해결로 기록. 이번
  세션에 발견했는데 research 문서에만 있었다 — 제안이 기각되더라도 갭은
  남으므로 base에 있어야 한다. 해법 후보(Effect가 자기 EpochMap을 듦)와
  접은 대안(deps를 한 노드로 수렴), 그리고 "useEffect처럼 N번 허용"
  선택지까지 같이 적었다.
- base/blocker-plan.md에 HasBlockedEmit == (next(withheld) ~= nil)이라는
  것과 "빈 배치면 무통지"가 그 일반화라는 것 기록(구현 시 둘을 따로 들지
  말 것).
- todos.md에 000번 신설 — 다음 세션 첫 작업, 고칠 문서 넷과 남은 미정
  하나(bit32 랩 vs +1)까지 명시.
- question.md 1번에 리비전 증가 방식 항목 추가.
- session-summary.md의 이 세션 항목이 "Gate만 다음 세션 — M2를 막는 유일한
  항목"에서 멈춰 있던 것을 전량 갱신(같은 날 Gate/에포크가 확정됐고,
  code-review 두 번에 19건이 나왔으며, Epoch/Brand 제안이 대기 중).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:32:48 +09:00

29 KiB

2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), Gate/에포크 리서치 신설

요약: 사용자 요청으로 QA 5라운드 문항지(205문항) 를 만들고, 같은 세션에 회신을 받아 1차 처리까지 끝냈다. 즉시 반영 14건, 되물은 것 7건, 그리고 새 research 문서 둘(gate-primitive.md, state-epoch-validation.md)이 나왔다. 처리 결과의 소스는 qa-request/pre-implementation-qa-round5-followup.md.

1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음

4라운드 종결 시점엔 사용자 지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 5라운드를 안 만들기로 했고 그 문장이 세 곳 (todos.md 00번, README.md qa-request 행, 4라운드 followup H-7)에 적혀 있었다. 같은 날 사용자가 5라운드를 요청 — "4차에서 예로 넘어갔던건 스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을 쌓아보자." → 세 곳 전부 정정하고 문항지를 신설했다.

범위를 셋으로 좁힌 게 이번 라운드의 설계: (1) 4라운드에 문항이 아예 없던 영역(project-setup-plan.md/quad-types-plan.md, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 4라운드 회신 이후 확정된 것 (Detach/_detached/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화(예: debounce-throttle-plan.md는 1100줄인데 4라운드 문항이 9개뿐이었음).

2. 문항지 작성 중 이미 잡힌 것

문항을 쓰는 과정 자체가 감사가 됐다:

  • EF-3 — 4라운드 followup이 "반영 완료"로 적은 E-10(dedup 대칭)이 effect-plan.md실제로는 안 들어가 있었다. → 일반 교훈으로 CR-1("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는 base/ 본문")을 문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다.
  • DE-9KeyGone 소멸 루프가 새 값을 반환받으면 settle의 교체 분기로 들어가 rawAdd(self, result, 0)(범위 밖 인덱스)로 터진다.
  • IM-1architecture.md는 "지금은 New() 미노출 싱글톤"이라는데 실제 코드는 module.New = New + return New().
  • CR-2/CR-9Tween:Map vs Mapped 이름과 isBoundAlive의 세 번째 분기가 결정이 필요한데 어느 추적 목록에도 없었다.
  • DC-9 — Blocker 게이팅이 recompute를 O(N²)→O(N)으로 줄였는데 setOffsetSource의 즉시 계산이 그 자체로 O(N²)라 상쇄된다.

3. 회신으로 확정된 것 (즉시 반영)

  • slot._detached는 lazy — 모든 Slot이 빈 테이블을 미리 갖지 않는다 (사용자: "테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가?"). getDetached(slot) getOrCreate.
  • prev 없이 Detach를 반환해도 nop — 사용자가 prev 유무를 추적할 의무 없음.
  • KeyGonenil/None/Detach — prev도 새 값도 error("KeyGone 을 받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다").
  • Owned = false에서 Detach_detached에 안 들어간다 — 남의 것에 소유권을 유지하는 건 모순이라 rawUnmount로 처리하고 다음 prevnil. 부수로 unowned에선 Detachnil이 같은 동작이 되고 _detachCleanup_owned 분기가 도달 불가가 되어 삭제됐다.
  • 조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다Owned=false가 약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설.
  • groupClaimKeys 키 = (inst, groupValue) → k, nameClaims보다 위치 claim이 먼저. Frame { a, a } 갭이 이걸로 닫힘.
  • Tween<T>:Mapped 확정(-ed 관례).
  • "게이팅 먼저" — M2가 M3의 Blocker에 의존하던 순서 문제를 로드맵 순서 유지가 아니라 앞당기는 쪽으로 결정. 단 앞당기는 대상이 Blocker가 아니라 그 아래 공용 Gate 노드로 바뀌었다(아래 4절).

4. 새로 열린 것 — research 문서 둘

  • research/gate-primitive.mdDT-4에서 사용자가 정리: 시간 기반 게이트를 공개 Blocker API 위에 못 얹는 이유가 순서 보존이다 ("이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제"). 그래서 emit을 가로채는 공용 노드가 필요하고, 공개 API로 낸다. 사용자 스케치는 Gate(function(emit) return function() ... end end). 남은 건 이름(Gater가 어색하다는 지적 — 에이전트 권고는 Gate 그대로), :Apply 팩토리 여부, Blocker의 얹힘 방식, M2 범위.
  • research/state-epoch-validation.mdSS-2/SS-3에서 사용자가 제기한 glitch: DFS 전파 중 Observer가 Get()을 부르면 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트 Source들의 에포크(count) 를 들고 Get() 때 비교하는 것. 에이전트 분석: 진단·방향 타당(MobX/Adapton류 선례), 정확성 결정이지 최적화가 아님, 다만 중복 통지는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화 해야 함. M3(State 구현) 전에 결론 필요.

5. 2차 회신 — C절 전량 확정 (같은 세션)

되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup F절):

  • Slot:Replace 신설(B-5) — 사용자가 "교체는 제거+삽입이 아니라 replace가 나아 보인다"고 제시. :List의 교체가 spliceArraysDown+spliceArraysUp 쌍(시프트 2회·recompute 2회)에서 rawReplace 한 번(시프트 0) 으로 바뀌었고, 그 부수로 C-7(:Single 교체 시 Length가 잠깐 줄었다 느는 것)이 같이 사라졌다.
  • 에포크 안에서 중복 통지도 접기로(B-7) — emit이 (source, count)를 실어오므로 판정이 O(1). 2026-08-14에 폐기된 옛 dedup과 다른 장치임을 문서에 못 박았다(그건 invalid 플래그 기반이라 Observer 영구 침묵 모드가 있었고, 에포크는 매 Set마다 새 값이라 그 모드가 없다). 구현 요구로 seen/computedAt 두 카운트 분리가 추가됐다. 에이전트가 붙였던 "선언 안 된 의존성을 UB로 명문화" 조건은 사용자 기각("이전과 다른게 없다").
  • 물리 조작은 주입 op(C-1) — "slot 의 해당 동작은 base 이므로 parent 를 모른다". 의사코드 전체의 element.Parent = .../element:Destroy()mountInst/unmountInst/disposeInst로 정정(9곳)하고 경계 절을 신설. rawAdd 의사코드도 이때 처음으로 문서에 들어갔다.
  • rawAddLength:Set 제거(C-2) — Lengthrecompute만 쓴다.
  • 래핑/언래핑을 Slot 전체 연산으로(C-3) — wrapElement/unwrapElement 한 쌍 + 래퍼의 _wrapped 역참조. 사용자가 예상한 대로 wrappers[key] / mounted[key] 분리가 필요 없어졌다.
  • setLengthanchor 인자 신설(C-4) — 부기 키와 생명주기 앵커 분리. 4라운드 D-56이 역전돼 archive/bindlifetime-slot-owner-reversed.md로 갔고, 형태 미정으로 열려 있던 isBoundAlive 세 번째 분기 항목이 같이 닫혔다.
  • Effect(fn, ...deps) 확정(C-6) — Ref도 의존성이 될 수 있고, 최소 1회 실행(useEffect 동일), trailing lazy 위치 인자, RefSet될 때만 발화.
  • Gate만 다음 세션으로"고칠것이 많으므로 … 지금 세션 상 지식만 이전될 수 있게 두세요." 그래서 research/gate-primitive.md는 재료만 모아둔 상태로 두고 question.md/todos.mdM2를 막는 유일한 항목으로 남겼다.

6. (1차 시점 기록) 되물었던 것

파급 큰 순서로: rawAddLength:Set 제거(지금 서술이 "기여도 합" 정의와 충돌하고 recompute와 이중 기록), updateFn이 State를 반환할 때의 래핑/prev identity(래핑이 공개 Slot:Add에만 있어 reconcile 경로엔 없다는 실제 갭), setLength의 Observer 앵커를 물리 target으로 되돌리기 (사용자 문제 제기 — "우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지", 되돌리면 4라운드 D-56의 백엔드 요구사항과 isBoundAlive 세 번째 분기가 통째로 불필요해짐), rawAdd 의사코드 초안 승인(문서에 정의가 아예 없었음), Gate 이름·표면, Effect의 다중 의존성(Ref 포함) 안.

6-1. 3·4차 — mountInst의 삽입 위치에서 시작해 offset 모델이 정리됨

2차까지 끝난 뒤 사용자가 mountInst/unmountInst가 index를 안 받는 걸 문제 삼았다("웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"). 이 질문이 결국 offset 부기 모델 전체를 정리하게 만들었다:

  • None이 두 뜻을 겸하고 있었다 — 정의는 "실제 마운트를 하지 않는 위치"인데 plain 요소가 None + setLength(1)로 등록되고 있었다. 그래서 그 자리의 offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다. → None의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는 lengthList가 답), 숫자가 필요한 쪽은 Dispatch.getOffsetAt(ownerKey, i)로 pull한다.
  • 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다. recomputesum = 0으로 시작하고 ownerKey.Offset을 읽는 자리가 없어서, depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다(depth 1만 쓰던 동안 로컬==절대라 안 드러남). → base를 시드하고, 중첩 Slot이 자기 Offset을 관측해 자식 offset을 다시 미는 구독을 추가.
  • 반영하다 하나 더 — 재마운트가 Offset Source를 새로 만들고 있었다. 언마운트가 slot.Offset을 일부러 보존하는 이유(SL-75/DC-6: 이미 렌더된 요소들이 그 Source를 구독한 채 딸려 나감)가 재마운트에서 무너지고 있었음 → slot.Offset or Source(0)으로 identity 재사용.
  • 에이전트 제안(bk.offsetList push)은 사용자안(pull + getOffsetAt)에 밀려 폐기됐고, 이어서 bk.base 필드도 사용자 지적으로 걷어냈다 ("이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데" — 같은 값을 두 곳에 두는 중복 상태). isSlot 분기는 남는데, 그건 타입 분기가 아니라 duck-typing이 금지돼 있어서(Roblox userdata의 미정의 키 인덱싱 에러, brand-plan.md) 브랜드 검사가 유일한 안전 경로이기 때문.

이 라운드의 패턴: 세 결함 전부 "사용자가 표면적인 질문 하나를 던졌더니 그 아래에서 나왔다"mountInst의 인자 하나가 offset 모델의 두 결함을 끌어냈다.

6-2. 커밋 전 감사 2라운드 — 실제 결함 다수

핸드오버 준비로 quad-doc-auditor를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸 인용하는 base / 2: 인덱스 레이어·히스토리). 둘 다 실질적인 걸 잡았다.

  • Owned가 코드에 도달하지 못하고 있었다settle/destroySlotTree/ releaseElementself._owned를 9곳 넘게 읽는데 Slot:List/Slot:Single 의사코드가 opts 인자를 안 받고 있었다. 확정한 옵션이 문서 안에서 배선이 끊긴 채 있었던 셈.
  • effect-plan.md가 자기 자신과 모순 — 최상단 시그니처와 "trailing args sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채 Effect(fn, ...deps) 절이 추가돼 역전 배너 없이 정반대 두 서술이 공존하고 있었다.
  • 손대지 않은 문서의 사각지대ROADMAP.md 백로그 문단과 debounce-throttle-plan.md가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다 (이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건 conventions.md가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지 모른다"의 교과서적 사례.
  • 인덱스에 옛 결론이 남음 — State-에포크의 "중복 통지는 안 고쳐진다 / UB 명문화 필요"가 같은 날 정반대로 확정됐는데 question.md/README.md만 안 따라왔다.
  • 그 외: indexOfRaw 미정의, mountSlotTree의 전제 미명시, ReplacedestroyOld 미서술, Gate 이름이 용어 대기열에 없음, 주입 op 목록 미갱신, M2 체크박스 문장 깨짐 등.

그리고 감사 회신 자리에서 결정 셋이 더 확정됐다raw*index로 통일 (오래 열려 있던 캐비엇 종결), 래핑은 raw* 바깥, 그리고 getOffsetAt접두합 캐시(invalidAfter — 단일 함수가 필요한 만큼만 이어붙임). offset 물리 재배치 질문도 "DOM은 insert가 알아서 밀어낸다"로 닫혔다.

6-3. 마지막 라운드 — native* 계층 확정, C-7 역전

주입 op 셋(mountInst/unmountInst/disposeInst)으로는 Move/Swap을 아예 표현할 수 없다는 사용자 지적에서 시작해 물리 조작 계층이 재설계됐다.

  • 층위 정의가 생겼다raw* = Slot 스코프(평탄화 전), native* = 확정된 offset/length 기반 물리 연산(평탄화 후).
  • 표면은 여섯(nativeInsert/Extract/Remove/Move/Swap/Dispose). Replace는 별도 op이 아니라 newElements가 있는 Remove/Extract이고, 파괴/비파괴는 불리언이 아니라 이름으로 가른다(Roblox의 "그 자리에서 바로 Destroy" 융합을 열기 위해).
  • 대상 요소를 배열로 넘겨야 한다(target, offset, count)로 찾을 수 있는 건 DOM뿐이고 Roblox는 자식이 순서 없는 집합이라 offset 역조회가 안 된다.
  • C-7("부기가 물리보다 항상 먼저")이 역전됐다 — base엔 물리적으로 자리를 비워둘 수단이 없고 미는 주체는 백엔드의 삽입 연산 자신이다. 규칙이 "자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중" 하나로 줄었다. 원문은 archive/bookkeeping-before-physical-reversed.md.
  • 같은 라운드에서 getOffsetAt의 접두합 캐시도 사용자 의사코드로 정정 — 단일 함수 + invalidAfter, 무효화는 min(invalidAfter, i) 하나.

7. 남긴 파일

  • qa-request/pre-implementation-qa-round5.md — 문항지(205문항)
  • qa-request/pre-implementation-qa-round5-response.md — 회신 원문
  • qa-request/pre-implementation-qa-round5-followup.md처리 결과의 소스 (절이 라운드마다 쌓임 — 마지막 절이 최신)
  • archive/bindlifetime-slot-owner-reversed.md — 역전된 D-56 원문
  • research/gate-primitive.md, research/state-epoch-validation.md

8. 후속 — State 에포크 안 3차 정정 (같은 날)

사용자가 research/state-epoch-validation.md를 직접 읽고 기제 서술 세 건을 정정했다: (1) sourceList 순회는 rawInvalidfalse일 때만 돈다(문서는 반대로 적고 있었다, 목적은 "못 받은 emit 받기"), (2) emit은 count 없이 발행 source만 싣는다, (3) 에이전트가 요구했던 seen/computedAt 두 카운트 분리는 철회 — count 갱신과 rawInvalid = true가 같은 스텝이라 캐시 오인 경로가 없다. 부수로 "순회가 발견한 변경을 뒤로 emit 할 것인가"가 열렸는데, 다이아몬드 쪽은 사용자가 스스로 안전으로 정정했고 게이트 쪽만 해제 emit이 source = nil을 싣는 규약으로 남았다(게이트는 보통 최종단에 쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와 qa-request/pre-implementation-qa-round5-followup.md의 M절.

9. 후속 — 순회의 count 갱신 문제 + Gate:Apply가 아니다 (같은 날)

(1) 순회가 count를 올리면 통지가 죽는다. 사용자 지적 — 순회로 발견해 count를 최신으로 올려두면 뒤늦게 온 진짜 emit이 삼켜져 하류가 그 에포크를 영영 못 받는다(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열). 사용자 해법은 (b) 순회도 emit 한다이고, 게이트 경우엔 그 emit을 해제 시점까지 민다는 것. 에이전트는 (c) 순회는 rawInvalid만 세우고 count는 안 올린다를 대안으로 냈다 — 원인이 정확히 "순회가 count를 올리는 것"이라 그것만 안 하면 통지 죽음도 게이트 누출도 nil emit 규약도 안 생긴다. 대가는 emit이 올 때까지 Get()마다 재계산, 그리고 OffWithoutEmit 경로에서 count가 안 따라잡는 것. 또 (b)에서 "게이트까지 민다"를 실제로 구현하려면 순회하는 노드가 상류 게이트를 알아야 하는데 sourceList가 루트로 평탄화돼 있어 불가능하고, 유일하게 깔끔한 기제(게이트를 에포크 경계로)는 blocker-plan.md의 ":Get()엔 영향 없음" 확정 계약을 뒤집는다는 걸 짚었다. 미결research/state-epoch-validation.md §5의 3번이 소스.

(2) Gate:Apply가 아니라 State 메소드. 사용자 확정 — "state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음." 경계를 정확히 적어두면, Apply가 노드를 못 만드는 게 아니라(확정 예시 capAt(100):With 노드를 만든다) **프리미티브는 메소드 / 유저랜드 조합 팩토리는 :Apply**라는 층위 구분이다. 부수 결론 셋 — Debounce/Throttlestate:Apply(...) 관용구는 그대로 유효(팩토리가 내부에서 :Gate를 부르면 됨), Blocker 배선 문제는 이미 확정된 state:Block(blocker) 메소드로 자동 해소, __call은 안 씀(사용자 선호 + Luau 함수 타입 자리 통과 여부 불확실). research/gate-primitive.md의 2번이 해소로 갱신됨.

10. 후속 — 순회 처분을 테이블 둘로 확정 (같은 날)

9절에서 열어둔 (b)/(c)를 사용자가 제3안으로 닫았다: 판정 기준을 둘로 나눠 sourceCountMap(값 유효성, 순회가 앞당겨 올림)과 sourceEmitMap(전파 dedup, 상류의 진짜 emit을 기다림)을 따로 둔다. 순회는 emit을 안 하므로 게이트 누출이 아예 안 생기고, 뒤늦은 emit은 "count는 같은데 emit 기록이 다름"으로 걸려 정상 전파된다. (c)의 유일한 약점(Get()마다 재계산)도 사라진다 — 순회가 sourceCountMap을 실제로 올리기 때문. 같이 나온 rawEmit+nil 안은 구조 위생(상류 emit과 내부 emit의 진입점 통일)만 살리고 해법으로는 안 쓴다(막는 게이트는 보통 상류에 있어 자기 rawEmit을 태워도 누출이 남고, nil emit은 하류마다 전체 순회를 강제해 연쇄한다). M절에서 철회했던 두-카운트 분리가 다른 근거로 되살아난 셈이다. 이제 이 안은 기제가 다 정해졌고 남은 건 채택 여부 자체다.

11. 종결 — Gate 표면 확정 + 에포크 채택, 두 문서 base/ 승격 (같은 날)

사용자가 Gate를 **탑레벨 프리미티브 없이 state:Gate(setup) 메소드 + GateNode**로 확정하고("Blocker 는 해당 내부 배선을 따른다"), 이어서 State 에포크도 채택했다("gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다"). research/gate-primitive.mdbase/gate-plan.md, research/state-epoch-validation.mdbase/state-epoch-plan.md로 승격.

에포크 채택으로 source-state-plan.md의 두 확정 서술("항상 전파" / "중복 통지는 안 접음")이 역전돼 archive/always-propagate-no-dedup-superseded.md로 옮겼다. 2026-08-14의 invalid 기반 dedup 금지를 되돌린 게 아니라는 것을 역전 문서·source-state-plan.md·README.md 세 곳에 모두 못박았다 — 이 구분이 흐려지면 "영구 침묵" 버그로 되돌아간다.

부수로 스파이크 05-store-state-diamond-propagation이 다시 rewrite-required/로 갔다(다이아몬드 Observer가 이제 변경당 1회만 울어야 해서 핵심 assert가 정반대). 처리 전량은 qa-request/pre-implementation-qa-round5-followup.md의 O절.

12. /code-review high — 12건 전부 유효, 그중 3건은 실제 설계 구멍

O절 커밋 직후 사용자가 돌린 리뷰에서 12건이 나왔고 전부 유효했다. 특히 게이트가 유보했다 내보내는 emit이 어느 source를 싣는가는 M절이 이미 짚어뒀는데("nil 규약만 Gate 설계와 같이 확정하면 된다") 표면 확정 때 같이 안 닫힌 것으로, 그대로 두면 blocker:Off()의 배치 통지가 하류에서 삼켜진다 — setup 시그니처에 영향이 있어 M2 착수 전 항목으로 되돌렸다 (같은 날 "M2를 막는 항목 없음"이라 적었던 todos.md 00번도 정정).

교훈이 하나 더 있다: 이번에도 감사자 각도가 아니라 diff 각도에서만 보이는 것이 다수였다(개수 하드코딩, 배너 vs 본문, 같은 파일 안의 모순 문장, 방금 옮긴 파일을 가리키는 새 텍스트). conventions.md의 "/code-review는 감사자를 대체하지 않는다" 항목이 다시 확인된 셈. 전량은 qa-request/pre-implementation-qa-round5-followup.md의 P절.

13. /code-review 3건에 대한 사용자 회신 — 2건 확정, 1건은 근거가 반증됨

게이트 emit의 출처는 emit(self) + 흡수 집합으로 닫혔다. 에이전트가 권고한 (c)를 사용자가 더 단순화한 형태 — 게이트에 자체 count를 주는 대신 withheld : {[source]=true}만 들고 있다가 풀 때 자기를 출처로 emit 하고, 동기 전파라 반환 뒤 table.clear하면 하류 전원이 집합을 본다. 검토 결과 setup 시그니처는 안 바뀐다 — 집합을 채우는 건 정책이 아니라 노드이기 때문(P절이 "M2 표면에 영향"이라 적은 건 기우였다).

에포크 쪽은 "재계산 때 count 전부 갱신"만 확정되고, 그걸 제기한 에이전트 근거는 사용자가 반증했다 — 전파가 동기라 A:Set() 파동이 끝난 뒤에야 Z:Set()이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다. 그 과정에서 대원칙 하나가 명문화됐다: 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다. 전량은 followup의 Q절.

14. 마지막 단순화 두 건 — 게이트 통과/유보 미구분, 새 노드 맵 비대칭 초기화

사용자가 Q절 기제를 한 번 더 줄였다. 게이트는 정책 실행 전에 무조건 출처를 withheld에 넣고, 통과시킬 때조차 자기를 출처로 전파한다 — "지연과 비지연을 구분할 이유가 없다". 그래서 Q절이 넣었던 "정책이 emit을 불렀는지 노드가 되짚는다"는 감지 로직이 통째로 사라졌다.

새 노드의 두 맵은 비대칭으로 초기화한다: sourceEmitMap은 비우고 (새 노드는 개념적으로 emit을 받아본 적이 없으므로 nil이 곧 "변경"), sourceCountMap은 상류에서 전부 끌어와 실제 count로 채운 뒤 rawInvalid = true(순회가 훑을 목록이 곧 이 맵이라 비워두면 "유효"로 오판). 이걸로 :With 병합 규칙이 필요 없어지며 /code-review Med-3이 닫혔다. 전량은 followup의 R절.

15. 게이트가 게이트 emit을 받는 경우 — 미정의였고, 풀어 담는 것으로 확정

사용자가 마지막으로 잡은 구멍. "받은 출처를 그대로 넘긴다"는 규칙이 하류가 또 게이트일 때 깨진다 — 상류 게이트가 전파 직후 집합을 비우므로 하류 게이트가 나중에 풀면 빈 집합을 내보내 변경이 증발한다. 수신 시점에 풀어서 자기 withheld에 합치는 것으로 확정. 겹겹이 쌓여도 각 층이 자기 집합을 들고 있어 어느 층이 먼저 풀리든 정보가 안 샌다. 부수로 게이트의 sourceEmitMap은 수신 때가 아니라 실제 전파 때 갱신하는 것으로 못박았다. 전량은 S절.

16. 두 번째 /code-review high — 7건 전부 유효, 유실 경로 둘

e2b85bc 직후 돌린 리뷰. 특히 둘이 실제 유실이었다: (1) withheld를 페이로드로 그대로 넘기면 재진입(전파 중 Observer가 Set) 시 중첩 전파의 clear가 바깥 전파를 빈 집합으로 만들어 변경이 증발한다 → 전파 직전에 새 테이블로 스왑해 배치를 떼어내는 것으로 수정. (2) OffWithoutEmit()이 집합을 안 비워, 매 프레임 On()/OffWithoutEmit()을 도는 Dispatch.drive 배치 게이팅에서 집합이 단조 증가하고 나중에 폐기분이 같이 실려 나간다 → 그 경로도 비우도록 확정, withheld도 weak key로 명시.

부수로 "무조건 withheld에 넣는다"가 수신 규칙을 건너뛴다는 뜻으로 읽히던 모호함(다이아몬드에서 Throttle 유령 trailing emit)과 STATUS의 런타임 개수, followup D절의 옛 research/ 현재형 서술을 고쳤다. 재진입 계약과 "소스 없는 emit(빈 배치)"은 사용자 판단 항목으로 승격 — 후자는 Effect(fn, ...deps) 설치 구간 억제 용례에 직접 걸린다. 전량은 T절.

17. 사용자 반문 — "재진입 계약"은 없던 항목, emit은 flush

사용자가 T절을 되짚어 에이전트 서술 오류 둘을 잡았다. (1) gate-plan.md 6번의 "정책 안에서 emit() 재귀호출"은 blocker-plan.md의 재진입 절을 잘못 옮긴 것 — 그 절은 같은 인스턴스 중첩(On()/Off())을 말하고, 정책이 flush를 부르는 건 평범한 통과 경로다. 무한 되먹임은 2026-08-04 확정 원칙대로 UB이고 유한 재진입은 이미 지원(debounce-throttle-plan.mdonWindowEnd 주석)이라, 이 항목은 열린 게 아니라 정리 대상이었다. (2) 정책이 받는 emit은 페이로드를 정하지 않는 flush 트리거라 배치 스왑이 그 핸들 안에서 일어난다 — T절의 H-1은 모델의 구멍이 아니라 에이전트가 적은 "전파 후 table.clear" 의사코드의 결함이었다. 수정은 그대로 유효하고 서술만 고쳤다.

그래서 Gate에 남은 사용자 판단은 빈 배치 emit 하나뿐이다. 전량은 U절.

18. 빈 배치 emit — 에이전트 권고 기각, 기존 Blocker 계약의 일반화로 확정

마지막 열린 항목. 에이전트는 "빈 배치 = 무조건 통지"를 권고했으나 사용자가 기각했다 — 빈 배치는 "더 던질 게 없다"는 뜻이고 그걸 흘리는 건 State 층에 Source:Emit을 추가하는 격이다. 확정은 통지 자체를 안 함이고, 확인해보니 새 규칙도 아니었다: blocker-plan.md의 "HasBlockedEmit이 false면 아무 것도 안 함(idempotent)"이 이미 같은 계약이고 withheld가 그 플래그의 일반화다.

따름정리로 Effect(fn, ...deps)의 설치 구간 억제가 Gate 소비자에서 빠졌다 — 설치 구간엔 Set이 없어 쌓일 게 없으므로 게이트가 내보낼 것이 없고, Effect 내부 플래그면 충분하다. effect-plan.md의 "Gate보다 뒤" 순서 제약도 같이 사라졌다. 이로써 Gate에 사용자 판단 항목은 남지 않았다. 전량은 V절.

19. Epoch/EpochMap/Brand 제안, 그리고 핸드오버 (세션 종료)

에이전트가 "다중 의존성 Effect에서 한 파동에 fn이 두 번 돈다"는 갭을 제기하자(A → b, A → c일 때 접어줄 공통 하류가 없어 에포크 dedup이 못 접는다), 사용자가 그걸 계기로 더 큰 재구성을 제안했다 — 에포크 부기를 State에서 떼어 EpochMap으로 컴포지션하고, emit 페이로드를 Epoch 인터페이스로 일반화하고, 그러려면 Brand를 인스턴스 브랜드(다중 태깅)로 바꾸는 것. Effect가 자기 EpochMap을 들면 그 지점이 공통 하류가 되어 갭이 닫힌다.

두 차례 회신으로 사실상 전량 확정됐다. 그 과정에서 에이전트 주장 셋이 반증됐다 — (a) "포함 관계가 생성자로 흩어진다"는 자기 등록이 다중 등록을 강제하지 않으므로 틀렸고, (b) "재계산 후 sync 연산이 따로 필요하다"는 Update{Epoch}를 받는다는 걸 놓친 것이었으며, (c) "오버플로해도 다르다"는 사용자 근거는 double이 랩이 아니라 포화한다는 점에서 정정됐다(도달 불가능이라 결론은 유지).

승격은 사용자 지시로 다음 세션"전면 승격을 하고 싶지만, 이 세션은 너무 길어요." 감사도 승격 이후에 돌리기로 했다. 핸드오버로 base/ 네 문서(state-epoch-plan/source-state-plan/effect-plan/brand-plan) 상단에 이 제안을 가리키는 ⚠️ 배너를 달았고, todos.md000번을 신설해 다음 세션의 첫 작업으로 지정했다. 다중 dep Effect 갭 자체도 effect-plan.md에 미해결로 기록했다 — 제안이 기각되더라도 갭은 남으므로.