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
This commit is contained in:
qwreey 2026-08-25 00:29:15 +09:00
parent 19cd046275
commit 56f8269236
Signed by: qwreey
GPG key ID: D28DB79297A214BD
26 changed files with 869 additions and 495 deletions

File diff suppressed because one or more lines are too long

View file

@ -1217,3 +1217,78 @@ hot path다.
`base/state-epoch-plan.md` §2, 근거 기록은 `base/state-epoch-plan.md` §2, 근거 기록은
`reference/epoch-brand-composition.md` §4의 4번. `reference/epoch-brand-composition.md` §4의 4번.
(필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.) (필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.)
## [해소됨, 2026-08-24] M2/M3 마일스톤 경계 — 순서 교체로 확정
2026-08-22 `ROADMAP.md` 전반 점검에서 드러난 항목. **설계 결정이 아니라
마일스톤 *순서* 문제**라 당시 `question.md`의 최우선 절이 아니라 2번
항목으로 따로 뒀었다. 2026-08-24에 사용자가 **(a) 순서 교체**를 선택해
같은 날 전량 반영됐다.
**발단** — 옛 M2(디스패치 엔진)와 옛 M3(Store/State/Source)의 의존이
양방향이라 그 순서로는 M2를 끝까지 짤 수 없었다.
- **디스패치 → 반응형 (본체 의존)**: `Dispatch.setLength`
`len: number | State<number>`를, `Dispatch.setOffsetSource`
`Source<number>`를 받고, `recompute``offset:Set()`을 부른다
(`base/dispatch-core-plan.md`의 "Length/Offset" 절). 2026-08-22에
디스패치 쪽으로 앞당겼던 `GateNode`/`Blocker`도 State 위에 얹힌다.
- **반응형 → 디스패치 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로
가드가 `Dispatch.addHandler` + `Handler.luau` 계약을 쓴다 — 레지스트리
등록 표면만 있으면 되므로 디스패치 **전체**를 요구하지 않는다.
**선택지와 판단** — (a) 순서 교체 / (b) 디스패치를 둘로 분할 / (c) 지금
구조를 두고 구현 시 알아서 오간다. **사용자 선택은 (a)**("(a) M2와 M3의
순서를 바꾸는게 맞겠던데"). 그 자리에서 같이 확인된 근거:
1. **의존의 비대칭**이 명확하다 — 무거운 쪽(본체 의존)을 아래에 깔고
가벼운 쪽(핸들러 등록)을 뒤로 미루는 게 맞다.
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`라는 원칙을 스스로 무효화한다.
**같이 확정된 것 — 순서만 바꿔서는 안 끝났다.** 옛 M2 안에 반응형보다
먼저 와야 하는 항목이 셋 섞여 있었고, 이들은 새 M2 앞머리의
`### 공통 기반` 절로 옮겨졌다(State-free이자 dispatch-free):
- **`Brand.luau`** — `Source``SourceBrand`+`EpochBrand`에 등록돼야 함
(`base/source-state-plan.md`), `isState`/`isObserver`/`isEffect`가 전부
여기 얹힘.
- **`Relate.luau`** — `bindLifetime`/`unbindLifetime`이 그 위에 구현됨
(`base/lifecycle-pattern.md`의 `InstData`/`BindData`).
- **`LifetimeHandle.luau` 인터페이스** — State 전파 루프가 발화마다
`canExecute`를, 이중 바인딩 게이트가 `canBound`를 부름. 이미 M8→디스패치로
한 번 앞당긴 전례가 있던 항목이라 한 칸 더 앞당긴 것뿐.
반대로 새 M3로 넘어간 것은 둘 — Observer/Effect **동적 경로 가드 등록**과
**`ObserverEffectLeafHandler`**(`H-39`). 둘 다 "핸들러를 등록한다"뿐이라
본체와 잘라내기 쉽고, **이것이 M2가 M3에 개념상 지던 유일한 의존**이었다 —
그 둘을 M3로 미뤘으므로 **빌드 순서상 역방향 간선은 남지 않는다**:
```
L0 공통 기반 Brand · Relate · LifetimeHandle(인터페이스) ┐
L1 반응형 Source/State/Store · EpochMap · Gate/GateNode · ├ M2
Blocker · :Compute/:Apply · Observer · Effect ┘
L2 디스패치 Handler · Dispatch 코어 · chains · None · ┐
setLength/setOffsetSource/getOffsetAt · ├ M3
Leaf · 가드 등록 · ObserverEffectLeafHandler ┘
```
**⚠️ 번호 재부여의 부작용** — 라이브 문서의 `M2`/`M3` 참조는 전부 새 번호로
맞췄지만(코퍼스의 참조가 거의 전부 "그 내용이 사는 마일스톤"을 가리켜서
기계적 맞교환으로 의미가 보존됨), **`session/`·`archive/`·`qa-request/`는
히스토리 문서라 소급 수정하지 않았다.** 2026-08-24 이전에 쓰인 그 문서들의
`M2`/`M3`는 **옛 의미**(M2=디스패치, M3=반응형)로 읽을 것. 이 경고는
`ROADMAP.md`의 M2 배너에도 있다.

View file

@ -483,7 +483,7 @@ end
멈춤 — 조용한 오작동이 아니라 **반복 재현되는 시끄러운 실패**. 그래서 멈춤 — 조용한 오작동이 아니라 **반복 재현되는 시끄러운 실패**. 그래서
별도 롤백 장치를 넣지 않음(코퍼스의 "에러=패닉 상태, 그 이후 정합성은 별도 롤백 장치를 넣지 않음(코퍼스의 "에러=패닉 상태, 그 이후 정합성은
관리 대상 아님" 원칙과 같은 결). 원자적 롤백이 필요하다고 판단되면 관리 대상 아님" 원칙과 같은 결). 원자적 롤백이 필요하다고 판단되면
그때 그룹 `process`에만 국소적으로 넣을 수 있음 — `question.md` 3번에 그때 그룹 `process`에만 국소적으로 넣을 수 있음 — `question.md` 2번에
열어둠. 열어둠.
- **`groupKey(v, name)`는 그룹 값 객체별·이름별 메모이즈** — 같은 그룹 - **`groupKey(v, name)`는 그룹 값 객체별·이름별 메모이즈** — 같은 그룹
값이 재프로세스될 때 같은 키가 나와야 claim이 자기 자신과 안 부딪힘. 값이 재프로세스될 때 같은 키가 나와야 claim이 자기 자신과 안 부딪힘.

View file

@ -16,13 +16,16 @@
문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이 문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이
위험을 구조적으로 우회한다. 위험을 구조적으로 우회한다.
**store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State **store 개발(M2)과 밀접하게 연관됨** — `state:Block(blocker)`가 State
위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State 위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함.
**⚠️ [2026-08-22 정정] 구현 마일스톤은 M3가 아니라 M2다** — 여기 "State와 **[2026-08-24 재확정] 구현 마일스톤은 다시 M2다 — "State와 같은
같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것"이라고 적혀 있었으나, 마일스톤에서 함께 구현"이라는 원래 서술이 맞다.** 2026-08-22엔 "게이팅
`Dispatch.drive`의 배치 등록이 `Blocker`를 호출하므로 "게이팅 먼저" 결정에 먼저"(`Dispatch.drive`의 배치 등록이 `Blocker`를 호출한다) 결정에 따라
따라 `Blocker.luau` 체크박스가 M2로 이동했다. 별도 파일로 두는 것은 그대로. `Blocker.luau` 체크박스를 디스패치 쪽으로 앞당겼었는데, 2026-08-24에
마일스톤 순서 자체가 교체되어(반응형이 M2, 디스패치가 M3) 앞당길 이유가
사라졌다 — 게이팅은 여전히 디스패치보다 먼저 지어진다. 별도 파일로 두는
것은 그대로.
**그리고 이제 `Blocker`는 바닥부터 짜는 게 아니라 공용 `GateNode` **그리고 이제 `Blocker`는 바닥부터 짜는 게 아니라 공용 `GateNode`
(`base/gate-plan.md`) 위에 얹는 정책이다** — 노드를 다시 만들지 말 것. (`base/gate-plan.md`) 위에 얹는 정책이다** — 노드를 다시 만들지 말 것.

View file

@ -201,7 +201,9 @@ leading/trailing/통과 후 창 재개방은 **완전히 동일**. 그래서 공
생겼으니 이름 붙여 꺼내는 것뿐. 생겼으니 이름 붙여 꺼내는 것뿐.
**[2026-08-21 실현 — 이 권고가 확정됐다]** 그 노드는 `state:Gate(setup)` **[2026-08-21 실현 — 이 권고가 확정됐다]** 그 노드는 `state:Gate(setup)`
만드는 **`GateNode`**이고 **M2**에서 구현된다(`base/gate-plan.md`). 다만 만드는 **`GateNode`**이고 **M2**에서 구현된다(`base/gate-plan.md` —
**[2026-08-24]** 2026-08-22엔 디스패치 쪽이었다가 마일스톤 순서 교체로
반응형 코어로 돌아왔다). 다만
"내부 공용"은 아니게 됐다 — **공개 표면**이다. `Debounce`/`Throttle` 쪽 "내부 공용"은 아니게 됐다 — **공개 표면**이다. `Debounce`/`Throttle` 쪽
관용구는 안 바뀐다: `Debounce{...}`가 돌려주는 팩토리가 내부에서 관용구는 안 바뀐다: `Debounce{...}`가 돌려주는 팩토리가 내부에서
`s:Gate(policy)`를 부르므로 `state:Apply(Debounce{...})`가 그대로 성립한다. `s:Gate(policy)`를 부르므로 `state:Apply(Debounce{...})`가 그대로 성립한다.
@ -1186,15 +1188,15 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했
맨 뒤로 미뤄도 됨(다른 기능이 이걸 의존하지 않고, 없어도 다른 기능이 안 맨 뒤로 미뤄도 됨(다른 기능이 이걸 의존하지 않고, 없어도 다른 기능이 안
막힘). 막힘).
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/ **의존성**: State 코어(`ROADMAP.md` M2) + 백엔드 주입 표면(`setTimeout`/
`clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]** `clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]**
각각 **M2**(게이트/`Blocker`) / **M3**(State 코어) / **M8**(`Ref`)에서 **[2026-08-24 재정리]** 각각 **M2**(State 코어 · 게이트 · `Blocker`) /
확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" — **M8**(`Ref`)에서 확정되는 것들이라 그 이후 언제든 얹을 수 있다. 2026-08-22엔
`Blocker.luau`M2로 옮겨지기 전 기준). **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를 `Blocker.luau`디스패치 쪽으로 앞당겨져 있어서 "게이트/`Blocker`는 다른
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정 마일스톤"이라고 적었으나, 2026-08-24 마일스톤 순서 교체로 되돌아왔다. 그
"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로). "게이트 노드를 공용으로 빼는" 작업도 같은 M2에서 State 코어와 함께 한다 —
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는 사용자 결정 "게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에
것은 그대로이고, 바뀐 건 **언제**뿐이다. 표면/이름은 아직 미정 — 의존하므로)는 그대로 지켜진다. 1절에서 봤듯 같은 노드를 공유하므로 따로
`base/gate-plan.md`가 소스(이 문서의 1절이 그 일반화를 처음 하면 같은 걸 두 번 설계하게 되는 것도 그대로다. 표면/이름은 `base/gate-plan.md`가 소스(이 문서의 1절이 그 일반화를 처음
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무 권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
때나 나중에 얹으면 된다. 때나 나중에 얹으면 된다.

View file

@ -205,7 +205,7 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를 그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를
**반환**하는 함수로 둔다. **반환**하는 함수로 둔다.
구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라
M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 M3(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의, `quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
라이브러리 자체에 내장된 개발자 편의 기능. 라이브러리 자체에 내장된 개발자 편의 기능.

View file

@ -215,7 +215,14 @@ end
### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK` ### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
(2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/ (2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/
source-state-plan.md`의 "동적 경로 가드" 절 참고.) `EffectHandle` source-state-plan.md`의 "동적 경로 가드" 절 참고.)
**⚠️ [2026-08-24] 이 가드를 실제로 `Dispatch.addHandler`로 등록하는 것은
M3(디스패치)다.** `HANDLER_PRIORITY_FALLBACK` 상수도 `Dispatch.addHandler`
M3에서 처음 생기므로, M2(반응형 코어)에서 본체를 짤 때는 **핸들러 정의만
준비해두고 등록 호출은 미룬다** — `ROADMAP.md` M3의 "Observer/Effect 동적
경로 가드 등록" 체크박스가 그 자리다(2026-08-24 마일스톤 순서 교체의 산물,
M2가 M3에 개념상 지던 유일한 의존이라 이쪽으로 미뤄졌다).
`EffectHandle`
children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로 children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로
흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK, 흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK,
isHandlable = function(inst,k,v) return isEffect(v) end, process = isHandlable = function(inst,k,v) return isEffect(v) end, process =
@ -472,7 +479,7 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."* 모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M3 **우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M2
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에 `Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로 `Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로
확정돼 `Gate`에 안 걸린다. 확정돼 `Gate`에 안 걸린다.

View file

@ -4,8 +4,9 @@
메커니즘을 `state:Gate(setup)` **메소드**로 확정했다 — *"Gate 는 따로 프리미티브 메커니즘을 `state:Gate(setup)` **메소드**로 확정했다 — *"Gate 는 따로 프리미티브
없이 `state:Gate( (emit) -> ()->() )` 처럼 선언되고 마치 Compute 처럼 없이 `state:Gate( (emit) -> ()->() )` 처럼 선언되고 마치 Compute 처럼
GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다 GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다
← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저" ← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**(반응형 코어 —
결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다 **[2026-08-24 정정]** 2026-08-22에 디스패치 쪽으로 앞당겼다가 마일스톤 순서
교체로 되돌아옴, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 마일스톤 범위뿐이고, 둘 다
구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은 구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.** 날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.**
(**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나 (**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나
@ -35,18 +36,19 @@ emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`s
## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다 ## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을 1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
전제하므로 M2가 M3`Blocker.luau`에 구조적으로 의존한다. 사용자 결정: 전제하므로 M3가 M2`Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
**게이팅을 먼저 만든다.** (이건 그 결정에 이르게 된 *당시* 상태 서술이다 — **게이팅을 먼저 만든다.** (이건 그 결정에 이르게 된 *당시* 상태 서술이다 —
**[2026-08-22] 지금은 `Blocker.luau` 자체가 M2에 있다**, 아래 9번. **[2026-08-24] 지금은 `Blocker.luau`가 M2(반응형 코어)에 있고 그 M2가
**⚠️ 다만 그걸로 순환이 닫힌 건 아니다** — `state:Gate`는 State 메소드이고 먼저 지어진다**, 아래 9번. 2026-08-22에 잠깐 디스패치 쪽으로 옮겼던 것은
`GateNode`/`Blocker`는 State 위에 얹히므로, M2로 옮겨도 M2→M3 참조는 마일스톤 순서 교체로 되돌려졌다 — `state:Gate`가 State 메소드이고
그대로 남는다. 그 사실은 `.claude/question.md` 2번이 별도 미결로 다룬다.) `GateNode`/`Blocker`가 State 위에 얹히는 이상 게이팅을 State보다 먼저 둘
수는 없었고, 그래서 **반응형 전체를 앞으로 옮기는** 쪽으로 풀렸다.)
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를 2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용 못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를 `Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로
**당시** 서술은 "M3에서 Blocker를 만들 때"였다 — **[2026-08-22]** 지금은 **당시** 서술은 "반응형 마일스톤에서 Blocker를 만들 때"였다 —
`Blocker.luau`도 M2다, 아래 9번). **[2026-08-24]** 지금은 `Blocker.luau`도 M2다, 아래 9번).
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가: **사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 *"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
@ -330,17 +332,17 @@ end)
- **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가 - **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가
아니다** — 아래 7번 참고. 아니다** — 아래 7번 참고.
9. **M2 범위 — [2026-08-22 해소] `Gate``Blocker` 둘 다 M2다.** 9. **마일스톤 범위 — [2026-08-24 재확정] `Gate``Blocker` 둘 다 M2(반응형
`Dispatch.drive`의 배치 등록이 실제로 쓰는 건 코어)다.** `Dispatch.drive`의 배치 등록이 실제로 쓰는 건
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한 `blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한
그 세 메서드가 도는 형태까지는 M2에 필요한데, 그렇다고 `Blocker` 그 세 메서드가 도는 형태까지는 M3(디스패치)가 요구한다. 2026-08-22엔
M3에 남겨두면 M2가 다시 뒤 마일스톤을 참조하게 된다. 같은 날 `ROADMAP.md` 그래서 `EpochMap.luau`/`GateNode`/`Blocker.luau` 체크박스를 디스패치
전반 점검에서 `EpochMap.luau`/`GateNode`/`Blocker.luau` **체크박스가 전부 쪽으로 **앞당겼는데**, 셋 다 State 위에 얹히는 이상 그걸로는 순환이 안
M2로 이동**했다(사용자 판단). `Blocker``GateNode`를 다시 만들지 말고 닫혔다 — 디스패치가 여전히 `Source.luau`/`State.luau`를 필요로 했다.
그 위의 정책으로 얹을 것. **2026-08-24에 마일스톤 순서 자체를 교체**(반응형이 M2, 디스패치가 M3)해
**⚠️ 이 이동이 M2↔M3 의존을 없애지는 않는다** — 셋 다 State 위에 그 앞당김은 되돌려졌고, 셋은 반응형 쪽으로 복귀했다. 결과적으로
얹히므로 M2는 여전히 `Source.luau`/`State.luau`를 필요로 한다. 마일스톤 "게이팅 먼저"는 그대로 지켜진다 — 게이팅이 디스패치보다 먼저 지어진다.
경계를 어떻게 그을지는 `.claude/question.md` 2번(사용자 회신 대기). `Blocker``GateNode`를 다시 만들지 말고 그 위의 정책으로 얹을 것.
## 관련 문서 ## 관련 문서
@ -348,5 +350,5 @@ end)
- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이 - `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이
`Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량. `Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅" - `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅"
절이 M2가 실제로 요구하는 표면. 절이 M3가 실제로 요구하는 표면.
- `ROADMAP.md` M2/M3. - `ROADMAP.md` M2/M3.

View file

@ -8,7 +8,7 @@
설치·`pesde install` 실행으로 검증]** 설치·`pesde install` 실행으로 검증]**
**전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지 **전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지
M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격 M2 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격
`New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음 `New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음
(`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde (`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde
규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만, 규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만,

View file

@ -96,16 +96,20 @@ local quad = New()
quad.Dispatch.addHandler(function() end) quad.Dispatch.addHandler(function() end)
``` ```
`luau-analyze``TypeError: Key 'Dispatch' not found in table 'Quad'`. `luau-analyze``TypeError: Key 'Dispatch' not found in table 'Quad'`.
즉 M2`Dispatch.luau`를 만들고 `module:RunInit(InitDispatch)` 패턴(이 문서가 즉 M3`Dispatch.luau`를 만들고 `module:RunInit(InitDispatch)` 패턴(이 문서가
이미 예시로 보여준 그 패턴)으로 붙이면 **런타임엔 붙지만 타입엔 영원히 안 이미 예시로 보여준 그 패턴)으로 붙이면 **런타임엔 붙지만 타입엔 영원히 안
보인다.** `ROADMAP.md` M5의 quad-roblox 주입 경로도 같은 벽에 부딪힌다 — 보인다.** `ROADMAP.md` M5의 quad-roblox 주입 경로도 같은 벽에 부딪힌다 —
quad-roblox는 `quad-types`의 좁은 `Quad`만 본다. quad-roblox는 `quad-types`의 좁은 `Quad`만 본다.
**확정(사용자, 2026-08-24): `quad-types``Quad`를 마일스톤마다 갱신한다.** **확정(사용자, 2026-08-24): `quad-types``Quad`를 마일스톤마다 갱신한다.**
- **M2**가 `Dispatch: Dispatch` 필드와 그 타입 재수출을 여기 추가한다 — - 규칙이 쓰인 계기는 `Dispatch`이고, **M3**가 `Dispatch: Dispatch` 필드와
`ROADMAP.md` M2 체크리스트에 **항목으로 명시**한다(지금까지 아무도 이 그 타입 재수출을 여기 추가한다 — `ROADMAP.md` M3 체크리스트에 **항목으로
필요성을 항목화해두지 않았다). 명시**한다(지금까지 아무도 이 필요성을 항목화해두지 않았다).
- 이후 서브시스템도 같은 규칙을 따른다. - **[2026-08-24 정정] 다만 규칙이 *처음 적용되는* 마일스톤은 M2다** —
마일스톤 순서 교체로 반응형 코어가 앞에 오면서, `Source`/`State`/`Store`
필드 추가가 `Dispatch`보다 먼저 온다(`ROADMAP.md` M2의 `H-25` 파생 항목).
- 이후 서브시스템도 같은 규칙을 따른다 — 서브시스템을 붙이는 **모든**
마일스톤(M2 · M3 · M6 · M7 · M8 · M10)이 같은 항목을 진다.
- **"가벼운 타입 계약"이라는 이 패키지의 존재 이유와 상충하지 않는다** — - **"가벼운 타입 계약"이라는 이 패키지의 존재 이유와 상충하지 않는다** —
타입만 재수출하므로 런타임 무게는 안 는다. 타입만 재수출하므로 런타임 무게는 안 는다.
- 검토했다 기각된 둘: **quad-base 내부만 넓은 로컬 교차 타입** - 검토했다 기각된 둘: **quad-base 내부만 넓은 로컬 교차 타입**

View file

@ -3383,7 +3383,7 @@ state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는
- **확정 형태**: `source:SetAndDispose(value)``:Set(value)`와 **한 세트로 - **확정 형태**: `source:SetAndDispose(value)``:Set(value)`와 **한 세트로
묶인 `Source` 전용 콜론 메서드**. `Set`(언마운트) → 옛 값 `dispose` 순서를 묶인 `Source` 전용 콜론 메서드**. `Set`(언마운트) → 옛 값 `dispose` 순서를
안에서 수행하므로 호출부가 `Get()`으로 옛 값을 미리 잡아둘 필요가 없다. 안에서 수행하므로 호출부가 `Get()`으로 옛 값을 미리 잡아둘 필요가 없다.
- **`state:Apply`는 손대지 않는다** — 시그니처 영향이 없으므로 M3 착수 전 - **`state:Apply`는 손대지 않는다** — 시그니처 영향이 없으므로 M2 착수 전
결론이 필요하던 항목에서 빠진다(`question.md`/`.claude/todos.md`에서 제거). 결론이 필요하던 항목에서 빠진다(`question.md`/`.claude/todos.md`에서 제거).
#### 구현상 바뀌어야 하는 것 #### 구현상 바뀌어야 하는 것

View file

@ -314,7 +314,7 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로
유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더 유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더
강해짐. 강해짐.
### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M3 착수 전 결론 필요**) ### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M2 착수 전 결론 필요**)
**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State -> **사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State ->
State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지 State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지
@ -343,7 +343,7 @@ With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지
**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의 **해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의
불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가 불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에). (`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
**미검증 상태로 M3에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은 **미검증 상태로 M2에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은
작음"은 이 항목이 닫히기 전까지는 잠정이다. 작음"은 이 항목이 닫히기 전까지는 잠정이다.
**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 **결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가
@ -389,11 +389,12 @@ lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy
`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 `Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아
Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는
store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브
`base/blocker-plan.md`가 다룸(**[2026-08-22 정정]** 여기 "State 개발과 같은 `base/blocker-plan.md`가 다룸(**[2026-08-24 재확정]** "State 개발과 같은
마일스톤, `ROADMAP.md` M3에서 함께 구현"이라 적혀 있었으나 `Blocker.luau` 마일스톤, `ROADMAP.md` M2에서 함께 구현"이 맞다 — 2026-08-22엔 `Blocker.luau`
**M2**로 이동했고, 바닥부터 짜는 게 아니라 공용 `GateNode` 디스패치 쪽으로 앞당겨져 갈라져 있었으나 마일스톤 순서 교체로 되돌아왔다.
(`base/gate-plan.md`) 위의 정책이다 — 마일스톤 소속의 소스는 다만 바닥부터 짜는 게 아니라 공용 `GateNode`(`base/gate-plan.md`) 위의
`blocker-plan.md`의 정정 배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던 정책이라는 점은 그대로 — 마일스톤 소속의 소스는 `blocker-plan.md`의 정정
배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던
초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — 초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 —
`archive/batch-rejected.md` 참고. `archive/batch-rejected.md` 참고.
@ -665,7 +666,7 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그
제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐). 제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐).
**실측 필요 — `luau-test``15-type-compute-trailing-deps-typepack.luau` **실측 필요 — `luau-test``15-type-compute-trailing-deps-typepack.luau`
신규(ROADMAP.md M3 반영).** 순서 문제 자체는 위 정정으로 구조적으로 신규(ROADMAP.md M2 반영).** 순서 문제 자체는 위 정정으로 구조적으로
풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 — 풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 —
나머지는 그 결론을 뒷받침하는 대조군: (A) 균일 타입 dep 1개를 고정 나머지는 그 결론을 뒷받침하는 대조군: (A) 균일 타입 dep 1개를 고정
인자로 좁히는 대조군(실패하면 B/C/D를 볼 것도 없이 기반 자체가 문제), 인자로 좁히는 대조군(실패하면 B/C/D를 볼 것도 없이 기반 자체가 문제),
@ -1014,6 +1015,13 @@ retract/Destroy되면 자동으로 정리됨.
### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK` ### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK`
(2026-08-14 열한 번째 세션, `PreRef`의 동적 경로 가드와 같은 패턴.) (2026-08-14 열한 번째 세션, `PreRef`의 동적 경로 가드와 같은 패턴.)
**⚠️ [2026-08-24] 이 가드를 실제로 `Dispatch.addHandler`로 등록하는 것은
M3(디스패치)다.** `HANDLER_PRIORITY_FALLBACK` 상수도 `Dispatch.addHandler`
M3에서 처음 생기므로, M2(반응형 코어)에서 본체를 짤 때는 **핸들러 정의만
준비해두고 등록 호출은 미룬다** — `ROADMAP.md` M3의 "Observer/Effect 동적
경로 가드 등록" 체크박스가 그 자리다(2026-08-24 마일스톤 순서 교체의 산물,
M2가 M3에 개념상 지던 유일한 의존이라 이쪽으로 미뤄졌다).
`Observer`도 children 배열 리터럴 전용이라, 해시 파트 named 자리 `Observer`도 children 배열 리터럴 전용이라, 해시 파트 named 자리
등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 — 등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 —
전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, 전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,

View file

@ -3,11 +3,13 @@
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 **상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. **채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."* 채택하면 될것 같아요."*
**구현 마일스톤은 둘로 갈린다** — **[2026-08-22 정정]** 여기 "구현은 M3"라고만 **구현 마일스톤은 전부 M2**(반응형 코어)다 — 부기 객체 `EpochMap.luau`,
적혀 있었다. 부기 객체 `EpochMap.luau``Epoch` 인터페이스·리비전 갱신 `Epoch` 인터페이스·리비전 갱신 규칙, 그걸 `valueEpochMap`/`emitEpochMap`
규칙은 **M2**(`GateNode`가 `emitEpochMap`을 쓰므로 — `ROADMAP.md` M2), 둘로 컴포지션하는 State 본체 통합이 전부 한 마일스톤 안이다.
그걸 `valueEpochMap`/`emitEpochMap` 둘로 컴포지션하는 **State 본체 통합은 **[2026-08-24 재확정]** 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어서
M3**다. `EpochMap`/`Epoch`만 그리 앞당겨져 **둘로 갈려 있었는데**, 마일스톤 순서
교체(`ROADMAP.md`의 M2 배너)로 `GateNode`가 반응형으로 돌아오면서 그 분리
자체가 없어졌다.
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는 **⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을

View file

@ -175,7 +175,7 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 — 탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 —
네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는 네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는
`GetDynamic<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되, `GetDynamic<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되,
M3/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 3번). M2/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 최우선 절).
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 - 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
@ -216,8 +216,8 @@ end
(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적 (flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적
일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이 일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이
방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이
확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — 확인돼 M0/M2 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 —
`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증 `ROADMAP.md`의 M0/M2 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증
난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의 난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의
실측도 완료** — 스파이크(`luau-test/done/16-type-store-key- 실측도 완료** — 스파이크(`luau-test/done/16-type-store-key-
typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져 typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져

View file

@ -207,5 +207,5 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
| 검증할 것 | 왜 | 출처 | | 검증할 것 | 왜 | 출처 |
|---|---|---| |---|---|---|
| ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7``Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` | | ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7``Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` |
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 | | 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M2 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 최우선 절 |
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` | | `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |

View file

@ -10,12 +10,17 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
길게 잡음. 길게 잡음.
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(디스패치 **[2026-08-24 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(반응형
엔진)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 코어 — Source/State/Store)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ 단 M2 착수를 막는 같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ [2026-08-24] M2와
순서 문제가 하나 열려 있음** — M2↔M3 양방향 의존, 소스는 M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 문제가 (a) 순서
`.claude/question.md` 2번(설계 결정이 아니라 마일스톤 경계 문제라 교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계"
"설계 게이트는 없다"는 아래·`todos.md` 서술과 모순되지 않음). 저장소 루트에 절, 새 구성은 `ROADMAP.md`의 M2 배너). **2026-08-24 이전에 쓰인
`session/`·`archive/`·`qa-request/`의 `M2`/`M3`는 옛 의미**(M2=디스패치,
M3=반응형)다. 그 교체의 부작용으로 **M2 착수 전에 답이 필요한 항목이
`question.md` 최우선 절로 올라왔다**(무엇이 몇 개인지는 그 절이 소스 —
여기서 세지 않는다. 설계 게이트가 아니라 실측·표면 선택이라, 설계 게이트가
없다는 아래와 `todos.md`의 서술과 모순되지 않음). 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md` 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -12,18 +12,42 @@
--- ---
## ⭐ 최우선 — 설계 결정은 **없음**, 다만 순서 문제 하나 (2026-08-22 갱신) ## ⭐ 최우선 — **M2(반응형 코어) 착수 전에 답이 필요한 것 둘** (2026-08-24 갱신)
> **[2026-08-22] 아래 2번(M2/M3 마일스톤 경계)이 M2 착수를 막습니다.** > **[2026-08-24] M2/M3 마일스톤 경계 문제는 닫혔습니다.** 그건 설계 결정이
> 그건 "무엇을 확정할까"가 아니라 "어떤 순서로 짤까"라 성격이 달라서 > 아니라 순서 문제라 이 절이 아니라 별도 항목으로 뒀었는데, 사용자가
> 이 절이 아니라 2번에 뒀습니다 — **설계 결정 대기는 여전히 0건**입니다. > **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 같은 날 전량
> 반영됐습니다 — 결정과 근거는 `archive/question-resolved.md`
> "마일스톤 경계" 절, 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너.
>
> **⚠️ 다만 그 교체의 부작용으로 아래 둘이 "한 마일스톤 뒤"에서
> "바로 다음"으로 올라왔습니다.** 둘 다 반응형(옛 M3, 지금 M2)의 게이트라
> 예전엔 급하지 않았는데, 반응형이 먼저 지어지게 되면서 **지금 답이
> 필요합니다.** 순수한 *설계* 결정 대기는 여전히 0건입니다 — 하나는 실측
> 미완이고 하나는 표면 위치 선택입니다.
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
지목했고, **명문화 여부 결정 + `luau-test` 실측이 M2 착수 전에 필요**
`base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`
콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의
lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와
부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과
**`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는
dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌
확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M2/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
> `0-A`(재디스패치 하강 diff)는 열네 번째 세션에, **`0-W`(`Ref` 이중 > `0-A`(재디스패치 하강 diff)는 열네 번째 세션에, **`0-W`(`Ref` 이중
> 배치 방지)는 2026-08-14 열한 번째 세션에 확정·`base/` 반영 완료** — > 배치 방지)는 2026-08-14 열한 번째 세션에 확정·`base/` 반영 완료** —
> 그래서 이 문서엔 이제 "결정 대기" 절 자체가 없음(비어서 헤딩째로 > 그래서 이 문서의 "결정 대기" 절은 한동안 비어 있었음(위 두 항목이
> 삭제). 해소 전 원문과 결론은 > 2026-08-24 순서 교체로 올라오기 전까지). 해소 전 원문과 결론은
> `archive/question-resolved.md`, 뒤집힌 옛 재디스패치 모델은 > `archive/question-resolved.md`, 뒤집힌 옛 재디스패치 모델은
> `archive/dispatch-hintvalue-model-reversed.md`. > `archive/dispatch-hintvalue-model-reversed.md`.
> >
@ -115,42 +139,7 @@
- `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계 - `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계
선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. 선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음.
## 2. M2/M3 마일스톤 경계 — **M2 착수 전에 답이 필요** (2026-08-22 신설) ## 2. 낮은 우선순위 — 열려 있지만 급하지 않음
**M2(디스패치 엔진)와 M3(Store/State/Source)의 의존이 양방향이라,
`ROADMAP.md` 순서대로면 M2를 끝까지 짤 수 없습니다.**
- **M2 → M3 (본체 의존)**: `Dispatch.setLength`
`len: number | State<number>`를, `Dispatch.setOffsetSource`
`Source<number>`를 받고, `recompute``offset:Set()`을 부릅니다
(`base/dispatch-core-plan.md`의 "Length/Offset" 절). 2026-08-22에 M2로
옮긴 `GateNode`/`Blocker`도 State 위에 얹힙니다. 즉 **M2는
`Source.luau`/`State.luau` 없이는 구현이 안 됩니다.**
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
`Dispatch.addHandler` + `Handler.luau` 계약을 씁니다 — 이건 레지스트리
등록 표면만 있으면 되므로 M2 **전체**를 요구하지 않습니다.
**⚠️ 다만 "M2 앞머리 두 항목(`Dispatch/init.luau` + `Handler.luau`)이면
된다"고는 말할 수 없습니다** — `Dispatch/init.luau`에는 `Dispatch.drive`
들어 있고, `dispatch-core-plan.md`가 *"적용 지점 — `Dispatch.drive`
`attachSlot`, 각각 자기 owner 키로 별도 Blocker"*라고 확정해 `drive`
게이팅을 씁니다. 즉 그 첫 항목 자체가 State-free가 아닙니다. 선택지 (b)로
쪼갠다면 `drive`를 어느 쪽에 두느냐가 경계선이 됩니다.
**선택지**:
- **(a) M2와 M3의 순서를 바꾼다** — 반응형(Source/State/EpochMap/Gate/
Blocker)을 먼저 짜고 그 위에 디스패치를 올림. 얕은 쪽(가드 Handler)만
뒤로 미루면 됨. 지금까지의 결정 흐름("게이팅 먼저")과 방향이 같음.
- **(b) M2를 둘로 쪼갠다** — `Dispatch.getHandler`/`process`/`retractFrom`/
`Handler`/`Brand`/`Relate`/`chains`까지가 M2a, State가 필요한
Length/Offset·게이팅은 M3 뒤의 M2b로.
- **(c) 지금 구조를 두고 구현 시 알아서 오간다** — 로드맵은 "순서"가
아니라 "묶음"으로만 읽음.
**[2026-08-22 기준] 이 항목이 M2 착수를 막습니다** — `.claude/todos.md`
0번이 "M2 착수를 막는 설계 항목은 없다"고 하는 것은 **설계** 얘기이고,
이건 설계가 아니라 **순서** 문제라 별개입니다.
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
@ -176,21 +165,6 @@
남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라
우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는
그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절. 그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`
콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의
lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와
부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과
**`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는
dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌
확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
지목했고, **명문화 여부 결정 + `luau-test` 실측이 M3 착수 전에 필요**
`base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절.
- **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패 - **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패
롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이 롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이
이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스 이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스
@ -205,8 +179,8 @@
읽는 게 quad 관습"이라는 언급은 2026-08-06 후속 세션에서 해소 — 읽는 게 quad 관습"이라는 언급은 2026-08-06 후속 세션에서 해소 —
채택 안 함으로 확정, `base/event-plan.md` "이벤트 핸들러는 채택 안 함으로 확정, `base/event-plan.md` "이벤트 핸들러는
self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔 self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔
착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M2 Dispatch/ 착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M3 Dispatch/
M3 Source/M5 `D` 생성자) 시점에 훅 확장 지점만 고려해두면 됨. M2 Source/M5 `D` 생성자) 시점에 훅 확장 지점만 고려해두면 됨.
- **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는
패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로
올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요.
@ -228,9 +202,13 @@
Instance를 동적 배열 원소로 받을 수 있는지, retract 시 어떻게 다루는지. Instance를 동적 배열 원소로 받을 수 있는지, retract 시 어떻게 다루는지.
**Slot 코어 구현(M6) 시점에 확인**`research/v1-compat-plan.md` 7-3. **Slot 코어 구현(M6) 시점에 확인**`research/v1-compat-plan.md` 7-3.
> **없어진 번호에 대해**: 예전 "0번(추가 프리미티브)"과 "2번(구현 착수 > **⚠️ 번호는 재사용된다 — 옛 문서가 가리키는 번호를 그대로 믿지 말 것.**
> 직전 감사 결과)"은 전원 해소되어 통째로 `archive/question-resolved.md` > 예전 "0번(추가 프리미티브)"과 "2번(구현 착수 직전 감사 결과)"은 전원
> 갔음. 우선순위1 11개의 개별 상태가 궁금하면 > 해소되어 통째로 `archive/question-resolved.md`로 갔고, 그 뒤 **"2번"은 두
> 번 더 다른 내용으로 재사용됐다** — 2026-08-22엔 "M2/M3 마일스톤 경계"
> (2026-08-24 해소·아카이브 이관), 지금은 바로 위 "낮은 우선순위" 절이다.
> 옛 세션/아카이브 문서가 `question.md` N번을 가리키면 **그 문서가 쓰인
> 시점의 번호**로 읽을 것. 우선순위1 11개의 개별 상태가 궁금하면
> `research/pre-implementation-audit.md`가 원본이자 최신. > `research/pre-implementation-audit.md`가 원본이자 최신.
--- ---

View file

@ -471,10 +471,10 @@ Tween mock 등 동적 동작 포함")와 목적이 다름:
설계 시 "고려는 해두되 지금 확정/구현하지는 않는" 참고용 메모로 남김 설계 시 "고려는 해두되 지금 확정/구현하지는 않는" 참고용 메모로 남김
(`ROADMAP.md`의 M2/M3/M5 근처에 훅 확장 지점 존재 가능성만 인지해두는 정도): (`ROADMAP.md`의 M2/M3/M5 근처에 훅 확장 지점 존재 가능성만 인지해두는 정도):
- M2(디스패치 엔진) 구현 시 `process`/`retract` 스캔 루프에 나중에 훅 - M3(디스패치 엔진) 구현 시 `process`/`retract` 스캔 루프에 나중에 훅
하나를 끼워 넣기 쉬운 모양으로 짜여 있는지 정도만 유의(지금 훅 자체를 하나를 끼워 넣기 쉬운 모양으로 짜여 있는지 정도만 유의(지금 훅 자체를
만들 필요는 없음). 만들 필요는 없음).
- M3(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기 - M2(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기
쉬운 생성자 모양인지만 유의. 쉬운 생성자 모양인지만 유의.
- M5(quad-roblox `D` 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 - M5(quad-roblox `D` 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기
쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미 쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미

View file

@ -179,7 +179,7 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
`modifier-plan.md` 8번 절), 네임스페이스 이름으로 쓰면 "이 특정 `modifier-plan.md` 8번 절), 네임스페이스 이름으로 쓰면 "이 특정
모듈"과 "패턴을 가리키는 일반 용어"가 헷갈릴 수 있어 후보에서 제외. 모듈"과 "패턴을 가리키는 일반 용어"가 헷갈릴 수 있어 후보에서 제외.
`.claude/question.md` 3번(낮은 우선순위)에도 반영. 용어 정리 라운드 `.claude/question.md` 2번(낮은 우선순위)에도 반영. 용어 정리 라운드
(`question.md` 1번, `Brand`/`Tag`류)와 같은 카테고리로 나중에 같이 (`question.md` 1번, `Brand`/`Tag`류)와 같은 카테고리로 나중에 같이
검토해도 됨 — 급하지 않음. 검토해도 됨 — 급하지 않음.

View file

@ -89,7 +89,7 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌
여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가
슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이
전체 체인을 배열로 들고 있는 쪽으로 정리됨. 상세는 `base/ 전체 체인을 배열로 들고 있는 쪽으로 정리됨. 상세는 `base/
dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M3/M4. 아래는
원래 발견 당시 기록. 원래 발견 당시 기록.
**위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 90-91행 — **위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 90-91행 —
@ -107,7 +107,7 @@ dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는
**제안**: `Dispatch/StoreBind.luau`가 "마지막으로 선택된 핸들러" 자체를 **제안**: `Dispatch/StoreBind.luau`가 "마지막으로 선택된 핸들러" 자체를
`(inst, k)`별 상태로 들고 있다가, 새 `realv` 처리 전에 그 핸들러의 `(inst, k)`별 상태로 들고 있다가, 새 `realv` 처리 전에 그 핸들러의
`retract`를 호출하는 식으로 지금 결정해두는 게 좋아 보임 — M2/M4에서 바로 `retract`를 호출하는 식으로 지금 결정해두는 게 좋아 보임 — M3/M4에서 바로
부딪힐 지점. 부딪힐 지점.
### 1-3. 우선순위 스캔의 동률 처리, 매치 실패 시 동작이 정의 안 됨 — [해소됨, 2026-08-12 열일곱 번째 세션] ### 1-3. 우선순위 스캔의 동률 처리, 매치 실패 시 동작이 정의 안 됨 — [해소됨, 2026-08-12 열일곱 번째 세션]
@ -116,7 +116,7 @@ dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는
(`HANDLER_PRIORITY_HIGH` 등, "밴드+오프셋" 패턴)로 애초에 안 나게 유도, (`HANDLER_PRIORITY_HIGH` 등, "밴드+오프셋" 패턴)로 애초에 안 나게 유도,
매치 실패는 조용한 무시 없이 즉시 `error`(브랜드+`typeof` 출력, provider 매치 실패는 조용한 무시 없이 즉시 `error`(브랜드+`typeof` 출력, provider
초기화 확인 안내)로 확정. 핸들러 등록 시점 동률 감지 print 경고와 초기화 확인 안내)로 확정. 핸들러 등록 시점 동률 감지 print 경고와
`Dispatch.listHandlers()`류 전체 목록 조회 함수도 M2 기본 기능으로 `Dispatch.listHandlers()`류 전체 목록 조회 함수도 M3 기본 기능으로
확정 — 상세는 `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 확정 — 상세는 `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리"
절. 아래는 원래 발견 당시 기록. 절. 아래는 원래 발견 당시 기록.
@ -131,7 +131,7 @@ dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는
**제안**: 최소한 "매치 실패는 에러(silent 무시 금지)"만이라도 지금 **제안**: 최소한 "매치 실패는 에러(silent 무시 금지)"만이라도 지금
결정해두면 구현 중 인터럽트를 막을 수 있음. 동률은 "등록 순서가 tiebreak" 결정해두면 구현 중 인터럽트를 막을 수 있음. 동률은 "등록 순서가 tiebreak"
정도로 명시만 해둬도 충분. M2(Dispatch 엔진) 착수 직전 확인. 정도로 명시만 해둬도 충분. M3(Dispatch 엔진) 착수 직전 확인.
### 1-4. provider(팩토리) 미주입 상태에서 dispatch가 호출되면 어떻게 되는지 세 번째 케이스가 빠짐 — [해소됨, 2026-08-12 열일곱 번째 세션] ### 1-4. provider(팩토리) 미주입 상태에서 dispatch가 호출되면 어떻게 되는지 세 번째 케이스가 빠짐 — [해소됨, 2026-08-12 열일곱 번째 세션]
@ -153,7 +153,7 @@ nil-index 크래시 vs 조용한 no-op)가 안 정해져 있음.
**제안**: base dispatch 엔진이 "아직 provider 미주입" 상태를 감지해 명확한 **제안**: base dispatch 엔진이 "아직 provider 미주입" 상태를 감지해 명확한
에러를 던지도록 지금 결정해두면, 구현 중 흔한 초기화 순서 실수를 훨씬 덜 에러를 던지도록 지금 결정해두면, 구현 중 흔한 초기화 순서 실수를 훨씬 덜
헷갈리게 만들 수 있음. 1-2번과 같은 타이밍(M2)에 같이 확정. 헷갈리게 만들 수 있음. 1-2번과 같은 타이밍(M3)에 같이 확정.
### 1-5. `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 nil-hole 함정에 그대로 노출됨 ### 1-5. `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 nil-hole 함정에 그대로 노출됨
@ -278,7 +278,8 @@ element별 weak-set) — throw 조건을 "Slot 핸들러의 `process`가 같은
**[2026-08-07 세 번째 세션 갱신 — 반영 완료.]** 아래 제안대로 **[2026-08-07 세 번째 세션 갱신 — 반영 완료.]** 아래 제안대로
`LifetimeHandle.luau`/`PerInstanceState.luau` 인터페이스가 `ROADMAP.md` `LifetimeHandle.luau`/`PerInstanceState.luau` 인터페이스가 `ROADMAP.md`
M2로 이동됐고, M8은 quad-roblox 실제 구현만 담당하도록 분리됨 — 더 이상 M2로 이동됐고(**[2026-08-24]** 마일스톤 순서 교체로 반응형 코어 앞머리의
"공통 기반" 절), M8은 quad-roblox 실제 구현만 담당하도록 분리됨 — 더 이상
열린 항목 아님, 아래는 원래 발견 당시 기록. 열린 항목 아님, 아래는 원래 발견 당시 기록.
**위치(당시)**: `ROADMAP.md` M8 "Ref" — `"LifetimeHandle 인터페이스 + quad-roblox **위치(당시)**: `ROADMAP.md` M8 "Ref" — `"LifetimeHandle 인터페이스 + quad-roblox
@ -287,40 +288,44 @@ M2로 이동됐고, M8은 quad-roblox 실제 구현만 담당하도록 분리됨
**문제**: `base/lifecycle-pattern.md`("생명 바인드 유틸"을 State-invalidate **문제**: `base/lifecycle-pattern.md`("생명 바인드 유틸"을 State-invalidate
리스너 클로저 등록에도 재사용)와 `base/slot-plan.md`(Slot의 `retract` 리스너 클로저 등록에도 재사용)와 `base/slot-plan.md`(Slot의 `retract`
같은 canExecute 패턴을 그대로 씀)는 둘 다 이 유틸을 State/Store 구독 같은 canExecute 패턴을 그대로 씀)는 둘 다 이 유틸을 State/Store 구독
(M3/M4 영역)과 Slot(M6)에서 이미 쓴다고 명시하는데, `LifetimeHandle` (M2/M4 영역)과 Slot(M6)에서 이미 쓴다고 명시하는데, `LifetimeHandle`
인터페이스 자체는 M8에서야 정의된다. 즉 M4/M6이 개념적으로 필요로 하는 인터페이스 자체는 M8에서야 정의된다. 즉 M4/M6이 개념적으로 필요로 하는
base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전.
**제안**: `LifetimeHandle.luau`(quad-base, 인터페이스만)를 M2(Dispatch **제안**: `LifetimeHandle.luau`(quad-base, 인터페이스만)를 M3(Dispatch
엔진) 또는 M3(Store/State)로 옮기고, M8은 "quad-roblox 실제 구현(Instance 엔진) 또는 M2(Store/State)로 옮기고, M8은 "quad-roblox 실제 구현(Instance
`Connected` 기반)"만 담당하도록 분리. M1 mock에도 이 인터페이스의 트리비얼 `Connected` 기반)"만 담당하도록 분리. M1 mock에도 이 인터페이스의 트리비얼
스텁(항상 true)을 붙여두면 M4/M6 테스트가 자연스러워짐. 스텁(항상 true)을 붙여두면 M4/M6 테스트가 자연스러워짐.
### 1-10. `store.key`의 레코드 필드 타이핑 검증이 M0가 아니라 M3로 밀려 있음 — [해소됨, 2026-08-12 열일곱 번째 세션] ### 1-10. `store.key`의 레코드 필드 타이핑 검증이 M0가 아니라 M2로 밀려 있음 — [해소됨, 2026-08-12 열일곱 번째 세션]
**해소**: 걱정했던 "검증 난이도" 자체가 사라짐 — Luau `type function` **해소**: 걱정했던 "검증 난이도" 자체가 사라짐 — Luau `type function`
(`https://luau.org/types/type-functions/`)으로 `Store<T>``T`의 각 (`https://luau.org/types/type-functions/`)으로 `Store<T>``T`의 각
필드를 `Source`로 감싼 레코드 타입을 실제로 합성 가능함을 확인(tbox에서도 필드를 `Source`로 감싼 레코드 타입을 실제로 합성 가능함을 확인(tbox에서도
이미 쓰이는 패턴). 결과가 구조적으로 `Source<T>`를 만족하기만 하면 Luau가 이미 쓰이는 패턴). 결과가 구조적으로 `Source<T>`를 만족하기만 하면 Luau가
이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이 이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이
없어졌으니 M0/M3 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로 없어졌으니 M0/M2 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로
안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드 안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드
타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key- 타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key-
typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전 typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전
드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/ 드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/
REPORT.md` 6-1절). 아래는 원래 발견 당시 기록. REPORT.md` 6-1절). 아래는 원래 발견 당시 기록.
**위치**: `ROADMAP.md` M0 vs M3 `"store.key dot-access 타입 추론 확인"`. **위치**: `ROADMAP.md` M0 vs M2 `"store.key dot-access 타입 추론 확인"`.
**문제**: M0의 정의 자체가 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 **문제**: M0의 정의 자체가 "추론만으로 확정하고 실제 Luau로 부딪혀본 적
없는 것"을 검증하는 단계다. `base/source-state-plan.md`가 요청한 M0 항목( 없는 것"을 검증하는 단계다. `base/source-state-plan.md`가 요청한 M0 항목(
"Source가 State를 만족하는 제네릭 메소드 체이닝"의 솔버 안정성)은 이미 "Source가 State를 만족하는 제네릭 메소드 체이닝"의 솔버 안정성)은 이미
반영됐지만, 이건 `:Compute` 같은 제네릭 메소드 체이닝만 다루고 `{key: 반영됐지만, 이건 `:Compute` 같은 제네릭 메소드 체이닝만 다루고 `{key:
Source<number>}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기 Source<number>}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기
대칭성 논거의 핵심 전제)은 별개로 M3에 남아있다. 같은 리스크 카테고리인데 대칭성 논거의 핵심 전제)은 별개로 Store/State 마일스톤에 남아있다. 같은
M1(스캐폴딩)·M2(디스패치 엔진) 투자가 먼저 이뤄진 뒤에야 검증되는 셈이라, 리스크 카테고리인데 M1(스캐폴딩)·디스패치 엔진 투자가 먼저 이뤄진 뒤에야
여기서 걸리면 이미 만든 스캐폴딩/디스패치 타입 시그니처를 다시 손봐야 할 검증되는 셈이라, 여기서 걸리면 이미 만든 스캐폴딩/디스패치 타입 시그니처를
수 있음. 다시 손봐야 할 수 있음. (**[2026-08-24]** 이 문단은 *발견 당시*의 서술이고,
그 우려는 **[2026-08-15 실측 완료]**로 이미 해소됐다. 덧붙여 2026-08-24
마일스톤 순서 교체로 Store/State가 디스패치보다 **먼저** 오게 되어 "디스패치
투자를 다 한 뒤에야 검증된다"는 전제 자체도 더는 성립하지 않는다 — 히스토리
블록이라 원문 논거는 그대로 두고 마일스톤 번호만 뺐다.)
**제안**: M0 항목에 "`store.key`가 실제로 `Source<T>` 레코드 필드로 **제안**: M0 항목에 "`store.key`가 실제로 `Source<T>` 레코드 필드로
안전하게 추론되는지"도 같이 넣을 것 — 어차피 같은 스파이크 파일에서 몇 줄 안전하게 추론되는지"도 같이 넣을 것 — 어차피 같은 스파이크 파일에서 몇 줄
@ -714,7 +719,7 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴
개념을 참조하는 채로 남아있진 않은지 확인 — 이미 "Source가 State를 개념을 참조하는 채로 남아있진 않은지 확인 — 이미 "Source가 State를
만족함" 최신 모델로 정정돼 있어 문제없음. 만족함" 최신 모델로 정정돼 있어 문제없음.
- `ROADMAP.md``store.key = value`(구 `__newindex`) 모델을 암시하는 - `ROADMAP.md``store.key = value`(구 `__newindex`) 모델을 암시하는
잔여 표현은 없음 — M3/M4 서술 모두 문법을 명시하지 않아 최신 `:Set()` 잔여 표현은 없음 — M2/M4 서술 모두 문법을 명시하지 않아 최신 `:Set()`
모델과 직접 충돌하는 곳은 없음. 모델과 직접 충돌하는 곳은 없음.
- M9(컴포넌트 합성)이 M7(Modifier)·M8(Ref) 뒤에 오는 순서 — M9는 "M0 - M9(컴포넌트 합성)이 M7(Modifier)·M8(Ref) 뒤에 오는 순서 — M9는 "M0
스파이크(named-parameter 전달)를 정식 Modifier/Ref로 검증"하는 단계라고 스파이크(named-parameter 전달)를 정식 Modifier/Ref로 검증"하는 단계라고
@ -732,7 +737,7 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴
착수 전에 열려있는 우선순위1 항목이 없음. 아래는 남은 실측/검증 항목만 착수 전에 열려있는 우선순위1 항목이 없음. 아래는 남은 실측/검증 항목만
정리(전부 "설계는 확정, 실제 Luau/구현으로 부딪혀볼 것만 남음" 상태): 정리(전부 "설계는 확정, 실제 Luau/구현으로 부딪혀볼 것만 남음" 상태):
- **M2(Dispatch) 착수 시**: 1-2/1-3/1-4는 설계 확정 완료 — M2 스파이크에서 - **M3(Dispatch) 착수 시**: 1-2/1-3/1-4는 설계 확정 완료 — M3 스파이크에서
다단 체인 케이스, 동률 감지 디버그 print, 매치 실패 에러 경로가 실제로 다단 체인 케이스, 동률 감지 디버그 print, 매치 실패 에러 경로가 실제로
맞게 동작하는지 실측. 맞게 동작하는지 실측.
- **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측(1-9는 반영 완료, 위 - **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측(1-9는 반영 완료, 위

View file

@ -1839,3 +1839,38 @@ CRUD는 상호배타"라며 승인받은 분기가 **재마운트 경로를 안
자리 넷을 열거해 세기")으로 잡은 게 그래서고 거기서 4건이 더 나왔다. 자리 넷을 열거해 세기")으로 잡은 게 그래서고 거기서 4건이 더 나왔다.
감사에서 파생된 새 결정 셋(주입 op `onDestroying` 신설, `EffectHandle`의 필드 감사에서 파생된 새 결정 셋(주입 op `onDestroying` 신설, `EffectHandle`의 필드
다섯, `quad-types``Quad` 갱신을 M3/M6/M7/M8/M10에도 항목화)도 같이 반영했다. 다섯, `quad-types``Quad` 갱신을 M3/M6/M7/M8/M10에도 항목화)도 같이 반영했다.
## 2026-08-24-02 — M2/M3 마일스톤 순서 교체 (반응형 먼저)
2026-08-22가 남긴 마지막 열린 항목(마일스톤 순서)을 사용자가 **(a) 순서 교체**로
닫았다 — 이제 **M2=반응형 코어, M3=디스패치 엔진**이다. 의존이 양방향처럼
보였지만 디스패치→반응형만 본체 의존이었고, 옛 순서로는 디스패치 마일스톤의
`mock 대상 테스트`조차 State 없이 불가능했다. 부수로 **`Brand`/`Relate`/
`LifetimeHandle` 인터페이스가 M2 앞머리 "공통 기반" 절로** 왔고(반응형이 이 셋을
먼저 요구), 2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`는
**되돌아왔다**(앞당길 이유가 사라짐, "게이팅 먼저"는 그대로). 역방향으로 남은
건 "핸들러를 등록한다"뿐인 둘(동적 경로 가드, `ObserverEffectLeafHandler`)이라
M3로 넘겼다 — 그래서 빌드 순서상 역방향 간선이 없다.
**대가 하나** — 반응형이 앞으로 오면서 그 게이트 둘(중간 State GC 실측,
`store:GetDynamic` 위치)이 낮은 우선순위에서 **`question.md` 최우선 절로
승격**됐다. **실행 중 실수 하나** — 일괄 치환에 `\bM([23])\b`를 써서 한글이
바로 뒤에 오는 `M2는`/`M2로` 93건이 안 바뀌었다(Python `\w`는 유니코드라 한글도
단어 문자). `git show HEAD:<경로>`로 되돌린 뒤 ASCII 경계 lookaround로 재실행해
248건 전량 교체. **라이브 문서만 새 번호로 맞췄고 `session/`·`archive/`·
`qa-request/`는 히스토리라 소급 수정하지 않았다** — 그 경고를 인덱스 레이어
다섯 곳에 박아뒀다. 원문은 `session/2026-08-24-02-milestone-order-swap.md`.
**검증**: `quad-doc-auditor` 루프가 **7라운드에서 새 발견 0건으로 수렴**
(10→8→1→1→2→2→0, 총 24건 수정, 라운드마다 각도 교체). 가장 값이 큰 건
5라운드(구현 순서 시뮬레이션) — 새 M2에서 `Source`/`State`/`Store`가
`EpochMap.luau`보다 앞에 놓여 **그 순서로는 `State.luau`를 못 짜는**
잡았고(State가 `valueEpochMap`/`emitEpochMap`을 컴포지션), 같은 라운드가
순서 교체의 전제("M2가 디스패치 없이 완주 가능한가")도 검증했다.
**수렴 뒤 `/code-review high`가 5건을 더 잡았고 전부 유효** — 다섯 중 넷이
*"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류(그중 둘은
`quad-types`의 "마일스톤마다 갱신" 규칙이 M3에 앵커된 채 첫 적용만 M2로
옮겨간 같은 뿌리)였고, 그중
하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가 문장
그대로 거짓이 된 것이었다(소급 수정 제외 대상을 `session/`·`archive/`·
`qa-request/`로만 잡은 게 샜다). `conventions.md`의 *"`/code-review`는
감사자를 대체하지 않는다"*가 또 재확인됐다.

View file

@ -0,0 +1,170 @@
# 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. **의존의 비대칭이 명확하다.** 디스패치 → 반응형은 *본체* 의존이라
우회 불가(`setLength`가 `State<number>`를, `setOffsetSource`
`Source<number>`를 받고 `recompute``offset: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.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.md` M2/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` 의존을 만들지
않는다"*로 확정해뒀다 — 마일스톤이 갈리면서 처음 눈에 띈 잔재.
**교훈**: 기계 치환은 *토큰*을 바꾸지 *주장*을 바꾸지 않는다. 치환 대상이
많을수록 "그 토큰을 설명하던 산문"과 "히스토리 블록"을 따로 훑어야 한다.

View file

@ -10,8 +10,9 @@
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate` **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md` 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`
**[2026-08-22 정정]** 구현 마일스톤은 갈린다: `EpochMap`/`Epoch` **[2026-08-24 재정정]** 둘 다 **M2**다: 2026-08-22엔 `EpochMap`/`Epoch`
인터페이스는 **M2**, State 본체 통합은 **M3**) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. 인터페이스만 디스패치 쪽으로 앞당겨 갈라져 있었으나, 마일스톤 순서
교체로 되돌아와 State 본체와 같은 마일스톤이 됐다) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다.
**[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** — **[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** —
부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가 부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가
`Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다 `Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다
@ -21,7 +22,7 @@
리비전 증가 방식도 `bit32` 랩으로 확정** — `Epoch`/`EpochMap`/`Brand`에 리비전 증가 방식도 `bit32` 랩으로 확정** — `Epoch`/`EpochMap`/`Brand`에
열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2). 열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2).
**[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다 **[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M3 항목으로
되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로 되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번. 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번.
**[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀 **[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀
@ -38,24 +39,26 @@
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md` `Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md`
순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.** 순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.**
**⚠️ [2026-08-22 신설] 다만 설계가 아니라 *순서*가 막고 있다.** **✅ [2026-08-24 해소] 설계가 아니라 *순서*가 막던 것도 닫혔다.**
`ROADMAP.md` 전반 점검에서 **M2와 M3의 의존이 양방향**인 게 드러났다 — 2026-08-22 `ROADMAP.md` 전반 점검에서 옛 M2(디스패치)와 옛 M3(반응형)의
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 `State<number>`/ 의존이 양방향인 게 드러났었는데, **사용자가 (a) 순서 교체를 선택**해
`Source<number>`를 쓰므로 M2는 `Source.luau`/`State.luau` 없이 구현이 같은 날 전량 반영됐다 — 이제 **M2가 반응형 코어, M3가 디스패치 엔진**
안 되고, 반대로 M3의 `Observer`/`Effect` 동적 경로 가드는 M2의 이고 역방향 의존이 없다. 결정·근거·기각된 선택지는
`Dispatch.addHandler`를 쓴다. 선택지 셋(M2/M3 순서 교체 / M2를 둘로 `archive/question-resolved.md`의 "마일스톤 경계" 절이 소스, 새 마일스톤
분할 / 지금 구조 유지)은 **`question.md` 2번이 소스** — 여기서 반복하지 구성은 `ROADMAP.md`의 M2 배너 — 여기서 반복하지 않는다. 부수로
않는다. 같은 점검에서 `EpochMap`/`GateNode`/`Blocker`/`None`이 M3·M7에서 `Brand`/`Relate`/`LifetimeHandle` 인터페이스가 M2 앞머리 "공통 기반"
**M2로 실제 이동**했고(각주로만 예고돼 있던 것), `Dispatch.drive` 절로 왔고, 2026-08-22에 디스패치로 앞당겼던
두 패스가 아니라 단일 일반화 `for`라는 `F-4-1` 정정과 물리 조작 주입 op `EpochMap`/`GateNode`/`Blocker`는 M2로 되돌아왔으며, Observer/Effect
이름 `native*` 확정이 `ROADMAP.md`/`dispatch-core-plan.md`에 반영됐다. 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는 M3로 갔다.
M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도 **⚠️ 2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/`의
`ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히 `M2`/`M3`는 옛 의미**(M2=디스패치, M3=반응형)로 읽을 것.
`luau-test/STATUS.md`). 같은 2026-08-22 점검에서 `Dispatch.drive`가 두 패스가 아니라 단일 일반화
`for`라는 `F-4-1` 정정과 물리 조작 주입 op 이름 `native*` 확정도
`ROADMAP.md`/`dispatch-core-plan.md`에 반영됐고, M0 체크박스가 검증했던
스파이크 중 재작성 대기인 것들도 `ROADMAP.md`의 "재검증 대기" 절로
모았다(현황의 소스는 여전히 `luau-test/STATUS.md`).
그 외 남은 것은 판단이 아니라 구현 시 정할 것 하나 — 스파이크 `05` 그 외 남은 것은 판단이 아니라 구현 시 정할 것 하나 — 스파이크 `05`
재작성(`luau-test/STATUS.md`). 재작성(`luau-test/STATUS.md`).
**[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날
`ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다.
**[2026-08-24 해소, 6라운드 `H-49`]** 여기 `Gate`의 "생명주기·재진입 계약"을 **[2026-08-24 해소, 6라운드 `H-49`]** 여기 `Gate`의 "생명주기·재진입 계약"을
같이 나열했었는데 **두 항목 다 부정확했다** — 재진입은 이미 같이 나열했었는데 **두 항목 다 부정확했다** — 재진입은 이미
`[2026-08-21 정리 — 열린 항목 아님]`으로 닫혀 있었고, 생명주기는 "판단이 `[2026-08-21 정리 — 열린 항목 아님]`으로 닫혀 있었고, 생명주기는 "판단이
@ -73,7 +76,7 @@
**M2/M3에 직접 걸리던 것들이 전부 닫혔다** — 말단 핸들러 4종의 **M2/M3에 직접 걸리던 것들이 전부 닫혔다** — 말단 핸들러 4종의
`setLength`/`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `setLength`/`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라
`quad.Dispatch`가 타입에러인 것(`H-25`, M2 체크리스트에 항목 신설), `quad.Dispatch`가 타입에러인 것(`H-25`, M3 체크리스트에 항목 신설),
`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계 `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계
결함 둘(`H-1`/`H-2`). 결함 둘(`H-1`/`H-2`).
@ -112,7 +115,7 @@
**`Owned = false`에서 `Detach``_detached`에 안 들어감**, 조상 파괴 시 **`Owned = false`에서 `Detach``_detached`에 안 들어감**, 조상 파괴 시
unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정 unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정
(`(inst, groupValue) → k`), `Tween<T>:Mapped` 이름 확정, 4라운드가 반영을 (`(inst, groupValue) → k`), `Tween<T>:Mapped` 이름 확정, 4라운드가 반영을
빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"(M2로 앞당김)**. 빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"**(당시엔 디스패치 마일스톤으로 앞당기는 형태였으나 **[2026-08-24]** 순서 교체로 반응형이 먼저가 되며 그 앞당김은 불필요해짐).
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인, **아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
`rawAdd``Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`, `rawAdd``Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect` `setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
@ -158,26 +161,29 @@
단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot` 단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot`
`slot._mounted = true``activateList` 호출 뒤로 옮기는 것 하나로 `slot._mounted = true``activateList` 호출 뒤로 옮기는 것 하나로
둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에 둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에
`bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M2가 M3의 `bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M3(디스패치)가
`Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영(마일스톤 M2의 `Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md (**[2026-08-24]** 그때 열어뒀던 마일스톤 재편 여부가 순서 교체로
마일스톤 정합성" 절 참고). 닫혔다 — 위 00번 머리말).
**아래는 M3 착수 전에 결론이 필요한 항목 목록****설계** 게이트 **⚠️⚠️ [2026-08-24 승격] 아래 둘은 이제 *바로 다음* 마일스톤의
얘기다. M0은 아예 막혀 있지 않고, M2도 설계로는 안 막혀 있으나 게이트다.** 순서 교체 전엔 반응형이 M3라 "한 마일스톤 뒤"의 일이었는데,
**[2026-08-22] 마일스톤 순서 문제 하나가 M2를 막는다**(위 00번의 반응형이 M2가 되면서 **지금 착수 직전에 결론이 필요한 항목**이 됐다
⚠️ 문단 + `question.md` 2번). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도 (위 00번이 "M2 착수를 막는 **설계** 항목은 없다"고 하는 것과 모순되지
⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들 않는다 — 하나는 실측 미완, 하나는 표면 위치 선택이라 성격이 다르지만,
**어느 쪽이든 M2를 짜기 전에 답이 있어야 한다**). 둘 다
`question.md`의 **최우선 절**로 올라가 있고(2026-08-24에 낮은 우선순위
절에서 승격), 각 `base/` 문서에도 ⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들
(`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키, (`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키,
`SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키 `SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키
타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md` 타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md`
`base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어 `base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어
"지금 할 일"로 읽히지 않던 것을 걷어낸 것. "지금 할 일"로 읽히지 않던 것을 걷어낸 것.
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M2 착수 전 필요.**
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. 됨(lazy `__index`와 충돌). M2/M4 착수 전 필요.
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy `question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
@ -287,13 +293,13 @@
백로그이지만 위 항목들과는 발단이 다름 — **사용자가 직접 요청한 실제 백로그이지만 위 항목들과는 발단이 다름 — **사용자가 직접 요청한 실제
기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니 기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니
실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로 실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로
확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지 확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M2를 막지
않고, **그 게이티드 노드는 [2026-08-21] `state:Gate`로 확정돼 M2에서 않고, **그 게이티드 노드는 [2026-08-21] `state:Gate`로 확정돼 M2에서
만들어진다**(`base/gate-plan.md`) — `Debounce`/`Throttle`은 그 위의 만들어진다**(`base/gate-plan.md`) — `Debounce`/`Throttle`은 그 위의
정책으로 얹으면 되고, 같은 설계를 두 번 할 일은 없어졌다. 정책으로 얹으면 되고, 같은 설계를 두 번 할 일은 없어졌다.
주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에 주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에
추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구 추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구
`question.md` 3번, 전량 해소로 항목 자체가 빠짐). `question.md` 낮은 우선순위 절, 전량 해소로 항목 자체가 빠짐).
**[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로 **[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로
신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll` 신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll`
(`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때 (`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때

View file

@ -1,12 +1,16 @@
# CLAUDE.md # CLAUDE.md
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트. Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은 **[2026-08-24 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은
M2(디스패치 엔진)** — 다만 **⚠️ M2 착수를 막는 항목이 하나 있음**: M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
M2와 M3의 의존이 양방향이라(M2의 Length/Offset 배관이 `State`/`Source`를 번호·순서가 맞바뀌었다** — 예전엔 M2=디스패치, M3=반응형이었는데 의존이
씀) 지금 순서로는 M2를 끝까지 짤 수 없다. 설계 결정이 아니라 **마일스톤 한 방향(디스패치 → 반응형)이라 반응형을 먼저 짓기로 확정했다. 그래서
순서** 문제이고, 선택지와 근거의 소스는 `.claude/question.md` 2번(사용자 **2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의
회신 대기). 같은 상태를 `.claude/project-context.md` `M2`/`M3`는 옛 의미로 읽을 것**(라이브 문서는 전부 새 번호로 맞춰뒀음).
경위는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절,
새 구성은 `ROADMAP.md`의 M2 배너. **⚠️ M2 착수 전에 답이 필요한 항목이
남아 있다** — 무엇이 몇 개인지는 여기서 세지 말고 `.claude/question.md`
최우선 절을 볼 것. 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`. 항상 루트 `ROADMAP.md`.

View file

@ -6,17 +6,17 @@
올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은 올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은
사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소). 사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소).
## ⭐ 11. **[2026-08-22 신설] M2 착수를 막는 마일스톤 순서 결정** — 사용자 답변 필요 ## ✅ 11. **[2026-08-22 신설 → 2026-08-24 해소] 마일스톤 순서 결정**
**설계 결정이 아니라 순서 문제**라서 에이전트가 임의로 정하면 안 되는 자리다. 사용자가 **(a) 순서 교체**를 선택해 닫혔다 — 반응형이 M2, 디스패치가 M3다.
`ROADMAP.md` 전반 점검에서 **M2(디스패치 엔진)와 M3(Store/State/Source)의 결정과 근거는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절,
의존이 양방향**인 게 드러났다 — M2의 Length/Offset 배관이 `State<number>`/ 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너가 소스.
`Source<number>`를 쓰므로 **M2는 State/Source 없이 구현이 안 되고**, 반대로
M3의 동적 경로 가드는 M2의 `Dispatch.addHandler`를 쓴다(그쪽은 얕음).
**선택지 (a) M2/M3 순서 교체 / (b) M2를 둘로 분할 / (c) 지금 구조 유지**의 **⚠️ 다만 그 교체로 `question.md` 최우선 절에 항목 둘이 올라왔다** — 중간
근거와 상세는 **`.claude/question.md` 2번이 소스** — 여기서 반복하지 않는다. State GC 실측과 `store:GetDynamic` 위치. 둘 다 원래 반응형(옛 M3)의
답이 나오면 `ROADMAP.md`의 마일스톤 경계와 `question.md` 2번을 같이 닫으면 된다. 게이트라 급하지 않았는데 반응형이 먼저 지어지게 되면서 **지금 답이
필요**해졌다. 둘 다 설계 질문이라 이 문서가 아니라 `question.md`
소스다(아래 3번 항목이 가리키는 그 절) — 여기서 따로 번호를 만들지 않는다.
--- ---
@ -205,7 +205,7 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부 Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부
`Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데 `Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데
범위상 문제가 없다. 범위상 문제가 없다.
- **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2를 - **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2(지금 착수 대상)
막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에 막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에
사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입 사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입
애니메이션(`initValue`)" 절. 애니메이션(`initValue`)" 절.
@ -214,9 +214,11 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **[2026-08-22 갱신] 설계 결정 대기는 마음에 안 드는 것만 답해주면 됨 — **[2026-08-24 갱신] 순수 설계 결정
여전히 0건**이지만, **마일스톤 순서 결정 하나가 새로 열렸다**(`question.md` 대기는 여전히 0건**이고 2026-08-22에 열렸던 마일스톤 순서 결정도
2번, 위 11번 항목이 소스). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준] **해소됐다**(위 11번 항목). 다만 그 해소의 부작용으로 **`question.md`
최우선 절에 M2 착수 전 답이 필요한 항목 둘**이 올라와 있다(중간 State GC
실측, `store:GetDynamic` 위치). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준]
`question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 — `question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 —
마지막 남았던 0-W 마지막 남았던 0-W
`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지" `Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지"

View file

@ -7,23 +7,31 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 **2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.** 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
> **✅ [2026-08-22 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은 > **✅ [2026-08-24 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은
> M2(디스패치 엔진)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크 > M2(반응형 코어)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크
> 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그 > 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그
> 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. — **⚠️ 다만 M2를 바로 착수하면 안 됩니다: M2와 M3의 의존이 > 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다.
> 양방향이라 이 순서로는 M2를 끝까지 짤 수 없습니다**(설계가 아니라 > **[2026-08-24] 착수를 막던 마일스톤 순서 문제는 해소됐습니다** — M2와
> 마일스톤 **순서** 문제 — 아래 M2 배너와 `.claude/question.md` 2번, > M3의 번호를 맞바꿔 반응형을 먼저 짓기로 확정했습니다(아래 M2 배너가
> 사용자 회신 대기). M1까지의 산출물은 > 소스). **⚠️ 다만 그 교체로 M2 착수 전에 답이 필요한 항목 둘이
> `.claude/question.md` 최우선 절로 올라왔습니다** — 중간 State GC 실측과
> `store:GetDynamic` 위치(원래 반응형의 게이트였는데 반응형이 앞으로
> 오면서 같이 당겨짐). M1까지의 산출물은
> quad-base/quad-roblox 폴더+pesde.toml, 루트 > quad-base/quad-roblox 폴더+pesde.toml, 루트
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/ > default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로 > `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
> 재작성 대기 상태입니다** — 아래 "재검증 대기" 절 참고(개수·현황의 > 재작성 대기 상태입니다** — 아래 "재검증 대기" 절 참고(개수·현황의
> 소스는 `.claude/luau-test/STATUS.md`, 여기서도 그 절에서도 세지 않음). > 소스는 `.claude/luau-test/STATUS.md`, 여기서도 그 절에서도 세지 않음).
> >
> **[2026-08-22] 이 문서에 이번에 반영된 것**: `Dispatch.drive`가 두 > **[2026-08-24] 이 문서에 이번에 반영된 것**: M2(반응형)와 M3(디스패치)의
> 패스가 아니라 단일 일반화 `for`(`F-4-1`) / 물리 조작 주입 op 이름이 > **번호·순서 교체**, 그에 따라 `Brand`/`Relate`/`LifetimeHandle`
> `native*`로 확정 / `EpochMap`·`GateNode`·`Blocker`·`None`이 M2로 이동 / > 인터페이스가 M2 앞머리로 이동하고 `EpochMap`/`GateNode`/`Blocker`가 M2로
> M2↔M3 의존이 양방향이라는 것(M2 헤더 배너) / `[x]` 표기 의미 분리 > 복귀, Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`
> M3로 이동.
>
> **[2026-08-22] 그 전 회차에 반영된 것**: `Dispatch.drive`가 두 패스가
> 아니라 단일 일반화 `for`(`F-4-1`) / 물리 조작 주입 op 이름이 `native*`
> 확정 / `None`이 M7에서 디스패치로 이동 / `[x]` 표기 의미 분리
> (바로 아래). > (바로 아래).
> >
> **⚠️ `[x]`의 의미** — 체크박스는 **"짜야 할 코드"만** 담습니다. > **⚠️ `[x]`의 의미** — 체크박스는 **"짜야 할 코드"만** 담습니다.
@ -31,13 +39,13 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> 마일스톤의 **`### 확정된 것`** 절(항목이 여럿일 때) 또는 > 마일스톤의 **`### 확정된 것`** 절(항목이 여럿일 때) 또는
> **`- **[확정된 것 — 코드 아님]**`** 불릿(하나뿐일 때)에 둡니다. 예전엔 > **`- **[확정된 것 — 코드 아님]**`** 불릿(하나뿐일 때)에 둡니다. 예전엔
> 둘이 같은 목록에 섞여 있어 `[x]`만 보고는 코드가 있는지 알 수 > 둘이 같은 목록에 섞여 있어 `[x]`만 보고는 코드가 있는지 알 수
> 없었습니다 — M0/M1의 `[x]`는 실제 구현이고, M3/M6/M11에 섞여 있던 > 없었습니다 — M0/M1의 `[x]`는 실제 구현이고, M2/M6/M11에 섞여 있던
> `[x]`는 설계 확정·실측이었습니다. **새 항목을 추가할 때도 이 구분을 > `[x]`는 설계 확정·실측이었습니다. **새 항목을 추가할 때도 이 구분을
> 지킬 것.** > 지킬 것.**
> M1 착수 도중 > M1 착수 도중
> wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의 > wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의
> `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는 > `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M2가 의존하는
> `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서 > `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서
> 먼저 신설됨(`base/quad-types-plan.md`). > 먼저 신설됨(`base/quad-types-plan.md`).
@ -141,7 +149,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
Observer가 이제 변경당 **1회**만 울어야 함(옛 "emit은 항상 전파" 모델을 Observer가 이제 변경당 **1회**만 울어야 함(옛 "emit은 항상 전파" 모델을
검증 중). `base/state-epoch-plan.md` 기준으로 재작성 검증 중). `base/state-epoch-plan.md` 기준으로 재작성
- [ ] **`04`(Dispatch 체인 retractFrom)** — 하강 diff 확정으로 무효화. - [ ] **`04`(Dispatch 체인 retractFrom)** — 하강 diff 확정으로 무효화.
**M2 착수 시 같이 처리**하는 게 자연스러움 **M3 착수 시 같이 처리**하는 게 자연스러움
- [ ] **`19`(소유권/참조카운트 Relate 패턴)** — **B 섹션만** 낡음 - [ ] **`19`(소유권/참조카운트 Relate 패턴)** — **B 섹션만** 낡음
(공개 `AttributeKey(name)` + 인덱스 1 점유 체크가 폐기되고 그룹 전용 (공개 `AttributeKey(name)` + 인덱스 1 점유 체크가 폐기되고 그룹 전용
키 + `AttributeKeyHandler`의 이름 claim으로 바뀜, `0-Z` 확정). 키 + `AttributeKeyHandler`의 이름 claim으로 바뀜, `0-Z` 확정).
@ -154,13 +162,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**M8 착수 시 같이 처리** **M8 착수 시 같이 처리**
- [ ] **`15`(`:Compute` trailing deps 타입팩)** — 이형 다중 deps를 제네릭 - [ ] **`15`(`:Compute` trailing deps 타입팩)** — 이형 다중 deps를 제네릭
타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정). 타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정).
**M3 착수 시 같이 처리** **M2 착수 시 같이 처리**
- [ ] **`10`(Roblox Studio 확인)** — `bindLifetime`/`canExecute`/ - [ ] **`10`(Roblox Studio 확인)** — `bindLifetime`/`canExecute`/
`unbindLifetime` 재정정으로 무효화. **Studio 작업이라 `unbindLifetime` 재정정으로 무효화. **Studio 작업이라
`HUMAN_TODO.md` 1번(계정 분리)이 선행** `HUMAN_TODO.md` 1번(계정 분리)이 선행**
- [ ] **아직 파일이 없는 실측 항목**`R-11``table.insert` 구멍 재사용, - [ ] **아직 파일이 없는 실측 항목** — **중간 State GC**
**중간 State GC**(`base/source-state-plan.md`, 상류 strong / 하류 weak (`base/source-state-plan.md`, 상류 strong / 하류 weak 불변식 —
불변식 — `.claude/todos.md`가 "M3 착수 전 필요"로 지정) `.claude/todos.md`가 "M2 착수 전 필요"로 지정. **[2026-08-24 승격]**
순서 교체로 반응형이 바로 다음 마일스톤이 되면서 `question.md`
최우선 절로 올라갔다). **[2026-08-24 정리]** 여기 같이 적혀 있던
`R-11``table.insert` 구멍 재사용은 6라운드 `H-7`
**전제 자체가 없어져 폐기**됐다(`Ref.Callbacks`가 해시맵 셋이 되어
구멍 개념이 없음) — `luau-test/STATUS.md`가 소스
## M1 — 실제 스캐폴딩 ## M1 — 실제 스캐폴딩
@ -194,82 +207,34 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상 계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상
항상 충족돼 있었음) 항상 충족돼 있었음)
## M2 — 디스패치 엔진 ## M2 — 반응형 코어 (Source/State/Store)
> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래 > **⭐ [2026-08-24 순서 교체] 옛 M3(반응형)와 옛 M2(디스패치)의 번호를
> 체크리스트는 새 모델("하강 diff") 기준으로 갱신됐습니다.** 래핑 핸들러의 > 맞바꿨습니다.** 의존이 양방향처럼 보였지만 실제로는 한 방향이었고
> 선행 `retractFrom`은 폐기됐고, `Dispatch.process`가 슬롯의 `handler` > (디스패치 → 반응형이 본체 의존, 반대는 핸들러 등록 표면뿐), 옛 순서로는
> 먼저 비교해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` > 디스패치의 Length/Offset 배관도 그 마일스톤의 `mock 대상 테스트`도 State
> 재호출, 다르면 그 자리부터 전량 철거) 처리합니다. 정본은 > 없이 짤 수 없었습니다. 근거와 기각된 선택지는
> `.claude/base/dispatch-core-plan.md`(같은 세션에 `bind-system-plan.md`에서 > `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절.
> 분리 신설), 뒤집힌 옛 모델은
> `.claude/archive/dispatch-hintvalue-model-reversed.md`.
> **⚠️ [2026-08-22] M2는 이름과 달리 "디스패치만"이 아닙니다 — 반응형
> 하부구조 일부가 여기 있습니다.** `EpochMap`/`GateNode`/`Blocker`/`None`
> 체크박스가 M3·M7에서 옮겨왔습니다(각 항목의 이동 표시 참고). 그동안
> 각주로만 예고돼 있어 M2 체크리스트를 훑는 구현자에게 **항목으로 보이지
> 않던** 것을 고친 것이고, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
> 것과 같은 처리입니다.
> >
> **⚠️ 다만 이동으로 드러난 더 큰 문제가 하나 남아 있습니다 — M2와 M3의 > **⚠️ 이 날짜 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의
> 의존이 양방향이라, 이 순서대로면 M2를 끝까지 짤 수 없습니다.** > `M2`/`M3`는 옛 의미**(M2=디스패치, M3=반응형)입니다 — 히스토리 문서라
> 요지만: **M2는 `Source.luau`/`State.luau` 없이는 구현할 수 없고** > 소급 수정하지 않았습니다. 라이브 문서(`base/`/`research/`/`reference/`/
> (Length/Offset 배관이 `State<number>`/`Source<number>`를 쓰고, > `luau-test/`/인덱스 레이어)는 전부 새 번호로 맞췄습니다.
> `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅합니다), >
> 반대 방향은 핸들러 등록 표면만 요구하는 얕은 의존입니다. > 부수로 **`Brand`/`Relate`/`LifetimeHandle` 인터페이스가 디스패치에서 이
> **어느 쪽으로 가를지(순서 교체 / M2 분할 / 유지)는 사용자 결정 > 마일스톤 앞머리로 왔습니다** — 반응형이 이 셋을 먼저 요구하기 때문:
> 대기이고, 근거와 선택지의 소스는 `.claude/question.md` 2번입니다** — > `Source``SourceBrand`+`EpochBrand`에 등록되고(`Brand`), State 전파
> 여기서 반복하지 않습니다. > 루프가 매 발화마다 `canExecute`를 부르며(`LifetimeHandle`), 그 판정이
> `Relate`에 복사해둔 gcconn 위에 얹힙니다. 반대로 2026-08-22에 여기서
> 디스패치로 옮겼던 **`EpochMap`/`GateNode`/`Blocker`는 되돌아왔습니다** —
> 앞당길 이유 자체가 순서 교체로 사라졌고, "게이팅 먼저"는 그대로
> 지켜집니다.
### 공통 기반 — 반응형보다 먼저 (구 M2, 지금의 M3에서 이동)
> 셋 다 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. 이 절이
> 끝나야 아래 반응형 본체를 짤 수 있습니다.
- [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
(오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교**
같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`
자리 교체, 다르면 `retractFrom` 후 새로 설치. 반환값이 `nil`이면
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None`
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
**[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라
단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau
테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
`type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
(`luau-test/STATUS.md`)
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외
없이 자기 배열 자리의 `setOffsetSource(inst,k,None)``setLength(inst,k,0)`
등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이
빠져 있었으니 각 마일스톤에서 확인할 것: `TagHandler`(M10) /
`AttributeGroupHandler`(M10) / `RefLeafHandler`(M8) /
`ObserverEffectLeafHandler`(M3). 안 하면 `bk.N`이 다른 자리 등록으로
커질 때 그 구멍이 범위 안에 끼어 `recompute`가 **명시적 error로 죽는다**
(`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치).
**배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.**
소스는 `base/dispatch-core-plan.md`의 등록 책임 절
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types``Quad`
타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌
레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면
`module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔
되는데 `luau-analyze`에선 타입에러**다(실측 재현). `base/architecture.md`
확정된 결정 13번이 그 접근을 표준 사용법으로 못박아뒀다.
상세는 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절.
**⚠️ 이건 M2 한 번으로 끝나는 일이 아니다** — 그 문서가 *"마일스톤마다
갱신한다 … 이후 서브시스템도 같은 규칙을 따른다"*로 확정했으므로,
**서브시스템을 붙이는 모든 마일스톤이 같은 항목을 진다**:
M3(`Source`/`State`/`Store`) · M6(`Slot`) · M7(`Modifier`) ·
M8(`Ref`) · M10(`Tag`/`Attribute`). 빠뜨리면 그 마일스톤 완료 후
`quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선
타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.**
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
`process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable`
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()` - [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`, 브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`). **다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
@ -294,49 +259,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹 array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹
`Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두 `Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두
대상(키 vs 값)을 가리키게 돼서 갈라짐, `base/attribute-plan.md` 대상(키 vs 값)을 가리키게 돼서 갈라짐, `base/attribute-plan.md`
참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 참고) 전부의 기반. `isNone`은 레지스트리를 안 쓰고 `x == None` 항등
항등 비교 — `brand-plan.md``Brand` 절, 2026-08-07 여덟 비교이지만 **`Brand.luau``None`을 참조하지는 않는다**
번째 세션 신설) (**[2026-08-18 정정]** `brand-plan.md`가 *"`Brand → None` 의존을 만들지
- [ ] **[2026-08-22 M3에서 이동] `EpochMap.luau`** — 재사용 가능한 에포크 않는다"*로 확정 — `isNone``None.luau`(M3) 쪽에 산다. 이 마일스톤
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 분리로 처음 눈에 띈 잔재를 **[2026-08-24]** 정정한 것) —
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = `brand-plan.md``Brand` 절, 2026-08-07 여덟 번째 세션 신설)
{[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고
별도 모듈로 낼 것 — `GateNode`(아래)와 `State`(M3)/`Effect`(M3)가
전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와
리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md`
- [ ] **[2026-08-22 M3에서 이동] `state:Gate(setup)` + `GateNode`** —
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을
통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet`
스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
`base/gate-plan.md`. **"게이팅 먼저" 결정으로 M3에서 앞당겨진 항목**
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
- [ ] **[2026-08-22 M3에서 이동] `Blocker.luau`** — 위 `GateNode` 위에
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
(`base/blocker-plan.md`). 아래 `Dispatch.setLength`/`setOffsetSource`의
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M2에 필요**
- [ ] **[2026-08-22 M7에서 이동] `None.luau`(센티널) + `Dispatch/None.luau`
(`NoneHandler` + `NilHandler`)** — `None`은 modifier 전용 값이 아니라
디스패치 배관이라 여기 소속(`architecture.md`의 소스 트리에 있는 건
핸들러 쪽 `Dispatch/None.luau`뿐 — **[2026-08-22] 탑레벨 센티널
`None.luau``Brand.luau`는 그 트리에 아직 줄이 없다**, M2 구현 시
트리도 같이 채울 것).
`NoneHandler`는 배열/해시 구분 없이 `Dispatch.process(inst,k,nil,index+1)`
**재귀만** 하고(선행 `retractFrom` 없음 — 하강 diff), 실제 정리는
`NilHandler`(`isHandlable`이 `type(k) == "number" and v == nil`일 때만
매치하는 말단)가 `Dispatch.setLength(inst,k,0)` +
`Dispatch.setOffsetSource(inst,k,None)` 등록으로 맡는다.
**`Dispatch.drive``None` 스킵 분기는 없다**(반응형 값이 내놓는
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
`inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy
@ -373,9 +301,259 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`canExecute`/`unbindLifetime` — 확정" 절 참고. **이중 바인딩 금지 `canExecute`/`unbindLifetime` — 확정" 절 참고. **이중 바인딩 금지
게이트는 `canBound`**(`canExecute`는 emit 전파 게이팅 전용 — 게이트는 `canBound`**(`canExecute`는 emit 전파 게이팅 전용 —
**[2026-08-14 열한 번째 세션] `canBound`가 별도 진입점으로 재도입되어 **[2026-08-14 열한 번째 세션] `canBound`가 별도 진입점으로 재도입되어
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐 이 게이트를 그대로 탐
### 반응형 본체
- [ ] **`EpochMap.luau`** (**[2026-08-24]** 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
{[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고
별도 모듈로 낼 것 — `GateNode`(아래)와 `State`/`Effect`가
전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와
리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md`
- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의
재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
`invalid` 플래그가 아니다. **아래 `Source.luau`/`State.luau`가 이걸
전제로 짜여야 하므로 `EpochMap.luau`(위 항목)가 State 본체보다 먼저
온다**(`State`가 `valueEpochMap`/`emitEpochMap` 둘을 컴포지션한다 —
**[2026-08-24] 감사에서 순서가 반대로 놓여 있던 걸 잡아 앞으로 옮김**): `Source``type Epoch = { Revision: number }`
구조적으로 만족하고(`EpochBrand`에도 등록), 부기는 재사용 가능한
**`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`, `:Refresh`, `:Sync`,
`:TrackFrom``EpochSet = {[Epoch]: true}`, **배열 아님**)
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`).
- [ ] `Source.luau`/`State.luau`/`Store.luau`
- [ ] **[2026-08-18 신설]** `store:GetDynamic<<T>>(name): Source<T>` — 런타임에
이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은
기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저
확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지
아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 최우선 절)
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시
각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만
조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고,
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
`luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은
설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음)
- [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar
(2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M2
스파이크에서 확인. **[2026-08-24 정정, 6라운드 `H-13`]** 여기 원래
*"`Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 유지"*라고 적혀
있었는데 **`Effect``C-6`에서 이미 역전됐다** — 각 dep에 구독을 따로
걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건
`Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State
하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다")
- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`"
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
(State/Source는 `Observer`, `Ref``:Callback`)을 걸어
재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`
`EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간
억제 플래그가 그 `Update`보다 먼저 와야 함
(`base/state-epoch-plan.md`). Observer 구현 이후에 착수(의존 관계).
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
**동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md`
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** —
**`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과
거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) ·
**`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`).
`fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
**`...deps``fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서
**`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는
`base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
호출"로 정정 — 진짜 독립 경로는 둘뿐).
**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`
폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에
다시 갈라짐]** — "지금 묶어도 되는가"(bound 문맥)와 "지금
발화해도 되는가"(execute 문맥)는 호출부의 질문이 다르고
**[2026-08-18 구현 전 QA 정정] 판정값도 같은 게 아니라 서로의
부정**이라(`canBound(v) == not canExecute(v)`, 게이트는 항상
`if not canBound(v) then error(...)`), `Ref` 이중 배치
방지(`question.md` 0-W)를 계기로 `canBound`
별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은
공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`**
(emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가
leaf 경로와 무관하다는 것, leaf 생존 판정을 `bindLifetime``value`
`Relate`에 복사해둔 gcconn으로 하는 것은 안 바뀜 — `base/
lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전 경위는
`archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이
죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과**
(살아있는 바인딩만 막는 게 의도, 안 바뀜)
- [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) —
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을
통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet`
스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
`base/gate-plan.md`. **"게이팅 먼저" 결정의 산물**
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
- [ ] **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
(`base/blocker-plan.md`). **M3의** `Dispatch.setLength`/`setOffsetSource`의
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Source`/`State`/
`Store` 필드 추가 — **규칙 요지**: `New(): Quad`가 닫힌 레코드이고
`RunInit`은 반환값이 없어 타입을 못 넓히므로, 서브시스템을 붙이는
마일스톤마다 `quad-types``Quad`에 그 필드와 타입 재수출을 같이
추가한다. 안 하면 그 마일스톤 완료 후 `quad.Store` 접근이 런타임엔
되는데 `luau-analyze`에선 타입에러다(`H-25` 실측). **순서 교체로
이 마일스톤이 그 규칙의 첫 적용 지점**이고, 규칙의 정본은
`base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절
(M3의 `H-25` 항목이 같은 규칙을 `Dispatch` 기준으로 서술한다)
- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24
`H-14`] 이 항목은 `:Compute` 한정이다** — `Effect``fn`엔 deps가 안
넘어간다) — (`fn(self,
previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터
리스트 맨 끝이어야 하는 것과 같은 이유로 `previous?`가 deps 팩
**앞**에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째
세션에 순서 정정, `base/source-state-plan.md` "trailing deps를 fn에
lazy positional 인자로도 노출" 절) — 방향/순서는 확정,
`luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정)
- [ ] mock 대상 테스트
## M3 — 디스패치 엔진
> **✅ [2026-08-13 열네 번째 세션] 재디스패치 모델 교체 완료 — 아래
> 체크리스트는 새 모델("하강 diff") 기준으로 갱신됐습니다.** 래핑 핸들러의
> 선행 `retractFrom`은 폐기됐고, `Dispatch.process`가 슬롯의 `handler`
> 먼저 비교해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process`
> 재호출, 다르면 그 자리부터 전량 철거) 처리합니다. 정본은
> `.claude/base/dispatch-core-plan.md`(같은 세션에 `bind-system-plan.md`에서
> 분리 신설), 뒤집힌 옛 모델은
> `.claude/archive/dispatch-hintvalue-model-reversed.md`.
> **⭐ [2026-08-24 순서 교체] 이 마일스톤은 옛 M2입니다** — 번호를 반응형
> 코어와 맞바꿔 그 뒤로 옮겼습니다(경위는 M2 배너가 소스, 여기서 반복하지
> 않음). 2026-08-22에 여기로 옮겼던 `EpochMap`/`GateNode`/`Blocker`와,
> 반응형이 먼저 요구하는 `Brand`/`Relate`/`LifetimeHandle` 인터페이스는
> M2로 갔습니다. 여기 남은 것은 전부 디스패치 배관이고, **이제 이
> 마일스톤은 M2 위에 단방향으로 얹힙니다.** 개념상 역방향이던 둘
> (Observer/Effect 가드 등록, `ObserverEffectLeafHandler` — "핸들러를
> 등록한다"뿐인 것)은 **맨 아래 두 항목으로 여기 옮겨져** 있으므로,
> **빌드 순서상 역방향 간선은 없습니다.**
- [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
(오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교**
같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`
자리 교체, 다르면 `retractFrom` 후 새로 설치. 반환값이 `nil`이면
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None`
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
**[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라
단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau
테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
`type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
(`luau-test/STATUS.md`)
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외
없이 자기 배열 자리의 `setOffsetSource(inst,k,None)``setLength(inst,k,0)`
등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이
빠져 있었으니 각 마일스톤에서 확인할 것: `TagHandler`(M10) /
`AttributeGroupHandler`(M10) / `RefLeafHandler`(M8) /
`ObserverEffectLeafHandler`(이 마일스톤, 맨 아래). 안 하면 `bk.N`이 다른 자리 등록으로
커질 때 그 구멍이 범위 안에 끼어 `recompute`가 **명시적 error로 죽는다**
(`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치).
**배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.**
소스는 `base/dispatch-core-plan.md`의 등록 책임 절
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types``Quad`
타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌
레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면
`module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔
되는데 `luau-analyze`에선 타입에러**다(실측 재현). `base/architecture.md`
확정된 결정 13번이 그 접근을 표준 사용법으로 못박아뒀다.
상세는 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절.
**⚠️ 이건 M3 한 번으로 끝나는 일이 아니고, 첫 적용은 오히려 M2다** —
그 문서가 *"마일스톤마다 갱신한다 … 이후 서브시스템도 같은 규칙을
따른다"*로 확정했으므로, **서브시스템을 붙이는 모든 마일스톤이 같은
항목을 진다**: **M2(`Source`/`State`/`Store`, 규칙이 처음 적용되는
자리)** · M6(`Slot`) · M7(`Modifier`) · M8(`Ref`) ·
M10(`Tag`/`Attribute`). (**[2026-08-24]** 규칙 자체는 `Dispatch`
계기로 쓰였지만 마일스톤 순서 교체로 M2가 앞에 오게 됐다 — M2를 짜는
사람이 이 항목을 앞으로 참조하지 않아도 되게, 규칙 요지는 M2의
`H-25` 파생 항목에도 적어뒀다.) 빠뜨리면 그 마일스톤 완료 후
`quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선
타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.**
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
`process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable`
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
- [ ] **[2026-08-22 M7에서 이동] `None.luau`(센티널) + `Dispatch/None.luau`
(`NoneHandler` + `NilHandler`)** — `None`은 modifier 전용 값이 아니라
디스패치 배관이라 여기 소속(`architecture.md`의 소스 트리에 있는 건
핸들러 쪽 `Dispatch/None.luau`뿐 — **[2026-08-22] 탑레벨 센티널
`None.luau``Brand.luau`는 그 트리에 아직 줄이 없다**, M3 구현 시
트리도 같이 채울 것).
`NoneHandler`는 배열/해시 구분 없이 `Dispatch.process(inst,k,nil,index+1)`
**재귀만** 하고(선행 `retractFrom` 없음 — 하강 diff), 실제 정리는
`NilHandler`(`isHandlable`이 `type(k) == "number" and v == nil`일 때만
매치하는 말단)가 `Dispatch.setLength(inst,k,0)` +
`Dispatch.setOffsetSource(inst,k,None)` 등록으로 맡는다.
**`Dispatch.drive``None` 스킵 분기는 없다**(반응형 값이 내놓는
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/ - [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/ `Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
**`Dispatch.getOffsetAt(ownerKey,i)`** — **`Dispatch.getOffsetAt(ownerKey,i)`** —
@ -418,9 +596,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를 (`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를
lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는 lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감.
**[2026-08-22] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은 이제 **[2026-08-24 정정] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은
이 마일스톤에 체크박스로 있다**(위 세 항목) — 예전엔 M3에 있는 걸 M2(반응형 코어)에 체크박스로 있다** — 2026-08-22엔 각주로만 예고돼
각주로 가리키기만 했다. 결정 경위("게이팅 먼저", 그리고 앞당기는 있던 걸 이 마일스톤으로 앞당겼었는데, 같은 달 24일 마일스톤 순서
교체로 M2가 먼저 지어지게 되면서 셋 다 그리로 되돌아갔다(앞당길 이유가
사라짐). 여기서는 **M2가 이미 만들어둔 것을 호출하기만 한다.**
결정 경위("게이팅 먼저", 그리고 앞당기는
대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는 대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
`qa-request/pre-implementation-qa-round5-followup.md` `qa-request/pre-implementation-qa-round5-followup.md`
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다. `base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
@ -471,137 +652,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Dispatch.drive`의 진입도 항상 `1` `Dispatch.drive`의 진입도 항상 `1`
- **소유권 충돌 감지는 Dispatch의 일이 아님**(옛 점유 error 폐지) — - **소유권 충돌 감지는 Dispatch의 일이 아님**(옛 점유 error 폐지) —
필요한 도메인이 직접(Attribute 이름 claim, M10) 필요한 도메인이 직접(Attribute 이름 claim, M10)
- [ ] mock 대상 테스트 - [ ] **[2026-08-24 M2에서 이동] Observer/Effect 동적 경로 가드 등록** —
`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v가
## M3 — Store/State/Source Observer/Effect, process = error(...)}` 둘을 `Dispatch.addHandler`
등록(`k` 타입 안 가림, `PreRef`와 같은 패턴 —
- [ ] `Source.luau`/`State.luau`/`Store.luau` `base/source-state-plan.md`의 Observer 절, `base/effect-plan.md`
- [ ] **[2026-08-18 신설]** `store:GetDynamic<<T>>(name): Source<T>` — 런타임에 "동적 경로 가드" 절). **M2에서 Observer/Effect 본체를 짤 때는 이 등록만
이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 빼고 간다** — 레지스트리(`Dispatch.addHandler` + `Handler.luau` 계약)가
기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저 이 마일스톤에서 생기기 때문. **M2가 M3에 개념상 지던 의존은 이 항목과
확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지 바로 아래 항목뿐이었고, 둘 다 "핸들러를 등록한다"뿐이라 이쪽으로 미룰
아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 3번) 수 있었다** — 그래서 빌드 순서엔 역방향 간선이 남지 않는다.
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** 순서 교체(2026-08-24)의 근거
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 - [ ] **[2026-08-24 M2에서 이동, `H-39`]** `ObserverEffectLeafHandler.process`
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) — 자기 배열 자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을
State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는 등록 — 빠져 있었다(위 말단 핸들러 항목)
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시
각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만
조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고,
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
`luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은
설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음)
- [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar
(2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M3
스파이크에서 확인. **[2026-08-24 정정, 6라운드 `H-13`]** 여기 원래
*"`Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 유지"*라고 적혀
있었는데 **`Effect``C-6`에서 이미 역전됐다** — 각 dep에 구독을 따로
걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건
`Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State
하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다")
- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** —
**`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) ·
**`handle._cleanup`**(직전 cleanup 보관, `Rerun``Destroying` 클로저가
같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과
거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) ·
**`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`).
`fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
**`...deps``fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서
**`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는
`base/effect-plan.md`가 소스
- [ ] **[2026-08-24 `H-39`]** `ObserverEffectLeafHandler.process`가 자기 배열
자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 —
빠져 있었다(위 M2의 그 항목)
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Source`/`State`/
`Store` 필드 추가(위 M2 항목의 "마일스톤마다" 규칙)
- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24
`H-14`] 이 항목은 `:Compute` 한정이다** — `Effect``fn`엔 deps가 안
넘어간다) — (`fn(self,
previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터
리스트 맨 끝이어야 하는 것과 같은 이유로 `previous?`가 deps 팩
**앞**에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째
세션에 순서 정정, `base/source-state-plan.md` "trailing deps를 fn에
lazy positional 인자로도 노출" 절) — 방향/순서는 확정,
`luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정)
> **[2026-08-22 이동] `EpochMap.luau` / `state:Gate` + `GateNode` /
> `Blocker.luau`는 M2로 옮겼습니다** — 셋 다 M2가 실제로 호출하는데
> 각주로만 예고돼 있어서, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
> 전례대로 체크박스 자체를 옮겼습니다. M3는 그 위에 State/Source 본체를
> 얹기만 하면 됩니다.
- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의
재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
`invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸
전제로 짜여야 한다: `Source``type Epoch = { Revision: number }`
구조적으로 만족하고(`EpochBrand`에도 등록), 부기는 재사용 가능한
**`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`, `:Refresh`, `:Sync`,
`:TrackFrom``EpochSet = {[Epoch]: true}`, **배열 아님**)
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`).
- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`"
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
(State/Source는 `Observer`, `Ref``:Callback`)을 걸어
재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`
`EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간
억제 플래그가 그 `Update`보다 먼저 와야 함
(`base/state-epoch-plan.md`). Observer 구현 이후에 착수(의존 관계).
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
**동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md`
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
호출"로 정정 — 진짜 독립 경로는 둘뿐).
**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`
폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에
다시 갈라짐]** — "지금 묶어도 되는가"(bound 문맥)와 "지금
발화해도 되는가"(execute 문맥)는 호출부의 질문이 다르고
**[2026-08-18 구현 전 QA 정정] 판정값도 같은 게 아니라 서로의
부정**이라(`canBound(v) == not canExecute(v)`, 게이트는 항상
`if not canBound(v) then error(...)`), `Ref` 이중 배치
방지(`question.md` 0-W)를 계기로 `canBound`
별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은
공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`**
(emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가
leaf 경로와 무관하다는 것, leaf 생존 판정을 `bindLifetime``value`
`Relate`에 복사해둔 gcconn으로 하는 것은 안 바뀜 — `base/
lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전 경위는
`archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이
죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과**
(살아있는 바인딩만 막는 게 의도, 안 바뀜)
- [ ] mock 대상 테스트 - [ ] mock 대상 테스트
## M4 — 첫 end-to-end 반응형 업데이트 ## M4 — 첫 end-to-end 반응형 업데이트
@ -619,7 +683,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
정확히 불린다" 확인 + **`State<State<T>>`(값이 또 State/Source)가 정확히 불린다" 확인 + **`State<State<T>>`(값이 또 State/Source)가
인덱스 N/N+1로 안 겹치고 정상 동작하는지**(2026-08-13 다섯 번째 인덱스 N/N+1로 안 겹치고 정상 동작하는지**(2026-08-13 다섯 번째
세션에 UB→정상 지원으로 재정정) + **최초 마운트 직후 첫 재발행에서 세션에 UB→정상 지원으로 재정정) + **최초 마운트 직후 첫 재발행에서
인덱스 2의 retractor가 실제로 불리는지**(위 M2`SetStrong` 순서 인덱스 2의 retractor가 실제로 불리는지**(위 M3`SetStrong` 순서
버그가 정확히 여기서 증상으로 나타남) 버그가 정확히 여기서 증상으로 나타남)
## M5 — quad-roblox 최소 프로바이더 ## M5 — quad-roblox 최소 프로바이더
@ -696,7 +760,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고. 해결됨**, 위 M3의 `Dispatch.setLength`/`setOffsetSource` 항목 참고.
- **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ - **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션, `Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12 2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
@ -766,7 +830,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`disposeInst`가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과 `disposeInst`가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다. 재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).** 해결됨 — 위 M3의 `Dispatch.setLength`/`setOffsetSource` 항목
참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** — **[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) + `materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을 `mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
@ -855,7 +920,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`:List`**`prevKeys`**(옛 `keyIndex`의 강등판, 단순 키 집합). `:List`**`prevKeys`**(옛 `keyIndex`의 강등판, 단순 키 집합).
전부 `base/slot-plan.md`가 소스 전부 `base/slot-plan.md`가 소스
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Slot` 필드 추가 - [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Slot` 필드 추가
(위 M2 항목의 "마일스톤마다" 규칙) (위 M3 항목의 "마일스톤마다" 규칙)
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]** - [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일). - **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 — 비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 —
@ -934,7 +999,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 `Destroying` **`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 `Destroying`
걸고/끊는 것**으로 닫혔고(`base/effect-plan.md` + 걸고/끊는 것**으로 닫혔고(`base/effect-plan.md` +
`base/lifecycle-pattern.md`의 그 의사코드), 이 문장은 그 배선이 구현된 `base/lifecycle-pattern.md`의 그 의사코드), 이 문장은 그 배선이 구현된
뒤에야 참이 된다 — M3 `Effect` 구현이 M6의 이 항목을 **선행**한다는 뜻이다. 뒤에야 참이 된다 — M2 `Effect` 구현이 M6의 이 항목을 **선행**한다는 뜻이다.
(**[같은 날 재결정]** 처음엔 *"`EffectHandle`이 자기 `bindLifetime` 직후에 (**[같은 날 재결정]** 처음엔 *"`EffectHandle`이 자기 `bindLifetime` 직후에
건다"*였으나 `/code-review high`가 **그 호출부가 실재하지 않는다**는 걸 건다"*였으나 `/code-review high`가 **그 호출부가 실재하지 않는다**는 걸
지적했다 — 핸들은 남이 자기를 bind하는 걸 관측할 수 없고, `Effect` 지적했다 — 핸들은 남이 자기를 bind하는 걸 관측할 수 없고, `Effect`
@ -1022,9 +1087,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미 `brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미
구현돼 있어야 함) 구현돼 있어야 함)
> **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/ > **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/
> `NilHandler`)는 M2로 옮겼습니다** — `None`은 modifier 전용 값이 아니라 > `NilHandler`)는 M3로 옮겼습니다** — `None`은 modifier 전용 값이 아니라
> quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도 > quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도
> `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M2 > `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M3
> `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다. > `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다.
> M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를 > M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를
> 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md` > 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md`
@ -1043,12 +1108,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
빠져 있어 구현자가 존재 자체를 놓칠 수 있던 자리다. 의사코드는 빠져 있어 구현자가 존재 자체를 놓칠 수 있던 자리다. 의사코드는
`base/modifier-plan.md`의 flatten 절이 소스 `base/modifier-plan.md`의 flatten 절이 소스
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Modifier` 관련 - [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Modifier` 관련
표면이 노출돼야 하면 같이 갱신(위 M2 항목의 "마일스톤마다" 규칙) 표면이 노출돼야 하면 같이 갱신(위 M3 항목의 "마일스톤마다" 규칙)
## M8 — Ref ## M8 — Ref
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가 - [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가
(위 M2 항목의 "마일스톤마다" 규칙) (위 M3 항목의 "마일스톤마다" 규칙)
- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ - [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/
`:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref `:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단,
@ -1186,7 +1251,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **[2026-08-24 `H-39`]** `TagHandler`/`AttributeGroupHandler`가 자기 배열 - [ ] **[2026-08-24 `H-39`]** `TagHandler`/`AttributeGroupHandler`가 자기 배열
자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 — 자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 —
**둘 다 0건이었다**(위 M2의 그 항목). 같이 **`type(k) == "number"` 가드**도 **둘 다 0건이었다**(위 M3의 그 항목). 같이 **`type(k) == "number"` 가드**도
추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이 둘은 못 받았다) 추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이 둘은 못 받았다)
- [ ] **[2026-08-24 `H-41`]** `AttributeGroupHandler.process``groupClaimKeys` - [ ] **[2026-08-24 `H-41`]** `AttributeGroupHandler.process``groupClaimKeys`
위치 claim 배선 — 5라운드 `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고 위치 claim 배선 — 5라운드 `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고
@ -1196,7 +1261,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
없으면 `None`으로 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로 없으면 `None`으로 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로
심는** 동작이 된다 심는** 동작이 된다
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Tag`/`Attribute` - [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Tag`/`Attribute`
필드 추가(위 M2 항목의 "마일스톤마다" 규칙) 필드 추가(위 M3 항목의 "마일스톤마다" 규칙)
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
- [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler, - [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler,
`GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인 `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인
@ -1352,13 +1417,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`) 시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
— 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을 — 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` + 추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-21 5라운드] 아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-24]
그 공용 `Gate` 추출은 M3가 아니라 M2로 앞당겨졌다** — "게이팅 먼저" 그 공용 `Gate` 추출은 M2(반응형 코어)에서 이뤄진다** — 2026-08-21에
결정(위 M2 각주)으로 `Dispatch.drive`의 배치 등록이 쓰는 게이팅부터 "게이팅 먼저" 결정으로 디스패치 쪽에 앞당겨뒀다가, 2026-08-24 마일스톤
만들기로 했고, 그때 `Blocker`/`Debounce`/`Throttle`이 공유할 노드를 순서 교체(위 M2 배너)로 반응형이 먼저가 되면서 앞당길 필요 자체가
같이 빼둔다(따로 하면 같은 설계를 두 번 함). **[2026-08-21] 표면 확정 — 사라졌다. `Blocker`/`Debounce`/`Throttle`이 공유할 노드를 거기서 같이
빼둔다(따로 하면 같은 설계를 두 번 함). **[2026-08-21] 표면 확정 —
`state:Gate(setup)` 메소드**, `base/gate-plan.md`. `state:Gate(setup)` 메소드**, `base/gate-plan.md`.
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음. 프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M2를 막지 않음.
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`, 주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라 Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라
주의**)가 `bindLifetime`/`canExecute`와 같은 base 범용 유틸 그룹에 주의**)가 `bindLifetime`/`canExecute`와 같은 base 범용 유틸 그룹에