quad/.claude/session/2026-08-24-02-milestone-order-swap.md
qwreey 56f8269236
design: M2/M3 마일스톤 순서 교체 — 반응형 코어를 먼저, 디스패치를 그 위에
`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
2026-08-25 00:29:15 +09:00

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)로 기울어 있었고, 확인 결과 방향은 맞았다. 근거 셋:

  1. 의존의 비대칭이 명확하다. 디스패치 → 반응형은 본체 의존이라 우회 불가(setLengthState<number>를, setOffsetSourceSource<number>를 받고 recomputeoffset:Set()을 부름). 반대는 등록 표면 의존(핸들러 3개 등록)이라 뒤로 미루면 그만이다.
  2. 옛 순서면 디스패치 마일스톤의 mock 대상 테스트 체크박스가 원리적으로 불가능했다setLength/recompute를 State 없이 테스트할 수 없다. 반대로 반응형 코어는 순수 Lua라 luau만으로 단독 테스트가 된다.
  3. 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.luauSourceSourceBrand+EpochBrand에 등록돼야 함.
  • Relate.luaubindLifetime/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/StoreEpochMap.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.mdROADMAP.md M2/M3 항목).
  • (MEDIUM) 같은 뿌리의 다른 자리 — .claude/README.mdquad-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 의존을 만들지 않는다"*로 확정해뒀다 — 마일스톤이 갈리면서 처음 눈에 띈 잔재.

교훈: 기계 치환은 토큰을 바꾸지 주장을 바꾸지 않는다. 치환 대상이 많을수록 "그 토큰을 설명하던 산문"과 "히스토리 블록"을 따로 훑어야 한다.