quad/.claude/session/2026-08-22-01-roadmap-milestone-review.md
qwreey 9622ccd3e9
design: ROADMAP 마일스톤 경계 재편 + 전반 stale 정리
`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>
2026-08-22 03:19:13 +09:00

16 KiB

2026-08-22-01 — ROADMAP.md 전반 점검: 마일스톤 경계 재편과 stale 정리

사용자 요청: "마일스톤 전체가 이제 손봐야할 부분이 많다고 생각해. 전반을 확인해볼래?" — 직전 세션(2026-08-21-03)이 Epoch/EpochMap/Brand를 승격하며 커밋 메시지에 마일스톤 관련 항목 셋을 남긴 게 발단.

이 세션은 커밋하지 않았다 — 사용자 지시("이거 다 하고 내가 직접 코드리뷰 트리거해줄게 커밋 하지 말아줘"). 작업 트리에 17개 파일이 그대로 남아 있고, 사용자가 /code-review를 돌린 뒤 그 결과까지 반영해서 커밋하는 게 다음 단계다.

1. 무엇을 했나

ROADMAP.md 전문(1074줄)을 읽고 base/의 현재 확정과 대조했다. 발견을 셋으로 갈라 보고했다 — A(내용 모순), B(순서/소속), C(상태 표시 신뢰성). 사용자가 B와 C-3에 대해 각각 "실제로 옮긴다", "설계 확정은 별도 표기로 분리"를 선택했고 그대로 실행했다.

A. 모순 — 그대로 읽으면 반대로 구현되는 것

  • M2 첫 체크박스가 자기 자신을 부정했다. 불릿 머리는 하강 diff인데 꼬리가 "Dispatch.process는 diff를 하지 않음"(2026-08-13 다섯 번째 세션 모델, 같은 날 열네 번째 세션에 폐기)으로 끝났다.
  • Dispatch.drive의 순회 계약이 옛 버전이었다. QA 4라운드 F-4-1로 "두 패스 구현"이 폐기되고 단일 일반화 for로 정정됐는데 ROADMAP 두 곳이 안 따라왔다. 더 나쁜 건 dispatch-core-plan.md 자신이 모순이었다는 것 — F-4-1 정정(§390)이 "이식성 논거는 과했다"고 명시적으로 기각한 근거를 34줄 뒤 D-10 문단(§440)이 여전히 현재형으로 주장했다.
  • 물리 조작 주입 op 이름이 두 벌 돌았다. slot-plan.md/architecture.mdnative*로 넘어갔는데 ROADMAP M6와 dispatch-core-plan.md는 가칭 mountInst/unmountInst/disposeInst를 썼다. 뿌리는 dispatch-core-plan.md가 *"이름은 아직 가칭이라 정식 등재하는 건 그 확정 시점에 한다"*로 멈춰 있던 것 — 확정이 났는데 그 문단이 안 열렸다.
  • "주입되는 엔진 op 3개" 하드코딩. architecture.mdEngineOps.luau 줄을 단일 소스로 지정하고 개수를 지웠다.

B. 순서/소속 — 각주로 덮어온 자리 (사용자 선택: "실제로 옮긴다")

M2가 실제로 필요로 하는 모듈 넷이 전부 뒤 마일스톤에 체크박스로 있었고, 옮기는 대신 각주가 붙어 있었다:

모듈 있던 곳 옮긴 곳
GateNode / state:Gate 체크박스 자체가 없음(각주 안에만) M2
EpochMap.luau M3 M2
Blocker.luau M3 M2
None + Dispatch/None.luau M7 M2

GateNode가 특히 나빴다 — M2 체크박스를 훑는 구현자에게 항목으로 보이지 않았다. 전례가 있다: LifetimeHandle 인터페이스도 같은 이유로 M8→M2 실제 이동을 했었다(각주가 아니라).

C. 상태 표시

  • M0이 전부 [x]인데 검증 스파이크 둘이 무효화돼 있었고(01: 두 패스→ 단일 for, 05: Epoch 리비전 채택), 재작성 작업이 어느 체크박스에도 없었다. ### 재검증 대기 절을 신설해 7건 + 파일 없는 실측 2건을 모았다.
  • **머리 blockquote가 [2026-08-19 기준]**에서 멈춰 있었다.
  • [x]의 의미가 마일스톤마다 달랐다 — M0/M1은 "실제로 짜서 통과", M6/M11은 "설계 확정"(코드 0줄). initValue 항목은 아예 스코프 배제가 [x]였다. 사용자 선택대로 M6/M11에 ### 확정된 것 — 코드 아님, 구현 전 필독 절을 만들어 10개 항목을 옮기고, 체크박스는 "짜야 할 코드"만 남겼다.

2. 이동하다 드러난 것 — M2와 M3의 의존이 양방향이다

이게 이 세션의 실질적 산출물이다. EpochMap/Gate/Blocker를 M2로 옮기려다 확인한 것:

  • M2 → M3 (본체 의존): Dispatch.setLengthlen: number | State<number>를, setOffsetSourceSource<number>를 받고 recomputeoffset:Set(abs)/ ownerKey.Length:Set(sum)을 부른다(dispatch-core-plan.md:1279-1280, :1621). 즉 M2는 Source.luau/State.luau 없이는 구현이 안 된다. M2로 옮긴 GateNode/Blocker도 State 위에 얹히므로 마찬가지.
  • M3 → M2 (얕은 의존): state:Observer/Effect의 동적 경로 가드가 Dispatch.addHandler + Handler.luau 계약을 쓴다 — M2 앞머리 두 항목이면 충족.

선택지 셋((a) M2/M3 순서 교체 / (b) M2를 M2a·M2b로 분할 / (c) 유지)을 question.md 2번으로 신설하고 유일한 소스로 지정했다. ROADMAP.md M2 배너 / todos.md 00번 / CLAUDE.md / project-context.md / HUMAN_TODO.md 11번은 전부 가리키기만 한다.

중요한 구분: todos.md가 오래 유지해온 "M2 착수를 막는 설계 항목은 없다"는 여전히 참이다 — 이건 설계가 아니라 순서 문제다. 그 구분을 안 하면 두 서술이 모순으로 읽히므로 다섯 곳 전부에 명시했다.

3. 감사 루프 — 8라운드, 30건

conventions.md의 절차대로 quad-doc-auditor한 턴에 하나씩, 라운드마다 각도를 바꿔가며 돌렸다. 발견 추이: 9 → 5 → 7 → 3 → 3 → 1 → 1 → 0.

라운드별 각도: (1) diff 범위, (2) 인덱스 레이어 + base/ 프리미티브, (3) archive/·reference/·qa-request/, (4) 내 수정 자체를 의심, (5) 통독 시점 + architecture.md/source-state-plan.md/HUMAN_TODO.md, (6) 아직 안 본 base/ 17개 + session-summary.md, (7) "이름만 고치고 의미는 안 고친" 각도, (8) 마일스톤 소속 주장 전수.

이 루프에서 배운 것 — 내가 고치면서 새 결함을 3건 만들었다

  1. 하드코딩 "넷" — 배너에 *"검증 스파이크 이 재작성 대기"*라고 쓰면서 같은 문장에서 "개수·현황의 소스는 STATUS.md, 여기서 세지 않음"이라 선언했다. 실제로는 7개.
  2. debounce-throttle-plan.md의 M3 누락 — "전부 M3/M8"에서 Blocker만 M2로 옮기다 State 코어(M3)가 통째로 빠졌다.
  3. nativeInsert 치환 시 순서 주장 미검토 — 3라운드에서 dispatch-core-plan.mdmountInstnativeInsert로 바꾸면서 이름만 갈고 그 이름이 든 문장이 무슨 주장을 하는지 안 읽었다. 그 문단은 역전된 옛 C-7("부기를 먼저 완결하고 그 다음 물리 마운트")을 현재형으로 주장하고 있었고, 지금 단건 경로는 정반대다 (setOffsetSourcenativeInsertsetLengthrecompute, slot-plan.mdrawAdd 의사코드가 소스). 역전 배너를 스스로 달아둔 바로 그 절 안에 옛 주장이 살아 있었다.

셋 다 conventions.md 핸드오버 체크리스트 2번("배너를 달았으면 그 배너가 부정하는 문장을 같은 커밋에서 고쳤는지 확인")이 경고하는 패턴이다. 일괄 치환은 그 자체가 이 실패 모드를 만든다 — 치환한 줄이 속한 문단을 안 읽으면, 옛 주장이 새 어휘를 입고 살아남는다. 앞으로 용어/이름 치환을 할 땐 치환 지점마다 문단 단위로 읽을 것.

감사자가 잡은 것 중 성격이 두드러진 것

  • ROADMAP.md 맨 위 헤드라인 배너에만 M2 블로커 경고가 빠져 있었다CLAUDE.md/project-context.md/todos.md/question.md 넷은 다 넣어놓고, 정작 그 넷이 "진행 상황의 소스"로 지목하는 문서의 첫 배너를 빠뜨렸다. ROADMAP만 읽는 사람이 M2를 바로 시작할 수 있는 상태였다.
  • architecture.md 소스 트리에 EpochMap.luau가 아예 없었다 — ROADMAP M2가 "별도 모듈로 낼 것"이라 명시하는데, "다음 세션이 실제로 만들 구조"라 자임하는 정본에서 빠져 있었다.
  • source-state-plan.mdBlocker를 "M3에서 함께 구현"이라 서술blocker-plan.md엔 정정 배너를 달았는데 정작 그 사실을 처음 서술한 본문은 안 고쳤던, 체크리스트 2번 그대로의 사례.
  • HUMAN_TODO.md만 인덱스 레이어 동기화에서 빠졌다 — *"question.md엔 이제 '결정 대기' 절 자체가 없음"*이라 단정 중이었다.
  • 두 패스가 코퍼스 전반에 남아 있었다ref-plan.md가 "본체 루프"로 고친 같은 문장 안에서 다시 "두 패스로 처리"라고 썼다. 구현 단정 용법은 전부 "본체 루프"로 바꾸고, ref-plan.md/lifecycle-hooks-plan.md에 용어 각주를 신설했다. "두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은 시점 표기는 의도적으로 남겼다PostRef 절 제목이기도 하고 인용 대상이라, 바꾸면 절 참조가 깨진다.

4. /code-review high — 감사 0건 뒤에 9건 더

감사 루프가 0건으로 수렴한 뒤에 사용자가 /code-review high를 돌렸고 9건이 더 나왔다. 전부 유효했다. conventions.md가 2026-08-18에 실측으로 기록해둔 것("code-review는 감사자를 대체하지 않는다 — 둘이 보는 축이 다르다")이 이번에도 그대로 재현됐다.

가장 나빴던 것 — 내가 새로 쓴 체크박스가 역전 전 표기를 구현 지시로 복제했다. M2의 GateNode 항목에 *"내보내는 emit은 emit(self) + 흡수 집합"*이라 적었는데, 확정 계약은 정반대다 — state-epoch-plan.md §5는 *"게이트 노드 자체는 페이로드에 안 싣는다 … 하류는 게이트 identity를 한 번도 안 쓴다"*이고, 넘기는 건 스왑해 떼어낸 EpochSet 스냅샷뿐이다. emit(self)Epoch 일반화 이전 표기인데 gate-plan.md의 배너와 4번 제목에 잔존해 있었고, 내가 그걸 그대로 베껴 M2를 짜는 사람이 게이트 노드를 출처로 싣게 만드는 지시로 승격시켰다. 뿌리인 gate-plan.md 두 곳까지 같이 고쳤다.

나머지 여덟 건의 성격:

  • F-4-1 정정이 자기 절 안의 다음 문장을 거짓으로 만들었다. 구현을 단일 일반화 for로 확정해놓고, 아래 문단은 여전히 *"base는 이 우연한 동작에 기대지 않고 명시적으로 강제한다"*고 주장했다. 단일 for는 정확히 그 언어 동작에 기대는 구현이다. 따라서 재작성될 01이 검증할 것도 "우리 드라이버가 계약대로 도는가"가 아니라 언어 동작 그 자체로 바뀐다. 그리고 nil-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안 된다는 따름정리도 같이 적었다.
  • gate-plan.md가 마일스톤 순환을 "해소"로 서술했다. Blocker를 M2로 옮겼다고 순환이 닫히지 않는다 — state:Gate는 State 메소드라 M2→M3 참조가 그대로 남는다. question.md 2번이 인정한 바로 그 사실인데 gate-plan만 읽으면 닫힌 것으로 읽혔다.
  • "M2 앞머리 두 항목이면 충족"이 부정확했다. Dispatch/init.luauDispatch.drive가 들어 있고 그 자신이 배치 등록을 Blocker로 게이팅한다 (dispatch-core-plan.md의 "적용 지점" 확정). 선택지 (b)로 쪼갠다면 drive를 어느 쪽에 두느냐가 경계선이 된다 — 사용자가 (a)/(c)를 고를 때 결합도를 실제보다 얕게 볼 뻔했다.
  • "M0 체크박스는 전부 닫혔다"가 같은 편집이 M0에 넣은 미체크 8개와 충돌. 새로 선언한 [x] 규약상 그것들은 실제 열린 작업이다.
  • 단일 소스로 지정한 자리가 정작 불완전했다 — architecture.mdEngineOps.luau 줄에 setTimeout/clearTimeout이 없었고, 인접 RobloxFactory.luau 줄의 주입 대상 목록도 native* 없이 멈춰 있었다. "개수 하드코딩을 지우고 소스를 하나로"라는 의도가 그 소스가 불완전해서 성립하지 않았다. 소스 쪽을 실제로 채웠다.
  • 근거로 제시한 문서를 열면 확인이 안 되는 주장None.luau가 소스 트리에 "같은 위치로 있다"고 적었으나 트리엔 Dispatch/None.luau만 있고 탑레벨 센티널도 Brand.luau도 줄이 없었다.
  • 새로 세운 [x] 규약이 같은 문서에서 이미 깨져 있었다 — M3의 store.key 타입 추론 확인이 코드가 아닌데 [x]였다. 배너가 위반 사례를 "M6/M11"로 한정 열거해서 감사에서 빠졌다. 규약 문구를 일반화하고 그 항목도 뺐다.
  • 재작성 지시가 없는 경로를 가리켰다luau-test/done/01-...인데 실제 파일은 rewrite-required/에 있다. 하필 "⚠️ 스파이크 01 재작성 필요" 지시문 자체가 그랬다.

이 세션의 교훈을 한 줄로: 감사 0건은 "코퍼스가 스스로와 정합한다"는 뜻이지 "이번에 새로 쓴 문장이 옳다"는 뜻이 아니다. 특히 다른 문서에서 문장을 베껴 새 체크박스를 만들 때, 그 문장이 이미 역전된 표기인지를 확인해야 한다 — 감사자는 "베껴온 곳과 원본이 같다"를 보고 정합으로 판정한다(실제로 emit(self)가 gate-plan에도 남아 있었으므로 둘은 "일치"했다).

5. code-review 뒤 감사 2라운드 — 그리고 실측된 패턴 하나

사용자 요청으로 code-review 수정분에 감사를 두 라운드 더 돌렸다.

  • 1라운드 3건, 전부 "직전 수정이 만든 것"todos.mdemit(self)를 "사용자 확정"으로 인용하면서 내가 방금 그 표기를 부정하는 배너를 단 문서를 근거로 가리키던 것, "주입 op 전체 목록의 소스는 architecture.md"라고 선언한 6줄 아래에 그 목록이 그대로 있던 것(게다가 native*도 시간 op도 없는 불완전한 셋이라, 단일 소스를 세우려던 편집이 오히려 "목록이 두 곳"을 고착시킬 뻔했다), nil-hole 문단 중복.
  • 2라운드 1건 — 이번엔 내 수정이 만든 게 아니라 앞 라운드들이 놓친 기존 자리였다. module-lifecycle-plan.md"주입 대상 목록에 엔진 op 3개가 추가됨" + *"addTag/removeTag/setAttribute 채우는 타입 계약"*으로 같은 하드코딩을 들고 있었다. 스윕해보니 debounce-throttle-plan.md도 두 곳에서 "엔진 op 3개"를 관례 비유로 쓰고 있었는데, 그 문서 자신이 setTimeout/clearTimeout을 추가해 그 개수를 늘리는 주체라 자기모순이었다.

실측된 패턴 — 개수 하드코딩은 "한 곳을 고치면 끝"이 아니다

"주입 op 3개"라는 같은 사실이 네 문서(ROADMAP.md, dispatch-core-plan.md, module-lifecycle-plan.md, debounce-throttle-plan.md)에 흩어져 있었고, conventions.md 체크리스트 4번이 예고한 그대로 전부 갈라져 있었다. 처음 둘만 고치고 "단일 소스로 정리했다"고 선언한 게 이 세션인데, 선언 자체가 나머지 둘을 못 찾았다. 개수를 지울 땐 그 개수의 모든 사본을 grep으로 전수 찾아야 하고, "소스는 X다"라고 선언한 뒤엔 X가 실제로 완전한지다른 사본이 남았는지를 같은 자리에서 확인해야 한다.

6. 남은 것

  • question.md 2번(M2/M3 마일스톤 경계) 사용자 회신 — M2 착수 전 필요. HUMAN_TODO.md 11번이 사용자용 진입점.
  • 사용자가 직접 /code-review를 돌린 뒤 그 결과 반영 + 커밋. conventions.md가 이미 실측으로 기록해둔 대로(2026-08-18) code-review는 감사자가 못 보는 축을 본다 — 이번 변경은 (a) 마일스톤 경계 이동, (b) 여러 문서에 걸친 용어/이름 일괄 치환, (c) 확정 문서의 순서 계약 정정이라 정확히 그 축에 걸린다.