`question.md`에 열려 있던 마지막 항목(마일스톤 경계)을 사용자가
**(a) 순서 교체**로 닫았다 — 이제 **M2 = 반응형 코어(Source/State/Store),
M3 = 디스패치 엔진**이다. 결정과 기각된 선택지는
`archive/question-resolved.md`의 "마일스톤 경계" 절, 경위와 시행착오는
`session/2026-08-24-02-milestone-order-swap.md`.
## 왜 (a)인가
의존이 양방향처럼 보였지만 실제로는 한 방향이었다 — 디스패치→반응형은
*본체* 의존(`setLength`가 `State<number>`를, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부름)이라 우회
불가고, 반대는 *등록 표면* 의존(핸들러 셋)이라 미루면 그만이다. 옛
순서로는 디스패치 마일스톤의 `mock 대상 테스트` 체크박스조차 State 없이
불가능했고, 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다.
(b) 분할은 기존 `M2` 참조 전부를 "M2a인가 M2b인가"로 만들어 오히려 비싸고,
(c) 유지는 "순서의 소스는 `ROADMAP.md`"라는 원칙을 스스로 무효화한다.
## 그냥 맞바꾸기로는 안 끝났다
- **`Brand`/`Relate`/`LifetimeHandle`(인터페이스)이 M2 앞머리
`### 공통 기반` 절로** — 반응형이 이 셋을 먼저 요구한다(`Source`가
`SourceBrand`+`EpochBrand`에 등록되고, State 전파 루프가 매 발화마다
`canExecute`를 부르며, 그 판정이 `Relate` 위에 얹힌다). 셋 다 State-free
이자 dispatch-free라 어느 쪽에도 안 걸린다.
- **2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`가
M2로 복귀** — 앞당길 이유 자체가 순서 교체로 사라졌다. "게이팅 먼저"는
그대로 지켜진다(게이팅이 디스패치보다 먼저 지어진다).
- **Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는
M3로** — 둘 다 "핸들러를 등록한다"뿐이라 본체와 잘라내기 쉽다. 이것이
M2가 M3에 개념상 지던 유일한 의존이고, 미뤘으므로 빌드 순서엔 역방향
간선이 없다.
- **`EpochMap`이 State 본체보다 앞으로**(감사 5라운드 발견) — State가
`valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 옛 배치로는
`State.luau`를 못 짠다. 문서 자신이 *"아래 `Source.luau`/`State.luau`가
이걸 전제로"*라 쓰면서 그 둘이 위에 있는 형태로 증거를 남기고 있었다.
## 대가 하나 — 게이트 둘이 "바로 다음"으로 올라왔다
`question.md` 낮은 우선순위에 있던 **중간 State GC 미검증**과
**`store:GetDynamic` 위치**가 원래 반응형(옛 M3)의 게이트였는데, 반응형이
M2가 되면서 착수 직전 항목이 됐다 — 최우선 절로 승격했다. 순수 *설계*
결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택).
## 번호 재부여의 경계
라이브 문서의 `M2`/`M3` 참조 248건은 전량 새 번호로 맞췄다(코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜 기계적 맞교환으로 의미가
보존됨). **`session/`·`archive/`·`qa-request/`는 히스토리라 소급 수정하지
않았다** — 그 문서들의 `M2`/`M3`는 옛 의미(M2=디스패치, M3=반응형)이고,
이 경고를 인덱스 다섯 곳에 박아뒀다.
일괄 치환에 `\bM([23])\b`를 썼다가 Python `\w`가 유니코드라 한글이 붙은
93건(`M2는`/`M2로`)이 안 바뀌는 실수를 냈고, `git show HEAD:<경로>`로
되돌린 뒤 ASCII 경계 lookaround로 재실행했다. 그 부수로 `Relate`/
`LifetimeHandle` 참조들은 구 M2 → 신 M2로 두 번 옮겨져 **번호가 우연히
보존**됐다는 것도 드러났다(치환하면 안 되는 자리 — 감사가 잡아 되돌렸다).
## 검증
`quad-doc-auditor` 루프가 라운드마다 각도를 바꿔 **7라운드에서 새 발견
0건으로 수렴**(10→8→1→1→2→2→0). 가장 값이 큰 건 5라운드(구현 순서
시뮬레이션)로, 위 `EpochMap` 배치 오류를 잡았고 **순서 교체의 전제 자체도
검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음").
**수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부 유효** —
넷이 *"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류였고,
그중 하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가
문장 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 세 폴더로만 잡은 게
샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가
또 재확인됐다. `doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
`ROADMAP.md` 전문을 `base/`의 현재 확정과 대조한 전반 점검. 사용자 판단으로
마일스톤을 각주가 아니라 체크박스째 재편했다.
## 마일스톤 이동 (M3/M7 → M2)
`EpochMap.luau` / `state:Gate`+`GateNode` / `Blocker.luau` / `None`+
`Dispatch/None.luau`. 넷 다 M2가 실제로 호출하는데 각주로만 예고돼 있었고,
`GateNode`는 체크박스 자체가 없어 M2를 훑는 구현자에게 항목으로 보이지
않았다. `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던 전례와 같은 처리.
`Blocker`는 `GateNode` 위의 정책으로 얹는다(노드를 다시 만들지 말 것).
## ⭐ 새 미결 — M2와 M3의 의존이 양방향
이동하다 드러났다. `Dispatch.setLength`가 `State<number>`, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부르며, `Dispatch.drive`
자신도 배치 등록을 Blocker로 게이팅한다 — 즉 M2는 `Source.luau`/`State.luau`
없이 구현이 안 된다. 설계가 아니라 마일스톤 순서 문제라, 선택지 셋((a) 순서
교체 / (b) M2 분할 — 경계선은 `drive` / (c) 유지)을 `question.md` 2번으로
신설하고 유일한 소스로 지정했다. `HUMAN_TODO.md` 11번이 사용자 진입점.
## 모순 정정
- `Dispatch.drive` 순회 구현은 두 패스가 아니라 단일 일반화 `for`(`F-4-1`).
"배열→해시 먼저"는 그 루프가 지키는 계약. 코퍼스의 "두 패스"는 본체 루프의
옛 이름으로 정리(시점 표기는 유지, 용어 각주 신설). 따름정리로 "base는
언어 동작에 안 기댄다"는 서술이 거짓이 됐고, 재작성될 스파이크 `01`이
검증할 것도 언어 동작 자체로 바뀐다
- 물리 조작 주입 op 이름은 `native*` 확정(옛 가칭 `mountInst`/`unmountInst`/
`disposeInst` 폐기). 단건 경로 순서는 `setOffsetSource` → `nativeInsert` →
`setLength` → `recompute` — 역전 배너를 스스로 단 절 안에 옛 "부기 먼저"
주장이 두 문단 살아 있었다
- M2 첫 체크박스가 하강 diff와 폐기된 옛 모델을 한 불릿에서 둘 다 서술
## 상태 표시
- `[x]`는 "짜야 할 코드"만. 설계 확정은 `### 확정된 것` 절 또는 전용
불릿으로 분리(M3/M6/M11) — 이제 `[x]`는 M0/M1에만 남는다
- M0에 `### 재검증 대기` 절 신설 — 설계 변경으로 무효화된 스파이크들이
어느 체크박스에도 없어 잊히기 쉬웠다(현황의 소스는 `luau-test/STATUS.md`)
- 주입 op 개수 하드코딩을 네 문서에서 걷고 `architecture.md`의
`EngineOps.luau` 줄 하나로 단일화(그 줄에 빠져 있던 시간 op도 채움)
## 검증
`quad-doc-auditor` 10라운드(각도를 매번 바꿔 34건) + 사용자가 돌린
`/code-review high` 9건. `doc-check.py` ERROR 0. 경위와 실측된 실패 패턴은
`.claude/session/2026-08-22-01-roadmap-milestone-review.md`.
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
Co-authored-by: qwreey <me@qwreey.moe>
앞 세션이 컨텍스트 피로로 미뤄둔 승격(todos 000번)을 수행하고, 코퍼스에
쌓여 있던 [해소]/[정정] 층을 걷어냈다. 감사 3라운드 + /code-review high로
15건을 잡아 전부 반영했다.
승격 — base/ 넷 + 파급 넷
- state-epoch-plan.md 재작성: Epoch 인터페이스({Revision:number}, 그 자체로
키가 되는 unique 테이블, Source가 구조적으로 만족)와 EpochMap(Update/
Refresh/Sync/TrackFrom) 신설. State는 EpochMap을 둘 컴포지션 —
sourceCountMap/sourceEmitMap -> valueEpochMap/emitEpochMap. §1~§8로 재편.
- brand-plan.md 전면 재작성: 공유 레지스트리 + Brand.get(객체당 태그 하나)
-> 인스턴스 브랜드 Brand() + :register/:is, 다중 태깅 허용. 발단은 Source가
SourceBrand이면서 동시에 EpochBrand여야 하는데 옛 모양으로 표현 불가.
역조회는 제거(전수 조사에서 쓰는 자리 0). weak-key/테이블 아이덴티티/
duck-typing 기각 근거/predicate 합성은 전부 유지.
- source-state-plan.md: Source가 Epoch도 구조적으로 만족(Revision은 공개여야
타입 레벨에서 성립), Observer 클로저가 fn(self, from: (Epoch|EpochSet)?).
- ⭐ effect-plan.md: 다중 의존성 중복 발화 미해결 항목이 닫힘 —
EffectHandle이 자기 EpochMap을 들어 첫 번째만 통과시킨다.
- gate-plan.md/architecture.md/bind-system-plan.md/ROADMAP.md 어휘 통일,
EpochMap.luau가 M2에도 필요하다는 것 반영(GateNode가 씀).
리비전 갱신은 bit32.bnot(-rev) — 사용자 확정
- a>0이면 a-1, 0이면 4294967295인 랩어라운드 감소. 갱신과 랩이 FASTCALL
하나로 끝난다(luau 실측). 근거: 2^53 포화는 도달 불가능한데 그걸 피하려고
값을 double 영역까지 키울 이유가 없다, 매번 도는 hot path다.
- 에이전트가 이걸 band(rev+1, mask)로 잘못 옮기고 "그러니 bit32가 더 싼 건
아니다"라는 틀린 단서까지 달았다가 사용자 정정("제가 말한건 bit32.bnot(-a)
입니다"). 세 문서에 정정 경위를 남겼다.
- 따름정리: 리비전은 증가가 아니라 감소한다. ==/~= 만 쓰는 지금 규칙에서만
무해하다는 경고를 §2에 명시.
archive / flatten
- archive/brand-shared-registry-reversed.md 신설(옛 Brand 표면 원문).
- question.md 421->208줄: 해소 항목 18건을 archive/question-resolved.md로
이관. 그 문서가 스스로 정한 규칙("해소하면 여기서 지우고 archive로")을
다시 어기고 있었다. 이관분의 옛 필드명은 소급 수정하지 않고 머리에 경고만.
- todos.md 000번 삭제, "M3 착수 전 필요" 목록에서 해소 항목 일곱 제거
(실제로 열린 건 중간 State GC와 store:GetDynamic 둘뿐).
- research/ -> reference/ 이동 둘(epoch-brand-composition,
slot-attach-decomposition). 확정된 결정의 근거 기록은 research(상의 필요)도
archive(뒤집힘)도 아니므로, reference의 폴더 기준에 그 용도를 명문화했다.
감사 3라운드(각도: base 정합성 / 인덱스+luau-test / archive+qa-request)
- §8에 "기각된 대안 — 게이트를 에포크 경계로" 논거 신설(재작성 때 떨어뜨렸고,
두 문서가 서로 다른 없는 §번호를 대고 있었다).
- luau-test 스파이크 22를 done/ -> rewrite-required/(옛 Brand.set/get을 직접
구현). STATUS.md 개수와 절 제목의 하드코딩 개수 정리.
- state-epoch/source-state가 "Revision을 증가시킨다"고 적어놓고 20줄 뒤에
"감소한다"로 반박하던 자기모순 정정.
커밋 전 /code-review high — 9건, 전부 유효(감사자가 못 보는 축)
- ⭐ {Epoch}는 Luau에서 배열인데 실제 게이트 배치는 {[Epoch]:true} 집합.
그대로 ipairs로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다(gate-plan 4번이
애초에 고치려던 그 버그) -> EpochSet으로 확정.
- ⭐ 새 노드 시딩이 확정된 EpochMap 표면으로 표현 불가능했다(:With의 상류는
State이지 Epoch가 아니고, 키 열거/병합 연산이 없었음) -> :TrackFrom 신설.
- GateNode 예외(emitEpochMap을 전파 시점에 갱신)가 §4에 미기록.
- 설치 발화엔 from이 없다 -> 옵셔널로, Effect의 억제 플래그가 Update보다
먼저여야 함을 명시.
- 2^32 랩을 "똑같이 도달 불가능"이라 한 근거가 틀렸다(같은 척도로 285년 vs
72분). 실제 안전 근거는 충돌 조건이 한 점이라는 것으로 정정.
- 그 외 :Sync 용도 충돌, isEpoch 누락, TweenTag 3곳, Effect(fn,state?) 4곳,
§ 참조 3곳.
에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정
- :Refresh 유지 — "Update는 받은 것을 처리, Refresh는 내가 받았던 걸 처리라
표면적 의미 자체가 다르다"(오버로드로 합치지 않음).
- :Absorb -> :TrackFrom 개명 — absorb는 상위에서 제거할 것처럼 읽히고,
gate-plan이 이미 "흡수 집합"을 다른 뜻으로 쓴다. TrackFrom은 이 맵의 존재
이유("내가 뭘 추적하고 있나")를 그대로 쓰고 From이 비파괴를 못박는다.
Epoch/EpochMap/Brand에 열린 설계 항목 없음. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
사용자 지시로 승격은 다음 세션에("이 세션은 너무 길어요"), 감사는 승격
이후에 돌린다. 이번 세션에서 알게 된 것 중 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
에이전트 권고("빈 배치 = 무조건 통지")를 사용자가 기각. 빈 배치는 "이미 다
던져서 더 던질 게 없다"는 뜻이고, 같은 카운트가 두 번 흘러든 emit을 전파하지
않는 것과 같은 자리다. 그걸 흘리는 건 표면적으로 State 중간에 Source:Emit을
추가하는 격이라 Gate의 성격과 안 맞는다.
확정: next(withheld) == nil이면 통지 자체를 안 한다. 새 규칙이 아니라 기존
계약의 일반화임을 확인 — blocker-plan.md가 이미 "HasBlockedEmit이 false면
emit 값과 무관하게 아무 것도 안 함(idempotent)"으로 확정해뒀고
HasBlockedEmit은 next(withheld) ~= nil의 특수형이다. Debounce/Throttle도
if pending일 때만 passThrough()를 부른다.
따름정리 — Effect(fn, ...deps)의 설치 구간 억제가 Gate 소비자에서 빠졌다.
설치 구간엔 어떤 Set도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가
내보낼 것 자체가 없고, Effect 내부 플래그면 충분하다. effect-plan.md에서
"⚠️ 억제 장치의 모양은 Gate 설계에 딸려 있다"와 우선순위 문단의 "Gate보다
뒤다"라는 순서 제약이 같이 빠졌다.
이로써 Gate에 사용자 판단이 필요한 항목은 없다 — 남은 건 생명주기 계약과
M2 범위뿐이고 둘 다 구현 시 결정. 처리 전량은 V절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
High 둘은 실제 유실 경로였다.
- withheld를 페이로드로 그대로 넘기면 재진입에 깨진다. 전파 중 Observer가
Set을 불러 게이트에 재진입하면 중첩 전파가 끝나며 table.clear가 돌아
바깥 전파의 남은 갈래가 빈 집합을 받는다. 전파 직전에 새 테이블로 스왑해
배치를 떼어내고 그 배치를 페이로드로 넘기는 것으로 수정.
- OffWithoutEmit()이 집합을 안 비웠다. Dispatch.drive의 배치 게이팅이 매
프레임 On() -> OffWithoutEmit()을 돌므로 집합이 단조 증가하고(weak 설계와도
충돌) 나중에 아무 소스나 통과할 때 폐기분이 같이 실려 나간다. 그 경로도
비우도록 확정하고 withheld를 weak key로 명시.
나머지 수정 셋:
- "무조건 withheld에 넣는다"가 수신 규칙 1~3을 건너뛴다는 뜻으로 읽히던 것을
"정책의 통과/유보와 무관하게"로 명시(그대로 두면 다이아몬드에서 Throttle
정책이 두 번 돌아 유령 trailing emit이 나간다)
- luau-test/STATUS.md의 "런타임 12개"가 이미 나간 04/10/19를 포함한 옛
총계에서 이어져 온 수라 실제(9건)와 안 맞던 것
- followup D절 색인 표가 삭제된 research/ 두 문서를 현재형으로 서술하던 것
열린 항목으로 승격 둘(question.md의 "남은 것은 사용자 판단이 아니다"도 정정):
- Gate 재진입 계약 — 스냅샷으로 유실은 막았으나 정책 안 재귀 호출 계약은 미정
- 소스 없는 emit(빈 배치) — 정책이 상류 신호와 무관하게 emit()을 부르면 빈
배치가 나가 하류가 조용히 삼킨다. Effect(fn, ...deps) 설치 구간 억제
용례가 정확히 이 모양이라 그대로는 성립하지 않음. 권고는 "빈 배치 = 무조건
통지"
처리 전량은 round5-followup.md의 T절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
사용자 발견. R절까지의 규칙("출처가 GateNode면 받은 출처를 그대로 아래로
넘긴다")은 받는 쪽이 또 게이트인 경우가 정의돼 있지 않았고, 그대로 넘기면
깨진다 — 상류 게이트는 자기 전파가 끝나자마자 table.clear 하므로 하류
게이트가 출처만 들고 유보했다가 나중에 풀면 빈 집합을 내보내 변경이 통째로
증발한다.
확정: 수신 시점에 unfold 해서 자기 _withheld에 합친다(Source면 하나,
GateNode면 그 집합 전부). 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을
들고 있으므로 어느 층이 먼저 풀리든 정보가 안 샌다.
같이 못박은 것: 게이트의 sourceEmitMap은 수신 때가 아니라 실제로 전파할 때
집합 전체에 대해 갱신한다 — 그래야 "내가 하류로 던진 에포크"라는 맵의 뜻이
게이트에서도 참이 된다.
반영은 base/gate-plan.md 4번, base/state-epoch-plan.md §2, followup S절.
doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
1) 게이트는 통과와 유보를 구분하지 않는다. 상류 emit이 오면 정책 실행 전에
무조건 withheld[source] = true 로 넣고, 정책이 emit()을 부르면 게이트가
자기를 출처로 전파한 뒤(동기) table.clear 한다. 그냥 통과시킬 때도 상류
출처를 넘기지 않고 언제나 자기를 낸다 — 하류가 보는 차이는 집합 원소가
하나냐 여럿이냐뿐이고 판정 규칙은 같다. 그래서 직전 라운드에 넣었던
"정책이 그 자리에서 emit()을 불렀는지 노드가 되짚는다"는 감지 로직이
통째로 불필요해졌다.
2) 새 노드의 두 맵은 비대칭으로 초기화한다.
- sourceEmitMap: 비운다. nil ~= source.count 라 어떤 emit도 "처음 보는
것"으로 걸리고, 새 노드는 실제로 emit을 받아본 적이 없으므로 그게 맞다.
- sourceCountMap: 상류에서 전부 끌어와 실제 count로 채우고 rawInvalid를
true로 둔다. 순회가 훑을 목록이 곧 이 맵이라 비워두면 "훑을 게 없으니
유효하다"로 오판한다 — 여기는 lazy할 수 없고 "내가 뭘 추적하는가"가
필요하다.
그래서 :With 병합 규칙은 필요 없어졌다(생성 시점 라이브 count로 통일되므로
두 상류가 같은 소스에 다른 count를 들 일이 없다). /code-review Med-3이
열어둔 (b)/(c)가 이걸로 전부 닫혔다.
처리 전량은 round5-followup.md의 R절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
/code-review high가 잡은 3건에 대한 사용자 회신 반영.
1) 게이트가 유보했다 내보내는 emit — (c) 채택, 다만 에이전트 안보다 단순한
형태로. 게이트에 자체 count를 주는 대신 흡수한 소스 집합
withheld : {[source]=true} 만 들고 있다가, 풀 때 자기를 출처로 하류에
emit 하고 전파가 동기이므로 반환 뒤 table.clear 한다. 하류는 출처가
GateNode면 그 집합의 소스들에 평소 규칙(1~3)을 적용하고 받은 출처를
그대로 더 아래로 넘긴다. OffWithoutEmit도 안전(다음 진짜 emit이 스스로
낫게 함). 그래서 emit의 출처는 Source | GateNode 둘 다가 된다.
⭐ setup 시그니처는 안 바뀐다 — 흡수 집합을 채우는 건 정책이 아니라
노드이기 때문(노드가 onUpstreamEmit 전후로 정책이 그 자리에서 emit()을
불렀는지만 보면 됨). P절이 "M2 표면에 영향"이라 적은 건 기우였다.
2) 재계산 후 sourceCountMap은 자기가 읽은 상류 전부를 갱신한다(확정).
다만 이걸 제기한 에이전트 근거("A:Set(); Z:Set()이면 같은 값을 두 번
계산")는 사용자가 반증 — 전파가 동기라 A 파동이 완전히 끝난 뒤 Z:Set()이
시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다. 전부 갱신이
실제로 값을 하는 자리는 게이트 유보 중 하류가 Get()으로 앞당겨 읽는
경우뿐이고, 그때 해제 통지가 규칙 2(통지만)로 떨어져 재계산을 안 한다.
3) 같이 명문화한 대원칙: 무효화를 결정하는 건 언제나 count 비교지 emit의
도착이 아니다. emit은 "이 원천을 확인해봐"라는 요청일 뿐이라, 통과해도
count가 최신이면 캐시는 유효한 채로 남는다.
남은 열린 항목은 새 노드의 두 맵 초기값(복사 vs 첫 재계산 때 구성)과 그에
종속된 :With 병합 규칙뿐이고, 동기 전파 덕에 차이가 나는 경우가 게이트 유보
중 파생 노드가 생길 때 하나뿐이라 어느 쪽이든 무해 — M3 구현 시 결정.
처리 전량은 round5-followup.md의 Q절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
O절 커밋(c58c97a) 직후 돌린 리뷰에서 12건이 나왔고 전부 유효했다.
열린 항목으로 승격(임의로 정하지 않음):
- [M2 착수 전] 게이트가 유보했다 내보내는 emit이 어느 source를 싣는가.
확정된 setup은 (emit: () -> ()) -> (() -> ())라 양쪽 다 source를 안 받는데
에포크 수신 규칙은 전부 [source] 키로 판정한다 — 그대로면 blocker:Off()가
묶어둔 배치 emit이 하류에서 규칙 3으로 삼켜져 통지가 통째로 사라진다.
5라운드 M절이 이미 짚었는데 표면 확정 때 같이 안 닫힌 것. 후보 (a) emit(nil)
전체 확인 / (b) 유보 소스마다 emit(Blocker의 "정확히 1회"가 깨짐) /
(c) GateNode 자신을 source처럼 취급 — 권고는 (c). gate-plan.md 4번.
- [M3 착수 전] 두 맵의 초기값·:With 병합·재계산 시 갱신 범위.
규칙 1이 발행 소스 항목만 건드려 다중 소스 배치에서 같은 값을 두 번
계산하고, "상류에서 복사"는 순회가 앞당긴 지연분 상속 여부가 미정이라
새 노드가 통지를 삼킬 수 있다. state-epoch-plan.md §5 7번.
이에 따라 todos.md 00번의 "M2를 막는 설계 항목 없음"도 정정.
그 자리에서 수정:
- state-epoch-plan.md: §4/§5-2의 sourceList 잔재(코퍼스에 bk.sourceList라는
무관한 동명 식별자가 있어 오독 위험), §3의 "rawInvalid가 켜져도" 정정
- gate-plan.md: 배너가 부정하는 본문 두 문장을 같이 수정
- question.md 1번: Gate 이름 항목이 열린 채였던 것 해소로 갱신
- luau-test/STATUS.md: 05 이동이 반영 안 된 개수 3곳 + 같은 파일 안의
모순 문장("05가 다시 돌아왔다")
- comparison-fusion-vide.md: 배너 바로 위 본문이 배너와 어긋나던 것
- N절/README가 가리키던 research/ 옛 경로
처리 전량은 round5-followup.md의 P절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
사용자 확정 둘로 M2 착수를 막던 설계 항목이 전부 사라졌다.
1) Gate — 탑레벨 프리미티브를 만들지 않고 state:Gate(setup) 메소드로 확정.
ComputeNode와 같은 층위의 GateNode를 만든다. Blocker는 그 위의 별개
프리미티브로, 이미 확정돼 있던 state:Block(blocker)가 내부에서
self:Gate(policy)를 부른다. Debounce/Throttle의 state:Apply(...) 관용구는
그대로 — 팩토리가 내부에서 :Gate를 부르면 되기 때문. 이름 문제(Gater?)도
메소드 자리로 가면서 소멸. Get()엔 영향 없음(통지만 막음)까지 확정.
research/gate-primitive.md -> base/gate-plan.md.
2) State 에포크 — 채택 확정. 구현은 M3.
research/state-epoch-validation.md -> base/state-epoch-plan.md.
에포크 채택으로 source-state-plan.md의 두 확정 서술("emit은 항상 전파" /
"quad가 접지 않는 것은 중복 통지뿐")이 역전됐다. 원문은
archive/always-propagate-no-dedup-superseded.md. 지금 계약은 "invalid로는
절대 안 접고, 같은 소스의 같은 에포크가 두 번째로 도착했을 때만 접는다" —
2026-08-14의 invalid 기반 dedup 금지를 되돌린 게 아니라는 점을 역전 문서와
source-state-plan.md, README 세 곳에 못박음(흐려지면 "영구 침묵" 버그로
되돌아감).
같이 갱신: architecture.md 전파 모델 요약, blocker-plan.md(:Gate 배선 +
Get 계약이 에포크 안의 전제라는 것), debounce-throttle-plan.md(공용 게이트
권고가 실현됨 / 파동 단위 최적화 서술 정정), reference/comparison-fusion-vide.md,
source-state-plan.md의 Observer 계약 각주(이제 "새 에포크는 항상 통과"에
의존), ROADMAP M0 각주·M2 각주·M3 체크박스, README/question/todos 인덱스.
스파이크 05-store-state-diamond-propagation은 done/ -> rewrite-required/ 로
되돌렸다 — 다이아몬드 Observer가 이제 변경당 1회만 울어야 해서 핵심 assert가
정반대가 됐다(살릴 것/새로 넣을 것은 STATUS.md에 기재).
처리 전량의 소스는 qa-request/pre-implementation-qa-round5-followup.md의 O절.
doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:04:27 +09:00
Renamed from .claude/research/state-epoch-validation.md (Browse further)