`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
11 KiB
2026-08-24-02 — M2/M3 마일스톤 순서 교체 (반응형 먼저)
요청: "질문절에 마일스톤 순서 이슈는 (a) M2와 M3의 순서를 바꾼다 를 택하는게 맞겠던데 어떻게 봐?" → 검토 후 동의, *"진행해줘"*로 전량 반영.
1. 발단
2026-08-22 ROADMAP.md 전반 점검이 남긴 마지막 열린 항목. 옛 M2(디스패치
엔진)와 옛 M3(Store/State/Source)의 의존이 양방향이라 그 순서로는 M2를
끝까지 짤 수 없었다. 설계 결정이 아니라 순서 문제라 question.md의
최우선 절이 아니라 2번 항목으로 따로 뒀었다.
2. 검토 — (a)에 동의한 근거
사용자가 이미 (a)로 기울어 있었고, 확인 결과 방향은 맞았다. 근거 셋:
- 의존의 비대칭이 명확하다. 디스패치 → 반응형은 본체 의존이라
우회 불가(
setLength가State<number>를,setOffsetSource가Source<number>를 받고recompute가offset:Set()을 부름). 반대는 등록 표면 의존(핸들러 3개 등록)이라 뒤로 미루면 그만이다. - 옛 순서면 디스패치 마일스톤의
mock 대상 테스트체크박스가 원리적으로 불가능했다 —setLength/recompute를 State 없이 테스트할 수 없다. 반대로 반응형 코어는 순수 Lua라luau만으로 단독 테스트가 된다. - 2026-08-22의
EpochMap/GateNode/Blocker앞당김이 불필요해진다. 그 이동은 "디스패치가 먼저인데 디스패치가 쟤들을 호출한다"는 이유였다. 순서를 바꾸면 마일스톤 내용이 의존 계층과 그대로 일치하고 끌어온 항목이 0개가 된다. "게이팅 먼저"도 그대로 지켜진다(게이팅이 디스패치보다 먼저).
(b) 분할을 안 고른 이유: 참조 비용이 오히려 크다 — 기존 M2 참조
전부가 "M2a인가 M2b인가"로 애매해지는데 순서 교체처럼 기계적으로 못 푼다.
Brand를 앞으로 빼는 일은 (b)에서도 똑같이 해야 하고, 얻는 건 "디스패치
코어를 조금 일찍 짠다"뿐인데 M4 전까지 그걸 소비하는 게 없다.
(c) 유지를 안 고른 이유: project-context.md가 못박은 "순서의 소스는
ROADMAP.md"를 스스로 무효화한다.
3. 그냥 맞바꾸기로는 안 끝났다 — 공통 기반 셋
검토 중 드러난 것. 옛 M2 안에 반응형보다 먼저 와야 하는 항목이 셋 섞여 있었고, State-free이자 dispatch-free라 어느 쪽에도 안 걸린다:
Brand.luau—Source가SourceBrand+EpochBrand에 등록돼야 함.Relate.luau—bindLifetime/unbindLifetime이 그 위에 구현됨.LifetimeHandle.luau인터페이스 — State 전파 루프가 매 발화마다canExecute를, 이중 바인딩 게이트가canBound를 부름.
새 M2 앞머리에 ### 공통 기반 절을 만들어 셋을 옮겼다. 반대로 새 M3로
넘어간 것은 둘 — Observer/Effect 동적 경로 가드 등록과
ObserverEffectLeafHandler(H-39). 결과적으로 역방향 간선이 없다.
4. 실행 중 낸 실수 하나 — 한글 인접 M2가 안 바뀌었다
첫 일괄 치환에 re.sub(r'\bM([23])\b', ...)를 썼는데, Python re의
\w는 유니코드라 한글도 단어 문자다. 그래서 M2는/M2로/M2가처럼
한글이 바로 뒤에 오는 경우 \b가 성립하지 않아 155건만 바뀌고 93건이
그대로 남았다 — 코퍼스가 옛 번호와 새 번호가 섞인 상태가 됐다.
git checkout -- .은 하네스가 막아서(destructive) git show HEAD:<경로>로
26개 파일을 되돌린 뒤, ASCII 경계만 보는
(?<![0-9A-Za-z_])M([23])(?![0-9A-Za-z_])로 다시 돌려 248건 전량을
바꿨다. 교훈: 한국어 문서에서 \b로 영숫자 토큰 경계를 잡지 말 것.
5. 부작용 — 게이트 둘이 "바로 다음"으로 올라왔다
순서 교체의 실질적 대가. question.md의 낮은 우선순위 절에 있던 둘이
반응형(옛 M3)의 게이트였는데, 반응형이 M2가 되면서 착수 직전 항목이
됐다 — 그래서 최우선 절로 승격했다:
- 중간 State GC 미검증 — 상류 strong / 하류 weak 불변식 명문화 여부 +
luau-test실측(base/source-state-plan.md). store:GetDynamic위치 — 콜론 메소드 vs 탑레벨 함수 (base/store-plan.md).
순수한 설계 결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택).
6. 번호 재부여의 사각지대
라이브 문서의 M2/M3 참조는 전부 새 번호로 맞췄다 — 코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜서 기계적 맞교환으로 의미가
보존된다. 반면 session/·archive/·qa-request/는 히스토리 문서라
소급 수정하지 않았다. 2026-08-24 이전에 쓰인 그 문서들의 M2/M3는
옛 의미(M2=디스패치, M3=반응형)다 — 이 경고를 ROADMAP.md M2 배너,
루트 CLAUDE.md, project-context.md, todos.md, archive/ question-resolved.md에 같이 박아뒀다.
7. 반영 범위
실제로 diff에 남은 파일은 25개 + 이 세션 로그다(정확한 목록은
git show --stat이 소스 — 여기서 세지 않는다). 갈래만 적으면:
ROADMAP.md— 두 절 재편 + 최상단 배너 + 흩어진 마일스톤 참조. diff의 대부분이 여기다.question.md— 마일스톤 경계 항목 삭제·아카이브 이관, 3번→2번 재번호, 최우선 절 재작성(두 항목 승격), 번호 재사용 경고.archive/question-resolved.md— 해소 기록 신설.base/gate-plan.md/blocker-plan.md/debounce-throttle-plan.md/state-epoch-plan.md/source-state-plan.md— 2026-08-22 앞당김 서술을 되돌림 서술로. 뒤의 둘엔 "동적 경로 가드 등록은 M3" 캐비엇도 신설.base/effect-plan.md— 같은 캐비엇.base/quad-types-plan.md— "마일스톤마다 갱신" 규칙의 첫 적용 지점이 M2가 된 것.base/store-plan.md/attribute-plan.md/slot-plan.md/dispatch-core-plan.md/project-setup-plan.md,research/operator-sugar-plan.md/debug-tooling-plan.md,luau-test/STATUS.md— 번호 참조 갱신.research/pre-implementation-audit.md— 번호 갱신 + 히스토리 블록 정정 (8절 참고).- 인덱스 레이어:
.claude/README.md/todos.md/project-context.md/ 루트CLAUDE.md/HUMAN_TODO.md. session-summary.md+ 이 파일.
⚠️ diff에 안 남은 파일이 있다는 게 오히려 중요하다.
base/relate-plan.md·base/lifecycle-pattern.md·luau-test/README.md·
luau-test/의 스파이크 둘·reference/slot-attach-decomposition.md는
일괄 치환으로 한 번 바뀌었다가 감사 1라운드 수정으로 HEAD와 글자 단위로
같아졌다. 그 참조들이 가리키는 것(Relate/LifetimeHandle)이 구 M2에서
신 M2로 두 번 옮겨져 번호가 우연히 보존됐기 때문이다 — 즉 "치환하면
안 되는 자리"였고, 감사가 그걸 잡아 되돌린 결과 diff가 비었다.
8. 검증 — 감사 7라운드 수렴 후 /code-review high가 5건 더
quad-doc-auditor 루프는 라운드마다 각도를 바꿔 7라운드에서 새 발견
0건으로 수렴했다(라운드별 10→8→1→1→2→2→0, 총 24건 수정). 각도는
순서대로 — (1) 마일스톤 번호 정합성, (2) 인덱스 레이어와 이번에 새로 쓴
서술의 자기모순, (3) base/ 본문의 선행관계 서술, (4) 완결성(열거),
(5) 구현 순서 시뮬레이션, (6) 관례 준수와 2차 stale, (7) 조사가 가장 적게
닿은 파일.
**가장 값이 컸던 건 5라운드(구현 순서 시뮬레이션)**다. 문서 정합성이
아니라 *"이 로드맵대로 실제로 코드를 짤 수 있는가"*를 물었더니, 새 M2에서
Source/State/Store가 EpochMap.luau보다 앞에 놓여 있는 게
드러났다 — State가 valueEpochMap/emitEpochMap 둘을 컴포지션하므로 그
순서로는 State.luau를 못 짠다. 같은 라운드가 순서 교체의 전제 자체도
검증했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음"
확인).
그리고 수렴 뒤 사용자가 돌린 /code-review high가 5건을 더 잡았고 전부
유효했다 — conventions.md의 *"/code-review는 감사자를 대체하지
않는다"*가 또 한 번 실측으로 재확인된 셈이다. 다섯 중 넷이 "라벨은
치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진" 종류였고, 그중 둘
(아래 두 번째·세 번째)은 같은 뿌리 — quad-types의 "마일스톤마다 갱신"
규칙이 M3에 앵커된 채 첫 적용 지점만 M2로 옮겨간 것이다:
- (HIGH) M3의
setLength항목이 여전히 *"필요한GateNode/Blocker/EpochMap은 이제 이 마일스톤에 체크박스로 있다(위 세 항목)"*라고 말하고 있었다 — 셋은 M2로 되돌아갔고, "위 세 항목"이 가리킬 대상이 M3에 없으며, 15줄 위의 M3 배너가 정반대를 말한다. 핸드오버 체크리스트 2번 ("배너를 달았으면 그 배너가 부정하는 문장을 같은 커밋에서 고쳤는지")의 전형적 실패. - (MEDIUM)
quad-types의 "마일스톤마다 갱신" 규칙이 M3에 앵커돼 있는데 첫 적용 지점은 M2가 됐다 — M2 구현자가 아직 안 나온 마일스톤을 앞으로 참조해야 규칙을 알게 되는 구조. 규칙 요지를 M2 항목에도 적었다 (base/quad-types-plan.md와ROADMAP.mdM2/M3 항목). - (MEDIUM) 같은 뿌리의 다른 자리 —
.claude/README.md의quad-types-plan.md색인 행이 *"M3(Dispatch)를 시작으로 M2/M6/M7/M8/M10에 체크박스가 뿌려졌다"*로 치환돼 나열 순서가 뒤집혔다. M2가 먼저 지어지므로 "M3를 시작으로 … M2"는 성립하지 않는다 — 서브시스템을 붙이는 모든 마일스톤을 순서대로 적고 "계기는Dispatch, 첫 적용은 M2"로 갈라 썼다. - (MEDIUM)
research/pre-implementation-audit.md의 히스토리 블록이 기계 치환을 맞았다 — *"M1·M3(디스패치) 투자가 먼저 이뤄진 뒤에야 검증되는 셈"*이라는 원래 우려가 새 번호에선 문장 그대로 거짓이 됐다 (Store/State가 이제 디스패치보다 먼저다).session/·archive/·qa-request/는 소급 수정 대상에서 뺐는데research/안의 명시적 히스토리 블록은 그 방침에서 새어 있었다. - (LOW) M2로 옮겨온
Brand.luau항목이isNone을 자기 책임처럼 적고 있었는데,brand-plan.md는 2026-08-18에 *"Brand → None의존을 만들지 않는다"*로 확정해뒀다 — 마일스톤이 갈리면서 처음 눈에 띈 잔재.
교훈: 기계 치환은 토큰을 바꾸지 주장을 바꾸지 않는다. 치환 대상이 많을수록 "그 토큰을 설명하던 산문"과 "히스토리 블록"을 따로 훑어야 한다.