Compare commits

..

35 commits

Author SHA1 Message Date
068971fd56
qa: 손 트레이싱 6라운드 3·4차 패스 — 미탐색 문서 전수 + 축 전환, 발견 34건 (실측 검증 포함)
사용자 요청으로 6라운드에 두 패스를 더 이어붙였다. 3차는 *"지금 나온거
이외에 더 문제될만한게 있는지 … 그러고 나서 모든 요소들이 진짜 실존하는
문제인지 사실확인을 해줘"*, 4차는 *"문제를 계속 쌓고, 한번에 몰아 처리하는게
효율적이라, 한번 더 순회하고"*.

**`base/`는 여전히 한 줄도 안 고쳤다** — 전부 발견 보고이고 회신 대기.

## 3차 패스 (`H-21`~`H-38`) — 1·2차가 한 번도 안 연 문서 전체

`store-plan.md`/`source-state-plan.md` 코어, `modifier-plan.md`/
`component-composition-plan.md`/`bind-system-plan.md`, `event-plan.md`/
`onchange-plan.md`/`relate-plan.md`/`module-lifecycle-plan.md`/
`fallback-plan.md`/`lifecycle-hooks-plan.md`, `tween-plan.md`/
`debounce-throttle-plan.md`, `typing-limits.md`/`quad-types-plan.md`/
`project-setup-plan.md`와 **실제 커밋된 M1 코드**, `slot-plan.md`의 공개
CRUD·`:Single`·래핑·`dispose` 계층 + 통합 시나리오 넷.

**이번부터 추론으로 끝내지 않고 로컬 `luau` 0.734 / `luau-analyze`로 직접
재현했다.** 그 결과 **기존 `H-2`의 크래시 주장이 틀렸음이 드러났다** —
`table.insert(t, 0, x)`는 Luau에서 안 터지고 조용히 `t[0]`에 쓴다. 결론과
고칠 방향은 유효하지만 실제 결과는 크래시가 아니라 **영구 고아**다(claim은
됐는데 모든 walk가 `ipairs`라 아무도 안 닿음).

🔴 다섯: `unwrapElement`가 Instance에서 크래시(Roblox는 없는 멤버 인덱싱이
에러) / 기본 identity `updateFn`이 `KeyGone`을 그대로 반환해 항상 error /
전파 도중 새 구독자가 붙으면 **구독자 하나가 누락되고 다른 하나가 두 번
발화**(실측, 실행마다 결과가 다름) / `Tween<T>:Mapped` 재귀 제네릭 타입
누수(`luau-analyze` 0 진단) / `New(): Quad`가 닫힌 타입이라 M2가 붙일
`quad.Dispatch`가 타입에러(`luau-analyze`로 재현).

## 4차 패스 (`H-39`~`H-54`) — 문서 단위가 아니라 축 전환

핸들러 레지스트리 전수 / `ref-plan.md`·`attribute-plan.md` 심층 /
**`luau-test` 스파이크 실제 재실행** / 프리미티브 조합 매트릭스 /
`reference`·`archive`·로드맵 M3~M9 / **엔진·언어 사실 주장 전수 검증**.

**`H-39`(🔴)는 세 축에서 독립적으로 같은 결론에 도달했다** — 배열 자리를
차지하는 말단 핸들러 4종(`TagHandler`/`AttributeGroupHandler`/
`RefLeafHandler`/`ObserverEffectLeafHandler`)이 `setLength`/
`setOffsetSource`를 **아예 등록하지 않아** `Frame { Tag("x"), Child{} }`
같은 흔한 배치가 첫 마운트에 `recompute`의 명시적 error로 죽는다. 문서를
하나씩 읽는 방식으로는 구조적으로 안 보이는 종류(등록 의무는 디스패치 코어
문서에만 있고 각 핸들러 문서는 자기 로직만 서술).

그 외 🔴 둘: 공개 `Slot:Add` 의사코드가 CRUD 절이 확정한 가드 넷을 하나도
안 함(`H-37`은 이 문제의 반쪽이었다) / `groupClaimKeys` 확정이
`AttributeGroupHandler` 의사코드에 배선 안 됨.

**엔진 사실 검증의 성과 둘** — `H-21`의 전제가 공식 문서로 **확인**됐고,
반대로 **`PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부**임이
드러났다(`H-42`, 신규 템플릿 place는 이미 `Deferred`가 기본이고 코퍼스에
이 단어가 0건).

**스파이크는 깨끗하다** — `done/` 16개 전원이 `STATUS.md` 주장과 실행 결과
일치, GC 스파이크는 3회 반복에도 수치 동일, 설계 드리프트 0건.

색인 두 곳(`README.md`의 `qa-request/` 행, `todos.md` 00번)도 같이 갱신.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TPzatFCEs1CfWNPxF1pidr
2026-08-24 10:06:07 +09:00
f650a1cfd6
qa: 손 트레이싱 6라운드 2차 패스 — 1차 미탐색 영역 확장, 발견 9건 (+ 1차 전수 재검증)
사용자 요청("지금 나온거 이외에 더 문제될만한게 있는지 조사해봐줘 …
시스템 전체 계획을 봐도 좋고")으로 같은 파일에 2차 패스를 이어붙였다.
1차는 사용자가 지목한 5개 영역이 범위였고, 2차는 `dispatch-core-plan.md`
전문 / `source-state-plan.md` / `lifecycle-pattern.md` / `ref-plan.md` /
`tag-plan.md` / `attribute-plan.md` / `ui-shorthand-plan.md` /
`brand-plan.md` / `gate-plan.md` / `blocker-plan.md` /
`state-epoch-plan.md`와 `slot-plan.md`의 `raw*` 계층을 의사코드 단위로
훑었다.

**`base/`는 여전히 한 줄도 안 고쳤다** — 전부 발견 보고이고 회신 대기.

## 🔴 셋

- `H-11` **`Effect`의 leaf 사망 cleanup을 발화시키는 배선이 어느
  의사코드에도 없다.** `lifecycle-pattern.md`는 "`Effect`가 `Destroying`
  훅을 쓰는 유일한 소비자"라 못박고 `effect-plan.md`는 "leaf당 실제
  Destroying 바인딩 하나"라고 비용까지 적어뒀는데, leaf가 붙는 유일한
  경로인 `bindLifetime`의 실 구현 스케치는 gchold 등록 + gcconn 복사가
  전부다. `Effect`가 통째로 안 돌고, 그 위의 `OnDestroyed`와
  `slot._detachCleanup`(detach 요소를 파괴하는 *유일한* 경로 —
  같은 문서가 "GC 폴백이 아예 없으므로 필수"라 명시)이 같이 죽는다
- `H-12` `rawRemove`/`rawUnmount`/`rawDetach`에만 "아직 마운트 전"
  얼리리턴이 없다(`rawAdd`/`rawReplace`엔 있음). `Slot()` → `:Add(x)` →
  `:Remove(1)`이 `nativeRemove(nil, …)`로 터진다
- `H-13` `Effect(fn, ...deps)` 역전(5라운드 `C-6`)이
  `source-state-plan.md`에 반영 안 됐고, 거긴 아직 "기각"이 **일반
  원칙**으로 서 있다. 반응형 코어 정본만 읽고 구현하면 단수 dep으로
  되돌아가 `Ref` 의존성 갭이 재발한다

## 🟡🟢 여섯

`Effect`의 `fn` 시그니처 미확정(`self`/`previous` 자리, `Ref` dep은 lazy
핸들이 아님) / `newKeyIndex[key] = pos`가 마운트 안 된 키에도 배열
인덱스를 기록(`0` 포함) / `Dispatch.drive`의 Blocker 범위가 `F-4-1`
단일 루프와 안 맞고 `PostRef`가 게이트 안에서 발화하는 귀결이 미문서화 /
attribute 이름을 그룹 A→B로 옮기면 emit 순서에 따라 error /
`rawAdd`·`rawReplace`의 plain 분기가 `recompute`를 두 번 돌림 /
`Processed*RefHandler`의 `process` 시그니처가 핸들러 계약과 다름

## 커밋 전 `H-1`~`H-20` 전수 재검증

사용자 요청으로 커밋 전에 20건 전부를 `base/` 원문과 다시 대조했다.

- **`H-15` 철회(오탐)** — `ui-shorthand-plan.md`에 이미 사용자 판정
  예외(4라운드 `UI-11`, "`retractFrom(child, prop, 1)`을 정석으로 요구하지
  않는다 — 실익이 없다")가 있었는데 그걸 못 보고 `dispatch-core-plan.md`의
  일반 규칙만 인용했다. 번호는 비우지 않고 철회 기록 + 재발 방지 교훈으로
  남긴다(남은 건 인자 이름 `hint`가 폐기된 옛 용어라는 🟢 하나)
- **`H-2` 트레이스 정정** — 결론은 맞지만 수치가 틀렸다. `updateFn`이
  반환하는 중첩 Slot은 정의상 마운트 전이고 `Length`를 쓰는 주체는
  `recompute` 하나뿐이라(5라운드 `C-2`), 그 시점 `.Length`는 **항상 0**이다.
  원래 적은 `+3`이 나올 수 없다. 실제 동작은 더 나쁘다 —
  `isSlot` 분기가 언제나 `+0`이라 (a) 첫 아이템이면 `rawAdd(self, S, 0)`로
  바로 out of bounds, (b) `pos` 전진이 원래 노리던 목적("다음 형제의
  index가 물리 개수를 반영")도 첫 사이클엔 성립하지 않아 같은 데이터의
  `index`가 사이클마다 달라진다
- **나머지 18건은 유효 확인.** `H-3`은 `bk.invalidAfter` 대입이 코퍼스
  전체에서 `getOffsetAt` 안의 두 줄뿐임을, `H-11`은 `Destroying`이 산문에만
  등장하고 어떤 의사코드에도 연결되지 않음을 각각 grep으로 재확인
- 1차의 "이상 없다고 확인한 것" 중 `Ref` 콜백 발화 계약 표현도 정정
  ("이미 채워져 있으면" → 미설정이어도 무조건 1회)

색인 두 곳(`README.md`의 `qa-request/` 행, `todos.md` 00번)도 같이 갱신.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TPzatFCEs1CfWNPxF1pidr
2026-08-23 21:09:50 +09:00
471f64f9ee
qa: 손 트레이싱 6라운드 — 최근 확정분 전수 추적, 발견 10건 (회신 대기)
사용자 요청으로 최근 확정된 5개 영역을 실제 값으로 손 트레이싱했다 —
`Effect(fn, ...deps)` / `Gate`·`Blocker` / State 전파(`rawInvalid`·emit 지연) /
Slot의 `native*`·offset·length·mount / `Brand`·`Epoch`·`EpochMap`.
4·5라운드가 "예/아니오 문항지"였던 것과 달리 2·3라운드와 같은 성격이라
파일명도 `handtrace`로 갈랐고, 발견 번호는 `H-n`.

**`base/`는 한 줄도 안 고쳤다** — 전부 발견 보고이고 사용자 회신 대기.

## 🔴 셋 (크래시하거나 조용히 어긋남)

- `H-1` `:List`의 `keyIndex`가 사이클 **도중**엔 stale인데 `rawMove`/
  `rawReplace`/`releaseElement`/`rawDetach`의 live `_elements` 인덱스로
  쓰인다. 앞에 하나 끼우면 순서가 조용히 뒤집히고, 리스트 비우기는 소멸
  루프의 해시 순회 순서에 따라 `_elements[i] == nil`로 터진다. 5라운드가
  "raw\*를 전부 index 기준으로 통일"한 근거였던 *"그 값은 이미 `keyIndex`가
  들고 있다"*가 여기서 깨지므로 **그 결정의 전제를 다시 봐야 한다** — 세
  갈래(라이브 인덱스 맵 / `indexOfRaw` 기본 경로화 / reconcile 2패스)를
  적어뒀고 이게 유일한 사용자 판단 항목
- `H-2` `pos`가 리프 개수와 `_elements` 배열 인덱스를 겸한다. `updateFn`이
  중첩 Slot(멀티루트 결과)을 반환하면 첫 사이클에 바로
  `table.insert` position out of bounds
- `H-3` `getOffsetAt`의 접두합 캐시를 **아무도 무효화하지 않는다**.
  `invalidAfter = min(...)` 규칙이 표로만 존재하고 `setLength`/
  `gatedRecompute`/`_baseObserver`/`spliceArrays*` 어느 의사코드에도 없다.
  형제 길이가 변해도 뒤 형제 offset이 고정되고(`Length`만 맞고 offset은
  틀리는 형태), 포탈 재마운트는 옛 베이스가 든 캐시를 그대로 물려받는다

## 🟡🟢 일곱

`bk.invalidAfter`/`offsetCache`가 부기 스펙에 없고 초기값 `nil`이면 비교에서
터짐 / `spliceArraysUp`이 `bk.N`을 먼저 올려 스스로 금지한 창을 열고 그 안에
`nativeInsert`가 들어 있음 / `unmountSlotTree`가 미정의 `physicalTarget` 참조
(+ 순차 extract의 offset 스큐) / `Effect`의 `Ref` 의존성은 해제 API도
`canExecute` 게이팅도 없어 죽은 leaf에서 계속 발화 + 누수 /
`EffectHandle._observer`가 아직 단수라 N-deps에서 2번째 이후 재실행이 죽음 /
게이트 `withheld`가 flush 스왑에서 weak를 잃음 / `recompute`의 `sum` 시작값
주석이 stale.

## 이상 없다고 확인한 것

Epoch/EpochMap 판정 규칙(§1 다이아몬드 실제 추적), 게이트 flush 스냅샷과
`:Sync(batch)` 예외, `Effect`의 EpochMap dedup + `_installing` 순서,
`materializeSlotTree`/`mountSlotTree` 분해(depth 2 부기·물리 양쪽),
단건과 배치의 순서가 반대인 것, `Brand` 다중 태깅 — 그 문서 마지막 절이
소스이고, 다시 트레이싱할 필요 없다.

인덱스 레이어는 `.claude/README.md`(qa-request 행)와 `.claude/todos.md`
00번 갱신. `doc-check.py` ERROR 0.

Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-22 04:08:11 +09:00
9622ccd3e9
design: ROADMAP 마일스톤 경계 재편 + 전반 stale 정리
`ROADMAP.md` 전문을 `base/`의 현재 확정과 대조한 전반 점검. 사용자 판단으로
마일스톤을 각주가 아니라 체크박스째 재편했다.

## 마일스톤 이동 (M3/M7 → M2)

`EpochMap.luau` / `state:Gate`+`GateNode` / `Blocker.luau` / `None`+
`Dispatch/None.luau`. 넷 다 M2가 실제로 호출하는데 각주로만 예고돼 있었고,
`GateNode`는 체크박스 자체가 없어 M2를 훑는 구현자에게 항목으로 보이지
않았다. `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던 전례와 같은 처리.
`Blocker`는 `GateNode` 위의 정책으로 얹는다(노드를 다시 만들지 말 것).

##  새 미결 — M2와 M3의 의존이 양방향

이동하다 드러났다. `Dispatch.setLength`가 `State<number>`, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부르며, `Dispatch.drive`
자신도 배치 등록을 Blocker로 게이팅한다 — 즉 M2는 `Source.luau`/`State.luau`
없이 구현이 안 된다. 설계가 아니라 마일스톤 순서 문제라, 선택지 셋((a) 순서
교체 / (b) M2 분할 — 경계선은 `drive` / (c) 유지)을 `question.md` 2번으로
신설하고 유일한 소스로 지정했다. `HUMAN_TODO.md` 11번이 사용자 진입점.

## 모순 정정

- `Dispatch.drive` 순회 구현은 두 패스가 아니라 단일 일반화 `for`(`F-4-1`).
  "배열→해시 먼저"는 그 루프가 지키는 계약. 코퍼스의 "두 패스"는 본체 루프의
  옛 이름으로 정리(시점 표기는 유지, 용어 각주 신설). 따름정리로 "base는
  언어 동작에 안 기댄다"는 서술이 거짓이 됐고, 재작성될 스파이크 `01`이
  검증할 것도 언어 동작 자체로 바뀐다
- 물리 조작 주입 op 이름은 `native*` 확정(옛 가칭 `mountInst`/`unmountInst`/
  `disposeInst` 폐기). 단건 경로 순서는 `setOffsetSource` → `nativeInsert` →
  `setLength` → `recompute` — 역전 배너를 스스로 단 절 안에 옛 "부기 먼저"
  주장이 두 문단 살아 있었다
- M2 첫 체크박스가 하강 diff와 폐기된 옛 모델을 한 불릿에서 둘 다 서술

## 상태 표시

- `[x]`는 "짜야 할 코드"만. 설계 확정은 `### 확정된 것` 절 또는 전용
  불릿으로 분리(M3/M6/M11) — 이제 `[x]`는 M0/M1에만 남는다
- M0에 `### 재검증 대기` 절 신설 — 설계 변경으로 무효화된 스파이크들이
  어느 체크박스에도 없어 잊히기 쉬웠다(현황의 소스는 `luau-test/STATUS.md`)
- 주입 op 개수 하드코딩을 네 문서에서 걷고 `architecture.md`의
  `EngineOps.luau` 줄 하나로 단일화(그 줄에 빠져 있던 시간 op도 채움)

## 검증

`quad-doc-auditor` 10라운드(각도를 매번 바꿔 34건) + 사용자가 돌린
`/code-review high` 9건. `doc-check.py` ERROR 0. 경위와 실측된 실패 패턴은
`.claude/session/2026-08-22-01-roadmap-milestone-review.md`.

Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-22 03:19:13 +09:00
0498816c10
design: Epoch/EpochMap/Brand 전면 승격 + 해소 기록 flatten
앞 세션이 컨텍스트 피로로 미뤄둔 승격(todos 000번)을 수행하고, 코퍼스에
쌓여 있던 [해소]/[정정] 층을 걷어냈다. 감사 3라운드 + /code-review high로
15건을 잡아 전부 반영했다.

승격 — base/ 넷 + 파급 넷
- state-epoch-plan.md 재작성: Epoch 인터페이스({Revision:number}, 그 자체로
  키가 되는 unique 테이블, Source가 구조적으로 만족)와 EpochMap(Update/
  Refresh/Sync/TrackFrom) 신설. State는 EpochMap을 둘 컴포지션 —
  sourceCountMap/sourceEmitMap -> valueEpochMap/emitEpochMap. §1~§8로 재편.
- brand-plan.md 전면 재작성: 공유 레지스트리 + Brand.get(객체당 태그 하나)
  -> 인스턴스 브랜드 Brand() + :register/:is, 다중 태깅 허용. 발단은 Source가
  SourceBrand이면서 동시에 EpochBrand여야 하는데 옛 모양으로 표현 불가.
  역조회는 제거(전수 조사에서 쓰는 자리 0). weak-key/테이블 아이덴티티/
  duck-typing 기각 근거/predicate 합성은 전부 유지.
- source-state-plan.md: Source가 Epoch도 구조적으로 만족(Revision은 공개여야
  타입 레벨에서 성립), Observer 클로저가 fn(self, from: (Epoch|EpochSet)?).
-  effect-plan.md: 다중 의존성 중복 발화 미해결 항목이 닫힘 —
  EffectHandle이 자기 EpochMap을 들어 첫 번째만 통과시킨다.
- gate-plan.md/architecture.md/bind-system-plan.md/ROADMAP.md 어휘 통일,
  EpochMap.luau가 M2에도 필요하다는 것 반영(GateNode가 씀).

리비전 갱신은 bit32.bnot(-rev) — 사용자 확정
- a>0이면 a-1, 0이면 4294967295인 랩어라운드 감소. 갱신과 랩이 FASTCALL
  하나로 끝난다(luau 실측). 근거: 2^53 포화는 도달 불가능한데 그걸 피하려고
  값을 double 영역까지 키울 이유가 없다, 매번 도는 hot path다.
- 에이전트가 이걸 band(rev+1, mask)로 잘못 옮기고 "그러니 bit32가 더 싼 건
  아니다"라는 틀린 단서까지 달았다가 사용자 정정("제가 말한건 bit32.bnot(-a)
  입니다"). 세 문서에 정정 경위를 남겼다.
- 따름정리: 리비전은 증가가 아니라 감소한다. ==/~= 만 쓰는 지금 규칙에서만
  무해하다는 경고를 §2에 명시.

archive / flatten
- archive/brand-shared-registry-reversed.md 신설(옛 Brand 표면 원문).
- question.md 421->208줄: 해소 항목 18건을 archive/question-resolved.md로
  이관. 그 문서가 스스로 정한 규칙("해소하면 여기서 지우고 archive로")을
  다시 어기고 있었다. 이관분의 옛 필드명은 소급 수정하지 않고 머리에 경고만.
- todos.md 000번 삭제, "M3 착수 전 필요" 목록에서 해소 항목 일곱 제거
  (실제로 열린 건 중간 State GC와 store:GetDynamic 둘뿐).
- research/ -> reference/ 이동 둘(epoch-brand-composition,
  slot-attach-decomposition). 확정된 결정의 근거 기록은 research(상의 필요)도
  archive(뒤집힘)도 아니므로, reference의 폴더 기준에 그 용도를 명문화했다.

감사 3라운드(각도: base 정합성 / 인덱스+luau-test / archive+qa-request)
- §8에 "기각된 대안 — 게이트를 에포크 경계로" 논거 신설(재작성 때 떨어뜨렸고,
  두 문서가 서로 다른 없는 §번호를 대고 있었다).
- luau-test 스파이크 22를 done/ -> rewrite-required/(옛 Brand.set/get을 직접
  구현). STATUS.md 개수와 절 제목의 하드코딩 개수 정리.
- state-epoch/source-state가 "Revision을 증가시킨다"고 적어놓고 20줄 뒤에
  "감소한다"로 반박하던 자기모순 정정.

커밋 전 /code-review high — 9건, 전부 유효(감사자가 못 보는 축)
-  {Epoch}는 Luau에서 배열인데 실제 게이트 배치는 {[Epoch]:true} 집합.
  그대로 ipairs로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다(gate-plan 4번이
  애초에 고치려던 그 버그) -> EpochSet으로 확정.
-  새 노드 시딩이 확정된 EpochMap 표면으로 표현 불가능했다(:With의 상류는
  State이지 Epoch가 아니고, 키 열거/병합 연산이 없었음) -> :TrackFrom 신설.
- GateNode 예외(emitEpochMap을 전파 시점에 갱신)가 §4에 미기록.
- 설치 발화엔 from이 없다 -> 옵셔널로, Effect의 억제 플래그가 Update보다
  먼저여야 함을 명시.
- 2^32 랩을 "똑같이 도달 불가능"이라 한 근거가 틀렸다(같은 척도로 285년 vs
  72분). 실제 안전 근거는 충돌 조건이 한 점이라는 것으로 정정.
- 그 외 :Sync 용도 충돌, isEpoch 누락, TweenTag 3곳, Effect(fn,state?) 4곳,
  § 참조 3곳.

에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정
- :Refresh 유지 — "Update는 받은 것을 처리, Refresh는 내가 받았던 걸 처리라
  표면적 의미 자체가 다르다"(오버로드로 합치지 않음).
- :Absorb -> :TrackFrom 개명 — absorb는 상위에서 제거할 것처럼 읽히고,
  gate-plan이 이미 "흡수 집합"을 다른 뜻으로 쓴다. TrackFrom은 이 맵의 존재
  이유("내가 뭘 추적하고 있나")를 그대로 쓰고 From이 비파괴를 못박는다.

Epoch/EpochMap/Brand에 열린 설계 항목 없음. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-22 01:27:18 +09:00
168d3d8dcc
docs: 핸드오버 — Epoch/Brand 제안 승격을 다음 세션 첫 작업으로 지정
사용자 지시로 승격은 다음 세션에("이 세션은 너무 길어요"), 감사는 승격
이후에 돌린다. 이번 세션에서 알게 된 것 중 base/에 안 남아 있던 것을
전부 채웠다.

- base/ 네 문서 상단에 research/epoch-brand-composition.md를 가리키는 ⚠️
  배너 신설: state-epoch-plan(두 맵 -> EpochMap 둘), source-state-plan
  (Source가 Epoch를 구조적으로 만족 + Observer 클로저 fn(self, from)),
  brand-plan(인스턴스 브랜드 전면 재작성), gate-plan(emit 페이로드 타입).
  넷 다 "기제는 그대로 유효, 표현만 바뀐다 / 승격 전엔 이 문서가 정본"을
  명시.
-  base/effect-plan.md에 다중 의존성 Effect 갭을 미해결로 기록. 이번
  세션에 발견했는데 research 문서에만 있었다 — 제안이 기각되더라도 갭은
  남으므로 base에 있어야 한다. 해법 후보(Effect가 자기 EpochMap을 듦)와
  접은 대안(deps를 한 노드로 수렴), 그리고 "useEffect처럼 N번 허용"
  선택지까지 같이 적었다.
- base/blocker-plan.md에 HasBlockedEmit == (next(withheld) ~= nil)이라는
  것과 "빈 배치면 무통지"가 그 일반화라는 것 기록(구현 시 둘을 따로 들지
  말 것).
- todos.md에 000번 신설 — 다음 세션 첫 작업, 고칠 문서 넷과 남은 미정
  하나(bit32 랩 vs +1)까지 명시.
- question.md 1번에 리비전 증가 방식 항목 추가.
- session-summary.md의 이 세션 항목이 "Gate만 다음 세션 — M2를 막는 유일한
  항목"에서 멈춰 있던 것을 전량 갱신(같은 날 Gate/에포크가 확정됐고,
  code-review 두 번에 19건이 나왔으며, Epoch/Brand 제안이 대기 중).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:32:48 +09:00
782e576218
research: Epoch/EpochMap 2차 회신 반영 — :Sync 필수 주장 철회, 남은 미정 하나
- 리비전은 숫자(Revision)로 확정. 증가 방식만 미정 — 사용자는 bit32 랩을
  염두에 뒀고(uint32 랩어라운드라 double 포화가 안 생김) 그건 맞지만, 충돌
  거리는 오히려 짧아진다는 점을 같이 기록했다(평이한 +1은 2^53 포화,
  bit32 랩은 2^32마다 한 바퀴). 둘 다 도달 불가능이라 실질 위험은 없다.
- "재계산 후 전부 최신으로 맞추려면 Update 외의 연산(:Sync)이 필요하다"는
  에이전트 주장 철회 — Update가 Epoch|{Epoch}를 받으므로 전체 deps를 넘기면
  그게 곧 sync다. Update를 "하나만 받는 것"으로 좁게 본 착오. 노드 초기화도
  같은 연산 하나로 끝나고(반환값 무시 + rawInvalid = true), 이미 확정된
  생성 규칙과 정확히 같은 동작이다. :Sync는 읽기를 건너뛰는 순수 최적화
  변형으로만 남는다. 목록 순회 중 한 번 다름을 찾으면 반환값이 확정되므로
  나머지는 읽지 않고 쓰기만 하면 된다는 내부 최적화도 기록.
- Observer 클로저는 :Compute와 같은 모양 fn(self, from: Epoch|{Epoch})로
  방향 확정. 값이 아니라 핸들과 메타데이터만 넘기므로 "값을 안 실어주는
  구독" 계약이 안 깨진다.

base/ 승격은 사용자 확인 대기 — 승격 시 state-epoch-plan/source-state-plan/
effect-plan/brand-plan 넷을 같이 고쳐야 한다. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:27:15 +09:00
0ebf14405f
research: Epoch/Brand 제안 — 사용자 회신으로 결정 4건 해소, 오버플로 근거 정정
- Brand.get 역조회는 불필요로 확정(코퍼스가 쓰는 건 전부 isX 멤버십 질문).
- "포함 관계가 생성자로 흩어진다"는 에이전트 착오라 철회 — 자기 등록이 여러
  브랜드 등록을 강제하지 않으므로 isRef = PreRefBrand:is(x) or ... 처럼
  predicate 합성으로 지금과 똑같이 한 곳에 쓸 수 있다.
- Epoch는 Source가 구조적으로 만족하는 것으로 확정(State를 만족하는 기존
  패턴과 같은 모양).
- 리비전 필드는 공개로 확정. 근거가 정확하다 — 비공개면 구조적 만족이 타입
  레벨에서 성립하지 않는다. Store와 달리 Source는 키가 사용자 것이 아니라
  예약 이름이 늘어도 무방. 이름은 코퍼스 PascalCase 관례를 따라 Revision
  (숫자안) 또는 Token(테이블안) 권고.
- 계약은 "직전 값과 다르다"만 요구(순서 비교는 규칙 어디에도 안 쓰임).
  ⚠️ 다만 "오버플로가 나든 이전값과 다르다"는 근거는 정정 — Luau 숫자는
  double이라 랩어라운드가 아니라 포화하고, 2^53에서 n+1 == n이 되어 보장이
  정확히 그 지점에서 깨진다. 도달 불가능해서 문제가 아닌 것이므로 근거를
  "도달 불가능"으로 적어둠.
- Brand 전환 범위/마일스톤 해소 — 커밋된 M1 코드는 Brand를 아직 안 쓴다
  (quad-base/src는 init/Relate/Debug뿐) 전환 비용은 문서뿐.

남은 열린 항목 셋: 숫자 리비전 vs 테이블 토큰(권고는 숫자 — 테이블은 Set
한 번마다 할당이라 매 프레임 Set하는 소스에서 GC 압력), State가 EpochMap을
둘 들고 :Sync가 필요한지, Observer 클로저 인자.

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:15:22 +09:00
c1c046fabe
research: Epoch 인터페이스 + EpochMap 컴포지션, Brand 인스턴스화 제안 정리
사용자 제안을 research/epoch-brand-composition.md로 신설. 아직 아무것도
확정 안 함 — base/state-epoch-plan.md 등이 여전히 정본.

요지: (1) 에포크 부기를 State에서 떼어내 EpochMap:Update(Epoch|{Epoch}) ->
boolean(뒤로 전파가 필요한가)으로 컴포지션, (2) Observer가 emit 출처를
클로저 인자로 흘려주고 Effect가 자기 EpochMap을 들어 다중 dep 중복 발화를
접음, (3) emit 페이로드에서 게이트를 빼고 Source|{Source}로, (4) 나아가
Epoch = {Count: number} 인터페이스로 일반화, (5) 그걸 위해 Brand를
인스턴스화 가능하게(Brand() + :register/:is, 다중 태깅).

에이전트 평가: 방향 타당. 특히 다중 의존성 Effect 갭(A→b, A→c일 때 접어줄
공통 하류가 없어 fn이 두 번 도는 것)이 정확히 닫히고, 맵이 Source에서
요구하는 게 identity + 단조 카운터뿐이라는 점에서 Epoch 일반화가 계약을
정확하게 만든다. 다만 대가 둘을 명시 — Brand.get 역조회 상실, 그리고
2026-08-09에 의도적으로 설계한 "포함 관계가 코드 모양에 드러남"이 자기
등록 방식에서는 생성자로 흩어짐.

결정 7건을 열린 채로 기록(Epoch를 구조적 만족으로 둘지, Count 공개 여부,
인터페이스 계약, State가 EpochMap을 둘 들고 :Sync가 필요한지, Observer
클로저 인자, Brand 전환 범위, 마일스톤).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:01:44 +09:00
a55e726808
design: 빈 배치 emit은 통지하지 않음 — 에이전트 권고 기각, Blocker 계약의 일반화
에이전트 권고("빈 배치 = 무조건 통지")를 사용자가 기각. 빈 배치는 "이미 다
던져서 더 던질 게 없다"는 뜻이고, 같은 카운트가 두 번 흘러든 emit을 전파하지
않는 것과 같은 자리다. 그걸 흘리는 건 표면적으로 State 중간에 Source:Emit을
추가하는 격이라 Gate의 성격과 안 맞는다.

확정: next(withheld) == nil이면 통지 자체를 안 한다. 새 규칙이 아니라 기존
계약의 일반화임을 확인 — blocker-plan.md가 이미 "HasBlockedEmit이 false면
emit 값과 무관하게 아무 것도 안 함(idempotent)"으로 확정해뒀고
HasBlockedEmit은 next(withheld) ~= nil의 특수형이다. Debounce/Throttle도
if pending일 때만 passThrough()를 부른다.

따름정리 — Effect(fn, ...deps)의 설치 구간 억제가 Gate 소비자에서 빠졌다.
설치 구간엔 어떤 Set도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가
내보낼 것 자체가 없고, Effect 내부 플래그면 충분하다. effect-plan.md에서
"⚠️ 억제 장치의 모양은 Gate 설계에 딸려 있다"와 우선순위 문단의 "Gate보다
뒤다"라는 순서 제약이 같이 빠졌다.

이로써 Gate에 사용자 판단이 필요한 항목은 없다 — 남은 건 생명주기 계약과
M2 범위뿐이고 둘 다 구현 시 결정. 처리 전량은 V절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 22:35:58 +09:00
46287ee393
fix: "Gate 재진입 계약"은 잘못 옮긴 서술 — 열린 항목에서 제거, emit=flush로 정정
사용자 반문으로 에이전트 서술 오류 둘을 정정. 결론은 안 바뀌었고 근거와
열린 항목 목록만 정리됐다.

1) gate-plan.md 6번의 "onUpstreamEmit 안에서 같은 게이트의 emit()을 재귀적으로
   부르는 경우"는 blocker-plan.md의 재진입 절을 잘못 옮긴 것이다. 그 절은
   같은 Blocker 인스턴스를 중첩해 On()/Off() 하는 것을 말하고 정책의 emit()
   호출과 무관하며, 정책이 flush를 부르는 건 재귀가 아니라 평범한 통과
   경로다. 계약 셋으로 정리: 끝나지 않는 되먹임은 UB(dispatch-core-plan.md의
   2026-08-04 확정 원칙), 유한한 재진입은 지원(debounce-throttle-plan.md의
   onWindowEnd 주석이 이미 대비), 같은 인스턴스 중첩 금지는 Blocker 규칙 그대로.
   그래서 question.md의 사용자 판단 항목에서 재진입을 뺐다.

2) 정책이 받는 emit은 "이 값을 내보내라"가 아니라 "쌓인 걸 지금 흘려보내라"
   (flush)이고, debounce-throttle-plan.md가 이미 gate:passThrough()로 부르던
   것이다. 배치를 떼어내는 것도 그 핸들 안에서 일어나므로, 직전 커밋이
   "재진입 위험"이라 부른 것은 에이전트가 적은 "전파 후 table.clear" 의사코드의
   결함이지 모델의 구멍이 아니었다. 수정(flush 진입 시 스왑)은 그대로 유효하고
   서술만 그렇게 고쳤다.

Gate에 남은 사용자 판단은 빈 배치 emit 하나뿐. 처리 전량은 U절.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 22:27:07 +09:00
3b46d5ef68
fix: 두 번째 /code-review high 7건 — 유실 경로 둘 포함 5건 수정, 2건 승격
High 둘은 실제 유실 경로였다.
- withheld를 페이로드로 그대로 넘기면 재진입에 깨진다. 전파 중 Observer가
  Set을 불러 게이트에 재진입하면 중첩 전파가 끝나며 table.clear가 돌아
  바깥 전파의 남은 갈래가 빈 집합을 받는다. 전파 직전에 새 테이블로 스왑해
  배치를 떼어내고 그 배치를 페이로드로 넘기는 것으로 수정.
- OffWithoutEmit()이 집합을 안 비웠다. Dispatch.drive의 배치 게이팅이 매
  프레임 On() -> OffWithoutEmit()을 돌므로 집합이 단조 증가하고(weak 설계와도
  충돌) 나중에 아무 소스나 통과할 때 폐기분이 같이 실려 나간다. 그 경로도
  비우도록 확정하고 withheld를 weak key로 명시.

나머지 수정 셋:
- "무조건 withheld에 넣는다"가 수신 규칙 1~3을 건너뛴다는 뜻으로 읽히던 것을
  "정책의 통과/유보와 무관하게"로 명시(그대로 두면 다이아몬드에서 Throttle
  정책이 두 번 돌아 유령 trailing emit이 나간다)
- luau-test/STATUS.md의 "런타임 12개"가 이미 나간 04/10/19를 포함한 옛
  총계에서 이어져 온 수라 실제(9건)와 안 맞던 것
- followup D절 색인 표가 삭제된 research/ 두 문서를 현재형으로 서술하던 것

열린 항목으로 승격 둘(question.md의 "남은 것은 사용자 판단이 아니다"도 정정):
- Gate 재진입 계약 — 스냅샷으로 유실은 막았으나 정책 안 재귀 호출 계약은 미정
- 소스 없는 emit(빈 배치) — 정책이 상류 신호와 무관하게 emit()을 부르면 빈
  배치가 나가 하류가 조용히 삼킨다. Effect(fn, ...deps) 설치 구간 억제
  용례가 정확히 이 모양이라 그대로는 성립하지 않음. 권고는 "빈 배치 = 무조건
  통지"

처리 전량은 round5-followup.md의 T절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 22:14:16 +09:00
e2b85bca55
design: 게이트가 게이트 emit을 받는 경우 확정 — 출처를 넘기지 말고 풀어서 합친다
사용자 발견. R절까지의 규칙("출처가 GateNode면 받은 출처를 그대로 아래로
넘긴다")은 받는 쪽이 또 게이트인 경우가 정의돼 있지 않았고, 그대로 넘기면
깨진다 — 상류 게이트는 자기 전파가 끝나자마자 table.clear 하므로 하류
게이트가 출처만 들고 유보했다가 나중에 풀면 빈 집합을 내보내 변경이 통째로
증발한다.

확정: 수신 시점에 unfold 해서 자기 _withheld에 합친다(Source면 하나,
GateNode면 그 집합 전부). 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을
들고 있으므로 어느 층이 먼저 풀리든 정보가 안 샌다.

같이 못박은 것: 게이트의 sourceEmitMap은 수신 때가 아니라 실제로 전파할 때
집합 전체에 대해 갱신한다 — 그래야 "내가 하류로 던진 에포크"라는 맵의 뜻이
게이트에서도 참이 된다.

반영은 base/gate-plan.md 4번, base/state-epoch-plan.md §2, followup S절.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 22:02:01 +09:00
930e45bfad
design: 게이트 통과/유보 미구분으로 단순화 + 새 노드 두 맵 비대칭 초기화 확정
1) 게이트는 통과와 유보를 구분하지 않는다. 상류 emit이 오면 정책 실행 전에
   무조건 withheld[source] = true 로 넣고, 정책이 emit()을 부르면 게이트가
   자기를 출처로 전파한 뒤(동기) table.clear 한다. 그냥 통과시킬 때도 상류
   출처를 넘기지 않고 언제나 자기를 낸다 — 하류가 보는 차이는 집합 원소가
   하나냐 여럿이냐뿐이고 판정 규칙은 같다. 그래서 직전 라운드에 넣었던
   "정책이 그 자리에서 emit()을 불렀는지 노드가 되짚는다"는 감지 로직이
   통째로 불필요해졌다.

2) 새 노드의 두 맵은 비대칭으로 초기화한다.
   - sourceEmitMap: 비운다. nil ~= source.count 라 어떤 emit도 "처음 보는
     것"으로 걸리고, 새 노드는 실제로 emit을 받아본 적이 없으므로 그게 맞다.
   - sourceCountMap: 상류에서 전부 끌어와 실제 count로 채우고 rawInvalid를
     true로 둔다. 순회가 훑을 목록이 곧 이 맵이라 비워두면 "훑을 게 없으니
     유효하다"로 오판한다 — 여기는 lazy할 수 없고 "내가 뭘 추적하는가"가
     필요하다.
   그래서 :With 병합 규칙은 필요 없어졌다(생성 시점 라이브 count로 통일되므로
   두 상류가 같은 소스에 다른 count를 들 일이 없다). /code-review Med-3이
   열어둔 (b)/(c)가 이걸로 전부 닫혔다.

처리 전량은 round5-followup.md의 R절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:57:45 +09:00
356a308ce0
design: 게이트 emit 출처를 emit(self)+흡수 집합으로 확정, 재계산 시 count 전부 갱신
/code-review high가 잡은 3건에 대한 사용자 회신 반영.

1) 게이트가 유보했다 내보내는 emit — (c) 채택, 다만 에이전트 안보다 단순한
   형태로. 게이트에 자체 count를 주는 대신 흡수한 소스 집합
   withheld : {[source]=true} 만 들고 있다가, 풀 때 자기를 출처로 하류에
   emit 하고 전파가 동기이므로 반환 뒤 table.clear 한다. 하류는 출처가
   GateNode면 그 집합의 소스들에 평소 규칙(1~3)을 적용하고 받은 출처를
   그대로 더 아래로 넘긴다. OffWithoutEmit도 안전(다음 진짜 emit이 스스로
   낫게 함). 그래서 emit의 출처는 Source | GateNode 둘 다가 된다.
    setup 시그니처는 안 바뀐다 — 흡수 집합을 채우는 건 정책이 아니라
   노드이기 때문(노드가 onUpstreamEmit 전후로 정책이 그 자리에서 emit()을
   불렀는지만 보면 됨). P절이 "M2 표면에 영향"이라 적은 건 기우였다.

2) 재계산 후 sourceCountMap은 자기가 읽은 상류 전부를 갱신한다(확정).
   다만 이걸 제기한 에이전트 근거("A:Set(); Z:Set()이면 같은 값을 두 번
   계산")는 사용자가 반증 — 전파가 동기라 A 파동이 완전히 끝난 뒤 Z:Set()이
   시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다. 전부 갱신이
   실제로 값을 하는 자리는 게이트 유보 중 하류가 Get()으로 앞당겨 읽는
   경우뿐이고, 그때 해제 통지가 규칙 2(통지만)로 떨어져 재계산을 안 한다.

3) 같이 명문화한 대원칙: 무효화를 결정하는 건 언제나 count 비교지 emit의
   도착이 아니다. emit은 "이 원천을 확인해봐"라는 요청일 뿐이라, 통과해도
   count가 최신이면 캐시는 유효한 채로 남는다.

남은 열린 항목은 새 노드의 두 맵 초기값(복사 vs 첫 재계산 때 구성)과 그에
종속된 :With 병합 규칙뿐이고, 동기 전파 덕에 차이가 나는 경우가 게이트 유보
중 파생 노드가 생길 때 하나뿐이라 어느 쪽이든 무해 — M3 구현 시 결정.

처리 전량은 round5-followup.md의 Q절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:40:05 +09:00
cb838d3172
fix: /code-review high 12건 — 9건 수정, 3건은 열린 설계 항목으로 승격
O절 커밋(c58c97a) 직후 돌린 리뷰에서 12건이 나왔고 전부 유효했다.

열린 항목으로 승격(임의로 정하지 않음):
- [M2 착수 전] 게이트가 유보했다 내보내는 emit이 어느 source를 싣는가.
  확정된 setup은 (emit: () -> ()) -> (() -> ())라 양쪽 다 source를 안 받는데
  에포크 수신 규칙은 전부 [source] 키로 판정한다 — 그대로면 blocker:Off()가
  묶어둔 배치 emit이 하류에서 규칙 3으로 삼켜져 통지가 통째로 사라진다.
  5라운드 M절이 이미 짚었는데 표면 확정 때 같이 안 닫힌 것. 후보 (a) emit(nil)
  전체 확인 / (b) 유보 소스마다 emit(Blocker의 "정확히 1회"가 깨짐) /
  (c) GateNode 자신을 source처럼 취급 — 권고는 (c). gate-plan.md 4번.
- [M3 착수 전] 두 맵의 초기값·:With 병합·재계산 시 갱신 범위.
  규칙 1이 발행 소스 항목만 건드려 다중 소스 배치에서 같은 값을 두 번
  계산하고, "상류에서 복사"는 순회가 앞당긴 지연분 상속 여부가 미정이라
  새 노드가 통지를 삼킬 수 있다. state-epoch-plan.md §5 7번.
  이에 따라 todos.md 00번의 "M2를 막는 설계 항목 없음"도 정정.

그 자리에서 수정:
- state-epoch-plan.md: §4/§5-2의 sourceList 잔재(코퍼스에 bk.sourceList라는
  무관한 동명 식별자가 있어 오독 위험), §3의 "rawInvalid가 켜져도" 정정
- gate-plan.md: 배너가 부정하는 본문 두 문장을 같이 수정
- question.md 1번: Gate 이름 항목이 열린 채였던 것 해소로 갱신
- luau-test/STATUS.md: 05 이동이 반영 안 된 개수 3곳 + 같은 파일 안의
  모순 문장("05가 다시 돌아왔다")
- comparison-fusion-vide.md: 배너 바로 위 본문이 배너와 어긋나던 것
- N절/README가 가리키던 research/ 옛 경로

처리 전량은 round5-followup.md의 P절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:21:44 +09:00
c58c97a877
design: Gate 표면 확정(state:Gate + GateNode) + State 에포크 채택, 두 문서 base/ 승격
사용자 확정 둘로 M2 착수를 막던 설계 항목이 전부 사라졌다.

1) Gate — 탑레벨 프리미티브를 만들지 않고 state:Gate(setup) 메소드로 확정.
   ComputeNode와 같은 층위의 GateNode를 만든다. Blocker는 그 위의 별개
   프리미티브로, 이미 확정돼 있던 state:Block(blocker)가 내부에서
   self:Gate(policy)를 부른다. Debounce/Throttle의 state:Apply(...) 관용구는
   그대로 — 팩토리가 내부에서 :Gate를 부르면 되기 때문. 이름 문제(Gater?)도
   메소드 자리로 가면서 소멸. Get()엔 영향 없음(통지만 막음)까지 확정.
   research/gate-primitive.md -> base/gate-plan.md.

2) State 에포크 — 채택 확정. 구현은 M3.
   research/state-epoch-validation.md -> base/state-epoch-plan.md.

에포크 채택으로 source-state-plan.md의 두 확정 서술("emit은 항상 전파" /
"quad가 접지 않는 것은 중복 통지뿐")이 역전됐다. 원문은
archive/always-propagate-no-dedup-superseded.md. 지금 계약은 "invalid로는
절대 안 접고, 같은 소스의 같은 에포크가 두 번째로 도착했을 때만 접는다" —
2026-08-14의 invalid 기반 dedup 금지를 되돌린 게 아니라는 점을 역전 문서와
source-state-plan.md, README 세 곳에 못박음(흐려지면 "영구 침묵" 버그로
되돌아감).

같이 갱신: architecture.md 전파 모델 요약, blocker-plan.md(:Gate 배선 +
Get 계약이 에포크 안의 전제라는 것), debounce-throttle-plan.md(공용 게이트
권고가 실현됨 / 파동 단위 최적화 서술 정정), reference/comparison-fusion-vide.md,
source-state-plan.md의 Observer 계약 각주(이제 "새 에포크는 항상 통과"에
의존), ROADMAP M0 각주·M2 각주·M3 체크박스, README/question/todos 인덱스.

스파이크 05-store-state-diamond-propagation은 done/ -> rewrite-required/ 로
되돌렸다 — 다이아몬드 Observer가 이제 변경당 1회만 울어야 해서 핵심 assert가
정반대가 됐다(살릴 것/새로 넣을 것은 STATUS.md에 기재).

처리 전량의 소스는 qa-request/pre-implementation-qa-round5-followup.md의 O절.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:04:27 +09:00
76cf74e80d
design: 에포크 순회 처분을 sourceCountMap/sourceEmitMap 두 테이블로 확정
사용자가 열려 있던 마지막 자리를 제3안으로 닫음. 판정 기준을 둘로 나눈다 —
sourceCountMap(값 유효성)은 순회가 앞당겨 올리고, sourceEmitMap(전파 dedup)은
상류의 진짜 emit을 기다린다. emit 수신 규칙은 셋: count가 다르면 둘 다 갱신 +
rawInvalid + 전파 / count는 같은데 emit 기록이 다르면 전파만(순회가 앞질러
흡수한 경우) / 둘 다 같으면 삼킨다. 순회는 emit을 하지 않는다.

효과 — 통지가 죽는 "영구 침묵"이 사라지고, 순회가 emit을 안 하므로 게이트
누출 경로 자체가 없어져 source = nil 규약도 "게이트를 에포크 경계로" 같은
계약 반전도 불필요해진다. 직전 라운드에서 에이전트가 냈던 (c)안의 약점
(emit 도착 전까지 Get마다 재계산)도 sourceCountMap을 실제로 올리므로 없다.

같이 검토된 rawEmit+nil 안은 구조 위생(상류 emit과 내부 발생 emit의 진입점
통일)만 살리고 해법으로는 안 씀 — 막는 게이트는 보통 순회하는 노드 자신이
아니라 상류에 있어 자기 rawEmit을 태워도 누출이 남고, nil emit은 하류마다
전체 순회를 강제해 같은 문제를 연쇄시킨다.

M절에서 철회했던 seen/computedAt 분리가 다른 근거(순회가 값과 통지를
비대칭으로 앞당김)로 되살아난 것이라는 점도 명시. 이제 기제는 다 정해졌고
남은 건 채택 여부 자체 — README/question.md/ROADMAP 동기화.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 20:49:28 +09:00
9627558046
design: 에포크 순회의 count 갱신 문제 정리 + Gate는 :Apply가 아니라 State 메소드로 확정
(1) research/state-epoch-validation.md §5-3 재작성. 사용자 지적대로 순회가
count를 올리면 뒤늦게 온 진짜 emit이 삼켜져 하류 통지가 영구히 죽는다
(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열). 두 해법을
대조로 남김 — (b) 순회도 emit(사용자 제안, 게이트 누출이 남고 그 누출을
막을 기제가 둘 다 대가가 큼: 에포크 경계는 blocker의 ":Get()엔 영향 없음"
확정 계약을 뒤집고, source = nil 규약은 사후 정합성만 맞춤) vs
(c) 순회는 rawInvalid만 세우고 count는 안 올림(에이전트 권고 — 원인만
제거하므로 누출도 nil 규약도 계약 반전도 안 생김, 대가는 emit 도착 전까지
Get마다 재계산과 OffWithoutEmit 캐비엇). 미결로 남김.

(2) research/gate-primitive.md 2번 해소. 사용자 확정으로 Gate는
state:Gate(setup) 메소드다. 경계는 "Apply가 노드를 못 만든다"가 아니라
"프리미티브는 메소드 / 유저랜드 조합 팩토리는 :Apply". 그래서
Debounce/Throttle의 Apply 관용구는 유지되고, Blocker 배선은 이미 확정된
state:Block(blocker)가 내부에서 :Gate를 부르는 것으로 자동 해소되며,
__call은 쓰지 않는다.

question.md/ROADMAP 인덱스 동기화. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 20:24:51 +09:00
4c395cd383
design: State 에포크 안 3차 정정 — rawInvalid 기제 반전, seen/computedAt 분리 철회
사용자가 research/state-epoch-validation.md를 직접 읽고 기제 서술 세 건을
정정. 채택 여부 자체는 여전히 미정(M3 전 결론 필요).

- sourceList 순회 조건이 반대였다: rawInvalid == false일 때만 돈다.
  true면 재계산이 이미 확정이라 훑을 이유가 없고, 순회의 목적은 오직
  "못 받은 emit(게이트에 막혔던 것)을 여기서 먼저 받는 것".
- emit은 (source, count)가 아니라 발행 source만 싣는다 — 받는 쪽이
  source의 count 필드를 그냥 읽으면 된다.
- 에이전트가 요구했던 seen/computedAt 두 카운트 분리는 철회. count 갱신과
  rawInvalid = true가 같은 스텝이라 캐시 오인 경로가 없다.
- emit 수신 규약 확정형: 같으면 삼킴 / 다르면 count 먼저 갱신 →
  rawInvalid = true → 그 다음 뒤로 emit. 다른 소스 항목은 안 건드림.
- 부수로 열린 것: 순회가 발견한 변경을 뒤로 emit 할 것인가. 다이아몬드
  쪽은 사용자가 스스로 안전으로 정정(D도 count를 갱신해 중복을 삼킴),
  게이트 쪽만 "해제 emit이 source = nil을 싣고 받는 쪽이 전체 확인"
  규약으로 남음 — 게이트는 보통 최종단이라 채택을 막지 않는다는 판단.
- 비용 서술도 뒤집었고(훑는 쪽이 흔한 경로), question.md에 남아 있던
  이미 뒤집힌 옛 결론("중복 통지는 안 고쳐짐 / UB 명문화 필요")도 정정.

처리 기록은 round5-followup.md M절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 20:15:28 +09:00
a4b517aee2
design: native* 물리 조작 계층 확정 + C-7("부기가 물리보다 먼저") 역전
주입 op 셋(mountInst/unmountInst/disposeInst)으로는 Move/Swap을 아예 표현할
수 없다는 지적에서 시작해 물리 조작 계층 전체를 재설계했다.

층위 정의(사용자): raw*는 그 Slot 스코프 안의 연산(평탄화 전, _elements
인덱스), native*는 확정된 offset/length로 표현되는 물리 트리 연산(평탄화 후,
절대 좌표).

표면 여섯:
  nativeInsert (target, offset, elements)
  nativeExtract(target, offset, elements, newElements?)   -- 빼되 살림
  nativeRemove (target, offset, elements, newElements?)   -- 빼면서 파괴
  nativeMove   (target, fromOffset, elements, toOffset)
  nativeSwap   (target, offsetA, elementsA, offsetB, elementsB)
  nativeDispose(element)                                   -- 트리 밖 값 파괴

- Replace는 별도 op이 아니라 newElements가 있는 Remove/Extract(Splice도 동일)
  — 제거+삽입을 한 호출로 합쳐 리플로우 2회와 그 사이 인덱스가 어긋난 창을
  없앤다
- 파괴/비파괴를 불리언이 아니라 이름으로 가름 — 공개 CRUD의 Remove/Extract
  어휘를 물려받고, Roblox의 "Parent=nil 없이 그 자리에서 Destroy" 융합을 연다
- 빠지는 요소는 반드시 배열로 넘김 — (target, offset, count)로 대상을 찾을 수
  있는 건 DOM뿐이고 Roblox는 자식이 순서 없는 집합이라 offset 역조회가 안 됨
- nativeSwap은 별도 — Move는 사이를 전부 밀지만 Swap은 가운데 고정
- 미주입이면 에러가 아니라 조합 폴백(addTag 계열과 갈리는 지점)
- 전제: 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다

그 여파로 4라운드 C-7 일반 계약이 역전됨 — "Length를 먼저 올려 밀어내고 그
공간에 넣는다"는 그림은 base에 물리적으로 자리를 비워둘 수단이 없어 성립하지
않는다. 규칙이 "빼기는 물리 먼저/넣기는 부기 먼저" 두 얼굴에서
"자기 자리를 정하는 것(setOffsetSource) 먼저 / 뒤를 미는 것(setLength→
recompute) 나중" 하나로 줄었다. 배치 경로(materializeSlotTree→mountSlotTree)의
부기-전량-먼저는 C6가 요구하는 별개 사안이라 그대로.
원문은 archive/bookkeeping-before-physical-reversed.md.

같이: getOffsetAt을 사용자 의사코드대로 단일 함수 + invalidAfter로 정정
(무효화는 min(invalidAfter, i) 하나, recompute도 그 캐시 위에 얹혀 O(N)).

doc-check.py ERROR 0. 상세는
qa-request/pre-implementation-qa-round5-followup.md의 L절.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 20:07:21 +09:00
c6fdf1b348
qa: 구현 전 QA 5라운드 — 문항지·회신·전량 반영 + 감사 2라운드
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항).
범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup /
quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것
(Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐
받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다.

주요 확정/역전:
- slot._detached lazy화, KeyGone엔 새 값 반환도 error,
  Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount)
- Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음)
- raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결.
  래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸
- 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) —
  base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음
- Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커
  분리, 4라운드 D-56 역전(archive로)
- Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom),
  setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정
- recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던
  결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈),
  bk.N or 0(빈 Slot 크래시)
- Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦"
  역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k
- 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로
  바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목)

새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료),
state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성
결정이라 M3 전 결론 필요).

감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼
코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순,
그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는
M3에서"로 남아 있던 사각지대.

doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md
(A~K절, 마지막이 최신).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 18:19:58 +09:00
d5e5e1a1b9
fix: /code-review high 발견 2건 — plain-table :List 재마운트 크래시 등
quad-doc-auditor 6라운드 수렴 후 /code-review high로 diff 자체를 재검토한
결과. 감사자는 코퍼스 전체 정합성만 보고 diff 결함은 못 잡는다는 게
conventions.md가 이미 명시한 한계인데, 실제로 이번 diff 안의 결함이
나왔다.

- activateList의 재마운트 분기가 bindLifetime(physicalTarget, nil)로
  크래시할 수 있었음. _listObserver는 data가 reactive(State/Source)일 때만
  세팅되고 plain table data(문서가 지원하는 형태)면 영원히 nil인데,
  가드 없이 불렀음. 짝인 unmountSlotTree 쪽은 이미 방어돼 있었던 것과
  비대칭 — 가드 추가.
- "구독 시점" 절에 activateList의 옛 파라미터 이름 inst가 리네이밍 후에도
  남아 있어, 그 절만 읽는 구현자가 physicalTarget 리네이밍의 취지(owner
  키가 Slot일 수도 있는 문맥과의 혼동 방지)를 놓칠 위험 — 정정.

qa-request/pre-implementation-qa-round4-followup.md에 I-8로 기록.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 13:30:33 +09:00
4622fbeec8
qa: 반영 후 감사 6라운드 — 실제 크래시 3건 포함 18건 수정
커밋 9b7f847(Detach/KeyGone/Owned/attachSlot 분해) 반영 후 각도를 바꿔가며
quad-doc-auditor를 6라운드 돌린 결과. 라운드별 확실 발견 4/6/2/3/3/0으로
6라운드에서 새 발견 0건 — 수렴 확인 후 종료. 경위 전량은
qa-request/pre-implementation-qa-round4-followup.md의 I절이 소스.

트레이싱 라운드(4~5)가 잡은 실제 크래시 — 셋 다 Detach가 신설한 경로가
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것:

- I-1 (치명): rawDetach가 소유권을 유지하는데 재마운트는 rawAdd →
  claimOwner를 거치고, claimOwner는 같은 owner의 재클레임도 무조건 error다
  (2026-08-13 감사가 Slot{a,a}를 막으려고 넣은 것). 문서가 권장하는
  "prev를 그대로 반환하면 재마운트" 패턴이 그대로 죽었음. fromDetached
  플래그로 그 경로만 좁게 예외 처리.
- I-2: 재마운트된 자식 Slot이 activateList를 두 번 실행해 구독이 이중으로
  생기고 mounted/keyIndex 클로저 상태가 통째로 새로 만들어짐 → 멱등 가드.
  가드만으로는 :List 구독이 옛 physicalTarget에 앵커된 채 남아 포탈
  재마운트 후 조용히 멈추므로, _listObserver 핸들 보관 + 재앵커까지 처리.
- I-3: _detachCleanup이 releaseOwner를 안 불러 Owned=false 요소가 죽은
  Slot을 owner로 달고 남음 → 두 분기 공통으로 호출.
- I-6: 위 수정의 회귀 트레이싱 — destroySlotTree에 _listObserver 해제 누락,
  claimOwner의 옛 논증 두 문단이 fromDetached와 정면 모순, 소유권 예시
  코드가 C-4와 모순.
- I-7: 사용자가 별도 상의해 가져온 두 건 — _detachCleanup 설치를
  mountSlotTree → activateList로 이관(:List 없는 Slot마다 no-op Effect를
  트리 크기만큼 심고 있었음), activateList의 inst → physicalTarget 리네이밍.
  이관 근거가 멱등 가드 이전 동작을 전제하고 있어 가드 분기의 재앵커까지
  같이 반영. 이로써 _listObserver/_detachCleanup이 같은 범주로 통일됨.

I-4(materializeSlotTree 중 예외 시 Blocker 잔류)는 사용자 판단으로 pcall
없이 문서화만 — 아직 밟은 적 없는 경로이고 옛 단일 attachSlot에도 있었을
구조적 갭.

문서 정합성 라운드(1~3)에서 나온 것: slot-plan의 "값 교체는 비파괴" 잔존,
분해 완료 후에도 남아 있던 "논의 대기 중" 배너, attachSlot의 flush 루프를
가리키던 문장 5곳, README 색인 행이 2026-08-19에서 멈춰 있던 것,
qa-round4 문항지/followup의 "회신 대기" 상태줄, todos의 용어 목록 이중 소스,
dispatch-core의 raw* 일반 계약에 rawDetach 누락.

luau-test: 스파이크 01이 "재작성 필요" 마커를 단 채 done/에 남아 있어
STATUS.md 자신의 "폴더가 곧 상태" 규칙을 어기고 있었음 → rewrite-required/로
이동하고 개수 정정. "만들어야 할 스파이크" 절 신설(아직 파일조차 없는 실측
항목이 어느 폴더로도 표현되지 않아 구조적으로 잊히던 자리).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 13:20:42 +09:00
9b7f847014
design: Detach 보존 주체/KeyGone/Owned 확정 + attachSlot 분해
QA 4라운드 followup의 마지막 열린 항목(F-3)이 사용자 회신으로 전량
닫히면서 :List 요소 소유권 모델과 attachSlot 책임 분해를 base/에 반영.

- Detach 보존 주체를 userdata → slot._detached 필드로 전면 정정.
  근거는 gcconn 트릭 — detach된 quad-제작 Instance는 GC 폴백이 없어
  명시적 정리 경로가 필수인데 userdata는 :List에게 opaque라 처분 불가.
  재-Detach는 nop, prev 반환은 재마운트. raw 3형제(rawRemove/rawUnmount/
  rawDetach)로 "파괴하는가"와 "소유권을 놓는가"를 분리.
- KeyGone 센티널 신설 — 키가 사라진 자리도 조용히 처분하지 않고
  updateFn(KeyGone, 0, offset, prev, ud)로 한 번 더 묻는다. owner 사망
  시 최종 정리는 mountSlotTree가 거는 Effect가 담당.
- Owned 설치 플래그 신설 — Detach(사이클 단위)와 직교하는 축.
  state<Frame> 의미론 충돌(C-2)이 이걸로 닫힘.
- attachSlot을 materializeSlotTree(부기) + mountSlotTree(물리)로 분해.
  "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 한
  함수 안에선 동시 만족 불가라는 진단이 근거. 공개 표면은 두 줄짜리
  래퍼로 유지해 호출부 무변경. research/slot-attach-decomposition.md 확정.
- ROADMAP M6의 옛 Detach 서술 2건과 미결 마커 정정, question.md/todos.md
  해소 반영, session/2026-08-21-01 원문 + session-summary 색인 공백 4건 보강.

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 11:55:11 +09:00
448b961e7f
research: attachSlot 분해 — 일괄 마운트 차이 검토, 판단을 추천으로 갱신
사용자가 짚은 "물리 마운트가 관측 이후 일괄로 밀린다"는 차이를 따져본 결과
감수하는 비용이 아니라 개선이라는 결론.

- 마운트 순서 자체는 안 바뀐다(둘 다 깊이 우선 같은 순서) — 바뀌는 건 각
  Parent 대입 시점에 부기가 얼마나 완성돼 있는가뿐.
- 그 차이를 실제로 관측하는 주체가 있다: Parent 대입이 ChildAdded/
  DescendantAdded를 동기 발화시키므로 사용자 핸들러가 마운트 도중에 끼어든다.
  현행은 A의 ChildAdded 시점에 inner.Length가 0이고 뒤 형제 offset도 stale인
  미완성 스냅샷을 보여주는데, 분해하면 첫 발화 때 서브트리 전체가 최종값이다.
  slot.Length 구독자도 0→최종 두 번이 아니라 최종값으로 한 번 발화.
- "합치는 거대 함수"는 만들어도 목적을 못 이룬다 — 완전 병합은 자식 길이를
  그 자식 재귀가 끝나야 알므로 C6를 못 지키고(그게 지금 코드), 부분 병합은
  인터리브는 유지하지만 부모 레벨 C7 위반이 그대로 남는다. 실질 선택지는
  (A) 인터리브+자기교정 / (B) 분해+일괄 둘뿐.
- 부수 이득 둘: Blocker가 materialize만 감싸게 되어 "배치 등록 게이팅"이라는
  정의와 코드 범위가 일치하고, mountSlotTree가 순수 walk라 일괄 삽입이
  유리한 백엔드(웹 DocumentFragment 등)가 그 함수 하나만 갈아끼울 수 있는
  seam이 생긴다.

판단을 "약한 추천"에서 "추천"으로 갱신 — RC-1/RC-3/RC-4가 전부 한 함수 안의
줄 순서를 잘못 잡아 난 버그였는데, 분해하면 C2/C3/C7이 함수 경계로 강제되어
그 실수 클래스가 구조적으로 사라진다는 사용자 지적이 결정적. 다만 M6 착수 전
실제로 짜보며 확정해도 늦지 않다는 판단은 유지.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 11:31:11 +09:00
6fd93c6dc2
research: attachSlot 분해 구체안 — 공개 표면은 유지, 재귀만 둘로
사용자 질문("혹시 쪼갠다면 어떻게 쪼갤것 같아?")에 대한 답을 6절로 추가.

핵심은 쪼개야 하는 게 호출부에 보이는 함수가 아니라 재귀 자체라는 것 —
(B)가 요구하는 "부기는 bottom-up, 물리는 top-down"을 하려면 재귀가 두 번
돌아야 하고, 그게 "쪼갠다"의 실체다. attachSlot이라는 이름이 정당하다는
판단에 동의하므로 공개 진입점은 이름/시그니처/호출부 전부 그대로 두고
몸통만 비공개 재귀 헬퍼 둘(materializeSlotTree/mountSlotTree — 코퍼스가
이미 쓰는 unmountSlotTree/destroySlotTree의 ...SlotTree 접미사를 따름)로
나누는 안을 제시. attachSlot의 몸통은 두 줄이 된다.

setLength가 materializeSlotTree의 끝으로 가는 게 관건 — 자기 서브트리
부기가 다 끝난 뒤라 처음부터 최종값이고, 그러면서도 모든 Parent 대입보다
먼저다. 그래서 C6(최종값 등록)와 C7(부기가 물리보다 먼저)이 동시에 만족되고
배치 밖 재마운트의 부모 recompute가 2회→1회로 준다. C1~C7 전부 유지되는지
표로 재점검했고, 비용(_elements 순회 2회)과 안 고쳐지는 것(부모 등록과 자식
배치가 여전히 한 함수), 비대칭 하나(materialize의 거울상은 함수가 아니라
호출부 관용구)도 같이 적었다.

판단은 약한 추천 — 값이 틀려지는 문제가 아니라 "일반 계약을 세워놓고 자기가
예외"인 상태와 _mounted의 애매한 중간 시점(RC-3/RC-4의 출처)이 정리되는
것이 이득. M6 착수 시점에 실제로 짜보며 정해도 늦지 않다.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 11:20:30 +09:00
1e6cb0111d
design: QA 4라운드 3차 회신 반영 + attachSlot 분해 논의 자료 준비
확인받은 것 반영:
- Owned 설치 플래그 확정 — "누가 요소를 만들었는가"는 사이클마다 달라지는 게
  아니라 설치 시점 속성이라는 사용자 판단. Detach(사이클 판단)와 직교하는
  축이므로 반환값 계열에 unowned 센티널을 더 만들지 않는다. destroySlotTree/
  dispose도 이 플래그를 봐야 해서 클로저가 아닌 Slot 필드여야 함.
- props 순회를 일반화 for 한 번으로 정정 — flattened는 항상 평범한 Luau
  테이블(__pairs/__ipairs를 갈아끼운 ud가 들어올 경로가 없음)이라 옛 근거
  "다른 백엔드가 Lua 테이블이 아닌 자료구조로"는 inst엔 해당해도 flattened엔
  해당하지 않았다. 계약(배열 먼저)은 그대로, 구현만 1회 순회로. 스파이크 01은
  두 루프 버전이라 재작성 필요로 STATUS에 표시.

attachSlot 분해는 research/slot-attach-decomposition.md로 준비:
setLength를 flush 앞/뒤 어디에 둘지가 안 풀린 이유가 자리 선택이 아니라
"한 함수가 책임 일곱을 지고 있어서"라는 사용자 진단에 따라, 책임 목록과
순서 제약(전부 RC-1/RC-3/RC-4 등 실제로 밟은 버그가 출처)을 모으고 C6(길이
최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는
동시 만족 불가능함을 보인 뒤 분해 후보 넷을 대조. 확정은 아무것도 안 함.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 11:10:42 +09:00
f36bdcbe5d
design: QA 4라운드 2차 회신 반영 — B절 확인분 + C절 결정 7건
B절(설명 보강 재질문) 전부 확인됨. 확인 과정에서 나온 보강:
- B-1: (A) 분기는 "교체"라 stack-down이 아니고 retractFrom만 스택을 역순으로
  푼다는 구분을 명시. "자기 아래는 이미 정리된 뒤" 보장도 retractFrom 한정이고,
  (A)에서 아래가 살아 있는 게 깜빡임 없는 갈아끼우기의 근거.
- B-3: 고아 체인이 실제로 어떻게 생기는지 가상 위반 예시(MaybeWrapHandler) 추가.
- B-4: "Brand는 데이터 타입에 부작용 없이 런타임 명시 타이핑을 하기 위한 것"을
  존재 이유로 명시하고, duck-typing 기각 근거를 정확성/안전성 둘로 분리.

C절 결정 반영:
- C-3: flatten의 정확한 형태 확정 — in-place 뮤테이션(클론 안 함),
  ProcessedModifier로 소진, 인라인 우선이 `~= nil` 하나로 성립. 단 주신 코드의
  반복 방향은 역순이어야 "나중 modifier가 우선"이 성립해서 그것만 정정(F-4-2).
- C-4: destroySlotTree의 명시적 releaseOwner 제거. 파괴된 걸 재사용하는 코드는
  그 자체로 버그이므로 "비결정적으로 실패"보다 "항상 실패"가 낫다.
- C-6: recompute의 sourceList[i] == nil을 skip에서 즉시 error로 승격. 재추적
  결과 도달 경로가 없으므로 관측되면 부기가 깨진 것.
- C-7: "부기가 물리 트리 조작보다 항상 먼저"를 일반 계약으로 승격. 빼기는 물리
  먼저/넣기는 부기 먼저가 같은 원칙(좁은 쪽이 먼저)의 두 얼굴이라는 것과,
  yield 금지 덕에 프레임 경계가 안 끼므로 진짜 근거는 "백엔드가 전제할 수
  있게 하나로 고정"이라는 것까지.

followup F절에 남은 것: KeyGone 홀드 + Owned 설치 플래그 설계 제안(F-3),
단일 일반화 for 전환 여부/flatten 반복 방향/setLength 위치(F-4).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 10:47:45 +09:00
9642cf8f18
qa: QA 4라운드 followup — 회신 처리 결과 + 재질문 정리
사용자 회신을 (a) 바로 반영 (b) 설명 부족으로 재질문 (c) 사용자 판단 필요
(d) 조사로 닫힘으로 갈라 followup 문서로 정리했다. A절(반영 완료)은 앞
커밋에서 이미 base/에 들어갔고, 이 커밋은 그 기록과 남은 항목이다.

가장 큰 두 미결:

- C-1 KeyGone 센티널 — 키 소멸 시 updateFn을 한 번 더 불러 처분을 묻는
  사용자 제안. SL-45의 "파괴도 반환도 안 되고 참조만 끊기는" 상태를 정확히
  메우지만, 반환값 의미/userdata 수명/소멸 루프 순회 대상/index 인자 넷이
  안 정해지면 구현이 못 나감.
- C-2 "밀려난 prev는 dispose" vs state<Frame> 의미론 충돌 — 회신의 SL-43과
  SL-51이 서로 반대를 가리켰는데, 따져보니 둘 다 맞고 갈리는 축이 "누가 그
  요소를 만들었는가"였다. :List의 updateFn이 만든 건 reconcile이 지워야 하고
  (만든 쪽이 자기 손으로 못 지움), Slot:Add(state) sugar로 온 건 사용자
  소유라 죽이면 안 됨. 지금 설계엔 후자를 표현할 방법이 없어 선택지 셋을
  올리고 per-installation 소유권 옵션을 추천.

부수로 사용자가 문서에 아예 없던 갭 둘을 찾아냄 — Attribute 생성자 자신의
이름 겹침 정책(앞 커밋에서 "뒤가 이김"으로 명시)과, flatten이 소진한
Modifier 자리 처리(C-3, ProcessedModifier 센티널 vs flatten 압축).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-20 00:57:19 +09:00
41b96e6917
design: QA 4라운드 회신 1차 반영 — 즉시 처리 가능한 정정 20건
사용자 회신(pre-implementation-qa-round4-response.md) 중 판단이 명확한
항목을 base/에 반영. 굵직한 것만:

- LP-1: quad의 `Connected`는 "계산된 속성"이 아니라 그냥 RBXScriptConnection의
  네이티브 필드다 — rbvm 프록시 사정을 잘못 옮겨온 서술이었음. 실제 판정은
  "gcconn이 없음" / "있는데 Connected==false" 두 상태뿐.
- D-56: `bindLifetime`의 첫 인자가 Instance가 아닐 수 있다(Slot-in-Slot의
  ownerKey) — 백엔드가 반드시 핸들링해야 하는 요구사항으로 신설. gcconn
  트릭이 안 통하므로 세 번째 판정 분기가 필요하다는 것까지 명시.
- SL-75/D-60: 언마운트 시 `slot.Offset = nil`은 포탈을 깨뜨림(이미 구독
  중인 다운스트림이 영구히 끊김) — stale하게 두는 게 맞고, 마운트 전 기본값도
  nil이 아니라 0.
- SL-74: `SetAndDispose`는 `source:SetAndDispose(value)` 콜론 메서드로 확정.
  `Apply` 오버라이딩은 Source→State 단방향 때문에 타입이 안 성립.
- E-11: leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹는 것으로
  Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩
  게이트상 성립할 수 없는 문장이라 삭제.
- AT-20: 생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 값 비교가
  필요해 **원리적으로 불가능**하다(State 계약상 `:Get()` 비교 금지).
- UI-8: `mapTweenValue` 로컬 헬퍼를 `Tween<T>:Map(fn)` 공개 메소드로 승격.

부수로 D-3(retract가 깊은 인덱스부터인 이유)/D-10(두 패스를 명시하는 진짜
이유는 순서를 못 믿어서가 아니라 이식성)/LH-8(자기 아래 vs 자기 위)을 코드
예시로 풀어 썼고, TW-16으로 HUMAN_TODO에 initValue 항목을 신설했다.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-20 00:48:07 +09:00
e80aa11c4f
qa: 구현 전 QA 4라운드 문항지 — base/ 전 문서 확정 주장 전수 문항화
사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속")으로
base/ 26개 문서를 서브에이전트 없이 한 맥락에서 의존성 순서로 읽으며,
확정으로 적힌 주장을 전부 "예가 나와야 정상인 단정문"으로 뽑았다.
결론만이 아니라 근거까지 문항에 넣었는데, 코퍼스에 "결론은 그대로인데
근거가 뒤집힌" 사례가 여럿 있어서 결론만 물으면 그런 걸 못 잡기 때문.

⚠️로 열려 있다고 적힌 항목은 "확정이 맞나"가 아니라 "아직 열려 있다는
인식이 맞나"를 묻는 문항으로 따로 표시했다.

정정은 하나도 안 했다 — 사용자 지시대로 기록만 하고 회신 대기 상태.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-20 00:33:49 +09:00
qwreey
e290499fce
Merge pull request #10 from qwreey-bot/main
Sync 08.20 00:31 KST - Dev scaffolding
2026-08-20 00:31:53 +09:00
qwreey
2b48bd0bf3
Merge pull request #9 from qwreey-bot/main
Sync 08.19 13:36 KST - Dev docs
2026-08-19 13:37:05 +09:00
qwreey
4474b69ab1
Merge pull request #8 from qwreey-bot/main
Sync 08.19 02:19 KST - Dev docs
2026-08-19 02:21:23 +09:00
53 changed files with 16522 additions and 847 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
# [역전됨, 2026-08-21] "emit은 항상 전파된다 / 중복 통지는 접지 않는다"
**무엇이 뒤집혔나**: `base/source-state-plan.md`의 "전파 모델 확정" 절과
"다이아몬드 의존성은 무엇이 푸는가" 절이 확정해두던 두 서술 —
(1) emit은 자기 `invalid` 상태와 무관하게 **항상** 전파된다, (2) quad가 추가로
접지 않는 것은 **중복 통지**뿐이다.
**무엇으로 바뀌었나**: `base/state-epoch-plan.md`(소스 에포크 비교)를 채택하면서
**같은 소스의 같은 에포크가 두 번째로 도착하면 접는다**로 바뀌었다. 다이아몬드에서
값도 통지도 한 번씩만 간다.
**⚠️ 2026-08-14의 역전을 되돌린 게 아니다.** 그때 폐기된
(`archive/invalidate-dedup-propagation-reversed.md`) 것은 *"이미 `invalid`면 더 안
내려보낸다"*는 **플래그 기반** dedup이고, 그건 `:Get()`을 안 부르는 Observer가
**한 번 울고 영구히 침묵**하는 실패 모드가 있었다. 에포크 비교엔 그 모드가 없다 —
`Set`마다 카운트가 새 값이라 항상 통과하고, 접히는 건 같은 에포크가 두 경로로
도착한 두 번째뿐이다. **`invalid`로 접는 것은 지금도 금지**다.
**역전 이유**: 플래그가 아니라 에포크로 판정하면 (a) DFS 전파 도중 `Get()`
섞인 값을 캐시하는 glitch가 없어지고, (b) 그 판정이 중복 통지까지 O(1)로 접는다.
성능이 아니라 **정확성** 동기다 — 경위는 `base/state-epoch-plan.md` §1(실재하는
glitch)과 §4(수신 규칙). **[2026-08-22 정정]** 여기 `§1·§3`으로 적혀 있었으나
그 문서가 `Epoch`/`EpochMap` 승격 때 §1~§8로 재편되며 옛 §3("이게 실제로
무엇을 고치는가")은 규칙 본문으로 흡수됐다.
---
## 역전 전 원문 (2026-08-14~2026-08-21)
### "전파 모델 확정" 절에서
> - **⭐ emit(무효화 신호)은 구독자에게 *항상* 전파된다 — 자기 `invalid`
> 상태와 무관.** 신호를 받은 State는 자기 `invalid` 플래그를 세우고,
> **이미 `invalid`였더라도 그대로 아래로 전파한다.**
> - **`invalid` 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** —
> 전파를 제어하는 장치가 **아님**.
> - **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐**
> 지금은 `Blocker`가 유일하고 … **평범한 State는 절대 신호를 삼키지
> 않는다.**
### "다이아몬드 의존성은 무엇이 푸는가" 절에서
> 즉 **중복 재계산을 막는 주체는 pull-recompute + 캐시**이지, 전파를
> 중간에 끊는 게 아니다. Vide가 `todo.md`에 미해결로 남긴 "다이아몬드 중복
> **재평가**"는 이 캐시 구조로 풀리고, quad가 추가로 접지 않는 것은
> **중복 *통지***뿐이다 — `d` 아래의 `Observer`는 한 사이클에 두 번 울 수
> 있고, 그건 의도된 동작이다(통지를 접으려면 `Blocker` 같은 명시적 게이트를
> 쓸 것).
>
> - **순회 비용이 실측에서 문제가 되면** 그때 "**한 번의 전파 파동
> 안에서만** 같은 노드를 두 번 방문하지 않는다"는 최적화를 넣을 수 있음
> (방문 집합/에포크 카운터). 단 그건 **파동 단위**여야 하고, 지금처럼
> 시간에 걸쳐 유지되는 `invalid` 플래그로 하면 안 됨 … 이 최적화는
> 의미론에 안 보이는 순수 구현 사항이라 지금 결정할 필요 없음.
**마지막 문단에 대한 주석**: "파동 단위여야 한다"는 경고는 **`invalid` 플래그로
하지 말라**는 뜻이었고, 채택된 에포크 모델은 시간에 걸쳐 유지되는 카운트를 쓰지만
**소스별 에포크**라 그 함정에 안 걸린다(새 `Set`은 새 에포크). 또 이 최적화는
"의미론에 안 보이는 순수 구현 사항"이 아니었다 — 실제로는 `Get()`이 섞인 값을
돌려주던 것을 고치는 **의미론 변경**이었다.

View file

@ -0,0 +1,57 @@
# [역전됨, 2026-08-21] `bindLifetime`의 첫 인자가 Slot일 수 있다는 요구사항
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `D-56`에서 확정돼
`base/lifecycle-pattern.md``(1-1)` 절로 들어갔다가, **2026-08-21 구현 전 QA
5라운드 `C-4`에서 통째로 뒤집혔다.**
**왜 뒤집혔나**: 사용자 지적 — *"애초에 Slot 이 effect 나 다른 요소들을 소유할
수가 없다 … 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을
유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로
둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?"* 검토 결과 **부기 키와
생명주기 앵커를 한 인자가 겸하고 있던 게 원인**이었고, `Dispatch.setLength`
`anchor`를 따로 받도록 바꾸는 것만으로 이 요구사항 전체가 사라졌다. 지금 유효한
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단과
`base/lifecycle-pattern.md``(1-1)` 포인터.
**부수 효과**: 이 절이 요구하던 `isBoundAlive`의 "세 번째 분기"(gcconn도
`.Subscribed`도 없는 Slot-owned 바인딩을 판정하는 분기)는 **형태가 미정인 채로
열려 있던 항목**이었는데, 역전과 함께 필요 자체가 없어졌다.
---
## 역전 전 원문
#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`)
**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서
gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가
이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 —
`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey,
observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다
(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot``ownerKey`로 자기
자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계).
**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의
bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong
으로 gchold 비슷한걸 수행하면 됨."*
- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로
구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고,
Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의
필드든, `Relate(slot)``SetStrong`이든)와 **`value`가 그 Slot의 생존을
되물을 수 있는 근거**를 백엔드가 제공하면 된다.
- **왜 gcconn을 못 쓰는가**: gcconn 트릭은
`inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌
값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이
reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로
"Slot이 죽으면 기록도 같이 사라진다"가 성립한다.
- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는
gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned
바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로
잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이
세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함.
- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드)
이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로
둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안
된다"는 요구사항 자체**뿐.

View file

@ -0,0 +1,64 @@
# [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `C-7`에서 **모든 `raw*`가 따르는
일반 계약**으로 승격됐다가, **2026-08-21 5라운드에 `native*` 주입 op 계층이
확정되면서 폐기**됐다.
**왜 뒤집혔나**: 이 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고 비워진
공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를 비워둘
수단이 없다.** 미는 주체는 언제나 백엔드의 삽입 연산 자신이다 — 사용자 지적:
*"밀어내고 null을 넣어둘 수도 없는데, 그리고 nativeInsert 라는것 자체가
밀어내기 동작을 강제하는데, 그렇다면 length 를 나중에 설정한 다음 offset들이
무시되어야함."*
**무엇이 남았나**: "빼기는 물리 먼저 / 넣기는 부기 먼저"라는 두 얼굴 대신
**"자기 자리를 정하는 것 먼저(`setOffsetSource`) / 뒤를 미는 것 나중
(`setLength` → `recompute`)"** 하나로 줄었다. 배치 경로(`materializeSlotTree` →
`mountSlotTree`)가 부기 전량을 먼저 끝내는 것은 **C6가 요구하는 별개 사안**이라
그대로다. 지금 유효한 서술은 `base/dispatch-core-plan.md`의 "일반 계약 — 물리와
부기의 순서" 절.
---
## 역전 전 원문
### ⭐ 일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 `C-7` 승격)
**지금까지 이 규칙은 `rawAdd` 한 곳에만 적혀 있었다**(바로 아래 "동기 순서"
문단). `rawRemove`/`rawUnmount`는 의사코드가 우연히 같은 모양이었을 뿐
계약화돼 있지 않았고, `Splice`는 물리 detach/attach와 부기의 선후가 **아예
안 적혀 있었다.** 사용자 판정으로 **모든 `raw*`가 따르는 일반 계약으로
승격**한다(*"각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게
나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문"*).
> **계약**: 어떤 CRUD/재조정 연산이든 **`Length`/`offset` 부기 갱신이 물리
> 트리 조작(`Parent` 대입/해제)보다 먼저 완료**돼야 한다. 즉 "**먼저 밀어내고
> 그 공간에 넣는다**" / "**먼저 비운 걸 반영하고 그 다음 당긴다**".
- **왜 일반 계약이어야 하나**: 백엔드가 "밀어내기"를 물리적으로 구현해야
하는 경우(DOM `insertBefore` 밖의 백엔드 등), **부기가 이미 정확하다**는 걸
전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드
작성자가 매번 다시 확인해야 한다.
- **각 연산에 적용하면**:
- `rawAdd` — 부기(`setOffsetSource`/`setLength` 등록 → `recompute`) 완료 →
물리 마운트(`mountInst`). **[정정, 2026-08-21 5라운드 `C-2`]** 예전엔 여기
`Length:Set(newCount)`라고 적혀 있었으나 **틀렸다** — 아래 문단 참고.
- `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
`spliceArraysDown`
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이
먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
- `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어
붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를
1회로 묶는다는 최적화는 그 사이에서만** 일어난다.
- `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님.
- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중
코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
고정한다"**가 진짜 이유다.

View file

@ -0,0 +1,89 @@
# [역전됨] `Brand` — 공유 레지스트리 + `Brand.get(x) -> tag` (객체당 태그 하나)
> **역전일**: 2026-08-21. **대체된 곳**: `base/brand-plan.md`(인스턴스 브랜드로
> 전면 재작성). **역전 근거 원문**: `reference/epoch-brand-composition.md` §1의
> 6번 / §3.
>
> **뒤집힌 것은 API 표면 하나뿐이다** — weak-key 레지스트리를 쓴다는 것,
> 태그가 문자열이 아니라 테이블 아이덴티티라는 것, duck-typing을 안 쓰는
> 두 근거, 포함 관계를 predicate 합성으로 표현한다는 것은 **전부 그대로
> 살아 있다**(`base/brand-plan.md`가 소스). 아래는 그 표면의 옛 모양만
> 보존한 것.
## 무엇이 뒤집혔나
**옛 모양** — 모듈 하나가 공유 레지스트리 하나를 들고, 객체마다 태그를
**정확히 하나** 붙인다. 판별은 그 태그를 되읽어 비교한다.
```lua
local Brand = {}
local registry = setmetatable({}, {__mode = "k"})
function Brand.set(x, tag) registry[x] = tag end
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag,
ModifierTag =
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
Brand.set(newHandle, ObserverTag)
```
`isX`는 그 위에 얇게 얹혔다:
```lua
local function isSource(x)
return Brand.get(x) == SourceTag
end
local function isState(x)
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족
end
local function isPreRef(x)
return Brand.get(x) == PreRefTag
end
local function isPostRef(x)
return Brand.get(x) == PostRefTag
end
local function isRef(x)
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
end
```
`None` 처리도 이 모양에 묶여 있었다 — *"`None` 자체를 레지스트리에
평범하게 태깅하는 건 무방(사용자가 허용) — 그러면 특수 분기 없이도
`Brand.get(None)`이 답을 준다. 즉 '범용 introspection 창구'를 지키고
싶으면 특수 분기가 아니라 평범한 등록으로 지킨다."*
## 왜 뒤집혔나
**표현할 수 없는 관계가 실재했다.** `Epoch` 인터페이스가 도입되면서
`Source``SourceBrand`이면서 **동시에** `EpochBrand`여야 하는데,
"객체당 태그 하나" 모델로는 이걸 못 적는다. `Source`가 아닌 원천(외부
시계 등)이 `Epoch`로 참여하려면 다중 태깅이 필수다.
사용자 제안(2026-08-21): *"본인이 거기 속하면, 본인이 직접 해당 브랜드를
가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가 없어짐. 따라서
외부 확장도 쉬워진다."*
## 같이 버려진 것 — `Brand.get`의 역조회
옛 모양은 "이 값이 대체 무엇인가"를 되묻는 **범용 introspection 창구**를
겸했다. 새 모양엔 그게 없다(브랜드마다 자기 집합만 안다).
**대가가 아니었다** — 사용자 확인: *"확실히 의미가 없어진것 같습니다
필요하진 않아요."* 코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십
질문**이고, 역조회를 하는 자리는 전수 조사에서 하나도 없었다.
## 같이 검토됐다 철회된 우려 — "포함 관계가 코드에 드러난다"는 성질
에이전트가 "자기 등록 방식이면 `isRef`/`isState` 같은 포함 관계가 흩어진다"고
대가로 짚었으나 **착오였고 철회됐다.** 사용자 지적: *"여전합니다.
`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거
똑같습니다."* — 자기 등록이 **여러 브랜드에 등록하기를 강제하는 게 아니므로**,
각 타입은 자기 브랜드에만 등록하고 포함 관계는 지금처럼 predicate 합성으로
한 곳에 쓰면 된다. 2026-08-09에 세운 성질이 그대로 유지된다.

View file

@ -921,3 +921,299 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
툴링 체인(pesde types emit 등)의 지원 시점은 외부 사정이고 관측 수단이
에이전트에 없다. 그래서 질문을 닫고 `HUMAN_TODO.md` 8번(사용자가 시점을
파악하거나, 가능해질 때 에이전트에 알림)으로 옮겼다.
---
## [해소 배치 이관, 2026-08-21] `question.md`가 들고 있던 해소 항목 16건
**왜 옮겼나**: `question.md`는 스스로 *"항목을 해소하면 여기서 지우고
`archive/question-resolved.md`에 근거와 함께 옮길 것 — 다시 쌓이면 같은
문제가 반복됨"*이라고 규정해뒀는데, 2026-08-21 기준 파일의 절반이 다시
`[해소]` 마커로 차 있었다(2026-08-21 하루에만 여러 건이 닫힌 결과). 예고된
그대로 "필터해서 읽기 힘듦"이 재발한 것이라 이 규칙대로 일괄 이관한다.
**결정 내용은 하나도 안 바뀜 — 읽는 자리만 옮긴 것**이고, 지금 유효한
설계는 언제나 `base/`가 소스다.
**⚠️ 아래 항목 중 `Epoch`/`EpochMap` 관련 서술은 이관 시점 표현 그대로다** —
같은 날 `Epoch`/`EpochMap`/`Brand` 승격으로 **필드 이름이 바뀌었다**
(`sourceCountMap` → `valueEpochMap`, `sourceEmitMap``emitEpochMap`,
"소스"/"count" → "`Epoch`"/"리비전"). 결정 자체는 그대로이고 표현만
바뀌었으니, 현재형 규칙은 `base/state-epoch-plan.md`를 볼 것.
- **[해소됨, 2026-08-18] `DI``D`(Declarative) 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과
완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare
요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게
유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
보완하기로 같이 확정. 코퍼스 반영 완료 —
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
- **[해소됨, 2026-08-19] `PopOnly``Detach` 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: 이미 있는
`Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데,
`Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히
reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게
구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도
같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`
"`nil` 리턴은 파괴가 기본" 절.
- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`
구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정:
*"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가
필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만
앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로
바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로
이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고
로드맵 순서를 유지할지,
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩
offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.**
`setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`
직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot`
분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고
Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은
자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도
같이 잡았다(identity 재사용). 상세는
`qa-request/pre-implementation-qa-round5-followup.md`의 H절.
아래는 열려 있던 시점의 서술: 둘이 한 덩어리다:
(a) `setOffsetSource``None`이 "아무것도 안 차지함"과 "발행 채널 없음"
두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서
DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`
`sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩
Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만
써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) +
`mountInst(target, element, index)` + `recompute``base` 시드 + 중첩 Slot이
자기 `Offset`을 관측해 재계산하는 구독 하나 —
`qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스.
- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 —
아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제
자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이
없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는
기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위**
(`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항.
아래는 열려 있던 시점의 서술: `mountInst(target, element,
index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의
물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`
프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가
배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는
*다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해
뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만
으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치),
(b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때
필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수
있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.**
- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.**
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
length 를 이어붙이면 될듯."* → `bk.offsetCache` + **`bk.invalidAfter`**("여기까지는 유효")로 반영
(`base/dispatch-core-plan.md`). **무효화 규칙은 하나**
`invalidAfter = min(invalidAfter, i)`(`setLength`도 splice도 자기 인덱스까지,
베이스 변경만 `0`). `recompute`도 이 캐시 위에 얹혀 O(N)이라
**"캐시를 누가 채우나"라는 갈래가 없어졌다**(사용자 정정: 함수를 나눌 이유가
없다). 아래는 열려 있던 시점의 서술:
`settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을
부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는
`_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시
계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라
빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝
누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게
할지, 아니면 실측 전엔 그냥 둘지 판단 필요.
- **[해소, 2026-08-21 같은 날] 게이트가 유보했다 내보내는 emit이 싣는 출처 —
`emit(self)` + 흡수 집합.** `GateNode`가 흡수한 소스를 `withheld` 집합에
들고 있다가, 풀 때(=flush) **집합을 그 자리에서 떼어내 새 테이블로 스왑하고**
자기를 출처로 그 배치를 하류에 넘긴다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을
적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라
노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §4
(이관 당시엔 §2였으나 그 문서가 §1~§8로 재편됨).
- **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과
`:With` 병합.** `sourceEmitMap`**비우고**(어떤 emit이 와도 "처음 보는
것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다),
`sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**
(비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘
추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.**
재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`의 §2·§5 7번.
- **[해소, 2026-08-21 같은 날] `Gate` — 소스 없는 emit(빈 배치)은 **아무것도
안 한다**.** 에이전트 권고("빈 배치 = 무조건 통지")는 **사용자 기각**
*"쌓아둔것 자체가 없는데 뒤로 넘긴다는건 이상합니다 … 표면적으로 보면 State
중간에 Emit 을 추가하는 격"*. 새 규칙도 아니다: `blocker-plan.md`가 이미
"`HasBlockedEmit`이 false면 아무 것도 안 함(idempotent)"으로 확정해뒀고,
`withheld`는 그 플래그의 일반화다. **따름정리로 `Effect(fn, ...deps)`의 설치
구간 억제는 `Gate` 소비자가 아니게 됐다** — `Effect` 내부 플래그로 처리하고,
`effect-plan.md`에 있던 "`Gate`보다 뒤" 순서 제약도 사라졌다.
`base/gate-plan.md`의 7·8번.
- **같이 제기됐던 "재진입 계약"은 열린 항목이 아니었다**(사용자 지적으로
2026-08-21 정리) — `blocker-plan.md`의 재진입은 **같은 인스턴스 중첩**을
말하는 것이지 정책의 `emit()` 호출과 무관하고, 끝나지 않는 되먹임은
2026-08-04 확정 원칙대로 **UB**이며, 유한한 재진입은 flush 진입 시 스왑으로
이미 안전하다. `gate-plan.md`의 6번.
- **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)`
메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`
`state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로
프리미티브 없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute
처럼 GateNode(ComputeNode 처럼) 생성된다"*). `Get()`엔 영향 없음(통지만
막음)까지 확정. **[2026-08-21 정정]** 여기 "남은 것은 사용자 판단이 아니라
구현 시 정할 것들"이라 적었으나, 두 번째 `/code-review high`가 빈 배치
emit을 잠시 사용자 판단 항목으로 되돌렸다 — **같은 날 해소돼(바로 위 항목)
원래 서술로 돌아왔다.** 구현 시
정하면 되는 건 생명주기와 M2 범위뿐 — `base/gate-plan.md`
소스. 아래는 열려 있던 시점의 서술: 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이
`Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트
노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자
스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고,
**공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 —
(a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의
문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사),
(b) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지, 그리고 생명주기 계약.
**[2026-08-21 해소]** 여기 있던 "`:Apply` 팩토리인가"와 "`Blocker`가 그 위에
어떻게 얹히는가"는 닫혔다 — **사용자 확정으로 `Gate``:Apply`가 아니라
`:With`류 State 메소드**(*"state 의 전파를 손대는 작업이라 with 처럼 다른
노드가 나는게 맞음"*)이고, 그러면 `Blocker`는 이미 확정된
`state:Block(blocker)` 메소드가 내부에서 그걸 부르면 되므로 배선 문제 자체가
없어진다. 상세는 `base/gate-plan.md`.
- **[해소, 2026-08-21] State 재계산/전파 판정을 "소스 에포크 비교"로 바꿀지 —
채택 확정**(사용자: *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."*). 규칙 전량은 `base/state-epoch-plan.md`, 구현은 M3.
아래는 열려 있던 시점의 서술: 사용자 제안: 각 State가 자기 상류 루트
`Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다.
동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면
아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에
실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있다
(MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안
고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다
뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로
빠졌다. 이어진 3·4차 정정으로 **기제는 사실상 다 정해졌다** — 순회는
`rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은
count 없이 **발행 source만** 싣고, 순회가 발견한 변경은 **테이블 둘**로
처분한다(`sourceCountMap`은 순회가 앞당겨 올리고, `sourceEmitMap`은 상류의
진짜 emit을 기다림 — *"emit 바로 안하고 상류가 emit 해줄 때 까지
기다립니다"*). 그래서 통지가 죽지도 게이트를 새지도 않아 `source = nil`
규약도 필요 없어졌다. **남은 판단은 채택 여부 자체 하나**이고, State 내부 표현을
바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는
`base/state-epoch-plan.md`.
- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer
앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`
`(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를
분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고,
`isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가
없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는
열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은
"`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데,
사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른
요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 …
우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할
부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를
분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고,
`isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와
트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`.
- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** —
선택지 (c)로 확정: `updateFn`**`KeyGone`으로 한 번 더 불러 처분을
묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
**`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`
정리한다. 원래 갭이
치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
**GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`
"Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.
- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**,
`base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은
`attachSlot`). 근거 기록은 `reference/slot-attach-decomposition.md`.
아래는 그 열려 있던 시점의 서술: 한
함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치
게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의
최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에
만족되지 않는다**(지금은 후자를 지키고 전자를 포기 — 부모 `recompute`
1회 헛돎). 사용자 판단으로 확장 논의 대기 — 책임 목록/순서 제약 출처/분해
후보 넷은 `reference/slot-attach-decomposition.md`. **M6(`:List`) 착수 전
필요**, 선행으로 아래 `Detach` 보관 위치가 먼저 닫히는 게 나음.
- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면
error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정
(제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인
array-part 값 객체" 절.
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**.
**[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정**
(사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을
필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다
**위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절.
**이 항목은 닫혔다.**
- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의
공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게
들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼
`-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate`
그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`
불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는
`Valve`/`Relay`. **[2026-08-21 해소] `Gate`로 확정**(탑레벨 생성자를 안
만들고 `state:Gate(setup)` 메소드로 가면서 `Gater` 문제 자체가 사라짐 —
메소드 자리에서는 `:With`/`:Compute`와 나란히 자연스럽다). 노드 타입 이름은
`GateNode`. `base/gate-plan.md`의 1번이 소스.
## [해소됨, 2026-08-21] `Epoch`의 리비전 증가 방식 — `bit32` 랩으로 확정
**결론**: `self.Revision = bit32.bnot(-self.Revision)`.
리비전은 uint32 안에 머문다. 정본은 `base/state-epoch-plan.md` §2.
**[2026-08-22 정정]** 이 항목을 처음 적을 때 에이전트가 형태를
`bit32.band(rev + 1, 0xFFFFFFFF)`로 잘못 옮겼다 — 사용자가 말한 건 처음부터
`bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건 랩어라운드
**감소**를 **연산 하나로** 한다(`a > 0`이면 `a - 1`, `0`이면 `4294967295`).
**사용자 논거**: *"그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의
연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운
라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를
모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게
가고 싶어요."* — `2^53` 포화는 어차피 도달 불가능한 시나리오인데 그걸
피하겠다고 값을 double 영역까지 키울 이유가 없다는 것. 매 `Set`마다 도는
hot path다.
**에이전트가 달았다가 철회한 단서**: "`bit32`라서 더 싸다는 건 아니다 —
`n + 1`은 어느 쪽이든 double 덧셈이고 `bit32`는 fastcall을 하나 더 얹는다"고
적었으나, **이건 `band(rev + 1, mask)`라는 다른 형태를 놓고 한 비교라
틀렸다**(2026-08-22 정정). `bit32.bnot(-a)`는 덧셈 위에 얹히는 게 아니라
**갱신 자체를 대체**하는 단일 FASTCALL이므로, "native call이라 아주 빠르다"는
사용자 서술이 맞다.
**따름정리**: 이 방식은 **단조 증가가 아니라 랩어라운드 감소**다. 지금 규칙이
`==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를 넣고 싶어지면 이 결정부터
되짚어야 한다.
아래는 열려 있던 시점의 서술:
- **`Epoch`의 리비전 필드 증가 방식(3순위, 2026-08-21 신설)**: `bit32`
(uint32 랩어라운드라 double 포화가 없음, 사용자가 염두에 둔 쪽) vs 평이한
`+1`(할당·연산 더 쌈, 포화가 `2^53`이라 `bit32``2^32` 랩보다 충돌 거리가
오히려 넓음). **둘 다 도달 불가능이라 실질 위험은 없고 취향 문제다.**
**이게 `Epoch`/`EpochMap` 승격 후 남은 유일한 미정 항목이다**
`base/state-epoch-plan.md` §2, 근거 기록은
`reference/epoch-brand-composition.md` §4의 4번.
(필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.)

View file

@ -261,15 +261,16 @@ quad/
│ ├── pesde.toml
│ └── src/
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate``GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`)
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Dispatch/
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
@ -290,8 +291,8 @@ quad/
└── quad-roblox/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
@ -322,7 +323,7 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
프리미티브 타입 자신의 공개 어휘**라는 것:
1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/
`Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/
`Effect(fn, state?)`/`PreRef(default)`/`PostRef(default)`.
`Effect(fn, ...deps)`/`PreRef(default)`/`PostRef(default)`.
2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/
`:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`,
`ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/
@ -354,9 +355,11 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
`isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/
`bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가
아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/
`getHandler`/`addHandler`/`drive`, `Brand.set`/`get`) — 이 셋은 "타입
`getHandler`/`addHandler`/`drive`, `Brand()`의 `:register`/`:is`) — 이 셋은 "타입
고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브
자체가 아닌 것(Dispatch/Brand는 `Type(args)` 생성자가 없는 내부 엔진)의
자체가 아닌 것(Dispatch는 `Type(args)` 생성자가 없는 내부 엔진이고,
`Brand`는 생성자가 있지만 사용자 표면이 아닌 base 내부 유틸 — 사용자에게
노출되는 건 `isX` wrapper들이다)의
구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/
`priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract``process`
반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다
@ -449,10 +452,15 @@ push-invalidate(신호만)/
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는
**노드별 캐시**임을 명시 — `invalid`는 "내 캐시가 낡았다" 표시일 뿐이고
**emit 전파는 자기 `invalid` 상태와 무관하게 항상 일어남**. 전파를 늦추는
`Blocker` 같은 명시적 게이트뿐. 한때 `source-state-plan.md`가 "이미
`invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
`archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는
**emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md`
"이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
`archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]**
전파를 접는 판정은 이제 `invalid`가 아니라 **`Epoch` 리비전 비교**가 한다 —
같은 `Epoch`의 같은 리비전이 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서
값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이
사라진다. 그 부기는 재사용 가능한 **`EpochMap`**으로 떼어져 있어 State가 아닌
소비자(`Effect`)도 같은 판정을 쓴다. 규칙 전량은 `base/state-epoch-plan.md`,
명시적 게이트는 `base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만

View file

@ -239,9 +239,28 @@ end
그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다.
- **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹
객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다.
- **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지
(`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존
`nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠.
- **[해소, 2026-08-20 구현 전 QA 4라운드 `AT-11`] 이름은 `groupClaimKeys`
확정**(사용자: *"groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길
바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을
잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가
"그룹이 claim한 키들"을 가리켜 역할이 드러남.
- **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `AT-1`] 키는 `(inst, groupValue) → k`
확정.** **사용자 판정**: *"(inst, groupValue) → k 이면 충분하다. group 에 따라
key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name
이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만
위함임."*
- **왜 이걸로 충분한가**: 이 레지스트리가 잡아야 하는 건 **같은 그룹 객체가
같은 `inst`의 두 위치에 놓이는 것** 하나뿐이다. 서로 다른 그룹끼리의 충돌은
`groupKey(v, name)`가 그룹별로 다른 키 객체를 만들고 그 키의 이름 claim을
`nameClaims`가 검증하므로 이미 걸린다 — 여기서 다시 볼 필요가 없다.
- **판정**: `process` 시점에 `groupClaimKeys[inst][groupValue]`를 보고, 비어
있으면 `k`를 기록하고 통과, **이미 다른 `k`가 있으면 error**. retract에서
자기 `k`일 때만 지운다(위 "해제 → 재클레임 순서" 항목의 순서 보장 위에서
성립).
- **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째
위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야
`nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다.
- 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.**
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
@ -298,9 +317,16 @@ Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으
```
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] plain 테이블의 값은 raw T뿐 아니라
-- State<T>/Source<T>도 그대로 됨 — {count = 3, label = someSource, live = state}
-- 새 배선이 아니라, 이 값들이 결국 단일 키 경로로 위임돼 StoreBind가 언랩하기 때문
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책:
-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다**
-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의
-- 정책이고 생성자엔 적용되지 않음 — 겹침을 막고 싶으면 Attribute.Merged를 쓸 것
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
```
@ -324,6 +350,15 @@ Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자
시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시
error)과 결이 같음.
- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**.
- **[명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 `Attribute(a, b, ...)`
자신도 "뒤가 이김"이다** — 사용자 확인 요청(*"그리고 뒤에 오는기 이기는 것
또한, 잘 명시되어있나 봐야함"*)으로 점검한 결과, 위 API 목록이 `Merged`/
`Overridden`의 정책만 적어두고 **생성자 자체의 겹침 정책은 어디에도 안
적혀 있었다**. `Frame { Tag("a"), Tag("a") }`처럼 배열에 여러 개 나란히
놓는 것과 달리 생성자는 인자들을 **하나의 이름 맵으로 평탄화**하므로 겹침
판정이 반드시 필요하고, 배열 순서 규칙(`base/modifier-plan.md`의 "Merge
우선순위" — 나중 것이 우선)과 결을 맞춰 **뒤가 이기는 것**으로 명시한다.
겹침을 실수로 보고 싶으면 `Attribute.Merged`를 명시적으로 쓰면 된다.
- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지
`Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기,
`Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선
@ -400,12 +435,22 @@ end
그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회
실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미
"값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목)
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이
그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`
불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴
위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또
들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는
실제로 비용이 문제될 때 재검토.
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **[전면 정정, 2026-08-20 구현 전 QA 4라운드 `AT-20`] "체인이 그룹
전용이 된 지금은 이론상 최적화도 가능하지만 부품이 늘어나서 안 한다"는
옛 서술은 틀렸다 — 애초에 최적화가 성립하지 않는다.** 사용자 판정:
*"생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지
비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기
때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님."*
- **`Tag`와 결정적으로 다른 지점**: `Tag`는 자원이 **이름 집합**이라
"이름이 그대로면 할 일 없음"이 성립하지만, `Attribute`는 이름이 그대로여도
**그 이름에 붙은 값이 바뀌었을 수 있다** — 이름 목록 비교만으로는 아무것도
못 건너뛴다.
- **값을 비교하려면 `:Get()`을 해야 하고 그건 금지**(위 "값 비교(`:Get()`으로
old/new 비교)는 안 함" 항목) — State 계약상 캐시 비교를 하면 안 되고,
비교하려면 이전 값까지 따로 들고 있어야 한다. 즉 "부품이 늘어난다"가
아니라 **설계 원칙 위반이라 애초에 못 한다**.
- 그래서 **균일 철거→재등록이 유일한 경로**이고, 비용(재구독 + 같은 값
`setAttribute` 1회)은 수용한다.
- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가
자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진
이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음.

View file

@ -56,7 +56,8 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트
*네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 /
이벤트 네이밍 인체공학" 절에 그대로 있음.
- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`,
- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(**[2026-08-21 재작성]**
인스턴스 브랜드 `Brand()` + `:register`/`:is`, 다중 태깅 허용,
`isState`를 branded 타입 전부로 일반화) → **`base/brand-plan.md`**.
- **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** /
**`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제

View file

@ -18,12 +18,17 @@
**store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State
위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되
State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것.
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함.
**⚠️ [2026-08-22 정정] 구현 마일스톤은 M3가 아니라 M2다** — 여기 "State와
같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것"이라고 적혀 있었으나,
`Dispatch.drive`의 배치 등록이 `Blocker`를 호출하므로 "게이팅 먼저" 결정에
따라 `Blocker.luau` 체크박스가 M2로 이동했다. 별도 파일로 두는 것은 그대로.
**그리고 이제 `Blocker`는 바닥부터 짜는 게 아니라 공용 `GateNode`
(`base/gate-plan.md`) 위에 얹는 정책이다** — 노드를 다시 만들지 말 것.
**[2026-08-14 위치 명문화]** `Blocker`는 **quad에서 emit(무효화 신호) 전파를
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md`
`invalid` 상태를 이유로는 **절대 전파를 접지 않고**(`base/source-state-plan.md`
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/
debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에
@ -67,9 +72,28 @@ gated state의 동작:
`OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기
상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨.
**⭐ [2026-08-21] `HasBlockedEmit`은 게이트 흡수 집합의 특수형이다.**
`GateNode`가 드는 `withheld`(이번에 유보한 소스들)에 대해
`HasBlockedEmit == (next(withheld) ~= nil)`이고, 아래 "밀린 전파가 없으면
아무 것도 안 함(idempotent)"이 게이트 층위에서는 **"빈 배치면 통지 자체를
안 한다"**로 일반화된다(`base/gate-plan.md`의 8번). 구현 시 두 개를 따로
들지 말 것.
**⭐ [2026-08-21 신설] `state:Block(blocker)``state:Gate(setup)` 위에
얹힌다.** 위 "gated state의 동작"은 `Blocker`만의 특수 노드가 아니라
`base/gate-plan.md`가 확정한 **`GateNode`**(`ComputeNode`와 같은 층위)의
정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy`
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
**공개 표면(`Blocker()` 생성자와 `state:Block`)은 안 바뀐다** — 배선만
공용 노드를 쓰는 것.
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서
준다 — `base/source-state-plan.md`의 "Source 값을 직접 mutate한 뒤 전파 — `:Emit()`" 절("`Get()`은 라이브 레퍼런스를 준다" 캐비엇)과 일치.
**[2026-08-21] 이 계약은 `base/state-epoch-plan.md`가 의존하는 전제다** — 그
문서 §5의 3번이 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히
이걸 뒤집지 않기 위해서다. 바꾸려면 그쪽도 같이 봐야 한다.
## 사용 예시
@ -139,6 +163,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
"RC-1" 절에 논의 경위 기록).
**[보강, 2026-08-20 구현 전 QA 4라운드 `BK-9`] "영원히 안 만든다"로 못박은
건 아니다 — 지금 사용 케이스가 없을 뿐인 백로그다.** 사용자 판정: *"있는게
어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임 … 나중에 사용 필요 요구가
나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가
신설될 가능성이 존재하지 않는다고 못 박기는 이름."* 즉 (a) 구현 난이도가
낮고, (b) 신설된다면 이름은 `HasBlocked`가 아니라 **`HasBlockedState`**
쪽이 될 가능성이 높으며(그 Blocker에 걸린 gated state 중 대기 중인 게
있는가 = "블록된 state가 있는가"라 이름이 더 정직함), (c) 실제 요구가
나오기 전엔 안 만든다 — `conventions.md`의 "드문 오용이나 가상의 미래
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙 그대로.
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
@ -157,7 +191,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다**
재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의
`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다)
**그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려

View file

@ -2,7 +2,13 @@
> **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.**
> 자기 완결적인 유틸이라 디스패치 코어와 같은 파일에 있을 이유가 없었음.
> **내용은 옮기기만 했고 결정은 하나도 안 바뀜.**
>
> **[2026-08-21 전면 재작성] 공유 레지스트리 + `Brand.get(x) -> tag`(객체당
> 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, 다중 태깅
> 허용)로 바뀜.** 역전 원문은 `archive/brand-shared-registry-reversed.md`,
> 근거 기록은 `reference/epoch-brand-composition.md`. **뒤집힌 건 API 표면
> 하나뿐**이고 weak-key 레지스트리·테이블 아이덴티티·duck-typing 기각 근거·
> predicate 합성은 전부 그대로다.
**상태**: base — 동작/구현 방식은 확정, **이름 `Brand` 자체만 용어 정리
대기**(`question.md` 1번).
@ -22,62 +28,117 @@ Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서
통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로
(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).
**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로
사용(문자열 아님).**
**존재 이유 한 줄(2026-08-20 구현 전 QA 4라운드 `B-4`, 사용자 정리)**:
**`Brand`는 데이터 타입에 부작용을 남기지 않고 런타임 명시 타이핑을 하기
위한 것이다.** 값 자체엔 아무것도 안 심고(외부 weak 레지스트리), 판별이
읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이
한 줄에서 나온다.
```
local Brand = {}
local registry = setmetatable({}, {__mode = "k"})
## ⭐ 구현 — 인스턴스 브랜드, 브랜드마다 자기 weak 집합 하나 (2026-08-21 확정)
function Brand.set(x, tag) registry[x] = tag end
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
**`Brand()`가 브랜드 객체 하나를 만든다.** 그 객체가 weak-key 집합 하나를
들고, 값은 **자기가 속한 브랜드에 스스로 등록**한다.
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag,
ModifierTag =
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
```lua
local function Brand()
local members = setmetatable({}, {__mode = "k"})
return {
register = function(self, x) members[x] = true end,
is = function(self, x) return members[x] == true end,
}
end
-- 각 타입이 자기 브랜드를 하나씩 소유
local ObserverBrand, EffectBrand, TagBrand, AttributeBrand, TweenBrand,
BlockerBrand, StateBrand, SourceBrand, StoreBrand, SlotBrand,
RefBrand, PreRefBrand, PostRefBrand, ModifierBrand, EpochBrand =
Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(),
Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand()
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
Brand.set(newHandle, ObserverTag)
ObserverBrand:register(newHandle)
```
**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** —
Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는
무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열
리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블
레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이
되어 헷갈릴 여지가 없음.
**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** —
단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위
관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우
(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를
"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을
"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로
재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지
(포함 관계의 방향)가 코드 모양 자체에 드러나게 함:
**⭐ 다중 태깅이 이 설계의 존재 이유다.** 한 값이 **여러 브랜드에 동시에**
속할 수 있다 — 실제로 그게 필요한 자리가 있다:
```lua
-- Source는 Source이면서 동시에 Epoch다 (base/state-epoch-plan.md)
SourceBrand:register(source)
EpochBrand:register(source)
```
옛 모양(`Brand.get(x) -> tag`, 객체당 태그 하나)으로는 이걸 표현할 수 없었고,
그게 재작성의 직접 발단이다 — `archive/brand-shared-registry-reversed.md`.
**부수 이득 — 외부 확장이 열린다.** `Source`가 아닌 원천(외부 시계 등)이
`Epoch`로 참여하고 싶으면 `EpochBrand:register(self)` 한 줄이면 되고,
`isEpoch` 구현을 고칠 필요가 없다. 사용자 논거: *"본인이 거기 속하면, 본인이
직접 해당 브랜드를 가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가
없어짐. 따라서 외부 확장도 쉬워진다."*
**역조회는 없다** — "이 값이 대체 무엇인가"를 되묻는 창구는 제공하지 않는다.
코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십 질문**뿐이고, 역조회를 하는
자리는 전수 조사에서 하나도 없었다(사용자 확인: *"확실히 의미가 없어진것 같습니다
필요하진 않아요."*).
**브랜드 아이덴티티가 곧 테이블 레퍼런스다 — 문자열 태그를 안 쓰는 이유는
그대로 유효(사용자 제안)**: Luau의 인터닝된 문자열 비교도 이미 사실상 O(1)
포인터 비교라 성능 차는 무시할 만하지만, **오타 안전성**이 실질적 이득 —
태그가 오타난 문자열 리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데,
잘못된 브랜드 **변수**를 참조하면 즉시 드러나거나 최소한 진짜 다른 집합이
되어 헷갈릴 여지가 없음. 새 모양에선 이게 더 강해진다 — 브랜드가 값이라
`nil`을 인덱싱하면 그 자리에서 에러가 난다.
**메소드 이름이 소문자인 건 이 유틸의 기존 관례를 잇는 것**(`Brand.set`/
`Brand.get`이 그랬다). quad 공개 표면의 PascalCase 메소드 관례(`:Get`/`:Set`/
`:With`)와 다른데, `Brand`는 사용자가 직접 부르는 프리미티브가 아니라 base
내부 유틸이고 사용자에게 노출되는 건 `isX` wrapper들이다 — 이름 자체가
용어 정리 대기 항목이므로 케이싱도 그때 같이 본다(`question.md` 1번).
## `isX` wrapper — 포함 관계는 predicate 합성으로 (2026-08-09 열한 번째 세션)
**`isX`는 브랜드를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — 단순
항등인 경우(`isObserver(x) = ObserverBrand:is(x)`)와, 상위 관계(subtype)가
있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우(`isState`/`isRef`)로
갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 "집합 멤버십"(플랫한 셋
체크)으로 구현하던 방식을 "더 구체적인 predicate를 먼저 정의하고 그 위에
얹는" 합성 방식으로 재정리 — 동작은 동일하지만, 어느 predicate가 다른
predicate를 내포하는지(포함 관계의 방향)가 코드 모양 자체에 드러나게 함:
```lua
local function isSource(x)
return Brand.get(x) == SourceTag
return SourceBrand:is(x)
end
local function isState(x)
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족
return isSource(x) or StateBrand:is(x) -- Source가 State를 구조적으로 만족
end
local function isPreRef(x)
return Brand.get(x) == PreRefTag
return PreRefBrand:is(x)
end
local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정
return Brand.get(x) == PostRefTag
return PostRefBrand:is(x)
end
local function isRef(x)
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
return isPreRef(x) or isPostRef(x) or RefBrand:is(x)
end
```
**⭐ 다중 태깅이 가능해져도 포함 관계는 계속 이 합성으로 쓴다.** *"`Source`를
`StateBrand`에도 같이 등록하면 `isState`가 한 줄이 되지 않나"*는 하지 않는다 —
등록 지점이 여러 곳에 흩어지면 "어느 브랜드에 등록하는 걸 빠뜨렸나"가 조용한
버그가 되고, 포함 관계가 코드 모양에서 사라진다. **각 타입은 자기 브랜드에만
등록하고, 포함 관계는 predicate 한 곳에 쓴다.** 사용자 확인: *"여전합니다.
`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거
똑같습니다."*
- **예외는 "구조적 인터페이스"뿐**`Source``EpochBrand`에 같이 등록하는
건 포함 관계가 아니라 **서로 다른 축의 계약을 동시에 만족**하는 것이라
합성으로 표현할 수가 없다(`isEpoch`가 `isSource`를 알아야 할 이유가 없고,
`Source`가 아닌 원천도 참여해야 한다). 이게 다중 등록의 정당한 용례다.
**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만
염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을
@ -89,7 +150,7 @@ end
문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "`isState(x): boolean` 필요" 절에 있던 "별도 `isSource` 불필요" 서술은
`session/2026-08-07-08-none-sentinel-dispatch-brand.md`에서 이미 정정됨.
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 목록에서 빠져있던 것
추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가
재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를
`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로
@ -98,9 +159,9 @@ end
**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함
방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘.
- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) ==
PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로
얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)``true`.**
- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`PreRefBrand:is(x)`),
**`isRef(x)`는 그 위에 `RefBrand:is(x)`를 OR로 얹은 상위 개념** — 즉
**`isRef(preRefInstance)``true`.**
- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는
이제 `isHandlable``isRef(v) and not isPreRef(v) and not isPostRef(v)`
명시적으로 좁혀야 함**(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로
@ -109,52 +170,60 @@ end
명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘
정상 경로에선 거의 안 걸리지만, `base/ref-plan.md`의 두 동적 경로 가드
Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장).
`isModifier`도 같은 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념
없음).
`isModifier`도 같은 단순 항등(`ModifierBrand:is(x)`, 상위 개념 없음).
- **`PostRef``PreRef`와 완전히 같은 포함 방향** — `Ref` 런타임을 그대로
재사용하고 브랜드 태그만 다르므로 `isRef(postRefInstance)``true`.
재사용하고 브랜드만 다르므로 `isRef(postRefInstance)``true`.
`isRef`는 이제 `{Ref, PreRef, PostRef}` 셋을 통과시키는 상위 개념이고,
`isPreRef`/`isPostRef`가 각각 가장 구체적인 항등 — `PreRef`/`PostRef`
사이엔 포함 관계가 없음(서로 배타적인 형제).
**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** —
`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등
predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX`
wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`
`SlotBrand:is(x)`/`EffectBrand:is(x)`인 단순 항등 predicate, 브랜드
자체는 원래부터 목록에 있었지만 `isX` wrapper로 명시적으로 안 적혀 있던
것을 `base/modifier-plan.md`
"Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이
들어오면 즉시 error" 절이 필요로 해서 이번에
같이 적음.
**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 —
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용
introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이
내부적으로 `x == None`먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에
레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다.
사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에
의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v ==
None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."*
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 판별 창구가 범용
introspection을 겸하려면 `None`도 빠지면 안 되므로 *"내부적으로 `x == None`
먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 레지스트리 조회로 폴백하며,
`isNone`이 그 특수 분기의 구현체가 된다고 했다. 사용자 판정: *"Brand 는 None 을
참조할 필요는 없음. Brand 자체는 아에 의존성 없고, None 도 테깅되는건 맞으나,
isNone 대신 필요한 곳에서 v == None 하면 되는 일, 혹은 isNone 구현 자체를
그렇게 해주면 되는 일."*
- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장
밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다.
- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이
레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단
자체는 그대로 유효.
- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가
허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉
"범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한
등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는
것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다.
- **`None``NoneBrand`에 평범하게 등록하는 것 자체는 무방**(사용자가
허용) — 다만 **[2026-08-21]** 역조회 창구가 없어졌으므로 등록의 유일한
효용은 `NoneBrand:is(x)`뿐이고, 그건 `v == None`이 더 싸다. 어느 쪽이든
`Brand` 쪽 코드는 그대로다.
**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는
이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의
값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를
가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키
인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한
엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음.
이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**:
1. **정확성: false positive.** `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈
수 있는 **임의의 사용자 값**이다. 사용자가 우연히 `Compute`라는 필드를 가진
테이블을 넣으면 quad가 그걸 `State`로 오인한다. 브랜드는 quad가 만든 값에만
찍히므로 이 오인이 원천적으로 없다.
2. **안전성/비용: 인덱싱 자체가 터질 수 있음.** 일부 Roblox userdata는 **정의
안 된 키를 인덱싱하는 것만으로 에러를 던진다** — duck-typing을 하려면 판별
코드를 전부 `pcall`로 감싸야 하고, 그건 "판별은 부작용 없이 빠르게"라는
`isHandlable` 계약(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)과
정면으로 부딪힌다. 최악의 경우 엔진이 죽는 상황까지 있다.
weak-key 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. 브랜드마다
집합이 갈려도 **조회 비용은 그대로 한 번**이고, `isState`처럼 OR로 합성된
predicate만 최대 브랜드 수만큼 조회한다(전부 2~3개).
weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이
GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격).
GC되면 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인
Observer의 `:Subscribe` 레지스트리와는 반대 성격).
**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자
확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가
@ -164,6 +233,10 @@ State<any> ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는
패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만
"자동 narrowing"을 기대하면 안 됨.
**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상,
`.claude/question.md`에 반영.**
**마일스톤**: **커밋된 M1 코드는 아직 `Brand`를 안 쓴다**(`quad-base/src`는
`init.luau`/`Relate.luau`/`Debug`뿐, 2026-08-21 확인) — 이 재작성의 전환
비용은 문서뿐이었다. 실제 구현은 각 프리미티브가 만들어지는 마일스톤에서
같이 간다.
**이름은 전부 가칭 — `Brand`/`ObserverBrand`류 포함 용어 정리 대상,
`.claude/question.md`에 반영.**

View file

@ -194,11 +194,17 @@ leading/trailing/통과 후 창 재개방은 **완전히 동일**. 그래서 공
타이머를 (재)시작하려면 그 순간을 알아야 하는데 알 방법이 없어서, 결국
게이트 노드 내부 훅이 필요함.
**그래서 권하는 구현 방향**: `Blocker`의 게이티드 노드를 내부 공용
`Gate` 노드로 한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이
결정하는 State 노드"), `Blocker`/`Debounce`/`Throttle`이 그 위의 서로 다른
정책으로 얹히는 형태. 새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나
있는 걸 두 번째 사용처가 생겼으니 이름 붙여 꺼내는 것뿐.
**그래서 권하는 구현 방향**: `Blocker`의 게이티드 노드를 공용 게이트 노드로
한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이 결정하는 State 노드"),
`Blocker`/`Debounce`/`Throttle`이 그 위의 서로 다른 정책으로 얹히는 형태.
새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나 있는 걸 두 번째 사용처가
생겼으니 이름 붙여 꺼내는 것뿐.
**[2026-08-21 실현 — 이 권고가 확정됐다]** 그 노드는 `state:Gate(setup)`
만드는 **`GateNode`**이고 **M2**에서 구현된다(`base/gate-plan.md`). 다만
"내부 공용"은 아니게 됐다 — **공개 표면**이다. `Debounce`/`Throttle` 쪽
관용구는 안 바뀐다: `Debounce{...}`가 돌려주는 팩토리가 내부에서
`s:Gate(policy)`를 부르므로 `state:Apply(Debounce{...})`가 그대로 성립한다.
---
@ -287,14 +293,20 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
### 그래서 정정 후 그림 (권고)
- **emit(무효화 신호)은 구독자에게 항상 전파된다.** State가 이미
`invalid`인지는 전파 여부와 무관.
- **emit(무효화 신호)은 구독자에게 전파된다.** State가 이미 `invalid`인지는
전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — `Epoch` 리비전
비교를 채택하면서 **같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접힌다**
(`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지인 건 그대로.
- 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로**
막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요.
- 한 번의 변경이 여러 경로로 같은 노드에 닿는 **파동 내 중복 순회**가
- ~~한 번의 변경이 여러 경로로 같은 노드에 닿는 **파동 내 중복 순회**가
실측에서 문제가 되면, 그때 **파동 단위**(방문 집합/에포크 카운터)로
접으면 됨 — 이건 의미론에 안 보이는 **순수 구현 최적화**이고, 지금처럼
영속 플래그로 하면 안 됨.
영속 플래그로 하면 안 됨.~~ **[2026-08-21 실현·정정]** 실제로 채택된 건
파동 단위가 아니라 **소스별 영속 에포크**이고(그래서 "영속이면 안 된다"는
경고는 `invalid` 플래그에만 해당했던 것으로 좁혀짐), 순수 최적화도 아니라
`Get()`이 섞인 값을 돌려주던 것을 고치는 **의미론 변경**이었다 —
`base/state-epoch-plan.md`.
### 이 문서에 미치는 영향 — 세 개가 사라짐
@ -318,6 +330,11 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
> 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음
> `invalid`가 꺼짐.
**[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 `Epoch` 리비전
비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 `Epoch`의 같은 리비전이
두 번째로 도착하면 접힌다**(`base/state-epoch-plan.md`). `invalid`로 접는 것이
금지라는 이 문단의 요지는 그대로다.
그리고 "다 다시 써야 한다"는 지시로 **코퍼스 전체 정정을 같은 세션에
수행함**. 정정된 모델:
@ -562,7 +579,8 @@ h.Value:Flush()
> 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고,
> 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를
> 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건
> 알고리즘 + 인터페이스이고, 미배선 상태에선 엔진 op 3개와 같은 관례대로
> 알고리즘 + 인터페이스이고, 미배선 상태에선 `addTag`/`setAttribute` 계열
> 엔진 op과 같은 관례대로
> **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에
> "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에
> 의존함** — 스로틀만 타이머 없이 되는 게 아님.
@ -612,8 +630,12 @@ clearTimeout(handle: Timeout): ()
인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는
안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉
varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈.
- **미주입 백엔드에서는 base 스텁이 명확한 에러** — 엔진 op 3개와 동일한
관례. 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
- **미주입 백엔드에서는 base 스텁이 명확한 에러**`addTag`/`setAttribute`
계열 엔진 op과 동일한 관례(**[2026-08-22]** 여기 "엔진 op 3개"라고 세어
놨었는데, 정작 이 문서가 추가하는 `setTimeout`/`clearTimeout` 자신이 그
개수를 늘리는 쪽이라 자기모순이었다. 주입 op 전체 목록의 소스는
`base/architecture.md``EngineOps.luau` 줄. **주의**: `native*` 계층은
이 관례의 예외로, 미주입이 에러가 아니라 **조합 폴백**이다). 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
만들어낼 수 없음.
- **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도
trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된
@ -916,8 +938,9 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와
똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다**
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 "emit은 항상 전파된다"
규칙이 게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 전파 규칙(**[2026-08-21]**
"항상"이 아니라 "같은 에포크의 두 번째만 접는다" — `base/state-epoch-plan.md`)이
게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`
실제로 미루는 건
**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려
있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한
@ -1100,9 +1123,14 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했
막힘).
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/
`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현
시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야
함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번
설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
`clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]**
각각 **M2**(게이트/`Blocker`) / **M3**(State 코어) / **M8**(`Ref`)에서
확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" —
`Blocker.luau`가 M2로 옮겨지기 전 기준). **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정
"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로).
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는
것은 그대로이고, 바뀐 건 **언제**뿐이다. 표면/이름은 아직 미정 —
`base/gate-plan.md`가 소스(이 문서의 1절이 그 일반화를 처음
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
때나 나중에 얹으면 된다.

View file

@ -83,6 +83,48 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
"Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접
자원**(Observer 구독 등)만 정리하면 됨.
> **[용어 풀이, 2026-08-20 구현 전 QA 4라운드 `D-3`] "깊은 인덱스"가 뭘
> 뜻하는가 — 그리고 설치와 철거는 실제로 반대 방향이 맞다.**
> "깊다"는 **인덱스 숫자가 크다**는 뜻이다(트리 깊이가 아니라 **같은
> `(inst,k)` 체인 안에서의 재귀 깊이**). `State<State<Tag>>`를 예로 들면:
>
> ```
> (inst, k) 체인
> index 1 : StoreBind ← 바깥 State를 구독. "얕음"
> index 2 : StoreBind ← 안쪽 State를 구독(바깥이 재귀로 만든 것)
> index 3 : TagHandler ← 최종 Tag를 실제로 반영. "깊음"
> ```
>
> - **설치(`Dispatch.process`)는 1 → 2 → 3 순** — 사용자가 짚은 그대로.
> 각 레벨이 값을 한 겹 벗겨 `index + 1`로 재귀하므로 인덱스가 커지는
> 방향으로 진행한다.
> - **철거(`Dispatch.retractFrom`)는 그 반대인 3 → 2 → 1 순이 맞다**
> 아래 "Dispatch 체인" 절의 의사코드가 `for i = #list, index, -1`
> **꼬리부터 역순**으로 돈다. 즉 사용자가 되물은 *"달라질 때 5, 4, 3, 2
> … 순이 되는건 아니지?"*의 답은 **"맞다, 그 순서가 된다"** 이다.
> - **왜 반대여야 하는가**: index 2의 `StoreBind`가 index 3의
> `TagHandler`*만들어낸* 주체다. 만든 쪽을 먼저 지우면 만들어진
> 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것과 같은
> 이유(LIFO). 그래서 각 핸들러의 retractor는 자기가 재귀로 만든 하위
> 인덱스를 쫓아갈 필요가 없다: **자기 차례가 왔을 땐 이미 아래가 다
> 비어 있다.**
> - **주의 — "3 → 2 → 1"은 한 `(inst,k)` 체인 안에서의 이야기다.**
> 서로 다른 키(`(inst,k1)` vs `(inst,k2)`)는 완전히 별개 배열이고
> 서로의 순서와 무관하다(아래 "인덱스의 의미" 항목).
> - **⭐ [2026-08-20 `B-1`] "스택을 역순으로 푼다"는 `retractFrom`에만
> 해당한다 — (A) 분기는 스택을 푸는 게 아니라 그 자리를 *교체*하는
> 것이다.** 사용자 정리: *"같은 핸들러의 process로 retract 가 교체되는건,
> 말 그대로 교체라 stack down 이 아니고 retractFrom 는 stack down 을
> 수행한다."* 둘을 같은 말로 묶어 읽으면 안 된다:
> - **(A) 분기(같은 핸들러 재프로세스)** — `slot.retractor(v)` 하나만
> 불리고 **아래(index+1 이하)는 전혀 안 건드린다.** 그 자리 하나가
> 새 클로저로 갈아끼워질 뿐이라 순서 개념 자체가 없다.
> - **`retractFrom`(단순 철거)** — 그때만 꼬리부터 목표 인덱스까지
> **스택을 역순으로 푼다.**
> 위 "자기 아래는 이미 정리된 뒤"라는 보장도 **`retractFrom` 경로의
> 이야기**다 — (A) 분기에서 클로저가 불릴 땐 아래가 그대로 살아 있고,
> 그게 바로 "깜빡임 없이 갈아끼우기"가 성립하는 이유다.
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라
열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치
@ -152,10 +194,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
`base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고,
다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시
이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다.
`Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게
항상 호출 가능한지) 구현 시 정할 것.
그리고 `Dispatch.listHandlers()`
함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함.
**[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`] `Dispatch.listHandlers()`
`Quad.debug`와 무관하게 항상 호출 가능하다** — 옛 서술은 "같은 디버그 표면에
속하는지 구현 시 정할 것"으로 열어뒀으나, 사용자 판정으로 닫힘: *"listHandlers
는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고
싶다면 출력하는 용도임."* 즉 이 함수는 **아무것도 출력하지 않고 목록을 반환만
하는 순수 조회**이고, 찍을지 말지는 호출한 쪽이 정한다 — 게이팅이 필요한 건
"라이브러리가 스스로 콘솔에 쓰는" 동작(동률 경고 print)뿐이라 조회 함수는
애초에 그 대상이 아니다.
그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를
**반환**하는 함수로 둔다.
구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라
M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
@ -336,15 +384,40 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다
먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`,
`b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고,
**base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자
키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리.
이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌
다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에
기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열
키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는
참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열
슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상
먼저 처리된다는 게 base 자체의 보장**이 됨.
**base 드라이버가 이 순서를 계약으로 보장**한다 — 배열 슬롯에 놓인 어떤
값이든 모든 프로퍼티/이벤트 세팅보다 항상 먼저 처리된다.
**⚠️ [구현 방식 정정, 2026-08-21 구현 전 QA 4라운드 `F-4-1`] "계약으로
보장한다"와 "루프를 두 번 돈다"는 다른 얘기다 — 실제 구현은 일반화 `for`
**한 번**이다.** 옛 서술은 "명시적으로 두 패스로 나눠 돌기로 계약화"라고
적어 **구현까지 2회 순회로 못박은 것처럼** 읽혔고, 실제로 M0 스파이크
`01`도 숫자 `for` + 일반화 `for` 두 루프로 짜여 있었다. 사용자 판정으로
단일 순회로 정정: *"ipairs, pairs 를 따로 사용하게 되는게 아닌 단순
일반화 for 로써 얻어지는게 맞는 상태라면, 맞는 구현이다 … `__pairs`/
`__ipairs` 직접 구현체를 담은 ud 등을 받는 `flattened` 는 없고, luau
테이블만 사용하는게 맞음."*
- **`flattened`는 항상 평범한 Luau 테이블이다** — props는 사용자가 쓴
Lua 테이블 리터럴에서 오고, `flatten`도 그걸 제자리에서 뮤테이션할 뿐
(`base/modifier-plan.md`의 "flatten의 정확한 형태" 절). 메타테이블로
순회를 갈아끼운 userdata 같은 게 들어올 경로가 **없다.**
- **그래서 일반화 `for k, v in flattened do`가 배열 → 해시 순서를 그대로
준다** — 두 층위는 `type(k) == "number"`로 가르면 된다. 순회 1회 절약.
- **옛 근거 (1)은 과했다** — "다른 백엔드가 props를 Lua 테이블이 아닌
자료구조로 표현할 수도"는 **`inst`에는 해당해도 `flattened`에는 해당하지
않는다**(백엔드가 뭐든 사용자는 Luau로 props를 쓴다). 근거 (2)(숫자/문자열
키를 어차피 다르게 취급해야 함)는 그대로 유효하고, 그건 **한 루프 안의
분기**로 충분하다.
- **계약 자체는 안 바뀐다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
base가 보장하는 것이고, 백엔드가 자기 드라이버를 짜더라도 지켜야 한다.
바뀐 건 quad-base 자신의 구현이 그 보장을 **몇 번의 순회로 얻는가**뿐.
- **`nil`-hole 위험은 어느 방식이든 동일** — 구멍이 생기면 숫자 키 일부가
해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이
깨진다. 방어는 그대로 `02`/`06` 스파이크의 nil-hole 규율(`None` 관용구)에
맡긴다.
- **⚠️ 스파이크 `01` 재작성 필요** — `luau-test/rewrite-required/01-two-pass-array-hash-order.luau`
두 루프 버전이라 지금 계약의 구현과 안 맞는다. 단일 일반화 `for` 버전으로
재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index
순서"** 를 그대로 확인할 것.
**[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서
성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장
위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도
@ -353,10 +426,44 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은
**서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이
아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제
Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효.
**[실측 완료, 2026-08-19 M0 — 이후 `F-4-1`로 무효화, 지금은
`rewrite-required/`]** `luau-test/01-two-pass-array-hash-order.luau`
두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저,
array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던
항목은 닫혔다.
**⚠️ [혼동 방지, 2026-08-20 구현 전 QA 4라운드 `D-10`] 이 실측은 "Luau가
이 순서를 주는가"를 확인한 게 아니다.** 사용자 지적대로 **Luau의 일반화된
반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건
의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도
하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`).
**그런데도 base 드라이버가 이 순서를 *계약*으로 들고 있는 이유는 순서를
못 믿어서가 아니다** — **⚠️ [2026-08-21 `F-4-1` 이후 정정]** 여기 한때
이유가 둘("이식성" + "구분 비용이 이미 듦")이라고 적혀 있었으나, **위
`F-4-1` 정정 문단이 이식성 논거를 이미 기각했다**(`flattened`는 항상
사용자가 Luau로 쓴 평범한 테이블이라, 다른 백엔드가 와도 이 자료구조는
안 바뀐다 — 백엔드에 따라 달라지는 건 `inst`뿐). 남는 이유는 하나다:
**숫자 키와 문자열 키를 어차피 다른 의미로 처리해야 하므로, 순서까지
계약으로 고정하는 건 거의 공짜다.** 그리고 그 계약을 얻는 데 필요한
구현은 **두 패스가 아니라 한 루프 안의 `type(k) == "number"` 분기**다.
**⚠️ [2026-08-22 정정] `F-4-1` 이후로는 "base가 언어 동작에 안 기댄다"고
말할 수 없다.** 여기 한때 *"스파이크 `01`이 검증한 건 '우리가 짠 두 패스
드라이버가 계약대로 도는가'이지 언어 동작이 아니다"*, *"base는 이 우연한
동작에 기대지 않고 명시적으로 강제한다"*라고 적혀 있었는데, **단일 일반화
`for` 구현은 정확히 그 언어 동작에 기대는 구현이다.** 지금 정확한 서술은:
- base는 배열→해시 순서를 **계약으로 약속**한다(다른 백엔드가 자기
드라이버를 짜도 지켜야 한다).
- quad-base 자신은 그 약속을 **Luau 일반화 `for`의 순회 순서에 기대어**
지킨다 — 그게 `F-4-1`이 확정한 구현이다.
- 그래서 **재작성될 `01`이 검증할 것은 언어 동작 그 자체**다("일반화 `for`
배열 파트 전체를 해시보다 먼저, 배열 안에서는 index 순서로 주는가").
`01`은 두 루프 드라이버를 최소 재현한 것이라 이 질문을 안 물었다.
- **⚠️ 따라서 `nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안
된다** — 구멍이 나면 계약 위반이 아니라 **전제 위반**이라 계약이
지켜줄 수가 없다. 구체적인 깨짐 방식과 방어 위치는 위 "`nil`-hole
위험은 어느 방식이든 동일" 항목이 소스.
### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정)
@ -460,9 +567,12 @@ end
디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를
뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`
flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시
파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체.
**[2026-08-14 아홉 번째 세션] 이 두 패스 앞뒤에 `Ref` 계열 훅 처리가
파트(프로퍼티/이벤트) 나중이라는 **순서 계약대로**`(k,v)`
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체
(**[2026-08-21 `F-4-1`]** 그 계약을 얻는 구현은 두 루프가 아니라
**단일 일반화 `for` + `type(k) == "number"` 분기** — 위 `F-4-1` 정정
문단).
**[2026-08-14 아홉 번째 세션] 이 본체 루프 앞뒤에 `Ref` 계열 훅 처리가
붙음** — 앞에는 `PreRef`/`PostRef`를 한 번에 훑는 pre-pass(`PreRef`는
그 자리에서 fire, `PostRef`는 이 호출에만 로컬인 `postRefList`에 적재만
하고 둘 다 전용 센티널로 소진), 뒤에는 그 `postRefList`를 순회하며 각
@ -646,14 +756,28 @@ end
Dispatch 바깥에도 적용됨** — `dispose(value)`(`base/slot-plan.md`)는
Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은
`elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만
`disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
2026-08-14 열 번째 세션에 같은 원칙으로 확정.
`nativeDispose(element: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
**[2026-08-21 5라운드, 같은 날 이름 확정] 같은 계열이 `native*` 물리 트리
조작 계층으로 정리됐다**(base는 `Parent`를 모른다는 지적에서 나옴).
**⚠️ [2026-08-22 정정] 여기 한때 `disposeInst`/`mountInst(target, element,
index)`/`unmountInst(element)`로 적히고 "이름은 아직 가칭이라 정식 등재는
확정 시점에 한다"고 미뤄져 있었으나, 이름은 같은 날 `native*`
확정됐다** — `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/
`nativeSwap`/`nativeDispose`. 시그니처와 조합 폴백 규칙의 소스는
`base/slot-plan.md`의 "물리 조작은 주입 op다" 절이고, 주입 op 전체
목록의 소스는 `base/architecture.md`의 소스 트리 안 `EngineOps.luau`
줄이다 — 여기서 다시 나열하지 않는다.
같은 "base 소유 + op 주입" 원칙은 2026-08-14 열 번째 세션에 확정.
- **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은
엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작
(재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) —
이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend.
**주입되는 엔진 op**:
**Tag/Attribute가 쓰는 주입 op**(**⚠️ [2026-08-22] 이건 주입 op *전체
목록*이 아니다** — 이 절이 다루는 Tag/Attribute 경로에 필요한 셋일 뿐이고,
`native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`은 여기 없다. 전체
목록의 소스는 위에서 지정한 `base/architecture.md``EngineOps.luau`
하나다 — 여기에 다시 쌓지 말 것):
```lua
addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신
@ -915,6 +1039,28 @@ end
`NoneHandler`는 항상 재위임하므로 지금 위반 사례는 없지만, "조건부로만
재위임하는" 핸들러를 새로 만들면 재위임을 건너뛰는 그 자리에서
`Dispatch.retractFrom(inst, k, index + 1)`을 직접 불러 아래를 정리해야 함.
**[예시 추가, 2026-08-20 구현 전 QA 4라운드 `B-3`]** 규칙만으론 뭐가
위험한지 안 드러난다는 지적을 받아 가상의 위반 사례를 같이 적어둠:
```lua
-- ⚠️ 이런 핸들러를 새로 만들면 고아 체인이 생긴다
function MaybeWrapHandler.process(inst, k, v, index)
if v.enabled then
Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함
end
-- v.enabled가 false면 아무것도 안 함 ← 여기가 문제
return function() end
end
```
- 1차 사이클 `v.enabled == true` → index+1에 하위 체인이 설치됨.
- 2차 사이클에 **같은 핸들러**로 `v.enabled == false`가 오면 (A) 분기다 —
(A)는 정의상 **아래를 안 건드리므로** `retractFrom`이 안 불린다.
- 그런데 이번엔 재위임을 안 했으니 **index+1의 옛 하위 체인이 그대로
남는다.** 아무도 안 지우고, 옛 값에 대한 구독/부작용이 계속 산다.
- **해법**: `else` 자리에서
`Dispatch.retractFrom(inst, k, index + 1)`을 직접 부른다.
- **`HandlerChanged` 같은 마커 값은 두지 않음** — "핸들러가 바뀜"은 **그
자리 retractor가 `nil`로 불린다는 사실 자체**로 이미 표현됨. 별도 마커를
만들면 그것도 결국 "인자로 넘어오는 정체불명의 값"이 되어 옛 모델의
@ -1143,9 +1289,13 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
```lua
Dispatch.setLength(inst, i, len: number | State<number>)
Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?)
Dispatch.setOffsetSource(ownerKey, i, offset: Source<number> | None)
Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset
```
**[2026-08-21 5라운드]** `anchor`는 생명주기 앵커(생략 시 `ownerKey`, 자세한
건 아래 "`setLength` 구현" 절), `getOffsetAt`은 발행 채널(`Source`) 없이
숫자만 필요한 쪽(물리 삽입 위치 등)이 쓰는 pull 경로.
**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 —
`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을
@ -1204,8 +1354,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`
만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼
써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`
`Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에
참여할 게 없다는 명시적 선언. 대상은 일반 `Ref`뿐 아니라 **그 배열
`Slot:List` 절 참고. **⭐ [정정, 2026-08-21 G절] `None`은 "발행 채널이 없다"는 뜻이다** —
옛 서술은 "실제 마운트를 하지 않는 위치"였는데, **plain 요소도 `None`
등록**(마운트는 하지만 그 자리의 offset을 반응형으로 받아볼 소비자가 없음)하므로
정확하지 않았다. **순서 계산에 참여하는지는 `setLength`가 답한다**(0이면 안
차지). 숫자가 필요하면 채널 유무와 무관하게 `Dispatch.getOffsetAt(ownerKey, i)`
부르면 된다. 아래 "짝을 맞춰 `0`" 규칙은 **값이 정말 없는 자리**(Ref/`nil`)에만
해당한다 — plain 요소는 `None` + `setLength(1)`이 정상이다. 대상은 일반 `Ref`뿐 아니라 **그 배열
위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로
캐우칭된 미전달 Ref) — `setLength`
같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의
@ -1265,7 +1420,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
동일하게 적용"* — 즉:
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`
등록할 때마다 `bk.N``i`로 늘어난다(`Dispatch.drive`의 배열 파트
순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
순회, `materializeSlotTree`의 등록 배치, Slot의 런타임 단건 `rawAdd` 전부 이
하나의 규칙) — **`Dispatch.setOffsetSource``bk.N`을 건드리지
않는다**, 호출 순서가 항상 `setOffsetSource(i)``setLength(i)`라서
(아래 "`setLength` 구현" 절) `bk.N``setLength`에서만 올려야
@ -1280,7 +1435,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
등록 중(`Dispatch.drive`/`materializeSlotTree`의 등록 루프)엔 아래 "배치 등록을
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
@ -1345,6 +1500,13 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된
"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로
간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음.
**⭐ [2026-08-21 5라운드 `DC-14`] 게다가 그 마지막 경로조차 사용자에겐
막혀 있다** — `updateFn`이 도는 Slot은 정의상 `_listed`이고, `_crudUsed`
`_listed` 상호 배타 가드(`base/slot-plan.md`) 때문에 그 Slot의 공개 CRUD가
이미 error다(사용자 지적: *"외부 입장에서는 그럴 방법이 없어보인다. crud 가
list 시에는 더이상 불가능해지기 때문"*). 즉 같은 `(ownerKey, bk)` 재진입은
**정상 API로는 만들 수 없다** — `updateFn` 안에서 *다른* Slot을 건드리는 건
다른 `bk`라 무관하다.
**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입
가드 없음.**
@ -1361,25 +1523,122 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한
것뿐, 그래서 별도 방어 로직도 필요 없음.
**⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.**
물리 삽입 op(당시 가칭 `mountInst`, 확정 이름은 `nativeInsert`)에 삽입 위치를
어떻게 주느냐는 질문에서 결함 둘이 드러났고,
사용자가 제시한 방향으로 정리됐다:
1. **`sourceList``None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를
하지 않는 위치"라고 정의해놓고 정작 plain 요소를 `None` + `setLength(1)`
등록하고 있었다(그래서 그 자리의 offset 숫자가 **계산조차 안 됐고**, DOM류
백엔드가 삽입 위치를 알 방법이 없었다). **참여 여부는 `lengthList`가 이미
표현하므로** `sourceList`는 "반응형으로 받아볼 채널이 있나"만 답하면 된다.
2. **숫자가 필요한 쪽은 `Dispatch.getOffsetAt(ownerKey, i)`로 직접 뽑는다**
(사용자 제안: *"setOffsetSource 에선 source 를 받으면 그건 set 해주지만,
아니면 그냥 얼리리턴에 None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로"*)
— 모든 자리에 숫자를 밀어 넣는 네 번째 병렬 배열을 만들지 않고 **pull로**
둔다("관측해야 실체화된다" 원칙과 같은 결).
3. **`sum`이 owner의 자기 offset에서 시작한다** — 예전엔 `0`이라 **depth ≥ 2에서
중첩 Slot의 자식 offset이 부모 베이스만큼 어긋났다**(depth 1만 쓰던 동안
드러나지 않았음). **베이스를 따로 저장하지 않는다** — Slot이면 자기
`.Offset`이 곧 그 값이고(부모가 먼저 설정해주므로 이미 정확), 최상위 물리
inst엔 베이스라는 개념이 없어 항상 0이다. 한때 `bk.base` 필드에 복사해두는
안을 적었다가 **사용자 지적으로 걷어냈다**: *"bk.base 가 왜 필요한거임? …
이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데, 부모 slot 의 offset 읽는게
이미 정확해 … 최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."*
같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린 패턴 그대로다.
**`isSlot` 분기가 남는 건 타입 분기라서가 아니라 검사할 다른 방법이 없어서다**
`ownerKey.Offset`을 그냥 인덱싱해 확인하는 duck-typing은 Roblox userdata에서
정의 안 된 키 인덱싱이 에러를 던질 수 있어 금지돼 있다(`base/brand-plan.md`의
duck-typing 기각 근거).
4. **깊은 전파를 위해 중첩 Slot은 자기 `Offset`을 관측한다** — 앞 형제의 길이가
변해 자기 베이스가 밀리면 자기 자식들의 offset도 다시 계산돼야 한다(사용자:
*"자식 slot 의 offset 을 다시 설정해주기 위함이구나. offset의 깊은 전파를
위한거군"*).
```lua
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — nativeInsert의 삽입 위치,
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
function Dispatch.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey)
-- [2026-08-21 사용자 제안, 같은 날 의사코드 정정] **단일 함수 + 접두합 캐시.**
-- `bk.offsetCache[i]` = i 자리의 절대 offset, `bk.invalidAfter` = **여기까지는
-- 캐시가 유효**(그 뒤부터 다시 누적해야 함). 함수를 둘로 나누지 않는다 —
-- 이 하나가 필요한 만큼만 앞으로 이어붙이므로, 순차 호출이면 한 칸씩만
-- 늘어나 전체가 O(N)이 된다(사용자: *"그러면 알아서 순차적으로 합캐시가
-- 처리됨"*).
if bk.invalidAfter == 0 then
-- 시작점 — 1번 자리의 offset은 이 owner의 베이스 그 자체.
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
bk.invalidAfter = 1
end
if at <= bk.invalidAfter then
return bk.offsetCache[at] -- 유효 구간 — O(1)
end
local cur = bk.offsetCache[bk.invalidAfter]
for i = bk.invalidAfter, at - 1 do
cur += contribution(bk, i) -- lengthList[i](State면 :Get())
bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다**
end
bk.invalidAfter = at -- 여기까지 유효해짐
return cur
end
```
**⭐ [2026-08-21] 캐시 무효화 — 규칙이 하나다**
`bk.invalidAfter`**"이 인덱스까지는 캐시가 유효"**를 뜻하고, 무효화는 전부
같은 모양이다 — **`bk.invalidAfter = math.min(bk.invalidAfter, i)`**(앞으로만
당긴다):
| 무엇이 바뀌나 | 어디까지 당기나 | 왜 |
|---|---|---|
| `setLength(ownerKey, i, ...)`, 그리고 그 State가 나중에 emit할 때 | `i` | **`i` 자리의 offset은 안 바뀐다**(그건 `1..i-1`의 합) — 바뀌는 건 그 **뒤**뿐. 사용자: *"정확히 입력받은 자신 인덱스까지 당김"* |
| `spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제) | `i` | 같은 이유 — 삽입/삭제 후에도 `i` 자리의 offset은 여전히 `1..i-1`의 합이다 |
| owner의 베이스 변경(`ownerKey.Offset`이 바뀜 = `_baseObserver`가 도는 순간) | `0` | 1번 자리부터 전부 다시 |
**`recompute`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서
`getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로
두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 —
사용자: *"함수를 나눠야할 이유를 모르겠음. 하나로 두는게 나아보임."*)
```lua
local function recompute(ownerKey, bk)
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
-- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 —
-- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나
-- 전체 O(N).
local sum = 0
for i = 1, bk.N do
-- [2026-08-21 5라운드 감사] `bk.N or 0`**빈 Slot 크래시 방어**.
-- `bk.N``setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
-- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N``nil`
-- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`
-- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약.
for i = 1, bk.N or 0 do
local offset = bk.sourceList[i]
-- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라
-- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
-- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상
-- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는
-- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도
-- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로.
if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
offset:Set(sum)
-- [전면 정정, 2026-08-20 QA 4라운드 `C-6`] `nil`은 skip이 아니라 error.
-- 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수, 배치 중엔
-- Blocker 게이팅, 해제는 None, spliceArraysDown은 압축), nil이 보이면
-- 부기가 깨진 것 — 조용히 건너뛰면 위치 하나가 순서 계산에서 빠지는
-- 추적 어려운 오작동이 된다. 상세는 base/slot-plan.md의 "추가 방어 조치".
if offset == nil then
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
end
local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유)
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
offset:Set(abs)
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
end
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스
ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
end
```
@ -1416,7 +1675,13 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지
그건 호출하는 배치 쪽 책임):
```lua
function Dispatch.setLength(ownerKey, i, len)
-- [시그니처 변경, 2026-08-21 구현 전 QA 5라운드 `C-4`] 4번째 인자 `anchor` 신설 —
-- **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 분리**한다. 아래 절 참고.
-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라
-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리
-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다).
function Dispatch.setLength(ownerKey, i, len, anchor)
anchor = anchor or ownerKey
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
@ -1437,7 +1702,8 @@ function Dispatch.setLength(ownerKey, i, len)
if isState(len) then
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
bindLifetime(anchor, observer) -- **물리 target**의 생명주기에 귀속, Subscribe 아님
-- (ownerKey는 부기 키일 뿐 — 아래 절)
bk.observers[i] = observer
else
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
@ -1445,6 +1711,39 @@ function Dispatch.setLength(ownerKey, i, len)
end
```
**⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 —
4라운드 `D-56`의 결론을 되돌린다.**
4라운드는 "`ownerKey`가 Slot일 수 있으니 **백엔드의 `bindLifetime`이 Slot을
첫 인자로 받는 경우를 핸들링**하고, `isBoundAlive`에 세 번째 분기를 둬라"로
결론냈었다. 5라운드에서 사용자가 그 전제 자체에 의문을 제기했고(*"애초에
Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 observer/effect 는
실제 inst 에 불림 … 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지
다시 생각해봐야할 부분"*), 검토 결과 **되돌리는 쪽이 맞다**:
- 이 Observer가 살아야 하는 기간은 "이 Slot이 **그 물리 트리에 마운트돼
있는 동안**"이고, 그건 `physicalTarget`이 정확히 표현한다. Slot 자신의
생존은 부모의 `_elements` 강참조가 이미 보장한다.
- **`setLength`가 불리는 모든 자리에서 물리 target을 이미 알고 있다** —
`Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`),
런타임 단건 `rawAdd`/`rawReplace`(=`self._mountedInst`).
- 그래서 **`bindLifetime`의 첫 인자는 항상 물리 Instance**로 되돌아가고,
`base/lifecycle-pattern.md`가 지고 있던 백엔드 요구사항(비-Instance 첫 인자
핸들링)과 `isBoundAlive`의 **세 번째 분기가 통째로 불필요**해진다(그건
아직 형태가 미정인 채 열려 있던 항목이었다). 옛 결론 원문은
`archive/bindlifetime-slot-owner-reversed.md`.
- **포탈(언마운트→재마운트)에서도 자연히 맞는다**`unmountSlotTree`
`bk.observers``unbindLifetime`하고, 재마운트 시 `materializeSlotTree`
`physicalTarget`을 앵커로 다시 등록한다.
- **`getBookkeeping(ownerKey)`/`getBlocker(ownerKey)`는 그대로 Slot을 키로
쓴다** — 그건 `Relate`의 weak 키일 뿐 생명주기 앵커가 아니다.
- **`anchor``len`이 State일 때만 실제로 쓰인다**(상수 길이는 Observer를
안 만들므로). **생략 시 `ownerKey`로 폴백**하므로 최상위 호출부
(`Dispatch.drive`, `ProcessedPreRefHandler`/`NilHandler` 등 `inst`를 owner로
쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧
물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인
자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다.
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
@ -1472,8 +1771,8 @@ end
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가
기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
@ -1491,29 +1790,41 @@ end
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
배치 중이라도 항상 최신값을 보게 됨.
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`
`materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
재귀 케이스)도 같이 확정시킨다.
- **⭐ [2026-08-21 5라운드 `DC-11`] 이 마지막 호출이 실제로 하는 일은
"offset 채우기"가 아니다.** offset은 3번의 즉시 계산이 등록 시점마다
이미 정확히 넣어뒀고(position `i`의 offset은 `1..i-1`의 길이 합인데
그것들은 `i`보다 먼저 등록되므로), 이 호출에서 `offset:Get() ~= sum`
가드에 걸려 대부분 아무것도 안 쓴다. 실제 역할은 둘 —
**(a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정**
(사용자 추측대로 이게 주 목적), **(b) 등록된 뒤에 값이 바뀐 길이가
있으면 그 뒤 형제들의 offset 교정**. 그래서 `ownerKey`가 물리 `inst`
`Dispatch.drive` 경로에선 (a)가 없어 사실상 검증 패스에 가깝지만,
O(N) 순회에 `Set`이 거의 없으므로 분기해서 빼지 않고 그냥 항상 부른다.
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
스킵 — 참여 안 하는 자리는 계산할 게 없음):
**`setOffsetSource`의 즉시 계산(2026-08-18 신설, 2026-08-21 G절에 정리)** —
등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 +
`bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면
**얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가
필요한 쪽(예: `nativeInsert`의 삽입 위치)은 `getOffsetAt`을 직접 부른다:
```lua
-- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서
-- 이 함수는 "등록 + (채널이 있으면) 즉시 1회 발행"만 남는다.
function Dispatch.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source ~= None then
local sum = 0
for j = 1, i - 1 do
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
sum += (if isState(v) then v:Get() else v)
end
if source:Get() ~= sum then
source:Set(sum)
end
if source == None then
return -- 발행 채널이 없는 자리 — 계산할 이유가 없다. 숫자가 필요하면
-- 그때 `Dispatch.getOffsetAt(ownerKey, i)`을 직접 부른다.
end
local offset = Dispatch.getOffsetAt(ownerKey, i) -- 배치가 순서대로 처리되므로
if source:Get() ~= offset then -- 1..i-1은 항상 이미 등록돼 있음
source:Set(offset)
end
end
```
@ -1525,7 +1836,13 @@ position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
**적용 지점 — `Dispatch.drive``attachSlot`, 각각 자기 owner 키로
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
별도 Blocker**
(**[2026-08-21] `attachSlot` 쪽은 이제 정확히는 그 안의
`materializeSlotTree`다** — `attachSlot`이 "부기만 만드는 재귀"와 "물리만
붙이는 재귀" 둘로 분해되면서 Blocker가 **등록 쪽 하나만** 감싸게 됐고,
그래서 "배치 *등록* 게이팅"이라는 이 절의 정의와 실제 범위가 정확히
일치하게 됐다. 옛 코드는 물리 마운트까지 같이 감싸고 있었음.
`base/slot-plan.md`의 "재귀 메커니즘" 절이 소스): 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
@ -1554,13 +1871,75 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
Slot의 `rawAdd``self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder
갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제
트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set`
자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위
`Get` 가드와 같은 원칙을 호출부에서도 적용).
### ⭐ 일반 계약 — 물리와 부기의 순서 (2026-08-20 `C-7`로 승격, **2026-08-21 5라운드에 재정의**)
> **🔄 [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"는
> 계약은 폐기됐다.** 원문은 `archive/bookkeeping-before-physical-reversed.md`.
**왜 뒤집혔나**: 그 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고, 비워진
그 공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를
비워둘 수단이 없다**(자리를 비워 `null`을 꽂아둘 수도 없다). 미는 주체는
언제나 백엔드의 삽입 연산 자신이다 — `native*` 계층이 들어오면서 이게
명확해졌다(사용자: *"nativeInsert 라는것 자체가 밀어내기 동작을 강제하는데,
그렇다면 length 를 나중에 설정한 다음 offset들이 무시되어야함. 일종의 웹 돔과
같은 동작을 내도록 강제하는 시스템"*).
**지금의 계약 — 셋으로 줄었다**:
1. **물리 조작은 `native*`가 전담하고, 밀고 당기는 건 그 op 자신이 한다**
(DOM식 의미론을 시스템 전체가 강제).
2. **base의 offset 부기는 "배치 지시"가 아니라 계산값이다** — 이미 배치된
것을 옮기지 않는다(`base/slot-plan.md`의 웹 백엔드 문단, 5라운드 확정).
3. **순서 규칙은 "자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나다:
- **`setOffsetSource`는 먼저** — 그 자리의 offset은 `1..i-1`의 합이라
**자기 삽입/제거로 안 변한다.** 게다가 삽입 위치 계산이 이 값이고,
Slot이면 `activateList`가 곧바로 그 값을 쓴다(C1).
- **`setLength``recompute`는 나중** — 이게 **뒤를 미는** 쪽이다.
- 그래서 `rawAdd``spliceArraysUp``setOffsetSource``nativeInsert`
`setLength``recompute` 순서다(`base/slot-plan.md`).
4. **배치 경로는 여전히 "부기 전량 먼저"**`materializeSlotTree`
`mountSlotTree` 분해는 그대로다. 그때는 삽입 위치가 전부 확정된 뒤에
물리가 몰리는 것이고, C6("부모에게 미는 길이는 최종값")가 그걸 요구한다.
위 3번과 모순이 아니다 — 3번은 **단건 경로**의 규칙이다.
- **⚠️ 프레임 경계는 여전히 안 낀다**(yield 금지) — 그래서 이 순서 변경으로
사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임
순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다.
**⚠️ [2026-08-22 정정] 여기 있던 "동기 순서 — offset 갱신이 마운트보다
먼저 끝나야 함" 문단은 위 역전으로 폐기됐다.** 그 문단은 *"Slot의 `rawAdd`
부기를 먼저 완결하고(`setOffsetSource`/`setLength` 등록 → `recompute`) 그
다음에 물리 마운트를 호출한다"*고 적어, 바로 위 3번(자기 자리 먼저 →
`nativeInsert` → 뒤를 미는 것 나중)과 **정반대**였다. 실제 의사코드도 3번
쪽이다(`base/slot-plan.md`의 `rawAdd`). 이번 세션이 그 문단의 옛 op 이름만
`native*`로 바꾸고 순서 주장은 지나쳐서 남아 있던 것.
그 문단이 근거로 들던 "한 프레임 순서가 깨진 채 노출될 위험"은 **위
⚠️ 문단이 이미 기각**했다 — 프레임 경계가 안 끼므로 중간 상태 자체가
사용자에게 안 보인다.
**⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`
`self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자
지적(*"rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?"*)을 파고들다
확인된 것 셋:
1. **`newCount`(개수)는 더 이상 `Length`의 정의가 아니다** — `Length`
"요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)이라, 중첩이 있는
순간 개수로 `Set`하면 틀린 값이 된다.
2. **쓰는 주체가 둘이 되면 안 된다**`recompute`가 이미
`ownerKey.Length:Set(sum)`으로 확정 기록을 한다.
3. **그 자리의 `Get() ~= newCount` 가드는 아무것도 안 거른다**`rawAdd`에선
카운트가 **항상** 달라지기 때문. 가드가 값을 하는 건 `recompute`의 전체
순회 쪽뿐이다(위 `Get` 가드 문단).
**확정**: `Length`**`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리
부기를 등록하고 `recompute`를 한 번 부를 뿐이다.
**⚠️ [2026-08-22 정정]** 여기 *"그게 `Parent` 대입 앞에 오므로 '부기가 물리보다
먼저'는 그대로 지켜진다"*(+ 사용자 인용 *"recompute 가 length 를 잘 처리해놓고
나서 빈 공간에 들어가므로"*)라고 적혀 있었으나, 그건 **같은 라운드에 역전된
옛 C-7 일반 규칙**을 전제한 서술이다 — 지금 단건 경로는 `setOffsetSource`
`nativeInsert``setLength``recompute``recompute`가 물리 삽입보다
**뒤**다(위 "지금의 계약" 3번). 이 절의 결론(`Length`를 쓰는 주체는
`recompute` 하나뿐)은 그 순서와 무관하게 그대로 유효하다.
의사코드는 `base/slot-plan.md``rawAdd`/`rawReplace`가 소스.
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클
@ -1569,7 +1948,11 @@ Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/Layou
**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:
DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로,
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 —
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음
(**[2026-08-21 5라운드 재확인]** `nativeInsert`가 삽입 위치를 받게 된 뒤에도 이
결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면
백엔드 op이 **아토믹한 최소 단위**여야 한다) —
quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고,
`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느
물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히
@ -1588,7 +1971,20 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을
`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** —
사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이
나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상
읽을 수 있는 `Source<number>`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막
값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인
다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치"
항목. **⭐ [2026-08-21 5라운드 `DC-6`, 사용자 정밀화] 더 정확히는, 언마운트
시점에 그 Slot이 이미 렌더해둔 요소들이 `LayoutOrder` 등을 위해 이 Source를
**계속 구독한 채로 함께 딸려 나간다**는 게 핵심이다. 그 상태에서 재마운트
`slot.Offset`에 **다른 Source 객체**를 넣으면, 딸려 나갔던 요소들은 여전히
옛 객체를 보고 있어 새 위치가 반영되지 않는다 — 포탈이 그 지점에서 깨진다.
그래서 언마운트는 값이 stale하게 남는 걸 감수하고 **객체 identity를 유지**하고,
재마운트 시 `setOffsetSource`의 즉시 계산이 같은 객체에 새 값을 `Set`한다. 위 정정대로 이 값을
`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로,
`:List``updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md`
참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를
@ -1602,9 +1998,10 @@ store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Of
카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind
프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를
호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는
부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent =
부모 Instance에 자식을 끼워 넣는 경로(예: **사용자 코드**가 `newInst.Parent =
parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래
추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
추가/제거하는 것 — base 의사코드 쪽의 `Parent` 직접 조작은 2026-08-21에
전부 주입 op로 정정됐다, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB.
`Slot`이든 `state<Frame>`이든 둘 다 이미 이 두 API를 정확히 호출하는
유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절

View file

@ -22,8 +22,10 @@ mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인
합의됨.
```
Effect(fn, state?) -> EffectHandle
Effect(fn, ...deps) -> EffectHandle
```
**[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`(의존성 하나)였다 —
아래 "`Effect(fn, ...deps)`" 절이 소스.
**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은
이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음
@ -45,17 +47,17 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로
`useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은
반환 계약 하나로 처리).
- **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것**
React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성
결합 관용구(`base/source-state-plan.md` "`:With` + `:Compute`" 절)를
그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. **`Effect(fn, a, b,
c)`처럼 trailing args로 바로 받는 sugar는 의도적으로 안 만듦**(2026-08-11
세션, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing
args로 직접 받는 sugar" 절 참고) — `Compute`와 달리 Effect/Observer는
자기 자신이 결과를 담는 State 노드가 아니라서, 의존성이 둘 이상이면 그걸
합칠 **새 노드**(`:With`가 만드는 것)가 실제로 필요함. 그 비용을 sugar로
감추지 않고 `Effect(fn, state:With(a,b,c))`처럼 코드에 그대로 드러내는
게 의도된 선택.
- **🔄 [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-6`] "다수 의존성은 `:With`
묶어서 넘길 것 / trailing args sugar는 안 만듦"(2026-08-11 세션) — 뒤집혔다.**
지금은 `Effect(fn, ...deps)`가 의존성을 **여러 개 직접 받고 각각에 구독을
건다**(아래 "`Effect(fn, ...deps)`" 절이 소스). 옛 근거("의존성이 둘 이상이면
합칠 새 노드가 실제로 필요하니 그 비용을 sugar로 감추지 말자")가 무너진
이유는 둘 — (1) **`Ref`는 State가 아니라 `:With`로 합칠 수가 없어서**, 그
모델에선 `Ref`가 Effect의 의존성이 될 방법이 **아예 없었다**(실제 갭),
(2) 각 의존성에 구독을 따로 걸면 **합치는 노드 자체가 안 생긴다** — 감출
비용이 애초에 없다. 옛 서술이 인용하던
`source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의
근거가 됐다(인자 모양을 그 관용구 그대로 맞춤).
- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** —
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`
만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링 관용구(`modifier-plan.md`
@ -155,7 +157,19 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는
**`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`
부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나,
최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe
최소한 그 경로에서 cleanup을 앞당기면 안 된다.
**[강화, 2026-08-20 구현 전 QA 4라운드 `E-11`] "안 되거나/최소한"이 아니라
Observer와 정확히 같은 규칙으로 통일한다 — leaf 바인딩된 핸들에는
`:Unsubscribe()`가 아예 안 먹는다.** 사용자 지적: *"옵저버에선 leaf
바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는
Unsubscribe 안 먹어야 하는거 아님?"* — 맞다. `Observer`
`:Unsubscribe()`가 전역 경로 전용이고 leaf 해제는 `unbindLifetime`
담당한다는 게 이미 확정된 규칙인데(`base/source-state-plan.md`의 "이중
바인딩 금지" 절), `Effect`만 애매하게 열어두면 두 프리미티브의 규칙이
갈린다. **`State<Effect>` 재-dispatch와의 상호작용도 이 통일로 같이
닫힌다** — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹으므로,
아래 dedup 시나리오(값이 안 바뀌어 retract가 no-op인데 cleanup만
앞당겨져 Effect가 조용히 죽는 것)가 발생할 경로 자체가 없어진다. 사용자 판정: *"subscribe
한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선
그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."*
- **왜 위험한가**: leaf 바인딩 + `State<Effect>`/`State<Observer>`
@ -164,15 +178,28 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리
실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안
일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
- **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가
아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이
실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것).
`ObserverEffectLeafHandler` 의사코드 기준으론 `process`
`if old ~= v then bindLifetime(...) end`와 클로저의
`if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만,
**`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그
cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다.
M3 착수 전 확인할 것.
- **✅ [해소, 2026-08-21 — 구현 전 QA 4라운드 `E-10` 결론을 5라운드 `EF-3`에서
실제로 반영] dedup 경로의 process/retract 대칭은 성립한다.** 4라운드에
결론이 났는데 **이 문서에 반영이 누락돼 "미해결"로 남아 있던 것**을
5라운드가 잡아냈다(그 자체가 followup의 "반영 완료" 표를 신뢰 소스로
쓰면 안 된다는 사례 — 소스는 항상 `base/` 본문).
- **성립하는 이유**: 핸들러가 **이전 값(`old`)을 `Relate`로 직접 들고
있고**, `process``if old ~= v then ... end`와 클로저의
`if nextValue ~= v then ... end` **두 분기 안에서만** bind/unbind가
일어난다. 값이 같으면 retract도 아무것도 안 하고(= `old`를 지우지
않는다) `process`도 조회해서 같으면 그대로 넘어간다 — 양쪽이 같은
비교식을 쓰므로 한쪽만 도는 상태가 안 생긴다. **사용자 서술**(2026-08-20):
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
dedup 되어야하는듯."*
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
`handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
같은 `if` 안에 둘 것.
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
항목이 아니다.
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의
`:Unsubscribe()`는 "미래 재실행만
@ -209,6 +236,81 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
inst-scoped 경로)을 **같이** 쓰는 것뿐.
## ⭐ `Effect(fn, ...deps)` — 여러 의존성을 직접 받는다, `Ref`도 포함 (2026-08-21 구현 전 QA 5라운드 `C-6` 확정)
**갭이 실재했다**: 지금 `Effect``state` 하나만 받고, 여럿을 엮으려면
`:With`로 합쳐 하나의 State로 만들어야 한다. 그런데 **`Ref`는 State가 아니라**
(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서, **오늘은 `Ref`
Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 가 지금은 Ref에
대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로
합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐."*
**확정된 계약**(전부 사용자 확인, 2026-08-21):
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
State/Source면 `Observer`, `Ref``:Callback`. `:With`로 합치지 않는다.
- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를
**lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing
deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다.
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
한 번만 실행한다. **[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 —
게이트도 `Blocker`도 안 쓴다.** 한때 *"`Blocker`의 "`state:Block()` 없이
직접 쓰는" 용례를 그대로 재사용"*이라 적고 정확한 모양을 `Gate` 설계에
걸어뒀는데, `Gate`가 **빈 배치일 땐 통지를 안 하는 것**으로 확정되면서
성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤
`Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가
없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다.
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
`c`가 자기 observer를 **각각 정당하게** 깨워 `fn`이 두 번 돌았다. State 층
dedup으론 안 접힌다 — `b``c`는 서로 다른 노드라 접어줄 **공통 하류가
없고**, 둘 다 §4의 1번 규칙에 정당하게 걸린다(`base/state-epoch-plan.md`).
위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만).
- **확정된 해법**: `EffectHandle``EpochMap`**하나** 들고, 각 내부
Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만
`fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한
파동에 몇 개가 깨우든 첫 번째만 통과한다.
```lua
-- 각 dep의 내부 Observer가 공통으로 거는 클로저
function(self, from)
if handle._installing then return end -- 설치 구간 억제 (아래)
if handle._epochs:Update(from) then
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
end
end
```
**⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
(2026-08-21 커밋 전 `/code-review high` 발견).
- **`Ref` 의존성은 이 맵에 안 낀다** — `Ref``Epoch`가 아니고
`:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번
`fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제
자체가 없다).
- **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드
dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서
위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게
두는 선택지도 있었으나, 접는 비용이 맵 하나뿐이라 채택 안 함.
- 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap`
분리의 직접 발단이었다).
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M3의
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로
확정돼 `Gate`에 안 걸린다.
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)
**과거 미해결이었던 두 질문 모두 확정**:

303
.claude/base/gate-plan.md Normal file
View file

@ -0,0 +1,303 @@
# `Gate` — emit을 가로채는 게이트 노드 (2026-08-21 확정)
**상태**: **표면 확정.** `Blocker`/`Debounce`/`Throttle`이 공유하는 게이팅
메커니즘을 `state:Gate(setup)` **메소드**로 확정했다 — *"Gate 는 따로 프리미티브
없이 `state:Gate( (emit) -> ()->() )` 처럼 선언되고 마치 Compute 처럼
GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다
← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저"
결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다
구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.**
(**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나
그건 `Epoch` 일반화 **이전** 표기다 — 지금 싣는 건 떼어낸 `EpochSet`
스냅샷이고 게이트 노드 자신은 안 싣는다, 바로 아래 ⚠️ 문단이 소스.)
**⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고
`Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안
만들고 State 메소드 하나로 끝낸다**이다 — *"Gate 프리미티브를 만들고 Blocker 가
컴포지션 하는걸 생각했는데, 그럴 필요가 없네요. `:Gate` 는 또 Apply 에서 쓸만한
표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그
이전 시점 표현이니 이 배너 기준으로 읽을 것.
**⚠️ [2026-08-21 반영 완료] emit 페이로드는 `Epoch | EpochSet`이다**
(`EpochSet = { [Epoch]: true }` — **배열이 아니라 집합**,
`base/state-epoch-plan.md` §3) — 하류가
게이트 identity를 한 번도 안 쓰므로 게이트 노드 자체는 안 싣고 **떼어낸
`Epoch` 집합 스냅샷**만 넘긴다(근거 기록은
`reference/epoch-brand-composition.md`). 아래 4번의 기제(흡수 집합, flush 시
스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로다.
**한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류
emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)`
공개 메소드**로 낸다(탑레벨 생성자가 아니라 — 위 배너 참고).
`Blocker`/`Debounce`/`Throttle`이 그 위에 얹히는 서로 다른 정책이 된다.
## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
**게이팅을 먼저 만든다.** (이건 그 결정에 이르게 된 *당시* 상태 서술이다 —
**[2026-08-22] 지금은 `Blocker.luau` 자체가 M2에 있다**, 아래 9번.
**⚠️ 다만 그걸로 순환이 닫힌 건 아니다** — `state:Gate`는 State 메소드이고
`GateNode`/`Blocker`는 State 위에 얹히므로, M2로 옮겨도 M2→M3 참조는
그대로 남는다. 그 사실은 `.claude/question.md` 2번이 별도 미결로 다룬다.)
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로
**당시** 서술은 "M3에서 Blocker를 만들 때"였다 — **[2026-08-22]** 지금은
`Blocker.luau`도 M2다, 아래 9번).
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer
가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가
되면 이를 전혀 못 지키기 때문."* → 게이트가 **emit 경로 자체에 끼어들어야**
하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.
**공개 여부**: 사용자 판단 — *"이 API가 비공개일 이유는 없어보인다."*
내부 배관이 아니라 **공개 표면**으로 낸다. **[2026-08-21]** 다만 그 표면은
탑레벨 프리미티브가 아니라 **State 메소드 `:Gate`** 다(아래 2번).
## 제안된 모양 (사용자 스케치 그대로)
```lua
Gate(function(emit)
-- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
return function()
-- 상류 emit이 도착할 때마다 호출된다.
-- 여기서 `emit()`을 부를지 말지는 정책 마음.
end
end)
```
- `setup(emit) -> onUpstreamEmit` 2단 구조. 바깥 함수는 게이트 인스턴스가
만들어질 때 1회, 반환된 함수는 상류 emit마다.
- `Blocker`는 이 위의 정책 하나가 된다 — "켜져 있으면 `emit()`을 안 부르고
플래그만 세워두고, 꺼질 때 한 번 부른다"(`HasBlockedEmit`이 그 플래그).
- `Debounce`/`Throttle`도 정책 — 타이머를 걸고 창이 끝날 때 `emit()`.
## 아직 안 정한 것 (사용자 판단 필요)
1. **[2026-08-21 해소] 이름 — `Gate`.** 원래 걸림돌은 *"프리미티브 명을
Gater? 뭔가 이상하게 들어간다"*였는데, **탑레벨 프리미티브를 안 만들기로
하면서 문제 자체가 사라졌다** — 이름이 놓이는 자리가 `Blocker()` 같은
생성자가 아니라 `state:Gate(...)` **메소드**이고, 메소드 자리에서 `Gate`
`:With`/`:Compute`/`:Observer`와 나란히 자연스럽다. 노드 타입 이름은
`ComputeNode`와 짝을 맞춘 **`GateNode`**.
2. **[2026-08-21 해소] `:Apply`가 아니라 State의 메소드다 — `state:Gate(setup)`.**
**확정 형태**:
```lua
-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다
```
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
*"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로,
`Debounce{...}` 같은 유저랜드 팩토리가 내부에서 `s:Gate(policy)`를 부르는
형태로 `state:Apply(Debounce{...})` 관용구가 그대로 성립한다.
**사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."*
- **정확한 경계**: `Apply``factory(self)`일 뿐이라(`base/source-state-plan.md`의
"`state:Apply(factory)`" 절) 팩토리가 노드를 만드는 것 **자체는** 금지가
아니다 — 확정 예시의 `capAt(100)``:With` 노드를 만든다. 갈리는 지점은
**누가 프리미티브인가**다: `:With`/`:Compute`처럼 **전파 경로에 새 종류의
노드를 끼우는 것은 State의 메소드**, 그 프리미티브들을 조합한 **유저랜드
팩토리는 `:Apply`**. `Gate`는 전자다.
- **그래서 `Debounce`/`Throttle`은 `:Apply` 그대로 둔다**
`Debounce{...}`가 돌려주는 팩토리가 내부에서 `s:Gate(policy)`를 부르면
되므로 `base/debounce-throttle-plan.md`의 확정 관용구는 안 건드려도 된다.
- **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미
**`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로
확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는
것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다.
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
`Blocker`처럼 **여러 노드가 공유하는 정책**은 공유 상태를 바깥 객체가 들고,
노드별 `emit`만 2단 구조로 받아 등록하면 된다.
3. **[2026-08-21 확정] `Get()`과의 관계 — 값이 아니라 통지만 막는다.**
`Blocker`가 이미 그렇고(`base/blocker-plan.md`의 "`:Get()`엔 영향 없음"),
`Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md`
§4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지
않은 값도 `:Get()`으로는 보인다."**
`base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를
에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기
위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목.
4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — 흡수 집합
스냅샷.** 문제는 실재했다: 확정 `setup``(emit: () -> ()) -> (() -> ())`
양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부
`[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무
원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21
`/code-review high` 발견).
**확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다
개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다):
- `GateNode`가 **자기를 거쳐간 `Epoch` 집합**을 들고 있는다 —
`withheld : { [epoch] : true }`, **weak key**(`EpochMap`과 같은 이유,
`state-epoch-plan.md` §3).
- **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이
오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그
다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고,
안 부르면 집합에 그대로 쌓인다.
- **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와
무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도
평범한 노드처럼 `state-epoch-plan.md` §4의 규칙 1~3을 **먼저** 적용하고,
3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안
들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()`
번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이
`pending`을 세워 **이미 전달한 변경에 대한 유령 trailing emit**이
나간다.
- **⭐⭐ 정책이 받는 `emit`은 "이 값을 내보내라"가 아니라 "쌓인 걸 지금
흘려보내라"(flush)** — 페이로드를 정책이 정하지 않는다. 그래서 배치를
떼어내는 것도 **그 핸들 안**에서 일어난다.
`base/debounce-throttle-plan.md`가 이미 같은 것을 `gate:passThrough()`
("invalid 세팅 + 아래로 1회 전파")로 부르고 있다.
- **전파 페이로드는 `withheld` 자체가 아니라 flush 진입 시점에 떼어낸
스냅샷이다.** **[2026-08-21 정정]** 여기 한때 "전파가 반환된 뒤에
`table.clear`"라고 적었는데 그게 틀렸다 — 전파가 스택에 남아 있는 동안
제어가 같은 게이트로 되돌아오면(하류 Observer가 상류를 `:Set()`,
`debounce-throttle-plan.md``onWindowEnd` 주석이 이미 대비하는 경우)
중첩 flush의 `clear`가 돌아 **바깥 전파의 남은 갈래가 빈 집합**을 받는다.
**모델이 아니라 그 의사코드가 문제였다**`emit()`이 flush인 이상 들어가는
순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
```
local batch = self._withheld
self._withheld = {} -- 새 테이블. clear가 아니다
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
```
하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다.
중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이
중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미
최신이라 규칙 3으로 삼켜진다.
- **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥
통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로
생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가
없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정
규칙은 완전히 같다.
- 하류가 **평범한 노드**면 그냥 `EpochMap:Update(batch)`가 집합을 순회하고,
하나라도 걸리면 **받은 배치를 그대로** 더 아래로 넘긴다
(`state-epoch-plan.md` §4 — 단일이든 집합이든 같은 규칙이다).
- **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어
자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼
있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다**
상류 게이트가 넘기는 배치는 **그 전파에만 쓰이는 일회성 스냅샷**이라,
하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를
내보내게 된다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다:
```
-- 출처가 Epoch 하나면 그 하나를, 배치면 그 배치 전부를 편다
for epoch in unfold(from) do
self._withheld[epoch] = true
end
-- 그 다음 평소대로 정책 실행
```
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
먼저 풀리든 정보가 안 샌다.
- **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
(집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md`
§4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진
리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이
다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에
있으므로 무해하다.
- **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을
*버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며
끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다**
(전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
**단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로
했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
- 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`
다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`
부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
`debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을
둘지, 그건 정책(Debounce)만의 것으로 둘지.
6. **[2026-08-21 정리 — 열린 항목 아님] 재진입.** 여기 한때 *"`onUpstreamEmit`
안에서 같은 게이트의 `emit()`을 재귀적으로 부르는 경우"*라고 적혀 있었는데
**잘못 옮긴 서술이었다**(사용자 지적). `blocker-plan.md`의 "재진입(네스팅)"
절이 말하는 건 **같은 `Blocker` 인스턴스를 중첩해 `On()`/`Off()` 하는 것**
이고, 정책이 `emit()`을 호출하는 것과는 무관하다 — 애초에 정책이 flush를
부르는 건 재귀가 아니라 **평범한 통과 경로**다. 지금 계약은 셋으로 정리된다:
- **끝나지 않는 되먹임은 UB.** `base/dispatch-core-plan.md`의 2026-08-04
확정 원칙 그대로 — *"일반적인 재진입/무한루프는 방어 안 함,
provider/사용자 코드 버그로 간주"*. 게이트가 따로 가드를 두지 않는다.
- **유한한 재진입은 지원한다.** 전파 도중 소비자가 동기적으로 상류를
`:Set()`하는 건 이미 대비된 경우이고(`debounce-throttle-plan.md`의
`onWindowEnd` 주석), 게이트 쪽에서 그걸 안전하게 만드는 장치가 위 4번의
**flush 진입 시 스왑**이다.
- **같은 게이트/`Blocker` 인스턴스를 중첩해 쓰지 않는다**`Blocker`
기존 규칙이 그대로 적용된다(겹치는 배치는 새 인스턴스).
7. **[2026-08-21 해소] `Effect(fn, ...deps)`의 최초 1회 억제는 `Gate`
소비자가 **아니다**.** 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나,
위 8번(빈 배치는 통지 안 함)으로 **성립하지 않는 게 확인됐다** — 설치 구간엔
어떤 `Set`도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다.
`Effect`**자기 내부 플래그로** 설치 중 발화를 누르고 마지막에 한 번
직접 실행하면 되고, 새 메커니즘이 필요 없다. `base/effect-plan.md`의 그
항목에 달려 있던 "⚠️ `Gate` 설계에 딸려 있다"도 같이 해소됐다. 아래는
원 서술:
2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을
걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 **설치 구간 동안 발화를
눌러뒀다가 마지막에 한 번만 실행**해야 한다(`base/effect-plan.md`의 그 절).
`Gate`(또는 `Blocker`의 직접 사용)가 **"설치 구간을 감싸 최초 발화를 한
번으로 접는" 용례까지 커버해야** 한다 — 설계할 때 이 소비자를 같이 볼 것.
8. **[2026-08-21 해소] 소스 없는 emit(빈 배치) — 아무것도 안 한다.**
`/code-review high`가 "정책이 상류 신호와 무관하게 flush를 부르면 빈 배치가
나가 하류가 조용히 삼킨다"를 문제로 제기했고, 에이전트는 "빈 배치 = 무조건
통지"를 권고했다. **사용자 기각**:
> *"빈 배치면 이미 하류로 한번 다 던져서 더 던질게 없다는 의미입니다. 마치
> 두번 흘러들어온 같은 카운트의 emit 과 유사한데요. 그건 전파 안 합니다. …
> 애초에 Gate 는 중간에 emit 을 할 수 있는 핸들을 노출하는, `Source:Emit`
> 같은걸 주는 요소도 아니고, 쌓아두다 뒤로 넘기는건데, 쌓아둔것 자체가
> 없는데 뒤로 넘긴다는건 이상합니다."*
**확정: `next(withheld) == nil`이면 통지 자체를 안 한다.** 그래야 다른
State와 동작이 같아진다 — 빈 배치를 흘리는 건 표면적으로 **State 중간에
`Source:Emit`을 추가하는 격**이고, `Gate`는 그런 요소가 아니다.
- **새 규칙이 아니라 기존 계약의 일반화다.** `base/blocker-plan.md`는 이미
*"이미 `HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함
(idempotent)"*이라고 확정해뒀다 — 즉 `HasBlockedEmit`
`next(withheld) ~= nil`의 특수형이다. `Debounce`/`Throttle`도
`if pending`일 때만 `passThrough()`를 부른다
(`base/debounce-throttle-plan.md`의 `onWindowEnd`).
- **3번 항목의 "`emit`은 언제든 부를 수 있다"는 그대로 유효하다** — 언제
불러도 되지만, 쌓인 게 없으면 그 호출은 no-op이라는 뜻으로 읽는다.
- **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가
아니다** — 아래 7번 참고.
9. **M2 범위 — [2026-08-22 해소] `Gate``Blocker` 둘 다 M2다.**
`Dispatch.drive`의 배치 등록이 실제로 쓰는 건
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한
그 세 메서드가 도는 형태까지는 M2에 필요한데, 그렇다고 `Blocker`
M3에 남겨두면 M2가 다시 뒤 마일스톤을 참조하게 된다. 같은 날 `ROADMAP.md`
전반 점검에서 `EpochMap.luau`/`GateNode`/`Blocker.luau` **체크박스가 전부
M2로 이동**했다(사용자 판단). `Blocker``GateNode`를 다시 만들지 말고
그 위의 정책으로 얹을 것.
**⚠️ 이 이동이 M2↔M3 의존을 없애지는 않는다** — 셋 다 State 위에
얹히므로 M2는 여전히 `Source.luau`/`State.luau`를 필요로 한다. 마일스톤
경계를 어떻게 그을지는 `.claude/question.md` 2번(사용자 회신 대기).
## 관련 문서
- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상).
- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이
`Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅"
절이 M2가 실제로 요구하는 표면.
- `ROADMAP.md` M2/M3.

View file

@ -134,8 +134,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
### `OnDestroyed(fn)`
`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, state?)`가
`state` 생략 시 "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히
`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, ...deps)`가
**deps 생략 시** "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히
1회 호출되는 cleanup"이라는 기존 계약(`base/effect-plan.md` 28행)을
그대로 재사용 — 다만 여기서는 **설치 단계에서 실행되는 함수가 `fn`
자신이 아니라 `function() return fn end`라는 래퍼**라는 점에 주의.
@ -210,7 +210,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
> 이르게 된 조사/논거를 그대로 보존한 것.
`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절이 계약하는
두 패스(배열 파트 먼저, 해시 파트 나중) 기준으로 현재 base가 제공하는
본체 루프의 배열→해시 순서 계약(**[2026-08-22 용어]** 이 문서가 쓰는
"두 패스"는 그 본체 루프의 옛 이름이다 — 구현은 단일 일반화 `for`,
`base/ref-plan.md`의 같은 용어 각주와 `base/dispatch-core-plan.md`
`F-4-1` 정정 문단) 기준으로 현재 base가 제공하는
훅들의 타이밍을 정리하면:
| 훅 | 시점 |
@ -243,10 +246,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
걸리는 시점"을 `PreRef`와 동일하게 맞춤, 실제 콜백 fire와 시점이
갈리는 건 아래 항목뿐).
- **`ProcessedPostRefHandler``ProcessedPreRefHandler`와 완전히
대칭**: 정상 두 패스`ProcessedPostRef`를 매치해
대칭**: 정상 본체 루프`ProcessedPostRef`를 매치해
`setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를
반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는
소진 전 원본 값이 정상 두 패스의 매치 대상이어야 한다"고
소진 전 원본 값이 정상 본체 루프의 매치 대상이어야 한다"고
잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안
생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서
`isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도
@ -284,6 +287,34 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
것보다는 여전히 먼저** 불림. 이 캐비엇의 정본 서술은 `base/ref-plan.md`
"`PostRef`" 절 "보장 범위" 항목.
**[풀어쓰기, 2026-08-20 구현 전 QA 4라운드 `LH-8`]** 위 두 문단이 너무
압축돼 있어 그것만 읽고는 판단이 안 된다는 지적이 있어서, 같은 내용을
코드로 다시 적는다. `Frame { Frame { TextLabel {} , OnRendered(fn) } }`에서
안쪽 `Frame``fn`이 불릴 때:
```
바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함
└ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중
├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음
└ OnRendered(fn) ← ③ fn이 여기서 불림
(프로퍼티/이벤트) ← ② 이미 전부 세팅됨
```
- **①② = "자기 아래"** — 안쪽 `Frame`의 자식(그리고 그 서브트리 전체)과
자기 프로퍼티/이벤트. `fn`이 불릴 때 **전부 끝나 있다.**
- **⑤ = "자기 위"** — 안쪽 `Frame`이 바깥 `Frame`의 자식이 되는 일.
Lua 표현식 평가 순서상 **안쪽 `Frame{...}` 호출이 완전히 끝나야** 바깥
`Frame`의 props 테이블이 완성되므로, `fn`이 불리는 시점엔 바깥 `Frame`
아직 존재하지도 않는다. 조상 체인 전체가 마찬가지다.
- **그래서 "화면에 올라간 뒤"가 아니다** — 화면에 올라가려면 루트까지의
조상 체인이 다 이어져야 하는데 그건 ⑤ 이후 일이다. React
`componentDidMount`(DOM 삽입 **후**)를 기대하면 어긋난다.
- **원래 뭐가 헷갈렸나**: 처음엔 경계를 "이 인스턴스의 프로퍼티만이냐(a),
자식 서브트리까지냐(b)"로 놓고 "(a) 메커니즘으론 (b)를 못 준다"고
판단했는데, 배열 파트 루프가 각 자식을 **동기적으로 끝내고** 넘어가므로
①이 공짜로 따라온다 — (a)/(b)는 애초에 갈리는 지점이 아니었고, 실제로
갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다.
**착수 시점 선택지 — (a) 확정.** 원래 (a)/(b)/(c) 셋을 열어뒀었고
((b)는 일반 `Ref`로 근사, (c)는 계속 스코프 아웃), 사용자가 **(a)**
(위 `PostRef` 스케치대로 두 패스 뒤 `postRefList` 소비)를 선택함.
@ -336,6 +367,26 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
기대하는 것과 미묘하게 다른 시점"**을 가리키므로, 문서화 시 위 ①
절의 ⚠️ 항목을 반드시 같이 노출할 것(이름을 바꾸는 대신 문서로
대응하기로 한 것 — 이름의 친숙함이 주는 이득이 더 크다는 판단).
**[백로그 신설, 2026-08-20 구현 전 QA 4라운드]** 진짜
`componentDidMount`(= 조상 체인까지 이어져 화면에 실제로 올라간 뒤)에
해당하는 훅은 **지금 만들지 않되 백로그로 남긴다.** 사용자 메모:
*"OnRendered 라는 이름이 componentDidMount 같은거 구현 가능하면 좋긴
하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가
하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을
이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고,
나중에 필요하다는 의견이 나오면 재생각 해볼 예정."*
- **쓸모 있는 시나리오**: "화면에 그려지기 전에 애니메이션이 시작돼버리는"
것을 막는 것 — 진입 애니메이션(`base/tween-plan.md`의 `initValue`,
루트 `HUMAN_TODO.md` 10번)과 맞닿는 자리다.
- **왜 지금 안 만드는가**: `PostRef`와 달리 **자기 위**를 알아야 하므로
`Dispatch.drive` 한 번의 콜스택 안에서 표현할 수가 없고(부모의 drive가
자식을 붙인 뒤에야 알 수 있음), 별도 전파 경로가 필요하다 — "공짜"가
전혀 아니다. 그런데 구체적 사용 사례가 아직 안 보이므로
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고
구조를 복잡하게 만들지 않는다" 원칙대로 보류.
- **요구가 나오면 재검토** — 그때 `OnRendered`와 이름을 어떻게 가를지도
같이 정해야 한다(지금 `OnRendered`가 이미 그 이름을 점유 중이라
혼동 위험이 있음).
## 패키지 배치

View file

@ -40,8 +40,32 @@ end
```
연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를
바로 지우거나 재구성하지 않음. quad-v2도 이 모양을 그대로 채택: 라이프타임
홀더는 "내가 아직 살아있게 하는 뒷받침 참조"가 nil인지만 확인하면 됨.
바로 지우거나 재구성하지 않음.
> **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `LP-1`] quad는 이 rbvm 코드를
> 채택하지 않는다 — quad에서 `Connected`는 "계산된 속성"이 아니라 그냥 Roblox
> `RBXScriptConnection`의 네이티브 필드다.** 옛 서술("quad-v2도 이 모양을 그대로
> 채택: 라이프타임 홀더는 '내가 아직 살아있게 하는 뒷받침 참조'가 nil인지만
> 확인하면 됨")은 rbvm의 프록시 계층 사정을 quad에 잘못 옮긴 것이었다. 사용자
> 판정: *"Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시
> 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로
> 참조를 만들 이유도 없음."*
>
> **quad의 실제 판정은 두 상태뿐**(`isBoundAlive`, 아래 "(1)" 코드 블록):
> 1. **gcconn 자체가 없음** — 아직 바인드 안 됐거나, 이미 GC돼서 weak 릴레이션
> 항목이 비워진 상태. `BindData:GetWeak(value, "gcconn")``nil`.
> 2. **gcconn은 있는데 `.Connected == false`**`inst`가 방금 Destroy됐고
> 아직 GC는 안 된 구간. 엔진이 Destroy 시점에 모든 커넥션을 끊어주므로
> quad가 아무것도 안 해도 이 값이 저절로 뒤집힌다.
>
> **quad가 `Signal = nil`처럼 직접 참조를 끊는 자리는 없다** — 유일하게 "직접
> 끊는" 동작인 `unbindLifetime``gchold[value] = nil``BindData` 항목 제거일
> 뿐 커넥션 자체를 만지지 않는다. rbvm에서 실제로 가져오는 건 **"gcconn 트릭으로
> Instance 수명에 값을 매단다"는 관용구 하나**이고, `Connected`를 계산 속성으로
> 만드는 구현은 가져오지 않는다.
rbvm에서 실제로 재사용하는 부분은 아래 "(0)"/"(1)" 절의 gcconn/gchold
관용구이고, 위 `__index` 계산 속성 코드는 **참고용 원본 인용**으로만 남긴다.
### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측
@ -50,6 +74,14 @@ rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로
플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀.
quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일.
**[구체화, 2026-08-20 구현 전 QA 4라운드 `LP-2`] "예상보다 적을 수 있다"가 아니라
지금은 정확히 한 곳뿐이다 — `Effect`.** 아래 "2026-08-04 검증 라운드에서 보강된
내용" 절이 "이 훅을 쓰는 지점이 예상보다 적을 수 있다"고만 열어뒀던 걸 사용자가
확정해줌(*"당장은 Effect 뿐임"*). `Effect`의 leaf-death cleanup(`base/effect-plan.md`)이
이 훅을 쓰는 유일한 소비자이고, 그 위의 슈가 `OnDestroyed`(`base/lifecycle-hooks-plan.md`)도
결국 같은 경로다. 나머지(Observer 게이팅, Tag/Attribute 정리, Tween 취소)는 전부
gcconn `Connected` 판정이나 엔진 자체 정리로 커버되어 이 훅을 안 씀.
### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager)
rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를
@ -324,6 +356,21 @@ function canExecute(value)
end
```
#### (1-1) ✅ [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`] 첫 인자는 **항상 물리 Instance**다
여기 있던 절(4라운드 `D-56`)은 *"`Dispatch.setLength`의 `ownerKey`가 Slot일 수
있으니 백엔드의 `bindLifetime`이 비-Instance 첫 인자를 핸들링하고,
`isBoundAlive`에 세 번째 분기를 둬야 한다"*였다. **5라운드에서 뒤집혔다**
`setLength`가 **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 따로 받도록**
바뀌면서, 앵커는 언제나 물리 target이 된다(모든 호출부가 이미 그 값을 알고
있다). 그래서:
- **`bindLifetime`/`unbindLifetime`/`isBoundAlive`는 예전처럼 물리 Instance만
상대한다** — 백엔드에 추가 요구사항이 없고, `isBoundAlive`의 **세 번째
분기도 필요 없다**(형태 미정인 채 열려 있던 항목이 이걸로 닫혔다).
- 근거와 트레이싱은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절
바로 뒤 문단, 역전 전 원문은 `archive/bindlifetime-slot-owner-reversed.md`.
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다**`gchold[value]`
@ -513,9 +560,16 @@ quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서
정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서
이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 `[Event
"Destroying"]`을 직접 바인드해서 처리하면 되는 구조라, 라이브러리가 강제로
제공할 필요도 없음.
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 **`Effect`(그리고 그 슈가
`OnDestroyed`)를 쓰면 되는 구조**라, 라이브러리가 강제로 제공할 필요도 없음.
**[정정, 2026-08-20 구현 전 QA 4라운드 `LP-4`]** 옛 서술은 여기 정상 경로를
`[Event "Destroying"]`을 직접 바인드하는 것으로 적었는데, 그건 사용자가 엔진
이벤트를 손으로 다루라는 뜻이 되어 quad가 이미 제공하는 프리미티브를 우회하는
안내였다 — 사용자 판정: *"Effect 임. 그리고 그 슈거인 OnDestroyed 존재"*.
`Effect(fn)`이 반환하는 cleanup이 leaf 사망 시 정확히 1회 불린다는 계약
(`base/effect-plan.md`)이 정확히 이 용도이고, `OnDestroyed(fn)`은 그걸 감싼
순수 팩토리다(`base/lifecycle-hooks-plan.md`). `[Event "Destroying"]`을 직접
바인드하는 것도 물론 막히진 않지만 권장 경로가 아니다.
## 이름: `cleanup``retract`

View file

@ -39,6 +39,70 @@ cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드
아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`
"필수 관용구" 절 참고).
### flatten의 정확한 형태 — in-place 뮤테이션 + `ProcessedModifier` 소진 (2026-08-20 구현 전 QA 4라운드 `M-2` 확정)
**여기 있던 갭**: 위 문단은 "Modifier 항목의 필드를 뽑아 merge한다"고만 적고
**뽑아낸 그 배열 자리를 어떻게 하는지를 한 번도 안 적었다.** 그냥 지우면
배열에 구멍이 생기고, 그건 `PreRef` pre-pass가 `ProcessedPreRef`로 소진해야
했던 바로 그 이유(구멍 하나로 테이블 전체가 해시 취급이 되어 배열 파트
순서 보장을 잃음, `base/ref-plan.md`)에 정확히 걸린다.
**확정(사용자 판정)**: `Pre`/`PostRef`와 **완전히 같은 방식** — 전용 센티널
`ProcessedModifier`로 그 자리를 소진하고, 전담 nop Handler
`ProcessedModifierHandler`가 정상 `Dispatch.process` 경로에서 캐치해
`setOffsetSource(None)`/`setLength(0)`을 등록한다. **새 규칙이 하나도 안
늘어난다.**
**그리고 flatten은 새 테이블을 만들지 않는다 — 입력을 제자리에서 뮤테이션한다**
(사용자 판정: *"flatten은 클론을 할 필요도 없음. derive 에 들어갈 것 자체가
그 자체로 소비되는 테이블이라 그 이후 사용하는 경우가 존재하지 않고, 새
테이블을 만드는 비용 자체를 지불할 필요가 없음"*). props 테이블 리터럴은
그 호출 한 번을 위해 만들어져 소비되고 끝이므로 원본 보존 의무가 없고,
`Pre`/`PostRef` 소진이 이미 `flattened`를 제자리에서 갈아치우는 것과 같은
취급이다.
```lua
-- 사용자 제시 형태(반복 방향만 아래 ⚠️대로 정정)
function flatten(input)
for i = #input, 1, -1 do -- ⚠️ 역순 — 아래 참고
local value = input[i]
if not isModifier(value) then
continue
end
input[i] = ProcessedModifier -- 구멍 대신 센티널로 소진
for key, modValue in value... do
if input[key] ~= nil then
-- 이미 누가 차지한 자리는 건드리지 않음.
-- 언셋은 `None`(실재값)이므로 `~= nil` 확인 하나로 충분 —
-- 인라인 `None`도 "이미 차지함"으로 잡혀 modifier를 이김.
continue
end
input[key] = modValue
end
end
return input
end
```
- **왜 `~= nil` 하나로 인라인 우선이 성립하나**: 인라인 해시 키는 flatten이
돌기 **전에 이미 테이블에 들어 있다**. 그래서 "이미 값이 있으면 건너뛴다"가
곧 2번 절의 "인라인 키는 modifier보다 무조건 우선" 규칙이 된다 — 별도
분기가 필요 없음. 명시적 unset(`None`)도 실재값이라 같은 검사에 잡힌다.
- **⚠️ 반복은 반드시 역순(`#input` → `1`)이어야 한다.** "이미 있으면
건너뛴다"는 **먼저 쓴 쪽이 이기는** 규칙이라, 정방향으로 돌면 배열
**앞쪽** modifier가 이겨 2번 절의 "배열 순서상 **나중** modifier가 우선"과
정반대가 된다. 역순으로 돌면 마지막 modifier가 먼저 써서 이긴다 —
인라인 우선은 어느 방향이든 그대로 성립(인라인은 루프 시작 전에 이미
들어 있으므로).
- **배열 파트를 훑으며 해시 키를 같이 쓰는 것은 안전** — 숫자 `for`의 상한이
루프 진입 시 한 번만 평가되고, 해시 파트 추가는 그 순회에 영향을 주지
않는다.
- **`ProcessedModifier`의 공개 표면 위치**는 `Detach`/`None`과 같은 판단을
따른다 — 다만 이건 사용자가 볼 일이 전혀 없는 **내부 센티널**이라
최상위로 재노출할 이유가 없어 보인다(`ProcessedPreRef`/`ProcessedPostRef`가
그렇듯). 구현 시 그 둘과 같은 자리에 둘 것.
관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시
해야 하는지/clone이 필요한지는 별개 문제였는데, **[2026-08-14 세션] 그
재바인드 기능 자체가 기각**되어 질문이 없어짐
@ -84,6 +148,20 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라
**디스패치 단계**에서 풀린다:
- **⚠️ [명시 추가, 2026-08-20 구현 전 QA 4라운드 `M-5`] `None`만이 명시적
unsetter다 — `mod:TextColor3(nil)`은 "지운다"가 아니라 "그 필드가 아예 없는
새 Modifier를 만든다"이다.** 사용자 지적: *"mod:TextColor3(nil) mod 그룹에서
제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임."* 둘의 관측 가능한
차이는 **`Overridden`/flatten에서 갈린다**:
- `mod:TextColor3(nil)` → 그 필드가 **없는** Modifier. 나중에
`Modifier.Overridden(other, mod)`로 합치면 `other``TextColor3`가 **그대로
살아남는다**(덮어쓸 값 자체가 없으므로).
- `mod:TextColor3(None)` → 그 필드가 **`None`이라는 실재값으로 채워진**
Modifier. 같은 `Overridden`에서 `other`의 값을 **이긴 뒤**, 디스패치 단계의
`NoneHandler`가 실제로 지운다.
- `:Peek(key)`도 이 둘을 구별해서 돌려준다(`nil` vs `None`) — 위
"`:Peek`의 반환 타입" 항목이 `T | State<T> | None | nil`인 이유가 정확히
이것. 구현은 이미 이렇게 되겠지만 **문서화에서 이 구분을 반드시 짚을 것**.
- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.**
Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw
값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이
@ -152,7 +230,15 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴,
이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).**
**[관계 명시, 2026-08-20 구현 전 QA 4라운드 `M-9`] 다만 아래 9번 절의
`:Peek(key)`가 사실상 getter 역할을 한다** — 사용자 지적(*"사실 Peek 가
게터라고 봐도 되긴 함"*). 이 절이 "안 만든다"고 한 건 **setter와 짝을 이루는
필드별 getter**(`:FontSize()` 무인자 호출로 값을 꺼내는 모양, 또는 dot-access
겸용)이고, `:Peek`은 그것과 달리 **키를 인자로 받는 단일 범용 접근자**이며
설계 동기도 다르다(setter 인체공학이 아니라 `Apply` 팩토리가 현재 상태를
읽어야 해서). 두 서술이 모순이 아니라는 걸 문서화 시 같이 밝힐 것 —
"getter가 없다"만 읽으면 `:Peek`의 존재와 부딪히는 것처럼 보인다. 애초에 getter가
필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로
다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)`
변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/

View file

@ -253,8 +253,10 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
게 기본이어야 함. 켜는 건 명시적 opt-in.
- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑"
절과 같이 정할 것.
- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도
같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임).
- **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`
이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고
스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다.
상세는 `base/dispatch-core-plan.md`의 "우선순위 동률/매치 실패 처리" 절.
## Quad는 스크립트인가 라이브러리인가 (확정, 참고용)
@ -307,8 +309,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고
실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로
주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에
엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/
`setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기
Tag/Attribute용 엔진 op이 추가됨** — `addTag(inst,{string})`/
`removeTag(inst,{string})`/`setAttribute(inst,name,v)`(`v==nil`이면 삭제).
**[2026-08-22 정정] 여기 "엔진 op 3개"라고 세어놨으나 그 셋이 전부가
아니다** — 이후 `native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`이
같은 팩토리 뮤테이션 경로에 추가됐다. **주입 op 전체 목록의 소스는
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
세지 않는다. `Tag`/`Attribute`의 부기
알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막
한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가
소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/
@ -324,8 +331,11 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
"base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반
원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인
이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가
뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).**
즉 이 경로에서 백엔드 팩토리가 뮤테이션으로 채우는 건 **핸들러가 아니라
엔진 op 쪽**이다(**[2026-08-22 정정]** 여기 "`addTag`/`removeTag`/
`setAttribute`**만**"이라고 셋으로 못박혀 있었으나 위와 같은 이유로
그 셋이 전부가 아니다). 아직 아무 팩토리도 안 채운 슬롯의
기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한
no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는

View file

@ -150,10 +150,22 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을
서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능
(`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백,
`nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`
아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는
등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그
자리를 되찾아 쓸 수 있음.
`nil` → 빈 슬롯이라 스킵). **[정정, 2026-08-20 구현 전 QA 4라운드 `R-11`]
새 콜백/대기자 등록은 그냥 `table.insert`를 쓴다** — 옛 서술은 "`table.insert`가
아니라 비어있는 첫 슬롯을 선형 탐색해 재사용하는 등록 함수"였는데, 사용자
판정으로 뒤집힘: *"table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에
넣어주기에 table.insert 가 맞음 … None 으로 바꾸면 무한정 불어나지만,
nil이면 그렇지 않음."*
- 즉 소진으로 생긴 구멍을 `table.insert`가 알아서 되찾아 쓰므로, 별도
선형 탐색 등록 함수를 손으로 만들 이유가 없다. 아래 "왜 `None`이 아니라
`nil`인가" 절이 들던 "`table.insert`의 `#t`가 구멍 있는 테이블에서
미정의"라는 회피 근거도 이 배열에 대해서는 실무상 성립하지 않는 것으로
정정 — 그 절의 결론(`nil` 소진)은 그대로이고 **우회 방법만 단순해짐**.
- **⚠️ 실측 대상(M0/M8)**: 구멍 있는 테이블에서 `#t`가 반환하는 border는
Lua 명세상 "어떤 border든" 이므로, "항상 첫 `nil` 자리"가 Luau 구현에서
실제로 그렇게 나오는지는 스파이크로 확인할 것 — `luau-test`에 등록
함수/소진을 반복하는 케이스를 추가. 결과가 다르면 이 항목만 되돌리면
되고(옛 선형 탐색 버전), 나머지 설계엔 영향이 없다.
- **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정)
— 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안
맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는
@ -375,7 +387,7 @@ Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 ch
**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두
타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`
"확정된 디스패치 모델" 절에
새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
새로 추가된 순서 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤
어디에 놓느냐가
이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기
@ -430,12 +442,22 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저
마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가
동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서
base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의
base 드라이버는 위 본체 루프(배열→해시 순서 계약)를 돌기 **전에** 별도의
작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고,
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼
패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 본체
루프로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
범위가 좁고, "확정된 디스패치 모델" 절의 배열→해시 순서 계약과 별개로 그
앞에 얹히는 것.
**⚠️ [2026-08-22 용어] 이 문서가 여러 곳에서 쓰는 "두 패스"는 이
본체 루프의 옛 이름이다.** `Dispatch.drive`의 실제 구현은 **단일 일반화
`for` + `type(k) == "number"` 분기** 한 번이고(`F-4-1`,
`base/dispatch-core-plan.md`), "배열 파트 전체가 해시 파트보다 먼저"는
그 루프가 지키는 **계약**이지 루프를 두 번 돈다는 뜻이 아니다. 아래
"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은 **시점** 표기는 그대로
유효하다 — pre-pass와 `postRefList` 소비 루프가 본체 루프의 앞뒤에
붙는다는 뜻이고, 그건 이번 정정과 무관하다. **다만 "두 패스로
처리한다"처럼 구현을 단정하는 용법은 폐기**다.
- **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션 확정, 2026-08-14
아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로
보장.** 같은 인스턴스에 `PreRef`가 여럿 있으면, 이 pre-pass는 위
@ -489,10 +511,10 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음
(2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를
분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler`
참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스
참고. (2) 그 다음에야 비로소 평소의 배열→해시 본체 루프
**같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은
**정상 `Dispatch.process` 경로를 그대로 탄다**(아래
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 본체
루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤
Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정,
@ -544,7 +566,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
- **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`
`Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는
전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에
`Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가
`Dispatch.process` 자체를 절대 안 탄다(본체 루프가
`if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number`
조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데,
리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State<Slot|None> }`
@ -582,8 +604,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
문서화까지 검토할 것.
- **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신,
새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).**
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를
도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 순서로
도는 함수로 확정돼 있으므로(**[2026-08-22 정정]** 여기 "두 패스를 도는
함수"라고 적혀 있었으나 그 순서는 **계약**이고 구현은 단일 일반화
`for`다 — `base/dispatch-core-plan.md``F-4-1` 정정 문단),
그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst,
flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미
다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서
@ -628,7 +653,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
`PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정,
2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**,
위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만
매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔
매치)에는 다시 노출되지 않게** 하므로(정상 본체 루프 스캔 자체엔
`ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이
아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로
매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로
@ -660,7 +685,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서
즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인
PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드"
Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는
Handler(정상 본체 루프에서 매치)와는 별개 코드 경로 — 이 가드는
pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과.
- **관용구**: `Slot:List``updateFn`처럼 반복 호출되는 자리에서
`PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에
@ -781,7 +806,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
갈리는 건 아래 3번뿐).
- `postRefList`**`Relate` 같은 별도 저장소가 아님** — 이 함수
콜스택 안에서만 살면 되므로 그냥 로컬 테이블.
2. **정상 두 패스**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`
2. **정상 본체 루프**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`
소진된 슬롯은 `ProcessedPreRef`**완전히 대칭적으로** 정상
`Dispatch.process` 경로를 타고 아래 전담 Handler에 매치됨.
3. **두 패스가 끝난 뒤 `Dispatch.drive``postRefList`를 순회하며 각

File diff suppressed because it is too large Load diff

View file

@ -94,6 +94,18 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에
검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/
캐싱 단계 자체가 사라짐).
- **⭐ [2026-08-21 확정] `Source``Epoch` 인터페이스도 같은 방식으로
구조적으로 만족한다** — `type Epoch = { Revision: number }`이고, `:Set()`/
`:Emit()`이 그 `Revision`**갱신한다**(직전과 다른 값으로 — `Epoch`
계약이 요구하는 건 "다르다"뿐이라 방향은 계약이 아니고, 확정된 연산
`bit32.bnot(-rev)`는 실제로 **감소**한다). **`Revision`은 공개 필드여야
한다** — 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다(사용자:
*"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*). `Store`와 달리
`Source`는 키가 사용자 것이 아니라 예약 이름이 늘어도 충돌하지 않는다.
런타임 판별은 `SourceBrand`**동시에** `EpochBrand`에 등록하는 것으로
하고(다중 태깅, `base/brand-plan.md`), `State`를 만족하는 관계와 달리 이건
포함 관계가 아니라 **다른 축의 계약**이라 predicate 합성으로는 표현되지
않는다. 계약 전량은 `base/state-epoch-plan.md` §2가 소스.
- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는
다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류
매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임
@ -187,23 +199,26 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
- **⭐ emit(무효화 신호)은 구독자에게 *항상* 전파된다 — 자기 `invalid`
상태와 무관.** 신호를 받은 State는 자기 `invalid` 플래그를 세우고,
**이미 `invalid`였더라도 그대로 아래로 전파한다.**
- **`invalid` 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** —
전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가
재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐**
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고,
`base/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
들어감(설계 확정, 구현은 아직). **평범한 State는 절대 신호를 삼키지
않는다.**
- **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그
아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나
**틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는
`Observer`(아래 "`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가
**한 번 울고 영구히 침묵**하게 됨. 원문·역전 근거·영향 범위는
`archive/invalidate-dedup-propagation-reversed.md` 참고.
- **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 `Epoch` 리비전으로 바뀜]
emit은 자기 `invalid` 상태와 **무관하게** 전파되고, 접히는 것은 오직
**같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때**뿐이다.** 판정 규칙
전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과
어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건
서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`.
- **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는
표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다.
- **⚠️ `invalid`로 전파를 접는 것은 지금도 금지다.** 2026-08-14에 그
방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래
"`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가 **한 번 울고
영구히 침묵**하기 때문이고, 그 실패 모드는 지금도 유효하다(원문은
`archive/invalidate-dedup-propagation-reversed.md`). 리비전 비교엔 그
모드가 없다 — 매 `Set`이 **새 리비전**이라 항상 통과하고, 접히는 건
**다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째**뿐이다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 위 dedup 외엔 명시적인 게이트
요소뿐** — `state:Gate(setup)`(`base/gate-plan.md`)와 그 위에 얹히는
`Blocker`(`base/blocker-plan.md`), `base/debounce-throttle-plan.md`의 시간
기반 정책. **평범한 State는 그 외의 이유로 신호를 삼키지 않는다.**
- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 —
"필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/
생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한
@ -213,7 +228,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀
필요 없음.
- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함
("emit 필요 여부" 열린 질문은 이걸로 해소). 항상 전파해도 부담이 작은
("emit 필요 여부" 열린 질문은 이걸로 해소). 전파가 잦아도 부담이 작은
이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님.
### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화)
@ -231,18 +246,23 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
그대로 반환**.
즉 **중복 재계산을 막는 주체는 pull-recompute + 캐시**이지, 전파를
중간에 끊는 게 아니다(`base/architecture.md`의 전파 모델 요약과 같은
`invalid` 끊는 게 아니다(`base/architecture.md`의 전파 모델 요약과 같은
이야기). Vide가 `todo.md`에 미해결로 남긴 "다이아몬드 중복 **재평가**"는
이 캐시 구조로 풀리고, quad가 추가로 접지 않는 것은 **중복 *통지***뿐이다
`d` 아래의 `Observer`는 한 사이클에 두 번 울 수 있고, 그건 의도된
동작이다(통지를 접으려면 `Blocker` 같은 명시적 게이트를 쓸 것).
이 캐시 구조로 풀린다.
- **순회 비용이 실측에서 문제가 되면** 그때 "**한 번의 전파 파동
안에서만** 같은 노드를 두 번 방문하지 않는다"는 최적화를 넣을 수 있음
(방문 집합/에포크 카운터). 단 그건 **파동 단위**여야 하고, 지금처럼
시간에 걸쳐 유지되는 `invalid` 플래그로 하면 안 됨 — 그게 위 정정의
핵심. 이 최적화는 의미론에 안 보이는 순수 구현 사항이라 지금 결정할
필요 없음.
**⭐ [2026-08-21 역전] 중복 *통지*도 이제 접힌다.** 여기엔 원래 "quad가 추가로
접지 않는 것은 중복 통지뿐이고, `d` 아래 `Observer`가 한 사이클에 두 번 우는
것은 의도된 동작"이라고 적혀 있었으나, `base/state-epoch-plan.md` 채택으로
**두 번째 신호는 삼켜진다** — 그 신호가 나르는 리비전을 `d`가 이미 봤기
때문이다. 그래서 위 1단계는 "`d`가 두 번 받는다"가 아니라 **"두 번째는
`d`에서 멈춘다"**가 된다. 역전 원문과 이게 2026-08-14 역전을 되돌린 게 아닌
이유는 `archive/always-propagate-no-dedup-superseded.md`.
**부수로 같이 고쳐진 것 — 섞인 값(glitch).** 옛 모델에선 전파가 DFS라
`b` 가지가 먼저 끝까지 내려가고, 그 아래 Observer가 `d:Get()`을 부르면 `c`
아직 신호를 못 받아 **옛 캐시를 반환**해 `d``(b_new, c_old)`를 캐시했다.
리비전 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 —
상세와 재현 시나리오는 `base/state-epoch-plan.md` §1.
**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)**
@ -369,8 +389,11 @@ lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy
`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아
Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는
store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브
`base/blocker-plan.md`가 다룸(State 개발과 같은
마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던
`base/blocker-plan.md`가 다룸(**[2026-08-22 정정]** 여기 "State 개발과 같은
마일스톤, `ROADMAP.md` M3에서 함께 구현"이라 적혀 있었으나 `Blocker.luau`
**M2**로 이동했고, 바닥부터 짜는 게 아니라 공용 `GateNode`
(`base/gate-plan.md`) 위의 정책이다 — 마일스톤 소속의 소스는
`blocker-plan.md`의 정정 배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던
초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 —
`archive/batch-rejected.md` 참고.
@ -912,6 +935,26 @@ retract/Destroy되면 자동으로 정리됨.
두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를
따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도
이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨.
- **⭐ [2026-08-21 확정] `fn`의 시그니처는
`fn(self, from: (Epoch | EpochSet)?)``:Compute`와 같은 모양이다.** 사용자: *"Compute 와 유사하게 나올 수
있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."*
위 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절과 같은 결이다.
- `self`는 이 Observer가 붙은 State의 **lazy 핸들**(값이 아님).
- `from`은 **이 통지의 출처**다 — `Epoch` 하나이거나 `Epoch`들의 **집합**
(`{[Epoch]: true}`, 게이트가 유보를 풀며 떼어낸 스냅샷). 값도 리비전도
안 실린다. 분기는 `isEpoch`로. 계약은 `base/state-epoch-plan.md` §5가 소스.
- **⚠️ [2026-08-22 신설] 등록 시점의 즉시 1회 실행에는 `from`이 없다** —
그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `from`
**옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high`
발견 — 한때 non-optional로 적혀 있었다). `fn``from`을 실제로 쓰는
소비자라면 `nil`을 "설치 발화"로 분기해야 한다.
- **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라
**핸들과 메타데이터**뿐이다.
- 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄
`fn`이 없으니 인자 얘기가 아예 안 나온다.
- **`from`을 실제로 쓰는 첫 소비자는 `Effect`다** — 자기 `EpochMap`
들고 각 내부 Observer가 그걸 `Update`해서 다중 의존성 중복 발화를
접는다(`base/effect-plan.md`의 "`Effect(fn, ...deps)`" 절).
- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은
무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위
"전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가
@ -921,15 +964,18 @@ retract/Destroy되면 자동으로 정리됨.
`:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의
`noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()`
호출 여부를 작성자가 직접 결정하게 열어둔 것.
- **⚠️ 이 허용이 전파 모델의 "emit은 항상 전파된다"에 의존한다
(2026-08-14 명시).** `fn``:Get()`을 안 하면 상류 State는 계속
`invalid`로 남는데, 만약 "이미 `invalid`면 전파를 멈춘다"는 규칙이
있으면 **이 Observer는 두 번째 변경부터 영원히 안 울림**. 실제로
2026-08-14 이전까지 위 "전파 모델 확정" 절에 그런 문장이 있었고,
이 계약과 정면 충돌하는 상태로 방치돼 있었음
(`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은
같이 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면
반드시 이 항목부터 확인할 것.
- **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, 2026-08-21 갱신).**
의존하는 명제는 **"`Set` 한 번은 새 리비전이라 항상 통과한다"** 하나다 —
dedup이 접는 건 *같은* `Epoch`*같은* 리비전이 두 번째로 도착한
것뿐이라, 이 Observer는 매 변경마다 정확히 한 번 운다
(`base/state-epoch-plan.md` §4). **`invalid`로 전파를 접는 최적화는
지금도 금지**다 — `fn``:Get()`을 안 하면 상류 State는 계속
`invalid`로 남으므로, 그런 규칙이 있으면 **이 Observer는 두 번째
변경부터 영원히 안 울린다**(2026-08-14 이전에 실제로 그 문장이 위
"전파 모델 확정" 절에 있었고 이 계약과 충돌한 채 방치됐었다 —
`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 같이
움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 반드시 이
항목부터 확인할 것.
- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯
번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제
`fn`을 만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링(`modifier-plan.md`

View file

@ -0,0 +1,443 @@
# State 재계산/전파 판정 — `Epoch` 비교와 `EpochMap` 컴포지션 (2026-08-21 확정)
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."*
**구현 마일스톤은 둘로 갈린다** — **[2026-08-22 정정]** 여기 "구현은 M3"라고만
적혀 있었다. 부기 객체 `EpochMap.luau``Epoch` 인터페이스·리비전 갱신
규칙은 **M2**(`GateNode`가 `emitEpochMap`을 쓰므로 — `ROADMAP.md` M2),
그걸 `valueEpochMap`/`emitEpochMap` 둘로 컴포지션하는 **State 본체 통합은
M3**다.
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을
얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고, 역전 원문은
`archive/always-propagate-no-dedup-superseded.md`에 있다.
**읽는 순서**: 규칙만 필요하면 **§2~§4**만 보면 된다. §1은 왜 이걸 하는가
(실재하는 glitch), §5는 emit 페이로드, §6은 State 밖의 소비자, §7은 비용,
§8은 구현 시 확인할 것.
**히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은
`qa-request/pre-implementation-qa-round5-response.md`, 여러 라운드의 정정
경위는 `qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스.
`Epoch`/`EpochMap`으로 일반화한 마지막 라운드의 근거 기록은
`reference/epoch-brand-composition.md`.
## 1. 사용자가 지목한 문제 (실재함)
```
A ──> B ──┐
└──> C ──┴──> D
```
`A:Set()` 한 번에 대해 옛 모델(push-invalidate + pull-recompute, 판정 주체가
`invalid` 플래그)에서:
1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저
끝까지 내려간다** — `D``B`를 통해 신호를 받고, 그 아래 Observer가 발화한다.
2. 그 Observer가 `D:Get()`을 부른다. `D`는 상류를 `Get()`하는데, **`C`는 아직
신호를 못 받아 `invalid`가 아니다** → `C`는 **옛 캐시를 그대로 반환**한다.
3. `D``(B_new, C_old)`라는 **섞인 값**을 계산해 캐시하고 `invalid`를 끈다.
4. 뒤늦게 `C` 가지의 전파가 도착해 `D`가 다시 무효화되고, Observer가 또 울고,
그때서야 `(B_new, C_new)`로 교정된다.
**(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가
써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서
말하는 전형적인 **glitch**다.
`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 아래 규칙이 이걸 고친다.
## 2. `Epoch` — 판정의 최소 인터페이스
**판정에 필요한 건 둘뿐이다: identity와 "직전과 달라지는 리비전".** 그걸
이름 붙인 것이 `Epoch`다.
```lua
type Epoch = { Revision: number }
```
- **그 자체로 키가 되는 unique 테이블**이다 — `EpochMap``[epoch] = revision`
으로 들고 있는다.
- **`Source`가 이 인터페이스를 구조적으로 만족한다** — `Source``State`
구조적으로 만족하는 기존 패턴(`base/source-state-plan.md`의 "Source가 State를
만족함" 절)과 정확히 같은 모양. `Source:Set()`/`:Emit()`이 자기 `Revision`
**갱신한다**(직전과 다른 값으로 — 방향은 계약이 아니고, 아래 확정된 연산은
실제로 증가가 아니라 **감소**한다).
- **`Revision`은 공개 필드다.** 비공개면 구조적 만족이 **타입 레벨에서 성립하지
않는다**(사용자: *"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*).
`Store`와 달리 `Source`는 키가 사용자 것이 아니므로 예약 이름이 늘어도
충돌하지 않는다 — *"`Source` 는 예약 이름이 늘어나도 됩니다(store 아님)."*
- **런타임 판별은 `EpochBrand:is(x)`**(`base/brand-plan.md`). `Source`
`SourceBrand`이면서 동시에 `EpochBrand`에 등록된다 — 이 다중 태깅 요구가
`Brand`를 인스턴스 브랜드로 재작성한 직접 발단이다.
- **왜 `Source`가 아니라 `Epoch`인가**: 맵이 요구하는 건 identity + 리비전
둘뿐인데 그걸 `Source`로 못 박으면 계약이 실제보다 좁아진다. 사용자:
*"'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님."*
일반화의 실익은 **`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이
낀다**는 것이고, 그건 `EpochBrand:register(self)` 한 줄로 끝난다.
**계약은 "직전 값과 다르다"만 요구한다.** 순서 비교(`<`)는 아래 규칙 어디에도
안 쓰이고 전부 `==`/`~=`뿐이라, 단조 증가조차 계약으로는 과하다 — 실제로
**아래 확정된 방식은 증가가 아니라 감소한다**(그리고 0에서 한 바퀴 돈다).
규칙이 `==`/`~=`만 쓰기 때문에 그게 문제가 안 되는 것이므로, **나중에 순서
비교를 넣고 싶어지면 이 결정부터 되짚을 것.** 이름이 `Revision`인 것도
순서를 뜻하지 않는다 — "직전과 구별되는 표식"이라는 뜻이다.
- **배경 — 평이한 `+1`이었다면 `2^53`에서 계약이 깨졌다.** Luau 숫자는
double이라 랩어라운드가 아니라 **포화**한다 — `2^53`을 넘으면 `n + 1 == n`
되어 "다르다"는 보장이 정확히 그 지점에서 깨진다(**초당 100만 `Set`으로
285년**이라 도달 불가능하긴 하다). 아래 확정된 `bit32` 랩은 **이 지점 자체를
없앤다.** 이걸 근거로 적을 땐 "오버플로해도 다르다"가 아니라 **"도달
불가능하다"**로 적을 것 — 전자는 틀린 서술이다.
- **⭐ [2026-08-21 확정] 리비전 갱신은 `bit32.bnot(-rev)` 한 번이다 — 리비전은
uint32 안에 머문다.**
```lua
self.Revision = bit32.bnot(-self.Revision)
```
**[2026-08-22 실측] 이건 랩어라운드 감소다** — `a > 0`이면 `a - 1`,
`a == 0`이면 `4294967295`로 한 바퀴 돈다. `luau`로 확인한 값:
| `rev` | `bit32.bnot(-rev)` |
|---|---|
| 0 | 4294967295 |
| 1 | 0 |
| 2 | 1 |
| 4294967295 | 4294967294 |
(원리: `bit32.bnot(x) == 4294967295 - (x mod 2^32)`이고 `a > 0`에서
`(-a) mod 2^32 == 2^32 - a``a - 1`이 된다. 시작값이 무엇이든
상관없다 — 매 호출이 직전과 다른 값을 준다는 것만 계약이다.)
**사용자 논거**(2026-08-21): *"그건 luau 에서 native call 이라 아주
빨라요. 반면 double 의 연산이 느린편인데, 희소 수준이 아니라, 사실상
만나는걸 수년간 보기 어려운 라운드되어 동일해 무시되는 경우를 막기 위해
double 까지 올려야할 이유를 모르겠어요. 매번 도는 코드인지라, 값 싸게
native call + num 연산으로 가볍게 가고 싶어요."* — 즉 **`2^53` 포화는
어차피 도달 불가능한 시나리오인데, 그걸 피하겠다고 값을 double 영역까지
키울 이유가 없다**는 것. 이건 매 `Set`마다 도는 hot path다.
- **갱신과 랩이 같은 연산 하나다.** `bit32.bnot`은 Luau가 FASTCALL로
거는 빌트인이라, 별도의 덧셈도 마스킹도 없다 — 단항 부호 반전 하나가
붙을 뿐이다.
- 랩이라 **`2^53` 포화(`n + 1 == n`)가 아예 안 생긴다** — 위 배경 항목이
말하는 계약 파손 지점 자체가 사라진다.
- **⚠️ [2026-08-22 정정] 대신 생기는 `2^32` 랩은 "똑같이 도달 불가능"이
아니다.** 여기 그렇게 적어뒀는데 **수치가 틀렸다** — 위 배경 항목과 같은
척도(초당 100만 `Set`)로 `2^53`은 285년이지만 **`2^32`는 약 72분**이다
(20만 배 차이). 현실적인 부하(초당 1만 `Set`)로도 5일 남짓이다.
**그래도 위험하지 않은 이유는 도달 시간이 아니라 충돌 조건이 한 점이기
때문이다**: 오판정이 나려면 **어떤 맵 항목이 그 `Epoch`에 대해 정확히
`2^32`만큼 뒤처져** 있어야 한다. 한 바퀴에서 하나라도 어긋나면 값이 달라
정상 판정된다. 그 항목은 emit을 받거나 `:Refresh`를 도는 순간 갱신되므로,
"정확히 한 바퀴 동안 한 번도 안 건드려진 항목"이라야 한다. 확률적으로
무시 가능하다는 뜻이지 **산술적으로 불가능하다는 뜻이 아니다** — 이 문서가
바로 위에서 "근거를 정확히 적을 것"이라 규정했으므로 같은 기준을 적용한다.
(2026-08-21 커밋 전 `/code-review high` 발견.)
- **⚠️ [2026-08-22 정정] 여기 한때 `bit32.band(rev + 1, 0xFFFFFFFF)`
적고 "`bit32`가 double 덧셈 위에 fastcall을 하나 더 얹는다"는 단서를
달아뒀는데, 둘 다 틀렸다.** 사용자가 말한 형태는 처음부터
`bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건
**덧셈을 얹는 게 아니라 갱신 자체를 대체한다.** 그래서 "`bit32`라서
더 싸다"는 사용자 서술이 맞고, 그걸 반박한 에이전트 단서가 틀렸었다 —
`band(n + 1, mask)`라는 **다른 형태**를 놓고 한 비교였기 때문.
- **테이블 identity를 리비전으로 쓰는 대안은 채택 안 함**(사용자 선택:
*"Revision 숫자로 가는걸 저는 선택하고 싶어요"*). 기능적으로는 둘 다
성립하지만 테이블안은 **`Set` 한 번마다 테이블 하나를 할당**해서, 트윈처럼
매 프레임 `Set`하는 소스가 여럿이면 GC 압력을 만든다(quad는 GC-native
아키텍처라 이 축을 신경 써왔다).
## 3. `EpochMap` — 컴포지션 가능한 부기 객체
**에포크 부기를 State에서 떼어내 재사용 가능한 객체로 만든다**(사용자 제안).
그래야 **노드가 아닌 소비자(leaf)도 같은 판정을 쓸 수 있다**`Effect`
그 첫 수요자다(§6).
```lua
type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래 ⚠️ 참고)
EpochMap() -> EpochMap
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가"
EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음
EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음)
EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움
```
**⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라
`{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서
그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이
다르다** — 배치는 `base/gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }`
그대로 스왑해 넘긴 것이다. 이 표기를 믿고 `ipairs`로 구현하면 배치를 순회할 때
**원소가 0개**가 되어, 유보됐다 풀린 emit이 하류에서 전부 조용히 삼켜진다 —
`gate-plan.md` 4번이 애초에 고치려던 바로 그 버그다. **집합이어야 하는 이유는
게이트 쪽 요구**다: 유보 중 같은 `Epoch`가 여러 경로로 도착해도
`withheld[epoch] = true`가 저절로 접어주고, 게이트-게이트 unfold(같은 절)도
집합이라야 중복 없이 합쳐진다. (2026-08-21 커밋 전 `/code-review high` 발견.)
- **`:Update`가 이 객체의 전부다.** 넘어온 각 `Epoch`에 대해 저장된 리비전과
`epoch.Revision`을 비교하고, 다르면 새 값으로 덮는다. **하나라도 달랐으면
`true`.** 사용자: *"애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트
된게 있으면 true 를 던지는거라."*
- **`EpochSet`을 받으므로 sync 연산이 따로 필요 없다** — 전체를 넘기면
그게 곧 sync다. (한때 에이전트가 "`:Sync`가 필수"라고 적었으나 `Update`
"하나만 받는 것"으로 좁게 본 착오였고 철회됐다.)
- **⭐ 이건 `invalid`와 다른 물건이다**(사용자: *"이건 invalid 랑은 다른
구현이야."*). 반환값의 뜻은 "내 캐시가 낡았다"가 아니라 **"뒤로 전파가
필요한가"** 하나다.
- **내부 최적화**: 목록을 돌 때 diff 때문에 읽기가 들어가는데, **한 번
다름을 찾으면 반환값이 이미 `true`로 확정**되므로 나머지는 읽지 않고
쓰기만 하면 된다(사용자 제안).
- **`:Refresh`는 인자 없는 `:Update`다** — 자기가 이미 들고 있는 키 전부를
라이브로 다시 읽어 갱신하고, 하나라도 달랐으면 `true`. 아래 §4의 **순회**가
이걸 쓴다(순회가 훑을 대상 목록이 곧 이 맵 자신이라 인자로 받을 게 없다).
- **`:Sync`는 읽기를 건너뛰고 쓰기만 하는 변형**이다(반환값 없음). *"없다고
안되는건 아닌데, 그냥 다 안 읽고 set 만 해버리는것은 처음 셋팅에 도움은
됩니다."* **[2026-08-22 정정]** 여기 "초기화에만 쓴다"고 적혀 있었으나
`base/gate-plan.md` 4번이 게이트의 flush 경로에서도 `:Sync(batch)`를 쓰는
것으로 확정돼 있다 — 쓰는 자리는 **"반환값이 필요 없다고 이미 아는 곳"**
둘이다: 노드 생성 시딩, 그리고 게이트가 실제로 전파할 때.
- **⭐ [2026-08-22 신설] `:TrackFrom(other)` — 새 노드 시딩이 이걸 쓴다.**
`other`가 추적 중인 키를 전부 넘겨받아 **라이브 리비전으로** 채운다
(`other`의 저장값을 복사하는 게 아니다).
- **왜 필요한가**: §4의 시딩 규칙은 "상류의 `Epoch`를 전부 끌어와 채운다"인데,
`:With(a, b)`의 상류 `a`/`b`는 **State이지 `Epoch`가 아니다.** 그 State가
추적 중인 루트 `Epoch` 집합은 그 State의 `valueEpochMap` 안에만 있으므로,
키를 넘겨받는 연산이 없으면 시딩을 **표면으로 표현할 수가 없다**
(2026-08-21 커밋 전 `/code-review high` 발견).
- **새 설계가 아니라 이미 확정된 동작에 이름을 붙인 것**이다 — 사용자
확정 문구가 이미 *"전부 가져와서, 실제 count 로 둡니다"*였다. `:Refresh`
같은 성격의 명명이다(§4의 "순회").
- `Source`처럼 **자기가 곧 `Epoch`인 상류**는 `:TrackFrom`이 아니라
`:Sync(dep)`로 직접 넣는다 — 아래 시딩 규칙 참고.
- **이름 근거(사용자 확정, 2026-08-22)**: 가칭은 `Absorb`였는데 *"조금 상위
요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름"*이라 바꿨다 — 이
연산은 `other`**전혀 안 건드린다**. 게다가 `base/gate-plan.md`가 이미
"흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어 한 코퍼스 안에
같은 단어가 두 의미로 놓이는 문제도 있었다. `TrackFrom`은 이 맵의 존재
이유를 사용자가 표현한 말(*"'내가 뭘 추적하고 있나' 가 필요하죠"*)을 그대로
쓰고, `From`이 방향을 못박아 비파괴가 드러나며, 나중에 동적 의존성으로
생성 이후에 키를 더하는 자리가 생겨도 이름이 그대로 맞는다(그래서
`SeedFrom`보다 낫다). 후보 비교는 `question.md`가 아니라 여기서 끝났다 —
같은 자리에서 확정됐으므로 열린 항목이 아니다.
- **키는 weak다.** `epoch`가 죽으면 항목이 사라진다. `base/relate-plan.md`
경고하는 "값이 키를 되참조하면 안 된다"는 제약은 값이 숫자라 문제없다.
## 4. State는 `EpochMap`을 둘 컴포지션한다
```
State
valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성)
emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup)
rawInvalid : boolean -- "재계산이 필요하다"는 확정 플래그
```
**⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로
하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 맵
하나로 충분하다. 순회를 넣는 순간 그 둘이 갈라진다 — **순회는 값만 앞당기고
통지는 안 한다.**
**⭐ 대원칙 — 무효화를 결정하는 건 언제나 리비전 비교지 emit의 도착이 아니다.**
emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
리비전이 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자
정리: *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, (이미 count 가
최신이면) 캐시가 유효하다."* 아래 나머지 규칙은 전부 이 원칙의 따름정리다.
### 노드가 생길 때의 초기값 — 두 맵이 서로 다르다
- **`emitEpochMap`은 비운 채로 시작한다.** 어떤 emit이 와도 "처음 보는 것"으로
걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 노드에서 들어온 emit 은,
개념적으로 해당 노드가 한번도 받아본적 없는 emit 입니다."*
- **`valueEpochMap`은 반대로 상류가 추적 중인 `Epoch`를 전부 끌어와 채우고,
`rawInvalid = true`로 시작한다.** dep마다 갈린다 — **dep이 `Epoch`면**
(`Source`) `:Sync(dep)`, **dep이 State면** `:TrackFrom(dep.valueEpochMap)`.
분기는 `isEpoch`로 한다.
```lua
for _, dep in deps do
if isEpoch(dep) then self.valueEpochMap:Sync(dep)
else self.valueEpochMap:TrackFrom(dep.valueEpochMap) end
end
self.rawInvalid = true
``` 이쪽은 비워두면 안 된다 — 순회가 훑을
대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로
오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
- 그래서 **`:With`에서 두 상류가 같은 `Epoch`에 다른 리비전을 들고 있을 때의
병합 규칙도 필요 없다** — 어차피 생성 시점의 라이브 리비전으로 통일된다.
### emit을 받았을 때 — 두 맵을 각각 `Update` 하고 그 boolean으로 결정한다
```lua
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Update(from)
if valueChanged then self.rawInvalid = true end
if valueChanged or emitChanged then
-- 뒤로 emit (받은 from을 그대로 넘긴다)
end
```
세 갈래로 읽으면 이렇다:
1. **`valueEpochMap`이 달랐다** → 값이 낡았다. `rawInvalid = true`를 세우고
**뒤로 emit** 한다(두 맵 모두 갱신됨).
2. **`valueEpochMap`은 같은데 `emitEpochMap`이 달랐다** → **값은 이미 최신이지만
통지는 아직 안 나갔다**(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안
하류가 `Get()`으로 앞당겨 읽은 경우). `emitEpochMap`만 갱신되고 **뒤로
emit** 한다 — `rawInvalid`는 안 건드린다.
3. **둘 다 같다****삼킨다.** 다이아몬드에서 같은 리비전이 두 경로로 도착한
두 번째가 여기서 접힌다.
- **⚠️ [2026-08-22 신설] `GateNode`는 이 의사코드를 그대로 쓰지 않는다.**
위 코드는 `emitEpochMap`**수신 시점에** 갱신하는데, 게이트는
`base/gate-plan.md` 4번이 **전파할 때 `:Sync(batch)`로** 갱신하는 것으로
확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도
참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는:
- 판정(규칙 1~3)은 **똑같이** 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고
흡수 집합에도 안 들어간다(`gate-plan.md` 4번).
- 다만 `emitEpochMap:Update`를 수신 시점에 부르지 않으므로, **유보 중에
같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다.**
이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는
`:Sync(batch)`가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다.
- **이 예외가 여기 기록돼 있지 않았다**(2026-08-21 커밋 전 `/code-review high`
발견) — §4대로 구현하면 `gate-plan.md` 4번의 계약이 조용히 깨진다.
- **`from`이 하나면 판정은 O(1)이다** — 그 항목 하나만 본다. **다른 항목은
건드리지 않는다**(그 `Epoch`들은 자기가 직접 emit 하므로).
- **`from`이 집합이면**(게이트 배치, §5) `Update`가 알아서 순회하고, 하나라도
달랐으면 `true`를 준다 — 규칙이 그대로 성립한다.
### 재계산 판정
- **`rawInvalid == true`** → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
- **`rawInvalid == false`** → **그때만 `valueEpochMap:Refresh()`를 부른다.**
목적은 하나뿐 — **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에
막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). `true`가 나오면
`rawInvalid = true`로 만들고 재계산한다.
- **⭐ 순회는 `emitEpochMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
이게 없으면 **값은 맞는데 통지가 죽는** 실패 모드가 생긴다 — 2026-08-14에
폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다
(`archive/invalidate-dedup-propagation-reversed.md`).
### 재계산이 끝나면
- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
업데이트 하는건 맞아보입니다."*
- **`emitEpochMap`은 안 건드린다 — 계산은 통지가 아니다.**
- 이 "전부 갱신"이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가
`Get()`으로 앞당겨 읽는 경우**다. 그때 `valueEpochMap`이 앞서 있으므로,
나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을
다시 계산하지 않는다.**
## 5. emit 페이로드 — `Epoch | EpochSet`, 게이트는 안 싣는다
- **emit은 값도 리비전도 안 싣는다 — 싣는 건 "이 통지의 출처"뿐**이고, 그
출처는 `Epoch` 하나이거나 `EpochSet`(`{[Epoch]: true}`)이다. 받는 쪽이 거기서 리비전을
**그때그때 라이브로** 읽는다. 그래서 "리비전 5의 emit" 같은 건 없다.
- **게이트 노드 자체는 페이로드에 안 싣는다.** 게이트가 유보를 풀 때
(`blocker:Off()` 등) 넘기는 건 **그 자리에서 스왑해 떼어낸 `Epoch` 집합
스냅샷**이다(`base/gate-plan.md`의 4번) — 게이트의 살아있는 `withheld`
테이블이 아니다(재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기
위함). 하류는 게이트 identity를 **한 번도 안 쓴다** — 게이트를 에포크
경계로 만드는 안은 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목에서
기각됐다. 사용자: *"하류가 Gate 노드를 받을 이유도 없거든."*
- **런타임 분기는 `isEpoch`로 한다** — 단일이냐 집합이냐.
- **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로
아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 배치를 새로 낸다**
(`base/gate-plan.md`의 4번).
- **⚠️ 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기
`withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성
스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이
된다(같은 절).
- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의
8번) — 그래서 위 규칙이 빈 집합을 받는 경우는 없다.
## 6. State 밖의 소비자도 같은 판정을 쓴다 — `Effect`
`EpochMap`을 떼어낸 실익이 여기서 나온다. **`Effect`가 자기 `EpochMap`을 하나
들고** 각 의존성의 내부 Observer가 그걸 `Update`하면, 한 파동에 여러 dep가
깨워도 **첫 번째만 `true`**`fn`이 한 번만 돈다.
이건 `A → b`, `A → c`, `Effect(fn, b, c)`에서 **접어줄 공통 하류가 없어**
State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 지점이 곧
공통 하류가 된다. 계약 전량은 `base/effect-plan.md`의 "`Effect(fn, ...deps)`"
절이 소스.
**그래서 `Observer` 클로저는 출처를 인자로 받는다** —
`fn(self, from: (Epoch | EpochSet)?)`. `:Compute``fn(self, ...)`와 같은
모양이고(설치 발화에는 출처가 없어 `nil`이다),
값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은
안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스.
## 7. 비용
사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해
인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘:
- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 `Epoch`의 수**다.
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
큰 경우는 드물다.
- **훑는 쪽(`rawInvalid == false`, 즉 "안 바뀐 것 같다")이 흔한 경로**다.
그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 **재계산은
안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 건너뛰고 바로
재계산한다.
- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
## 8. 구현 시 확인할 것
- **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다**
"emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은
중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 `Epoch`
같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면
2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다.
- **선언 안 된 의존성에 대한 UB 조항은 안 만든다.** 에이전트가 "새 모델은 더
강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 **사용자가 기각**:
*"그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을
던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이
stale**이었고 이 변경이 악화시키는 게 없다.
- **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고
재계산이 조금 더 잦아질 뿐이다.
- **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`
갱신하면 그대로 동작한다(`:Set()`과 같은 연산 — §2).
- **`blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로**에서 `emitEpochMap`
뒤에 남아도, 그 `Epoch`의 **다음 진짜 emit**이 §4의 1번(둘 다 다름)으로
걸려 정상화된다 — 별도 조치가 필요 없다.
- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 리비전 비교
없이는 섞인 값이 실제로 관측되는지, (b) 비교를 넣으면 사라지는지 대조.
- **기각된 대안 — 게이트를 에포크 경계로.** 게이트가 **자기 자신을 하나의
`Epoch`로** 내세우고(자기 `Revision`은 유보를 풀 때만 갱신) 하류가 상류
원천 대신 **그 게이트만** 추적하게 하는 안. 배치를 나를 필요가 없어져
페이로드가 단순해지지만, **`base/gate-plan.md` 3번의 공개 계약을
정면으로 깬다** — *"게이트를 통과하지 않은 값도 `:Get()`으로는 보인다."*
- **깨지는 경로**: 게이트가 붙들고 있는 동안 하류가 `:Get()`을 부르면
§4의 재계산 판정이 `valueEpochMap:Refresh()`를 돈다. 그 맵이 추적하는
게 게이트 하나뿐이면 게이트의 `Revision`은 아직 안 올랐으므로 **"나는
최신"으로 오판하고 옛 캐시를 반환**한다. 지금처럼 원천 `Epoch`를 직접
추적해야 순회가 상류의 실제 리비전을 보고 스스로 낡음을 알아챈다.
- 즉 게이트는 **emit만 가로채지 값을 가리지 않는다**는 성질이 이 문서의
핵심 보장(`:Get()`은 항상 최신)과 한 몸이다. `Blocker`
(`base/blocker-plan.md`의 "`:Get()`엔 영향 없음")와
`base/debounce-throttle-plan.md` §4도 같은 계약 위에 서 있으므로,
이걸 뒤집으면 셋이 같이 무너진다.
- **기각된 대안 — 순회가 만든 emit을 `rawEmit`으로 흘리고 `nil`을 던지는 안.**
구조 위생은 좋았으나(상류 emit과 내부 발생 emit이 같은 진입점) 이 문제의
해법으로는 못 쓴다 — (a) 막고 있는 게이트는 보통 **순회하는 노드 자신이
아니라 상류**에 있어 누출이 그대로 남고, (b) `nil` emit은 받는 쪽마다 전체
순회를 강제해 같은 "영구 침묵"이 하류에서 연쇄로 재발한다. 지금 안은 순회가
애초에 emit을 안 하므로 두 문제가 **생기지 않는다.**
- **곁가지 — 폴링용 슈가.** 사용자 제안: *"폴링을 위해서는 Apply(Realtime())
같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수
있게 해주는것. 단순하게 `Ref<T>` 로 변환해주는 등"*. **이 문서의 결정과
독립**이고 `research/operator-sugar-plan.md`의 콤비네이터 계열에 속한다.

View file

@ -185,7 +185,7 @@ State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>``Ref`/`Observer`/
`Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/
`Tag`처럼 순수 raw 데이터 값(별도 `TweenBrand`)이라, Modifier 필드/
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래
@ -373,7 +373,7 @@ quad-roblox 레벨 편의 함수라 base 계약에 영향 없음.
## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정)
- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween`
predicate/`TweenTag` Brand)만. 엔진 무관.
predicate/`TweenBrand` Brand)만. 엔진 무관.
- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에
`isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) +
`Animate.luau`(편의 콤비네이터, 신규).
@ -449,6 +449,37 @@ Tween(opts: {
}) -> Tween<T>
```
## `Tween<T>:Mapped(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설, 이름은 2026-08-21 5라운드 확정)
**동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로
감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이
`Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다.
그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로
**`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도
무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*(이름은 이후
`Mapped`로 확정 — 아래 마지막 항목)
```lua
tween:Mapped(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환
```
- **타입이 안전하게 성립한다**`Tween<T>`는 immutable raw 값이고 `Value` 외의
필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value``U`로 바꾼
`Tween<U>`를 만드는 건 타입 레벨에서 깨끗하다.
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고
`table.clone``Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의
clone 체이닝과 같은 계열).
- **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.**
`local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고
`FAST:Mapped(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만
**사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는
것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단).
- **✅ 이름 — `Mapped`로 확정(2026-08-21 구현 전 QA 5라운드 `TW-2`).**
코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 `Added`/`Removed`/`Overridden`처럼
과거분사, `base/source-state-plan.md`의 "네이밍 — `Compute``-ed`가 아닌 이유"
절)를 그대로 적용한 결과 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로
`Mapped`가 맞다(**사용자 확정**: *"Mapped 로 확정"*).
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)
기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고

View file

@ -96,11 +96,21 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면
디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든
`UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은
표시·판정용, `Relate`는 조회용**으로 역할이 갈린다.
- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는
`Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata
동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`).
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지
구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다.
- **[해소, 2026-08-20 구현 전 QA 4라운드 `UI-5`] 자식도 반드시 gcconn/gchold
셋업을 거친다 — 안 거치면 이 숏핸드 자체가 애초에 동작을 못 한다.** 원래
"구현 시 확인할 것"으로 열어뒀던 항목인데, 사용자 판정으로 닫힘: *"애초에
똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터
실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든
gcconn/gchold 가 셋업되는게 맞음."*
- **논거**: 아래 "Tween 지원" 절대로 이 숏핸드는 자식 프로퍼티를 직접 쓰지
않고 `Dispatch.process(child, prop, v, 1)`로 위임하는데, 그 값이
`State``StoreBind``bindLifetime(child, observer)`를 부른다 — 그
시점에 `child`에 gcconn/gchold가 없으면 **거기서 곧바로 실패**한다.
즉 "조용히 미아가 되는" 시나리오가 아니라 **즉시 드러나는 전제 조건**이고,
숏핸드가 자식을 만드는 경로도 일반 인스턴스 생성과 같은 셋업을 타야 한다.
- **따라서 `ensureManagedChild`는 자식을 만들 때 일반 인스턴스 생성과
동일한 경로**(gcconn/gchold 셋업 포함)를 써야 한다 — 별도 확인 항목이
아니라 구현이 반드시 만족해야 하는 계약.
- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**:
*"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운
dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."*
@ -179,7 +189,14 @@ end
State 레이어를 먼저 다 풀어내므로 이 Handler가 실제로 보는 `v`
`number` 아니면 `Tween<number>` 둘 중 하나.
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** 숏핸드는
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.**
**[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`
`Tween` 자신의 공개 메소드 `:Mapped(fn)`으로 올라갔다**(이름은 2026-08-21 5라운드 `TW-2`에서 확정) —
`base/tween-plan.md`의 "`Tween<T>:Mapped(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로
두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는
게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만
읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Mapped(wrap)`, 아니면
`wrap(v)`): 숏핸드는
"스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8`
`CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`).
`v``Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만**
@ -215,14 +232,24 @@ PropertyHandler의 "첫 세팅은 애니메이션 없이 즉시"(`prev == nil`)
상황 — 계속 애니메이션되길 원하면 자식이 살아있도록 `nil`로 내리지 말고
값만 바꿀 것.
**자식을 없앨 때의 정리 책임은 이 Handler에 있음** — `v``nil`이 되거나
retractor가 `nil` 힌트로 불려 자식을 파괴할 때, 실행 중인 엔진 Tween이
남아있을 수 있으므로 `Dispatch.retractFrom(child, prop, 1)`을 같이
부르는 게 정석(자식 Instance를 `Destroy`하면 엔진 트윈도 같이 죽고
`chains`도 weak-keyed라 결국 GC되지만, "즉시" 끊는 건 명시적 호출뿐).
`retractor` 안에서 **다른 키**에 대한 `retractFrom`을 부르는 건 허용된
경로임(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은
`(inst,k)`에 대한 재진입).
**[정정, 2026-08-20 구현 전 QA 4라운드 `UI-11`] 자식을 파괴할 때
`Dispatch.retractFrom(child, prop, 1)`을 "정석"으로 요구하지 않는다 — 실익이
없다.** 옛 서술은 "실행 중인 엔진 Tween이 남아있을 수 있으므로 같이 부르는 게
정석"이었는데, 사용자 판정: *"자식 파괴 시 사실 Tween 은 엔진에 의해 자동
멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라
의미가 없을것이다."*
- **두 겹으로 무의미하다**: (a) Roblox 엔진이 Destroy 시점에 그 인스턴스에
걸린 Tween을 알아서 정리하고(`base/lifecycle-pattern.md`의 "retract는 Destroy 시점에 필요 없는 이유가
엔진 레벨에서 한 번 더 보강됨" 절), (b) `PropertyHandler`가 반환하는 retractor는 애초에
몸체가 no-op이라(`base/tween-plan.md`의 "왜 `retract`가 더 이상 필요
없는가" 절) 불러봐야 하는 일이 없다.
- **그래도 호출이 금지되는 건 아니다**`retractor` 안에서 **다른 키**에
대한 `retractFrom`을 부르는 것 자체는 여전히 허용된 경로다
(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 `(inst,k)`
대한 재진입). 다만 이 자리에서 **필요하지 않다**는 것.
- `chains``child`에 대해 weak-keyed라 자식을 버리면 결국 GC된다 — 명시적
정리가 필요한 자원이 이 자리엔 없다.
## store-bind — 이 숏핸드도 지원

View file

@ -11,7 +11,7 @@
| 폴더 | 뜻 | 누가 처리 |
|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) — **`16`은 [2026-08-15] 통과로 `done/`에 있음**(아래 참고) | 에이전트 |
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정 → 재작성 통과했다가 **[2026-08-21]** 소스 에포크 채택으로 다시 합류) — **`16`은 [2026-08-15] 통과로 `done/`에 있음**(아래 참고) | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 5차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | — |
@ -44,10 +44,16 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| 환경 | 필요한 것 | 해당 파일 |
|---|---|---|
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17, 18, 19, 20 |
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 |
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 17, 18, 19, 20, 22 |
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13, 14, 15, 16, 21, 23 |
| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 |
**[2026-08-21 정정]** 이 표가 `21`/`22`/`23`을 빠뜨린 채 `13`을 "런타임 부분/
타입 부분"으로 쪼개 적고 있었다 — `13`의 런타임 절반은 2026-08-19에 `22`
분리돼 나갔으므로 지금 `13`은 **순수 타입 스파이크**다. **각 파일이 어느
환경에서 도는지는 파일 맨 위 주석이 소스**이고, 이 표는 그걸 환경별로 묶어
보여주는 편의 색인일 뿐이다 — 파일이 늘면 여기도 같이 고칠 것.
**16은 특히 `type function`이라는 비교적 최근/계속 진화 중인 Luau 기능을
쓰므로, luau-analyze 버전이 오래되면 아예 문법 자체를 못 알아볼 수 있음**
— 그 경우는 실패가 아니라 "이 Luau 버전에서 type function 자체가 아직
@ -71,7 +77,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
| `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong``handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 |
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | ROADMAP M0-1 |
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 → 2026-08-21 다시 `rewrite-required/`]** 2026-08-19엔 당시 모델("emit은 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 짜서 통과했으나, **소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드 두 번째 통지가 접히게 되어** 핵심 assert가 정반대가 됨 — 이제 Observer는 변경당 **1회**만 울어야 한다. 상태의 소스는 `STATUS.md` | ROADMAP M0-1 |
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 |
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 |
@ -88,7 +94,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" |
| `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md``recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 |
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지 | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md``Brand` 절 |
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`"⭐ 구현 — 인스턴스 브랜드" 절 |
| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
## 공통 유틸리티

View file

@ -1,6 +1,16 @@
# 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 마지막 갱신: **2026-08-21**`Brand`가 **인스턴스 브랜드**로 전면
> 재작성되면서(`base/brand-plan.md`) 옛 `Brand.set`/`Brand.get`을 직접 구현해
> 쓰던 `22``done/``rewrite-required/` 이동(검증 대상인 `isRef`/`isPreRef`
> 포함 관계 자체는 그대로). 같은 날 `Epoch`/`EpochMap` 승격도 있었으나 그건
> 이미 `rewrite-required/`에 있는 `05`의 지침에 반영돼 있다. 직전 갱신도
> 같은 날 — QA 4라운드 `F-4-1``Dispatch.drive`
> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
> `01`이 낡아 `done/``rewrite-required/` 이동(검증 대상인 순서 계약
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을
> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
@ -35,9 +45,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -62,7 +72,10 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
## 🟠 `rewrite-required/` — 스파이크가 낡음
(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다. 예전엔 이 제목이
개수를 들고 있다가 실제와 어긋난 적이 있다.)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
@ -77,11 +90,27 @@
`10`**Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성
후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다.
**[2026-08-21] `01``05`가 합류** — 둘 다 같은 "설계가 바뀐" 유형이다.
통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`
**이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다.
**[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스
브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해
쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다
(`archive/brand-shared-registry-reversed.md`). **검증 대상 자체는 그대로
유효하다** — `isPreRef`/`isPostRef`가 서로 배타적 형제이고 둘 다 `isRef`
`true`라는 포함 관계는 재작성 후에도 안 바뀌었다. 옮기는 이유는 결론이
틀려서가 아니라, 통과 상태로 `done/`에 두면 **구현자가 그 파일의 `Brand`
구현을 참고 모델로 오독**하기 때문이다.
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|---|---|---|
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
| `05-store-state-diamond-propagation.luau` | 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 | **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) |
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
## ⚪ `not-run/` — 이 환경에서 못 돌림
@ -93,26 +122,33 @@
|---|---|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (19건)
## ✅ `done/` — 통과 or 판정 끝
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다.)
**지금 `done/`에 있는 런타임 스파이크는 `02`/`03`/`06`/`07`/`11`/`17`/`18`/`20`,
전원 통과**(crash 0 / FAIL 0). 나머지는 타입 스파이크다.
**[2026-08-21] `22`는 여기서 빠졌다** — `Brand` 인스턴스 브랜드 재작성으로
파일이 쓰는 `Brand.set`/`Brand.get`이 옛 API가 되어 `rewrite-required/`
이동(검증 대상 자체는 유효, 위 표의 재작성 지침 참고).
**[2026-08-21 정정]** 여기 "런타임 12개"라고 적혀 있었는데, 그 산술이 이미
`rewrite-required/`로 나간 `04`/`10`/`19`까지 포함한 옛 총계에서 이어져 온
것이라 실제와 안 맞았다(두 번째 `/code-review high` 발견). — **[열네 번째 세션] `04`/`19`는
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
런타임 절반, PostRef까지 확장)가 합류**:
`05`는 현행 모델로 재작성해 잠시 돌아왔다가 **[2026-08-21] 소스 에포크
채택으로 다시 나갔으며**, 신규 `22`(구 `13` 런타임 절반, PostRef까지
확장)가 합류**:
| 파일 | 확인된 것 |
|---|---|
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 |
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
| `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 |
| `22-runtime-ref-preref-postref-brand` | **[2026-08-19 신규]** `isPreRef`/`isPostRef`가 서로 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)임을 확인, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라냄 |
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:
@ -157,3 +193,18 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
```
추측이 아니라 **실제로 GC가 안 됨**`Slot`의 두-`Relate` 수정이 필수
조치였음이 입증.
## 🔵 만들어야 할 스파이크 — 아직 파일이 없음 (2026-08-21 신설)
폴더가 곧 상태인 이 문서에서 **"아직 파일조차 없는 실측 항목"**은 어느
폴더로도 표현되지 않아 그냥 잊혔다. 실제로 QA 4라운드 followup(H-7)이
"실측으로 남은 것"의 소스로 이 문서를 지목했는데 여기 항목이 없었다.
앞으로 이 절이 그 소스다 — 파일을 만들면 `not-run/` 또는 실행 결과에 따라
해당 폴더로 옮기고 여기서 지운다.
| 검증할 것 | 왜 | 출처 |
|---|---|---|
| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` |
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 |
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |

View file

@ -10,9 +10,12 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
길게 잡음.
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치
엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(디스패치
엔진)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ 단 M2 착수를 막는
순서 문제가 하나 열려 있음** — M2↔M3 양방향 의존, 소스는
`.claude/question.md` 2번(설계 결정이 아니라 마일스톤 경계 문제라
"설계 게이트는 없다"는 아래·`todos.md` 서술과 모순되지 않음). 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`
@ -41,6 +44,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
- `.claude/reference/`**[2026-08-07 신설]** base처럼 확정된 건 아니지만
base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷,
Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것.
**[2026-08-21 확장]** 확정된 결정의 **근거 기록**(그 결정이 왜 그렇게
났는지)도 여기 둠 — `research/`를 떠났지만 `archive/` 대상은 아닌 것들.
어떤 문서가 있는지는 `.claude/README.md`가 소스.
- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 전부
후순위. **어떤 문서가 있는지·우선순위가 뭔지는 여기서 세지도 나열하지도
않고 `.claude/README.md``research/` 표로 미룸**(개수뿐 아니라 파일명

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,288 @@
### D-3 — retractor는 자기 자원만 정리
retractor는 자기 하위 위임까지 쫓아가 정리할 필요가 없다 —
`Dispatch.retractFrom`**항상 깊은 인덱스부터 얕은 쪽으로** 정리하므로 이
클로저가 불릴 시점엔 자기 아래는 이미 정리된 뒤다. → **예/아니오**
-> 방향이 그게 맞나? 방향을 더 서술해주길 바람. 실제 process 에선 retract 가 인덱스 1 부터 5, 6, ... 순으로 작동함. 그런데 달라질 때 5, 4, 3, 2 ... 순이 되는건 아니지? '깊은 인덱스' 라는게 무슨 의미?
### D-8 — `Quad.debug` 플래그
동률 경고 print는 모듈 표면의 불리언 `Quad.debug`(기본 `false`)가 참일 때만
찍는다. `Quad.debug`는 새 공개 API 표면이라 `module-lifecycle-plan.md`에도
반영이 필요하고, 다중 인스턴스화 시 인스턴스별인지 전역인지는 그때 정한다.
`Dispatch.listHandlers()`가 이 플래그와 무관하게 항상 호출 가능한지도 구현 시
정한다. → **예/아니오**
-> listHandlers 는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고 싶다면 출력하는 용도임.
### D-10 — 두 패스 순회 계약
`Dispatch.drive`는 Lua 테이블의 우연한 순회 순서에 기대지 않고 **명시적으로 두
패스**(배열 파트 먼저, 해시 파트 나중)로 돈다. 이유는 (1) 다른 백엔드의
이식성, (2) 어차피 구분 비용이 드니 순서 고정이 거의 공짜. **M0 스파이크에서
실제 Luau로 순회 동작을 검증**할 항목이다. → **예/아니오**
-> 루아우의 일반화된 반복 for 이 이를 그냥 지원함. 어떻게 실제로 이해했는지 확인 필요
### D-32 — 재위임 핸들러의 (A) 분기 의무
재위임하는 핸들러는 (A) 분기에서도 **반드시 다시 재위임**해야 한다. 조건부로만
재위임하는 핸들러를 만들면 건너뛰는 자리에서 `Dispatch.retractFrom(inst,k,index+1)`
직접 불러야 한다. → **예/아니오**
-> 무슨말인지 확인 필요
### D-56 — `setLength`의 생명주기 경로
`setLength`가 만드는 Observer는 `:Subscribe()`가 아니라 `bindLifetime(ownerKey,
observer)`로 묶인다 — `ownerKey`가 죽을 때 같이 죽어야 하는 내부 배관이기 때문.
`setLength` 자신은 `recompute`를 직접 부르지 않고 항상 `gatedRecompute`
경유하며, Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이
게이트를 통과한다. → **예/아니오**
-> 맞음. 그런데 ownerKey 가 Slot일 수도 있음. 각 엔진의 bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong 으로 gchold 비슷한걸 수행하면 됨.
### D-58 — 마운트보다 offset 갱신이 먼저
`rawAdd``self.Length:Set(newCount)`(다운스트림 offset/LayoutOrder 갱신이 여기서
동기적으로 끝남) → `element.Parent = target` 순서로 호출해야 한다 — 안 그러면
Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출된다.
-> 추가적으로 보면, Length 설정도 마운트 전임. 액티베이션으로 length 가 결정되었을 때 그걸 넣어주고 나서 최종 마운트를 함.
### D-60 — `Slot.Length``Slot.Offset`은 별개
`Length`는 Slot이 스스로 노출하는 출력값(지금 실제 마운트된 개수, "n개 검색됨"
UI에 그대로 써도 됨)이고 `Offset`은 Dispatch가 등록받아 `recompute`가 채우는
입력값이다 — 서로 다른 두 `Source<number>`다. `Offset`은 마운트 전엔 `nil`이다.
-> 마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이 나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐
### LP-1 — `Connected`는 계산 속성
`Connected`는 저장되는 bool이 아니라 "내가 아직 살아있게 하는 뒷받침 참조가
nil인지"를 확인하는 **계산된 속성**이고, 해제는 그 참조를 `nil`로 만드는
것뿐이다(자료구조를 즉시 재구성하지 않음). → **예/아니오**
-> 무슨 말인지 모르겠음. Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로 참조를 만들 이유도 없음. gc 되어 아에 gcconn 이 없거나, 아직 gc 는 안 되었는데 Destroy 직후라 false 이거나 한것 뿐임.
### LP-2 — Instance 파괴 관측 지점
Instance 파괴 관측 지점은 `Destroying` **하나로 통일**하고 `AncestryChanged`
폴링은 안 쓴다. 다만 실제로는 이 훅을 쓰는 지점이 예상보다 적을 가능성이 크다.
-> 당장은 Effect 뿐임.
### LP-4 — 엔진 레벨 보강
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 정리해주므로
라이브러리가 따로 처리할 필요가 없고, 커스텀 Destroy-time 처리가 필요한
사용자는 `[Event "Destroying"]`을 직접 바인드하면 된다.
-> Effect 임. 그리고 그 슈거인 OnDestroyed 존재
### BR-7 — duck-typing 기각 근거
duck-typing을 안 쓰는 이유는 (a) 우연히 비슷한 모양의 값에 false positive,
(b) 일부 Roblox userdata는 정의 안 된 키 인덱싱 자체에서 에러를 던져 `pcall`
감싸야 하거나 최악의 경우 엔진이 죽는 상황까지 생김 — 둘 다다. → **예/아니오**
-> 더 설명좀 필요
### M-2 — flatten의 판별 수단
flatten은 배열을 훑으며 `isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고
나머지는 전혀 안 건드리고 배열 파트에 그대로 남긴다(그래서 `None`은 flatten을
그냥 통과한다). → **예/아니오**
-> 이것도 이 이후 ProcessedModifier / ProcessedModifierHandler 를 만들면 될듯.
Post/Pre Ref 와 유사히 처리 가능하다고 생각함.
### M-5 — `None`은 raw 저장 계층에만 있는 실재 센티널
`{ TextColor3 = None, mod }``mod:TextColor3(None)`도 둘 다 지원되고, Modifier
setter/`Overridden`/인라인 props는 `None`을 그냥 평범한 raw 값으로 저장·교체할
뿐 특별 취급이 전혀 없다. 실제 "지우기"는 디스패치 단계의 `NoneHandler`
담당한다.
-> 예, 다만 mod:TextColor3(nil) mod 그룹에서 제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임. 이미 그렇게 구현되겠지만, 문서화에 유의를 두지 않았다면 확인해두어야할 부분.
### M-9 — Setter는 리터럴과 변환 함수 둘 다
`:FontSize(value)``:FontSize(function(current) ... end)` 둘 다 지원하고,
**Getter는 안 만든다**(변환 함수 하나가 getter가 필요했던 유일한 케이스를
인라인으로 커버). `old`는 항상 "현재 저장된 그대로" 넘어간다(plain이면 raw 값,
State면 State 핸들 그 자체).
-> 사실 Peek 가 게터라고 봐도 되긴 함, 그래서 애매한 질문이였음.
### BK-9 — `HasBlocked` 신설 안 함
`IsBlocked`/`HasBlockedEmit` 필드는 그대로 유지하고 Blocker 자신의 새 최상위
플래그(`HasBlocked`)는 **신설하지 않는다**`OffWithoutEmit()`이 각 gated
state의 기존 `HasBlockedEmit`을 리셋해주는 것으로 충분하다.
-> 있는게 어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임. 단순 백로깅 상태로 두어도 되나, 당장 개발에 필요 없음. 나중에 사용 필요 요구가 나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가 신설될 가능성이 존재하지 않는다고 못 박기는 이름.
### E-10 — ⚠️ 미해결 항목 확인: dedup 경로의 대칭
그 dedup 경로에서 retract가 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안
하는지 대칭이 실제로 성립하는지는 **아직 확인 안 된 항목**이고, 특히
`EffectHandle`의 내부 Observer cascade가 dedup 분기 안에 제대로 들어가 있는지는
별도 확인 대상이다(M3 착수 전).
-> 확인해봐야한다 생각함. 이로 인해 relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup 이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면 deup 되어야하는듯.
### E-11 — `:Subscribe()`한 핸들의 `:Unsubscribe()` 의미 확장
Observer의 `:Unsubscribe()`는 "미래 재실행만 끊는다"로 충분하지만, Effect의
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회"이므로
`:Unsubscribe()`도 "지금 끝났다"는 신호로 취급해 (1) 내부 Observer 구독을 끊고
(2) 직전 cleanup을 정확히 1회 호출하며 (3) 이후 leaf가 실제로 죽어도 중복
호출되지 않는다. → **예/아니오**
-> 뭔가 애매함. 옵저버에선 leap 바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는 Unsubscribe 안 먹어야 하는거 아님? State<Effect> 오고 emit 당함 process 다시 나는데 문제가 안 되는거임? '(3) 이후 leaf가 실제로 죽어도 중복 호출되지 않는다.' 가 좀 이상해보임.
### R-11 — 분기와 소진
`type(v) == "thread"`면 대기자로 보고 resume 후 `[i] = nil`로 소진,
`"function"`이면 콜백으로 보고 호출만 하고 소진 안 함, `nil`이면 빈 슬롯이라
스킵한다. 새 등록은 `table.insert`가 아니라 **빈 슬롯을 선형 탐색해 재사용**한다.
-> 아니요. table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에 넣어주기에 table.insert 가 맞음. 단순 for 문에서는 해시 슬롯이든 어레이 슬롯이든 상관 없이 전부 for 가능해서, 여기선 callback 들 순서가 큰 상관 없어서 중간에 nil이 허용됨. 그리고 그 중간중간에 table.insert 가 잘 넣어주는게 맞음. None 으로 바꾸면 무한정 불어나지만, nil이면 그렇지 않음.
### SL-4 — 핸들러 계층 값 금지의 근거
`Dispatch/Leaf.luau`가 처리하는 leaf 케이스는 **그 컴포넌트가 지금 만들고 있는
Instance 자기 자신을 가리키는 self-ref 캡처**라 `inst`가 고정돼야 의미가
성립하는데, **Slot은 이미 존재하는 부모에 나중에 독립적으로 붙는 동적 리스트라
그 전제 자체가 없다** — Slot 안의 Ref가 무엇을 가리켜야 하는지 정의가 안 된다.
대체 경로(`slot:Add(Frame { Ref = myRef })`, 여기서 `Frame``Ref`라는 named
파라미터를 받는 **컴포넌트 함수**)가 있어 능력 손실도 없다.
-> 맞고, 틀린건 없는데 Frame {} 로 두면 리프 노드로 보일 가능성이 있어보임. 실 문서상 그렇게 있다면 MyComponent 정도로 바꿔주는게 혼선의 여지가 없음.
### SL-5 — `isMounted` 이중 추적 분리
Slot 컨테이너 자신은 `self._mounted` 필드 하나로, 개별 element는 전역 멤버십
(`elementOwner`)으로 추적한다. `self._mounted`의 트리거 시점은 **Instance
`Parent` 대입 완료가 아니라 `Dispatch.process`가 이 Slot에 대해 실제로 호출된
순간**이다 — 다른 모든 "마운트됨" 판정이 dispatch-process 시점 기준이라 여기만
post-effect 기준이면 일관성이 깨진다.
-> 더 풀어 서술해주길 바람. 판단 보류
### SL-40, SL-43 SL-45
slot 에서 키가 사라지면 nil 로 updateFn 호출되는거 맞지? 근데 데이터 자체가 nil 일수도 있다는 생각. Detach 와 유사한 'KeyGone' 등의 상태지정 enum/싱글톤을 제공해버리고, T|KeyGone 하는게 안전해보인다는 생각.
'`updateFn`이 새 값을 반환하면 밀려난 `prev`**언마운트만**' 는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접 destroy 를 호출 못함(reconcile 중 빼는게 안 되니까)
그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄.
Detach 홀드 중 키 소멸은 KeyGone으로 처리되면 해결될듯. updateFn 은 위에서 if v == KeyGone then ... 처리하는게 있는게 나아보임.
소멸 루프가 `keyIndex`를 순회하는 것 - 또한, 단순히 KeyGone 을 주고, userdata 를 지울지 말지는 유저가 결정하게 위임해버리는게 가장 깔끔하다고 보이는 부분임. 이것으로 SL-45 도 닫아짐.
### SL-48 — Destroy 이후가 공짜로 해결되는 이유
`inst`가 Destroy되면 gcconn이 죽어 `canExecute`가 거짓이 되고, `gchold`
`Relate(inst)` 아래 있어 그 안에 붙잡힌 Observer/클로저(`mounted`/`userdata`/
`keyIndex` 포함)가 전부 GC 대상이 된다 — 명시적으로 구독을 끊는 새 코드가 필요
없다.
-> 더 나아가 slot in slot 에서도 유효한가 생각해보아야함. 아마 그런것으로 알고있음. 피지컬 홀더랑 오너가 다르거든.
### SL-51 — `:Single``:List` 위의 순수 sugar
`:Single``:List`를 0/1개짜리 배열로 감싸는 sugar이고, **key를 고정값으로
두는 게 핵심**이다(값 자체를 key로 쓰면 매번 다른 item 취급돼 파괴+재생성이
강제됨). `index`를 안 넘기는 이유는 형제가 자기 하나뿐이라 항상 상수라서다.
-> 주의할 점이 보임. state<Frame> -> slot {frame} 형태가 될 때 이전 state 에서 변경으로 다른게 와도, slot 이 이전 frame 을 destroy 해버리면 안 됨. 뽑는게 안되면 안된다는건데, list 슈거에서 Detach 가 사용중인지 확인이 필요해보임. 또, Slot 안에 State 가 오면 이를 Slot으로 감싸주는게 유효할텐데, 거기서 안전한지도 봐야함(SL-66 에서 사용되는걸로 보이긴 하나, 안전 유무는 별도의 문제로 보임)
### SL-58 — 배치 밖 단독 재마운트의 엣지 케이스
`state<Slot>` 값이 steady state에서 교체될 때는 부모 Blocker가 이미 꺼져 있어
부모의 `gatedRecompute`가 아직 flush 안 끝난 `slot.Length`로 한 번 계산할 수
있지만, flush가 끝나면 자기 교정된다 — 크래시도 영구 오류도 아닌 한 프레임짜리
낭비라 **손대지 않기로** 했다.
-> offset 을 먼저 설정해 주어, 계산된 오프셋을 받은 다음 리스트의 액티베이션 이후 Length 를 확정된걸 setLength 하고 나서 마운팅 처리를 하는데, 이 경우 length 가 여러번 계산되어 낭비가 나진 않는거로 알고있음. 단순 위치계산 루프가 한번 돌지만, offset 이 전부 같아 set 안 일어나고 가벼운거로 아는데, 아님?
### SL-59 — 재귀적 `Clear()` 금지
죽는 서브트리 내부에서 요소 수만큼 shift+recompute가 반복되므로, 순수 파괴 walk만
하고 outer 쪽 recompute는 자기 위치 하나에 대해 한 번만 돈다. → **예/아니오**
-> 실 상황에 대한 설명 더 필요함. 이 글만 보아서는 어떤 상황인지 정확히 판단 어려워보임. 판단 보류.
### SL-63 — Length 변경은 offset 변경으로만 전파
`recompute``:Set()`하는 대상은 (a) 뒤 형제들의 offset, (b) owner가 Slot이면 그
`.Length` 둘뿐이고, Length 값 자체는 읽히기만 한다 — 새 전파 채널이 아니다.
-> (b) 가 뭔가 이상함. 오너의 length 를 직접 설정하지는 않을것임. 자신 length 를 변경하면, 자동으로 observer 에 등록된것으로 인해 length 가 업데이트 되는 방식일텐데, 그렇지 않음? 최종 리컴퓨팅 결과가 length 가 되는거 아니였음? 그걸 위해 blocker 로 전부 블록 하고도 레이아웃이 멀쩡하지만, 아래에서 offset/length 확인 순회 도는거 아녔음?
### SL-72 — `dispose` 범위에서 Observer/Effect 제외
Observer/Effect는 생존이 gcconn만으로 판정되고 "죽는 순간 트리 부기가 어긋나는"
문제가 원천적으로 없어 dispose 대상이 아니다 — 조기에 끊으려면
`unbindLifetime`으로 충분하다.
-> 우린 조기에 끊는걸 명시적으로 unbindLifetime 로 지원하지 않음. 그건 유저에게 드러나는 표면이 아니고, State<Observer?> 를 사용하는게 적절.
### SL-74 — ⚠️ 미해결 항목 확인: `SetAndDispose`
`Get()``Set(new)` → 옛 값 `dispose`라는 3단계가 불편하다는 지적에서 나온
`source:Apply(SetAndDispose(new))` 또는 `source:SetAndDispose(new)` 후보가 열려
있고, 전자는 `Apply``State`가 아니라 **`Source`를 넘겨주는 함수**여야 하므로
`state:Apply` 시그니처에 영향이 갈 수 있어 **M3 착수 전 방향만이라도** 정해야
한다.
-> 타입 문제 때문에 Apply 를 오버라이딩 해서 source 타입을 함수에 건내주는건 못함. 그럼 source -> state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 못함. 따라서 영향이 안 가고, 그냥 SetAndDispose() 로만 Set() 와 세트로 주는게 나아보이고, 그걸로 확정지어야할것 같다는 생각임.
### SL-75 — 해제 = 0/`None` 재등록
별도 unregister API는 필요 없고 `setOffsetSource(None)``setLength(0)` 재등록이
곧 해제다. 순서가 중요한 이유는 값이 틀려져서가 아니라 **죽는 중인 Source에
쓰기가 날아가기 때문**이고, 해제 시 `slot.Offset = nil`도 같이 해야 stale한
Offset을 공개하지 않는다.
-> 아님. nil 로 만들면 안 되는게, 포탈로 옮기는게 안 됨. 이미 offset 을 들고 가 바운딩 했다면 큰 문제가 생김. 그냥 stale하게 있는게 맞고, 나중에 offset이 멀쩡히 다시 설정되는게 옳음. 언마운트 시 offset stale 은 단순히 맞는 행동이고, 처음 생성 시 0 인것과 유사 동작임.
### SL-76 — `recompute``nil`도 관대하게 skip
정상 상태에선 항상 `None`이 계약이지만 해제/재마운트 전이 구간에서 `nil`
관측돼도 크래시 대신 skip이어야 한다 — 계약 완화가 아니라 순수 방어이고 등록
쪽은 여전히 `None`을 쓸 의무가 있다.
-> 말을 더 정리해주길 바람. 애초에 해제에서 nil이 관측 될 일이 없다고 생각하는데, 그게 아니라면 다시 더 자세히 말을 해주길 바람. 이것만으로는 판단이 어려움
### SL-78 — nested-Slot 결과의 Length만큼 건너뛰기
`updateFn`이 nested Slot을 반환하면 그 아이템은 물리적으로 `result.Length`개를
차지하므로 `pos`가 그만큼 건너뛴다. 남는 캐비엇은 `index`가 raw 스냅샷이라 nested
Slot의 Length가 outer reconcile 없이 나중에 바뀌면 이후 형제들의 `index`가 갱신
안 된다는 것이고, 이건 "`index`는 raw number" 설계의 당연한 연장이라 실시간
정확성이 필요하면 `updateFn`이 직접 처리해야 한다. → **예/아니오**
-> 의미를 모르겠음. 애초에 slot 내의 index 와, length 로 구해진 offset 은 다른 개념인데, 너무 섞어 말하는것 아닌지 생각해보길 바람. 또, Length 업데이트는 상위 slot 이 observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것임? LayoutOrder 내에서 index 만 쓰는게 아니다를 말하고 싶은건지, 더 자세히 말해주길 바람.
### AT-11 — ⚠️ 열린 항목 확인: `Frame { a, a }`
같은 그룹 객체를 두 위치에 놓으면 `groupKey(v, name)`이 그룹 객체별·이름별
메모이즈라 **완전히 같은 키**가 나와 claim 체크를 통과하고, 두 위치가 하나의
체인을 공유하다가 `k=1` retract가 `k=2`의 바인딩까지 철거한다. `Ref`처럼
`bindLifetime`으로 막을 수 **없는** 이유는 그룹 Attribute 값은 여러 곳에서 쓸 수
있어야 하기 때문이고, 그래서 **위치별 claim 레지스트리를 하나 더** 두기로 방향은
확정됐으나 **키를 무엇으로 할지와 `nameClaims`와의 공존은 미정**이다.
-> groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길 바람. 다만 충분하다고 생각하는 이름임.
### AT-13 — 해제→재클레임 순서는 Dispatch가 보장
같은 핸들러 재프로세스는 `retractor(v)``process`, 핸들러가 바뀌면
`retractFrom``process`라 어느 경로든 옛 claim 반납이 먼저다.
-> 정확히는 같은 핸들러 재프로세스는 retractor 를 process 에서 굴리고 자기 작업을 함. 따라서 process → calls retractor(v) → process new one 이 맞는걸로 보이는데. 단순 축약상 정보유실인것 뿐이지, 실제 구현은 저렇게 알고 있는게 맞음?
### AT-20 — 생존 이름도 매 사이클 철거→재등록
클로저는 인자(새 값)를 안 보고 자기가 등록한 키 전부를 균일하게 철거하며, 비용은
`StoreBind` 재구독과 같은 값 `setAttribute` 한 번뿐이다. 그룹 전용 체인이 된
지금은 최적화도 가능하지만 옛 이름 집합을 또 들고 있어야 해 부품이 늘어나므로
**기본은 균일 철거 유지**다.
-> 생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지 비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기 때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님.
### TW-12 — `CanAnimate`
생략하면 기본 `true`이고, `false`로 resolve되면 `Tween`으로 안 감싸고
`self:Get()`을 그대로 반환한다 — reduceMotion류 접근성 우회가 이 필드 하나로
표현된다. 케이싱은 나머지 필드와 맞춰 `CanAnimate`(PascalCase)다.
-> 단순 boolean 으로만 설명했는데, 정확히는 CanAnimate: state<boolean> | boolean | nil 이다. 이미 그럴것으로 보이고, 아니라면 정정해야함. 다른것과 똑같게, 필요 시 바로 Get()
### TW-16 — `initValue`는 에이전트 범위 밖
초기 진입 애니메이션은 필요해지면 **사용자가 직접 코드베이스+문서를 만지기로**
확정됐고, 에이전트는 임의로 착수하지 않는다.
-> 틀리진 않았는데, Human todo 에 언급이 없음.
### UI-5 — ⚠️ 확인 필요 항목
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 gcconn/gchold 셋업을
거치는지 구현 시 확인해야 하고, 안 거치면 여기서만 조용히 미아가 된다.
-> 애초에 똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터 실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든 gcconn/gchold 가 셋업되는게 맞음. 확인했고 해소된 요소가 될듯.
### UI-8 — `mapTweenValue`가 필요한 이유
`v``Tween<number>``wrap` 변환을 **`Tween`을 벗기지 않고 `.Value`에만**
적용해야 하므로, `table.clone``Value`만 교체해 `Tween(opts)`로 다시 만든다.
`wrap`이 항등인 키도 분기 없이 이 헬퍼를 거친다.
-> 그냥 펑터 구조를 그대로 줘도 무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성. Tween<T>:Map(T) -> Tween<T> 가 타입 상 안전히 가능하다. 이는 Tween<T> 에 대한 기본 정의를 두고 다른곳에서 Map 해서 재사용하는 구현도 가능케 할수도 있게 보이긴 하나, 사용 케이스가 넓지는 않을것. 단 외부에 보이는게 무해하고, 어차피 내부 구현 상 필요하므로 같이 만들어도 좋아보임. 혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임.
### UI-11 — 자식 파괴 시 `retractFrom` 호출
자식을 파괴할 때 실행 중인 엔진 Tween이 남아있을 수 있으므로
`Dispatch.retractFrom(child, prop, 1)`을 같이 부르는 게 정석이고, retractor 안에서
**다른 키**에 대한 `retractFrom`은 허용된 경로다. → **예/아니오**
-> 자식 파괴 시 사실 Tween 은 엔진에 의해 자동 멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라 의미가 없을것이다.
### ML-5 — 멱등 가드는 `module`을 키로 하는 `Relate`
`InitXxx`가 파일 스코프에 `Relate()` 하나를 두고 `module`을 키로 "이 인스턴스에
이미 Init됐는지"를 기록한다. `require` 캐시로는 부족한 이유는 그게 **파일**
단위인데 `New()`는 여러 `module` 테이블을 만들 수 있어서다. → **예/아니오**
-> 새로운 커밋에서 이것이 달라짐.
### ML-9
`Quad.debug: boolean`(기본 `false`)은 라이브러리 자체의 디버그 스위치이고 지금
게이팅하는 건 핸들러 우선순위 동률 경고 print다. 기본이 `false`인 이유는
라이브러리가 사용자 콘솔에 아무것도 안 찍는 게 기본이어야 하기 때문이고,
다중 인스턴스화 시 인스턴스별인지 전역인지와 `listHandlers()`가 이 표면에
속하는지는 **미정**이다.
-> D-8 가 해소시킴
### LH-8 — 스코프 판단이 틀렸던 것
"(a) 자기 프로퍼티만 / (b) 서브트리 전체" 중 "(a) 메커니즘은 (b)를 못 준다"는
판단이 틀렸음이 드러났다 — 배열 파트 루프가 각 자식의 마운트를 동기적으로
끝내므로 (a) 메커니즘이 사실상 (b) 스코프를 공짜로 준다. 진짜 경계는
(a)/(b)가 아니라 **"자기 아래 vs 자기 위"**였다.
-> 사람이 너무 이해하기 어려운 표현임. 틀린말은 아닌듯 한데, 너무 어려워서 이것만 보고 판단은 안될듯. 풀어 보여줘야함.
+
`OnRendered`라는 이름이 `componentDidMount` 같은거 구현 가능하면 좋긴하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가 하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을 이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고, 나중에 필요하다는 의견이 나오면 재생각 해볼 예정.
+
destroySlotTree 가 소유를 명시적으로 지워야할 이유가 있냐 의문. Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 동작일 수 있어보임. 정확한 형태가 어떤지 알아봐야할 상황.
+
Length 를 통해 '먼저 밀어내고 나서' 그 공간에 넣는다가 지금 관행인데, 다른 crud 와 list 도 이게 통하는지 봐야함. 만약 밀어내고 당기지 않은 상태에서 그 공간에 넣는다 하면, 밀어내는걸 구현해야하는 백엔드에서 골치아파짐. 지금 어떤 상황인지 확인해볼것
+
지금 명시적으로 Attribute(store1, store2, ..., {plain = "table도 됨"}) 로만 되어있고, plain= 에 state/source/T 가 올 수 있음을 안 알려주는 모양으로 나오는데, 그렇게 적어도 된다 생각함. 엔지니어링 비용이 없고, 그냥 이미 그렇게 구현되도록 만들 계획이였다고 생각함. 그리고 뒤에 오는기 이기는 것 또한, 잘 명시되어있나 봐야함.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
# 구현 전 QA 5라운드 — 사용자 회신 원문 (2026-08-21)
**이 파일은 회신 원문 그대로의 기록이다** — 처리 결과의 소스는
`pre-implementation-qa-round5-followup.md`. 4라운드와 같은 구성
(문항지 / 회신 원문 / 처리 결과 3파일).
---
### DE-7
_detached 를 모든 slot 이 가져야하나는 의문이 듦. 내부적으로 getOrSet 해서 detached 를 리턴해주는 유틸을 만들고, nilable 해도 되지 않나라는 생각. destroySlotTree 가 _detached 를 인덱스 해보고 nil 아닌지 보고 돌리는게 더 나아보이는데, 테이블 생성 비용을 모든 slot 이 가져야하나는 의문. List 슬롯에서만 작동하는것인데, 너무 광범위하지 않은가? if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가?
순수 구현 상 아무 문제 없겠지만, 단순 최적화 문제. 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진 않음.
+
아무것도 없는데 Detach 를 보내면 어떻게 되느냐. prev 없음을 유저가 추적해야하는가, 아니면 detach 를 그냥 prev == nil 일 때 던지면 무시해주느냐
prev 가 마운트 상태에서(Detach 안 함) 다시 prev 를 던지는것은 이번 변경으로 인해서 여전히 문제 없이 잘 작동하는가
### DE-9
error 하면 된다. KeyGone 을 받은 요소는 오직 데이터의 파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운 마운트나 생성을 거부한다.
### DE-11
맞다. 문서화에서만 유의하면 되는 부분. 단순 삽입/삭제가 빈번한 경우를 위한 최적화 일 뿐. 그 이상의 동작을 돕지 않는다.
### DE-13
_detachCleanup 이 정확히 어떤건지 모르겠음. _detached 에 들어가게 될 대상을 말하는것인지? 아니면 detach 된 요소들이 나중에 정리되어야할 때인지?
나중에 detach 했던 요소들을 청소하는것이라면, 소유한것이면 죽이는게 맞긴 하다.
다만 주의해야할 부분이 보인다. 애초에 unowned 의 state 로 받은것은 더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된 것이라 다시 들고와서 자기 자신에 붙이지 않음. 즉 detach 가 unowned 에 대해서 수행되면 prev 가 나중에 nil 이 되는것이다. 그래야 state<Frame> 에서 내부를 교체했을 때, 이전 요소를 안 건들이게 되는것이라 생각하는데, 내 생각이 잘못 흐른건지 검토해달라.
### DE-17
주의 할 점은, state 가 slot 에 바로 안 오기 때문에, updateFn 이 state 를 던져도 싱글 slot화 된다. 그리고 owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용이다.
나중에 새로운 state<Frame> 같은게 나온다면, 이전거는 prev 로 updateFn 이 받으니 그걸 어떻게 처리할지는 updateFn 의 몫. 특히 slot 래핑 안 된 상태로 받아야하는가도 생각해보아야한다. prev 는 이전에 던진 그대로 주는것이므로. 그리고 이전과 같은 state 를 던지면 멱등으로써 새로운 slot 을 만들지 않고 그대로 두는것도 여전해야한다.
### DE-22
의도가 맞다.
### AS-5
activateList 도 결국 상위에 setlength 를 하는것 아닌가? 게이트가 blocker 없이는 호출되지 않는지 확인 필요
### DC-6
더 정확히는, 슬롯을 뽑아냈을 때, 이전에 렌더된 요소들은 여전히 layout order 등을 위해 offset을 연결해두고 있다. 이 상황에서 아에 다른 값을 slot.Offset 으로 쓴다는것 자체가 나중에 포탈에서 깨지는 부분을 생성한다
### DC-11
필요 이유를 모르겠음. length 업데이트를 위한것임?
### DC-14
사실, 외부 입장에서는 그럴 방법이 없어보인다. crud 가 list 시에는 더이상 불가능해지기 때문.
### DC-19
rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?
### SS-2, SS-3
단순히 각 state 에, 이전 emit 을 발생시킨 발행 unique table 를 넣는건 어떤지 고민중. source 에서만 발행되고 emit 상 전파된다.
지금 보이는 문제로는
A -> B --> D
-> C -|
상황에서 결국 dfs 로 순회되어서 D 에 먼저 A 소스의 emit 신호가 온다. C는 아직 받기 전. invalid 하지 않아서 바로 캐시가 읽히고, 그대로 D에 캐시에 쓰인다. C의 변경으로 다시 emit 되긴 하지만, 이걸 잡을만한 방법이 없어보이진 않는다.
이런 문제를 전부 쉽게 푸는 방법이 존재하는데, emit 시 unique 한 테이블 하나를 전파하고 그 테이블 안에 count 값과 invalid 플래그가 들어간다.. 각 source 는 해당 테이블을 가지고 있다가 emit 된다면 invalid=false count=최신으로 값을 로 바꾼다.
with 에서는 여럿 받은 곳에 대한걸 관리하도록 둔다.
-> 생각하다 위쪽껀 이상했음. 이러면 모든 발행된 unique 를 들고 count 를 관리해야한다
아니면 반대로 count 부분만 담는 테이블을 만들고 연결하는건 어떤가?
unique 자신의 실제 인덱스와, source 의 count 와 비교해서 일치해지는가를 본다.
만일 파이프 뒷 요소로 get 되어진 경우 최상위 소스에서 해당 유니크를 가져와 덮어도 된다.
-> bfs 도 생각했는데, 순서를 꼬아두면 문제가 생긴다.
재정리: 차라리 이렇게?
State()
sourceList <- {
[source:weak] -> count
} weak 로 영향받는 source 들을 담는다. 앞단 요소에서 복사, with 시 합친다
rawInvalid <- 단순 emit 시에 바로 false 된다. 캐싱
invalid <- rawInvalid 캐시를 보고 true 라면 sourceList 보고 계산 필요 상태인지 확인한다
더 생각해볼 이야기라 백로깅이나 리서치에 들어가야할듯.
Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분이라 생각이 필요해보인다.
어차피 emit 단순히 전파는 false 이라 연산 비용이 없어 잘 전파되고. 복합 state 상태를 잘 관리해주는게 나아보인다.
대부분 옵저빙을 하여 state 를 읽지, 폴링해서 get 하는 경우도 잘 없으므로 문제 있는 구현으로 안 보인다.
폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref<T> 로 변환해주는 등의 작업을 하는 슈거를 줘도 된다.
비용은 다소 한정적으로 보인다. 해시 for은 이미 빠르고, Source가 중간중간 느는게 아니라 처음 시작점이라 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도라 충분히 가벼워보이는 단일 for 로 해결이 되는것으로 보임.
emit 은 이제, 발행시킨 source 와 count 를 전달하기만 하면 된다. count 를 쓰기 싫다면 단일 테이블을 써도 되는 부분. [source:weak] -> {}
선제 최적화라기 보단 확실히 정해진 동작으로 승격하는 일로 보이는데 어떻게 생각하는가?
### LC-3, LC-4
무슨말인지 모르겠다. 애초에 Slot effect 나 다른 요소들을 소유할 수가 없다.
심지어 내 생각으로는 slot owned 가 canBound 에 들어갈 이유가 있나? 모르겠다
왜냐면 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을 유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?
### EF-3
### AT-1
(inst, groupValue) → k 이면 충분하다. group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name 이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만 위함임. AT-2 도 같이 닫는다
### TW-2
Mapped 로 확정 CR-2 도 해결됨
### DT-4
그렇다. 이러면 스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer 가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 되면 이를 전혀 못 지키기 때문.
Gate 가 emit 에 중간에 가로채서 넘길지 말지 처리를 해줄 수 있게하는 방법을 제공하는게 맞다. 그리고 이 API가 비공개일 이유는 없어보인다.
### CR-3
게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함
생각 상 새로운 state 가 나오지는 않고, Blocker 와 유사히 동작한다. 내부 배선 상 이렇게 되면 되는거 아닌가 생각중.
Gate(function(emit)
^-- 위 emit 은 언제든 사용 가능
return function () <- 상위 emit 발생. emit 쓸지 말지는 자유
end
end)
다만 프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 문제.
### CR-4
부모가 죽기 전까진 detached 정리가 안되니 그건 맞다.
+
Effect 가 지금은 Ref에 대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로 합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐는 생각이 드는 지점. 처음 분기는 Blocker() 의 다른 사용법을 통해 막는다. 그게 더 나은 구현으로 보이는중.
+
확인할 부분이 있다. state<Slot> -> Slot single { Slot } 모양이 될 때 부모 Slot 이 length 를 잘 따라가는가? 아마 그렇다고 보는데, 정확한지 봐야한다.
+
뽑는것은 자유롭지만, Slot 이 들고있다 죽는건 죽어야하는데. 그 처리가 영향을 받았는지 궁금함. 즉
Frame {
Slot {
State(Frame) <- 여기 바인딩은 상위 Frame 죽으면 같이 죽음.
}
}
이건 Effect 쪽에서 뭔가 처리해줄 수 있는게 아닌게, 엔진 자체가 recursive 호출로 전부 죽이는게 일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작이기 때문.
따라서 State 에서 무언가 마운트 된 요소를 뽑아낼 때, 부모가 죽었다면 이미 죽은 요소가 된다. 이건 의도 동작인데, 언급이 되어있나 모르겠음.

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,11 @@
---
## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준)
## ⭐ 최우선 — 설계 결정은 **없음**, 다만 순서 문제 하나 (2026-08-22 갱신)
> **[2026-08-22] 아래 2번(M2/M3 마일스톤 경계)이 M2 착수를 막습니다.**
> 그건 "무엇을 확정할까"가 아니라 "어떤 순서로 짤까"라 성격이 달라서
> 이 절이 아니라 2번에 뒀습니다 — **설계 결정 대기는 여전히 0건**입니다.
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
@ -40,27 +44,16 @@
`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절,
`archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.)
- **[해소됨, 2026-08-18] `DI``D`(Declarative) 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과
완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare
요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게
유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
보완하기로 같이 확정. 코퍼스 반영 완료 —
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
- **[해소됨, 2026-08-19] `PopOnly``Detach` 확정** — 원문과 근거는
`archive/question-resolved.md`. 요지: 이미 있는
`Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데,
`Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히
reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게
구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도
같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`
"`nil` 리턴은 파괴가 기본" 절.
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
헷갈릴 수 있음.
- **`Owned`(3순위, 2026-08-21 신설)**: `:List`/`:Single`의 설치 시점
플래그(기본 `true`, `false`면 어떤 경로로도 파괴 안 함).
`elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐지만
**잠정 이름**이다 — 형용사라 옵션 테이블 키로는 자연스러운데, 실제로
묻는 건 "이 Slot이 요소의 수명을 책임지는가"라서 `OwnsElements`처럼
주어를 드러내는 쪽이 나을 수도 있음. `base/slot-plan.md`
"소유권은 설치 시점에 정해진다" 절.
- **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데
이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이
있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열
@ -86,13 +79,18 @@
안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`
쓰기 시작했음).
- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임
nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`
branded 타입 전부로 일반화) — `brand-plan.md``Brand`
절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을
전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`
이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
nominal 타입 판별 통합 메커니즘 — `base/brand-plan.md`에서 동작/구현
방식은 확정, 이름만 열린 질문(사용자가 직접 제기). `Tag`는 이미
quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
"type namespace"류를 사용자가 검토했으나 미확정. **(2026-08-08 재확인)**
사용자가 다시 짚었지만 여전히 미정.
- **[2026-08-21 갱신] 표면이 바뀌어서 "OOP 인스턴스의 클래스명을 얻는
느낌"이라는 원래 요구는 이제 안 맞는다** — 인스턴스 브랜드로 재작성되며
역조회(`Brand.get`)가 없어졌고, 지금 하는 일은 **집합 멤버십**
(`SomeBrand:is(x)`)이다. 이름 후보도 그 방향으로 다시 볼 것.
- **메소드 케이싱도 같이 볼 것**`:register`/`:is`가 소문자인데 quad
공개 표면 관례는 PascalCase다(`:Get`/`:Set`). base 내부 유틸이라 지금은
기존 `Brand.set`/`Brand.get` 관례를 이었지만, 이름을 정할 때 같이 정리.
- **`Tag`/`Added`/`Removed`/`Merged`(3순위, 사소함, 2026-08-08 세 번째
세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md`
"열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름
@ -117,19 +115,43 @@
- `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계
선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음.
## 2. M2/M3 마일스톤 경계 — **M2 착수 전에 답이 필요** (2026-08-22 신설)
**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. 낮은 우선순위 — 열려 있지만 급하지 않음
- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`
구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지,
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level
@ -164,31 +186,6 @@
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던
요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
`mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
`userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
기본" 절.
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 —
`base/attribute-plan.md`의 "이름 소유권" 절.
- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** —
`Get()``Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게
불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))`
(단 이때 `Apply``State`가 아니라 `Source`를 넘겨야 함)와
`source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번
범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것
(`state:Apply` 시그니처에 영향), `base/slot-plan.md``dispose` 절.
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
@ -201,10 +198,6 @@
문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적
롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정
불필요 — M10 구현 시점에 판단.
- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면
error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정
(제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인
array-part 값 객체" 절.
- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고.
채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를
넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은

View file

@ -43,9 +43,14 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열
— quad Store가 이 naive BFS 방식을 그대로 베끼면 안 되는 이유.
**[2026-08-14 보강]** quad가 이걸 피하는 방식은 "전파를 중간에 끊는 것"이
아니라 **애초에 push 시점에 계산을 안 하는 것**(pull-recompute + 노드별
캐시) — 신호는 두 경로로 두 번 도착해도 계산은 `:Get()` 때 한 번뿐.
즉 quad도 중복 *통지*는 접지 않고 중복 *재평가*만 안 일어남
캐시) — 신호가 두 경로로 도착해도 계산은 `:Get()` 때 한 번뿐.
(**[2026-08-21]** 이제 신호 자체도 두 번 안 온다 — 아래 갱신 참고.)
즉 quad도 중복 *재평가*는 안 일어남
(`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절).
**[2026-08-21 갱신]** 여기 있던 "중복 *통지*는 접지 않는다"는 뒤집혔다 —
소스 에포크 비교를 채택해 **두 번째 통지도 접힌다**
(`base/state-epoch-plan.md`, 역전 원문은
`archive/always-propagate-no-dedup-superseded.md`).
- **정리 모델**: 의존성 엣지(`parents`)와 구조적 소유(`owner`/`owned`)를 같은
`Node`에서 두 개의 별도 관계로 분리 — CHANGELOG 0.2.0에서 "destroy가 더 이상
reactive dependent까지 타고 내려가지 않고 owned만" 으로 명시적으로 고침(초기

View file

@ -0,0 +1,187 @@
# `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 — 결정 근거 기록 (2026-08-21)
**상태**: **[2026-08-21] 전량 확정·승격 완료.** 이 문서는 이제 "왜 그렇게
정했나"의 근거 기록이고, **지금 유효한 설계는 `base/`가 소스**다 —
`base/state-epoch-plan.md`(`Epoch`/`EpochMap`/State의 두 맵),
`base/brand-plan.md`(인스턴스 브랜드), `base/source-state-plan.md`(`Source`가
`Epoch`를 구조적으로 만족 + `Observer` 클로저 시그니처),
`base/effect-plan.md`(`Effect`가 자기 `EpochMap`을 듦).
**[2026-08-21 `research/``reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한
설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로
(`slot-attach-decomposition.md`와 같은 처리).
**[2026-08-21] 열린 항목 없음** — 마지막까지 남았던 리비전 증가 방식도
같은 날 `bit32` 랩으로 확정됐다(§4의 4번).
**⚠️ [2026-08-22] 아래 본문의 `{Epoch}` 표기는 제안 당시 표기 그대로다** —
승격 후 실측에서 그게 Luau의 **배열**(`{[number]: Epoch}`)이라 실제 게이트
배치(`{[Epoch]: true}` 집합)와 안 맞는 게 드러나 **`EpochSet`으로
확정**됐다(`base/state-epoch-plan.md` §3). 마찬가지로 `Observer` 클로저의
`from`은 **옵셔널**이다(설치 발화엔 출처가 없다). 정본은 언제나 `base/`.
발단은 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는" 갭
(`base/effect-plan.md`의 다중 deps 절, 2026-08-21 대화에서 에이전트가 제기).
아래는 그 결론에 이른 제안·평가·회신을 그대로 보존한 것 — 원래 서술은
"승격 대기 중인 제안"이었다.
## 1. 제안 (사용자 원문 요지)
1. **에포크 부기를 State에서 떼어내 컴포지션 가능한 객체로.** 가칭
`EpochMap()`, 표면은 `EpochMap:Update(Epoch|{Epoch}) -> boolean`이고
그 boolean이 **"뒤로 전파가 필요한가"** 를 뜻한다. *"이건 invalid 랑은
다른 구현이야."*
2. **`Observer`는 emit의 주체를 클로저 인자로 흘려준다**(값이 아니라 출처).
3. **`Effect`는 자기 `EpochMap`을 하나 갖고**, 각 dep의 observer 등록 함수
안에서 그걸 `Update` 한다 → 한 파동에 여러 dep가 깨워도 **첫 번째만
`true`** 라 `fn`이 한 번만 돈다.
4. **emit 페이로드에서 게이트를 뺀다.** `emit(from: Source|{Source})`이면
충분하다 — *"하류가 Gate 노드를 받을 이유도 없거든."* 런타임 분기는
`isSource`로.
5. **더 나아가 `Source`가 아니라 `Epoch`로 일반화**한다.
`type Epoch = { Count: number }`이고 **그 자체로 키로 쓸 수 있는 unique
테이블**. `emit(Epoch|{Epoch})`, `Update(Epoch|{Epoch})`.
*"'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님."*
6. **그러려면 `Brand`가 다중 태깅을 허용해야 한다** — 나아가 `Brand` 자체를
**인스턴스화 가능**하게: `Brand()`가 weak `[obj] = true|nil` 하나를 들고,
`SomeBrand:register(obj)` / `SomeBrand:is(obj)`. *"본인이 거기 속하면,
본인이 직접 해당 브랜드를 가져와 등록하면 … `isXXXX`에서 각각의 구현을
넣을 필요가 없어짐. 따라서 외부 확장도 쉬워진다."*
## 2. 에이전트 평가 — 방향은 옳다
- **`EpochMap` 분리는 이미 코퍼스가 발견한 구분을 형식화한다.** 같은 날
`state-epoch-plan.md`가 맵을 둘로 가른 이유가 정확히 "값 유효성"과 "전파
dedup"이 **비대칭으로 움직인다**는 것이었다(승격 후 정본은
`base/state-epoch-plan.md` §4의 "왜 둘인가 — 순회 때문이다" 문단).
후자만 떼어내 재사용 가능한 객체로 만들면, **노드가 아닌 소비자(leaf)도
같은 판정을 쓸 수 있다** — 지금 State에만 있어서 못 쓰던 것.
- **다중 dep `Effect` 갭이 이걸로 정확히 닫힌다.** `A → b`, `A → c`,
`Effect(fn, b, c)`에서 `b`/`c`는 서로 다른 노드라 **접어줄 공통 하류가
없어** 에포크 dedup이 못 도왔다. `Effect`가 자기 `EpochMap`을 들면
그 지점이 곧 공통 하류가 된다. 대안으로 검토했던 "deps를 하나의 파생
노드로 수렴시키기"보다 낫다 — 노드를 더 안 만들고, `effect-plan.md`
확정한 "의존성 N개면 내부 Observer도 N개" 구조를 안 건드린다.
- **게이트를 페이로드에서 빼는 것도 맞다.** 하류는 게이트 identity를 **한
번도 안 쓴다**(에포크 경계로 만드는 안은 `base/state-epoch-plan.md` §8의
"기각된 대안 — 게이트를 에포크 경계로" 항목에서 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도
그대로 유지된다.
- **`Epoch`로의 일반화가 실제로 계약을 정확하게 만든다.** 맵이 `Source`에서
요구하는 건 **identity + 단조 증가 카운터** 둘뿐이다. 그걸 이름 붙이면
`state-epoch-plan.md``루트 Source들의 에포크`라 부르던 것이 그냥 `Epoch`들이 되어
서술도 짧아지고, Source가 아닌 원천(외부 시계 등)도 특수분기 없이 낀다.
- **`Brand` 인스턴스화 — 다중 태깅 필요는 지금 실재한다.** `Source`
`SourceBrand`이면서 동시에 `EpochBrand`여야 하는데, 현행
`Brand.get(x) -> tag`는 **객체당 태그 하나**라 표현이 안 된다
(`base/brand-plan.md`). 게다가 지금 서브타입은 손으로 쓴 OR 체인
(`isState = isSource(x) or Brand.get(x) == StateTag`)인데, 자기 등록
방식이면 `StateBrand:is(x)` **한 번**으로 끝나 조회 수도 준다.
## 3. 대가 — [2026-08-21] 둘 다 사용자 회신으로 해소됨
에이전트가 대가로 짚었던 둘은 **둘 다 실제 비용이 아니었다.**
- **`Brand.get(x)` 역조회 상실** — *"확실히 의미가 없어진것 같습니다 필요하진
않아요."* 코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 멤버십 질문이고, "이
값이 대체 뭔가"를 되묻는 자리는 없다.
- **"포함 관계가 코드 모양에 드러난다"는 성질이 흩어진다** — **에이전트 착오,
철회.** 사용자 지적: *"여전합니다. `PreRefBrand` 가 존재할테니. 거기에
`is()` 를 해서, 코드에 전부 드러나는거 똑같습니다."* 자기 등록 방식이
**여러 브랜드에 등록하기를 강제하는 게 아니므로**, 지금처럼 각 타입이
자기 브랜드에만 등록하고 포함 관계는 predicate 합성으로 한 곳에 쓰면 된다:
```lua
local function isRef(x)
return PreRefBrand:is(x) or PostRefBrand:is(x) or RefBrand:is(x)
end
```
`base/brand-plan.md`가 2026-08-09에 세운 성질이 그대로 유지된다.
## 4. 결정 상태
1. **[해소] `Epoch``Source`가 구조적으로 만족한다** — 사용자: *"전자를
처음부터 생각했고"*. `Source``State`를 구조적으로 만족하는 기존 패턴과
같은 모양.
2. **[해소] 리비전 필드는 공개로 둔다** — *"`Source` 는 예약 이름이 늘어나도
됩니다(store 아님) 그냥 공개 필드로 두는게 맞는게, 그래야 타입 상 `Source`
`Epoch`를 만족해요."* **근거가 정확하다** — 필드가 비공개면 구조적
만족이 타입 레벨에서 성립하지 않는다. `Store`와 달리 `Source`는 키가 사용자
것이 아니므로 예약 이름이 늘어도 충돌하지 않는다.
- **이름**: 사용자가 `Count` 대신 `token`/`revision` 제안. 공개 필드는
코퍼스 관례가 PascalCase(`Slot.Length`/`Slot.Offset`/`Ref.Value`/
`blocker.IsBlocked`)이므로 **`Revision`**(숫자안) 또는 **`Token`**
(테이블안)을 권고.
3. **[해소] 계약은 "직전 값과 다르다"만 요구한다** — 순서 비교(`<`)는 지금
규칙 어디에도 안 쓰이고 전부 `==`/`~=`뿐이라, 단조 증가조차 계약으로는
과하다.
- **⚠️ 다만 사용자 근거 한 줄은 정정이 필요하다**: *"오버플로가 나든 뭐가
되든, 이전값과 다르다는 보장될거예요."* — Luau 숫자는 double이라
**랩어라운드가 아니라 포화**한다. `2^53`을 넘으면 `n + 1 == n`이 되어
**다르다는 보장이 정확히 그 지점에서 깨진다.** 실제로는 도달 불가능하고
(초당 100만 `Set`으로 285년), 그래서 **문제로 보지 않는다** — 근거만
"오버플로해도 다르다"가 아니라 **"도달 불가능하다"**로 적어둘 것.
4. **[해소] 숫자 리비전으로 간다** — 사용자 선택(*"Revision 숫자로 가는걸
저는 선택하고 싶어요"*). 아래는 판단 재료였던 대조: 사용자 대안: *"아니면 안전하게 그냥
테이블을 비교자로 씁시다. 별로 무겁지 않다고 보여요."* 비교가 identity
동등성이라 **기능적으로는 둘 다 성립한다.**
- **테이블**: 포화 문제 자체가 없음. 대신 **`Set` 한 번마다 테이블 하나를
할당**한다 — 트윈처럼 매 프레임 `Set`하는 소스가 여럿이면 GC 압력이
생긴다(quad는 GC-native 아키텍처라 이 축을 신경 써왔다).
- **숫자**: 할당 0, 비교도 더 쌈. 위험은 도달 불가능한 `2^53`뿐.
- **에이전트 권고도 숫자(`Revision`)** — 트윈처럼 매 프레임 `Set`하는
소스가 여럿이면 테이블안은 GC 압력을 만든다.
- **[해소, 2026-08-21] `bit32.bnot(-rev)`로 확정.** 사용자가 인용한
`bit32.bnot(-1) == 0`, `bit32.bnot(-0) == 4294967295`,
`bit32.bnot(-4294967295) == 4294967294`가 **예시가 아니라 연산 자체**
였다 — `bit32.bnot(-a)``a > 0`이면 `a - 1`, `0`이면 `4294967295`
**랩어라운드 감소**이고, 갱신과 랩이 **FASTCALL 하나**로 끝난다.
랩이라 double 포화(`n + 1 == n`)가 안 생긴다. 충돌 거리 자체는
오히려 짧아지지만(`2^53` vs `2^32`) **둘 다 도달 불가능이라 정확성은
동률**이고, 사용자가 hot path 비용을 근거로 골랐다: *"매번 도는
코드인지라, 값 싸게 native call + num 연산으로 가볍게 가고 싶어요."*
- **⚠️ 에이전트가 한 번 잘못 옮겼다** — 형태를 `band(rev + 1, mask)`
적고 "덧셈 위에 fastcall이 하나 더 얹힌다"고 단서까지 달았는데,
사용자 정정(*"제가 말한건, bit32.bnot(-a) 입니다"*)으로 둘 다 틀린
게 확인됐다. 실측·정본은 `base/state-epoch-plan.md` §2.
5. **[해소] State는 `EpochMap`을 둘 컴포지션하고, `:Sync`는 필수 연산이
아니다 — 에이전트 착오 정정.** 에이전트가 "재계산 후 전부 최신으로
맞추려면 `Update` 외의 연산이 필요하다"고 적었으나, **`Update`
`Epoch|{Epoch}`를 받으므로 전체 deps를 넘기면 그게 곧 sync다**(사용자:
*"애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트 된게 있으면
true 를 던지는거라"*). 에이전트가 `Update`를 "하나만 받는 것"으로 좁게
본 탓이다.
- **초기화도 같은 연산 하나로 끝난다**`:With`/`:Compute`의 deps를
전부 `Update`하고 **반환값은 안 보고** `rawInvalid = true`로 둔다.
이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §4의 "노드가
생길 때의 초기값" 절)과 정확히
같은 동작이다.
- **내부 최적화(사용자 제안)**: `Update`가 목록을 돌 때 diff 때문에
읽기가 들어가는데, **한 번 다름을 찾으면 반환값이 이미 `true`
확정**되므로 나머지는 읽지 않고 쓰기만 하면 된다.
- **`:Sync`는 순수 최적화로만 둘 수 있다** — 읽기를 아예 건너뛰고 쓰기만
하는 변형(반환값 없음). *"없다고 안되는건 아닌데, 그냥 다 안 읽고 set
만 해버리는것은 처음 셋팅에 도움은 됩니다."*
6. **[방향 확정] `Observer` 클로저 인자는 `:Compute`와 같은 모양** —
`fn(self, from: Epoch|{Epoch})`. 사용자: *"Compute 와 유사하게 나올 수
있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."*
`base/source-state-plan.md`의 "`:With`/`:Compute` — self 인자도 lazy
핸들로 통일" 절과 같은 결이고, 값이 아니라 **핸들과 메타데이터**만
넘기므로 *"값을 안 실어주는 구독"* 계약도 안 깨진다.
- 반영 시 같이 손볼 것: 그 계약 문단과 인자 없는 `state:Observer()` 유틸,
그리고 `base/effect-plan.md`의 내부 Observer 등록부(여기서 `Effect`
자기 `EpochMap``Update`한다).
7. **[해소] `Brand` 전환 범위와 마일스톤** — 역조회가 불필요하고 포함 관계
성질도 유지되므로 **인스턴스 브랜드로 전면 전환**이면 된다. **커밋된 M1
코드는 `Brand`를 아직 안 쓴다**(`quad-base/src`는 `init.luau`/`Relate.luau`/
`Debug`뿐, 2026-08-21 확인) — 전환 비용은 **문서뿐**이다. `Brand`라는 이름
자체는 여전히 용어 정리 대기(`question.md` 1번).
## 관련 문서 (전부 승격 반영 완료)
- `base/state-epoch-plan.md``Epoch`/`EpochMap`과 State의 두 맵, 수신 규칙.
- `base/brand-plan.md` — 인스턴스 브랜드(옛 단일 레지스트리는
`archive/brand-shared-registry-reversed.md`).
- `base/source-state-plan.md``Source``Epoch`를 구조적으로 만족,
`state:Observer(fn)``fn(self, from)` 계약.
- `base/effect-plan.md``Effect`가 자기 `EpochMap`을 들어 다중 deps 중복
발화를 접는다(이 제안이 닫은 갭).
- `base/gate-plan.md` — 배치 페이로드(4번), 빈 배치 무통지(8번).

View file

@ -0,0 +1,431 @@
# `attachSlot` 책임 분해 — 결정 근거 기록 (2026-08-21 확정)
**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` /
`mountSlotTree` / 얇은 `attachSlot`)이 소스다.
**[2026-08-21 `research/``reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한
설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로.
**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게
하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금
적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것
같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고
생각함."*
아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전
준비 자료"였다.
**⚠️ [2026-08-21 후속] 아래 2절의 제약 `C7`("부기 갱신이 물리 트리 조작보다
먼저")은 그 뒤 *일반 계약으로서는* 폐기됐다** — 같은 날 `native*` 주입 op 계층이
확정되면서(`archive/bookkeeping-before-physical-reversed.md`), "자기 자리를
정하는 것 먼저 / 뒤를 미는 것 나중" 하나로 줄었다. **이 문서의 결론은 그대로
유효하다** — 배치 경로(`materializeSlotTree` → `mountSlotTree`)가 부기를 먼저
끝내는 것은 `C7`이 아니라 **`C6`가 요구하는 별개 사안**이고, 그 역전 문서도
그렇게 명시한다. 다만 아래 표만 단독으로 읽고 `C7`을 지금도 유효한 일반
규칙으로 옮겨 쓰지 말 것 — 지금 유효한 서술은
`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절이 소스다.
**왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`
flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가
아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고
attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게
문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."*
정본은 `base/slot-plan.md`의 "재귀 메커니즘" 절 — **이 문서가 그 확정을
대체하지 않는다.**
---
## 1. `attachSlot`이 지금 하는 일 — 책임 일곱 개
`base/slot-plan.md`의 의사코드를 책임 단위로 쪼개면 이렇다(코드는 그 문서가
소스, 여기선 라벨만 붙임):
| 라벨 | 하는 일 | 층위 |
|---|---|---|
| **R1** | `offsetSource` 생성 → `Dispatch.setOffsetSource(ownerKey, position, ...)``slot.Offset` 공개 | **부모에 대한 자기 등록** |
| **R2** | `slot._listed``activateList``:List` reconcile이 `_elements`를 채움(물리 마운트 없음) | **내용 실체화** |
| **R3** | `slot._mounted = true`, `slot._mountedInst = physicalTarget` | **자기 상태 전이** |
| **R4** | `Dispatch.setLength(ownerKey, position, slot.Length)` | **부모에 대한 자기 등록** |
| **R5** | `getBlocker(slot):On()``:OffWithoutEmit()` + 마지막 `recompute` | **배치 게이팅** |
| **R6** | flush 루프 — 각 요소의 부기 등록(`setOffsetSource`/`setLength`) **+ 물리 마운트(`Parent` 대입)** | **자식 배치** |
| **R7** | 중첩 Slot 요소에 대해 `attachSlot` 재귀 | **재귀** |
**서로 다른 축이 넷 섞여 있다** — (a) 부모에게 나를 알리는 일(R1/R4),
(b) 내 내용을 만드는 일(R2), (c) 내 자식을 실제로 붙이는 일(R6), (d) 상태
플래그와 배치 게이팅(R3/R5). 사용자가 "기능이 너무 다양해졌다"고 한 게 이것.
**호출부는 셋**:
- `SlotHandler.process`(최상위) — `bindLifetime``attachSlot(slotValue, inst, inst, k)`
- R7(중첩) — flush 루프 안에서 재귀
- `rawAdd`(런타임 단건) — 이미 마운트된 Slot에 나중에 nested Slot을 `Add`할 때
---
## 2. 순서 제약과 그 출처 — 왜 지금 모양이 됐나
**이 제약들은 전부 실제로 밟은 버그에서 나왔다.** 분해안을 평가할 때 하나라도
깨면 그 버그가 되돌아온다.
| # | 제약 | 왜 | 출처 |
|---|---|---|---|
| **C1** | `slot.Offset`(R1)이 `activateList`(R2)보다 **먼저** | `:List``updateFn``offset`을 인자로 받는 계약 | `slot-plan.md` `:List` 파라미터 |
| **C2** | `activateList`(R2)는 `_mounted == false`인 상태에서 돌아야 함 → **R2가 R3보다 먼저** | 아니면 reconcile의 `rawAdd`가 매 항목마다 즉시 물리 마운트 + `setLength`를 태워 (a) Blocker 없이 `recompute`가 돌고 (b) nested Slot에 `attachSlot`**두 번** 불림 | `RC-3`/`RC-4`(QA 3라운드) |
| **C3** | `attachSlot`**반환될 때는** `_mounted == true`여야 함 | 런타임 `rawAdd`가 이 플래그로 "지금 붙일까 `_elements`에만 넣을까"를 가름 | `slot-plan.md` `rawAdd` |
| **C4** | `setOffsetSource``setLength`보다 **먼저** | `setLength` 끝의 `gatedRecompute`가 죽는 중인 Source에 `:Set`을 날림 | `dispatch-core-plan.md` 해제 순서 계약 |
| **C5** | R5의 Blocker가 R6 **전체**를 감쌈 | 없으면 등록마다 `recompute`가 돌아 O(N²) | `RC-1` 해결(QA 2라운드) |
| **C6** | R4가 넘기는 `slot.Length`의 **최종값**은 R5/R7이 끝나야 정해짐 | 중첩 Slot 요소의 `.Length`는 그 요소의 `attachSlot`이 돌아야 확정됨 | 구조적 |
| **C7** | 부기 갱신이 물리 트리 조작보다 **먼저** | 백엔드가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있어야 함 | `dispatch-core-plan.md` 일반 계약(QA 4라운드 `C-7`) |
### ⭐ C6와 C7이 정면으로 부딪힌다 — 이게 `F-4-3`의 근본
- **C7을 지키려면** R4(부모에게 내 길이 알리기)가 R6(자식 물리 마운트)보다
먼저여야 한다.
- **C6를 지키려면** R4는 R6/R7이 끝난 **뒤**여야 최종값을 알 수 있다.
**지금은 C7을 지키고 C6를 포기했다** — R4가 `slot.Length`(값이 아직 `0`
State **객체**)를 넘기고, flush가 끝나 `recompute`가 실제 값을 넣으면 부모가
Observer로 다시 반응해 스스로 교정한다. 대가는 **배치 밖 재마운트에서 부모
`recompute`가 2회 도는 것**(뒤에 형제가 있으면 그 offset들이 두 번 `Set`됨).
**단일 함수로는 둘 다 만족할 수 없다** — 한 함수 안에서 R4의 자리가 하나뿐이기
때문. 그래서 이건 "어느 줄에 놓을까"가 아니라 **분해 문제**다.
---
## 3. 분해 후보
### (A) 현행 유지 — 단일 `attachSlot`
- C7 지킴, C6 포기(자기 교정 1회).
- **비용**: 배치 밖 재마운트마다 부모 `recompute` 1회 낭비. 크래시도 영구
오류도 아님(`SL-58`에 이미 "손대지 않기로" 기록됨).
- **문제**: 사용자가 지적한 "기능이 너무 다양함"은 그대로 남는다.
### (B) 2단 분리 — `prepare` / `mount` ⭐ 사용자 제안에 가장 가까움
*"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도"*
를 그대로 구조화하면 이 모양이 된다. **핵심은 R6를 부기(R6a)와 물리(R6b)로
쪼개는 것.**
```lua
-- 개념 스케치. 이름/시그니처 전부 가칭
local function prepareSlot(slot, physicalTarget, ownerKey, position)
-- R1
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
-- R2 (여전히 _mounted == false — C2)
if slot._listed then activateList(slot, physicalTarget) end
-- R5 + R6a + R7-prepare : 부기만, 물리 마운트 없음
local blocker = getBlocker(slot)
blocker:On()
for i, element in ipairs(slot._elements) do
if isSlot(element) then
prepareSlot(element, physicalTarget, slot, i) -- 재귀 → element.Length 확정
Dispatch.setLength(slot, i, element.Length)
else
Dispatch.setOffsetSource(slot, i, None)
Dispatch.setLength(slot, i, 1)
end
end
blocker:OffWithoutEmit()
recompute(slot, bk) -- 여기서 slot.Length가 **최종값**으로 확정 (C6 만족)
end
local function mountSlot(slot, physicalTarget)
slot._mounted = true -- R3 (C3)
slot._mountedInst = physicalTarget
for i, element in ipairs(slot._elements) do
if isSlot(element) then mountSlot(element, physicalTarget) -- R7-mount
else element.Parent = physicalTarget end -- R6b
end
end
-- 호출부(최상위)
prepareSlot(slotValue, inst, inst, k)
Dispatch.setLength(inst, k, slotValue.Length) -- R4 — 이제 최종값 (C6 + C7 동시 만족)
mountSlot(slotValue, inst)
```
- **C6와 C7을 둘 다 만족한다** — 부모에게 넘기는 길이가 처음부터 최종값이고,
그 등록이 어떤 `Parent` 대입보다도 먼저다.
- **각 함수의 일이 하나로 좁아진다**`prepareSlot` = "부기를 정확하게
만든다", `mountSlot` = "그 부기대로 트리에 붙인다".
- **`_mounted`의 의미가 정직해진다** — 지금은 "`activateList`는 지났고 flush는
아직"이라는 어정쩡한 중간 시점인데, 분리하면 문자 그대로 "mount 단계를
지났는가"가 된다.
- **비용**: `_elements` 순회가 2회로 늘고, 호출부가 **셋 다** 두 함수를 순서대로
불러야 한다(빠뜨리면 half-attached 상태).
### (C) 3단 분리 — `register` / `activate` / `mount`
R1(부모 등록)까지 따로 떼는 안. `Dispatch.drive`의 최상위 배열 파트도 같은
3단으로 맞추면 "배치 등록 → 실체화 → 마운트"라는 하나의 모양이 코퍼스 전체에
반복된다.
- **이득**: `Dispatch.drive``attachSlot`이 지금 서로 비슷한데 미묘하게
다른 구조(전자는 Blocker + 본체 루프, 후자는 Blocker + flush)인 걸 하나로
수렴시킬 수 있음.
- **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득
대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**.
### (D) 최소 변경 — 분리 없이 문서만
`attachSlot` 안을 R1~R7 주석 블록으로 명시하고 각 제약(C1~C7)을 그 자리에
달아둠. 코드는 그대로.
- **이득**: 위험 0. **비용**: 근본 문제(C6/C7 충돌, 책임 과다)는 안 풀림.
---
## 4. 어떤 분해든 같이 정해야 하는 것
1. **`activateList``data:Observer(fn)` `bindLifetime`은 어느 단계인가.**
지금은 `activateList` 안에서 `bindLifetime(inst, observer)`를 부르는데,
(B)에서 그건 prepare 단계다 — 아직 아무것도 물리적으로 안 붙은 시점에
`physicalTarget`에 생명주기를 묶는 게 맞는지.
2. **호출부를 감싸는 얇은 `attachSlot`을 남길지.** 남기면 `SlotHandler.process`/
`rawAdd`가 지금처럼 한 줄로 끝나고 "한쪽만 부르는" 오용도 막힌다. 대신
"결국 다시 한 함수"라 분해의 이득이 반쯤 희석된다.
3. **`Dispatch.drive`도 같은 모양으로 맞출지**(후보 (C)와 직결).
4. **prepare만 하고 mount 안 한 중간 상태를 어떻게 다룰지** — 방어할지, UB로
둘지. 코퍼스 기조상 UB + 문서화가 자연스러워 보이지만 확인 필요.
5. **`_mounted` 소비처가 새 정의로도 맞는지** — (a) `:List()`가 마운트 이후에
불릴 때 즉시 `activateList`하는 분기, (b) `rawAdd`의 "붙일까 말까" 분기.
(B)에서는 `_mounted`가 mount 단계에서 켜지므로, prepare와 mount 사이에
`:List()`가 불리는 경로가 있는지 따져야 한다.
6. **`Detach` 정리용 `Effect`(QA 4라운드 `F-3`)를 어디에 설치할지** — 그건
`physicalTarget`에 묶이므로 mount 단계가 자연스럽다. **`F-3`이 먼저
닫히는 게 순서상 낫다** — 그 결정이 이 분해의 요구사항을 하나 더 얹는다.
---
## 5. 지금 상태 요약
> **[2026-08-21 갱신] 사용자 판단으로 (B) 방향 + "공개 표면은 안 쪼갬"까지
> 좁혀졌다** — 구체안은 **6절**, "물리 마운트가 일괄로 밀리는" 차이에 대한
> 검토와 판단 갱신은 **7절**이 최신이고, 이 절과 3절은 거기까지 온 경로다.
- **확정된 건 없다.** `base/slot-plan.md`의 단일 `attachSlot`이 여전히 정본.
- **급하지 않다** — (A)로 두어도 동작은 맞고(자기 교정), M2/M3 착수를 막지
않는다. 다만 **M6(`:List`) 구현 전에는 정해두는 게 낫다** — 그 시점에
`activateList`/`rawAdd`/`attachSlot`을 실제로 짜기 때문.
- **선행 항목**: 위 4-6번대로 `F-3`(`Detach` 보관 위치 + `KeyGone`)이 먼저
닫히면 이 논의의 요구사항이 완전해진다.
---
## 6. (B)를 실제로 쪼갠다면 — 구체안 (2026-08-21, 사용자 질문에 대한 답)
**사용자 판단**: *"자식중 slot 이 있으면 activateList 로 실체화 하고 길이를
구해서 대입해주는게 맞는듯. 즉 말했던것인 B. 그런데 쪼개야할지 의문이 들긴 함.
attachSlot 이라는 이름이 하는 일로써 정당해보이긴 함 … 혹시 쪼갠다면 어떻게
쪼갤것 같아?"*
### 6-1. 먼저 — **공개 표면은 안 쪼개도 된다**
`attachSlot`이라는 이름이 정당하다는 판단에 동의한다. **쪼개야 하는 건
호출부에 보이는 함수가 아니라 재귀 자체다.**
- (B)가 요구하는 건 **"부기는 bottom-up으로 다 만든 뒤, 물리는 top-down으로
붙인다"** 인데, 그러려면 재귀가 **두 번** 돌아야 한다(한 번은 길이를
올리려고, 한 번은 붙이려고).
- 재귀를 두 번 돌리려면 진입점이 둘이거나 모드 파라미터가 필요하다 —
**그게 "쪼갠다"의 실체**이고, 공개 `attachSlot`을 없애자는 뜻이 전혀 아니다.
- 그래서 제안은 **`attachSlot`은 이름·시그니처·호출부 전부 그대로 두고, 몸통을
비공개 재귀 헬퍼 둘로 나누는 것**. `attachSlot`의 몸통은 두 줄이 된다.
### 6-2. 구체안 — `materializeSlotTree` / `mountSlotTree`
이름은 코퍼스가 이미 쓰는 `unmountSlotTree`/`destroySlotTree`의 `...SlotTree`
접미사(= 재귀 walk)를 그대로 따랐다.
```lua
-- 비공개 재귀 1: 부기만 만든다. 물리 마운트 없음.
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- R1 — offset 먼저(C1: activateList가 updateFn에 넘겨야 함)
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
-- R2 — _mounted는 여전히 false (C2: RC-3/RC-4)
if slot._listed then activateList(slot, physicalTarget) end
-- R5 + R6a + R7 — 자식 부기만, 재귀로 길이를 bottom-up으로 확정
local blocker = getBlocker(slot)
blocker:On()
for i, element in ipairs(slot._elements) do
if isSlot(element) then
materializeSlotTree(element, physicalTarget, slot, i) -- 자기 길이를 slot[i]에 등록하고 옴
else
Dispatch.setOffsetSource(slot, i, None) -- C4 순서
Dispatch.setLength(slot, i, 1)
end
end
blocker:OffWithoutEmit()
recompute(slot, bk) -- 여기서 slot.Length가 최종값으로 확정
-- R4 — 자기 길이를 부모에게. 이제 **최종값**이다 (C6)
Dispatch.setLength(ownerKey, position, slot.Length)
end
-- 비공개 재귀 2: 물리만 붙인다.
local function mountSlotTree(slot, physicalTarget)
slot._mounted = true -- R3 (C3)
slot._mountedInst = physicalTarget
for i, element in ipairs(slot._elements) do
if isSlot(element) then mountSlotTree(element, physicalTarget)
else element.Parent = physicalTarget end -- R6b
end
end
-- 공개 진입점 — 이름/시그니처/호출부 전부 그대로
local function attachSlot(slot, physicalTarget, ownerKey, position)
materializeSlotTree(slot, physicalTarget, ownerKey, position)
mountSlotTree(slot, physicalTarget)
end
```
**핵심은 `setLength``materializeSlotTree`의 *끝*으로 간 것** — 자기
길이를 부모에게 알리는 일이 자기 서브트리 부기가 다 끝난 뒤이므로 **처음부터
최종값**이고, 그러면서도 **어떤 `Parent` 대입보다도 먼저**다. 나머지 재귀
단계에서 자연스럽게 대칭이 된다(중첩 슬롯은 자기 `materialize` 끝에서
`slot[i]`에 자기 길이를 등록하므로, 부모 루프가 따로 등록해줄 필요가 없다).
### 6-3. C1~C7 재점검 — 전부 유지된다
| 제약 | 어디서 지켜지나 |
|---|---|
| C1 (`Offset` → `activateList`) | `materializeSlotTree` 앞 두 줄 |
| C2 (`activateList`는 `_mounted == false`) | `_mounted``mountSlotTree`에서만 켜짐 — **오히려 지금보다 더 확실해짐**(중간에 켜질 자리가 아예 없음) |
| C3 (반환 시 `_mounted == true`) | `attachSlot``mountSlotTree`를 부르고 끝남 |
| C4 (`setOffsetSource` → `setLength`) | 중첩: 자식 materialize의 첫 줄(offset) → 마지막 줄(length). 평범: 루프 안 두 줄 |
| C5 (Blocker가 자식 배치 전체를 감쌈) | `blocker:On()``OffWithoutEmit()`이 루프를 감쌈. 자식의 `setLength`가 부모 blocker에 막혀 스킵되는 것도 그대로 |
| **C6 (최종값으로 등록)** | ✅ **이제 만족**`recompute` 다음 줄이라 최종값 |
| **C7 (부기가 물리보다 먼저)** | ✅ **이제 만족** — 모든 `Parent` 대입이 `mountSlotTree`에 몰려 있고 그건 부기가 다 끝난 뒤 |
**부수 이득**: 지금은 배치 밖 재마운트에서 부모 `recompute`가 **2회**
돌았는데(`Length=0`으로 한 번, 확정 후 한 번 — `SL-58`) **1회로 준다.**
### 6-4. 비용과 남는 것
- **`_elements` 순회가 2회**로 는다. 배열 walk 두 번이라 할당도 없고 실측에서
문제될 규모로 보이진 않지만, 명시해둘 비용.
- **비공개 함수가 둘 는다.** 다만 호출부(셋: `SlotHandler.process`, 런타임
`rawAdd`, 재귀)는 **하나도 안 바뀐다** — "한쪽만 부르는" 오용 위험이
구조적으로 없다(공개 진입점이 여전히 하나).
- **안 고쳐지는 것**: `materializeSlotTree`는 여전히 "내 부모에게 등록"(R1/R4)과
"내 자식 배치"(R5/R6a)를 같이 한다. 그것까지 떼려면 부모 등록을 호출부로
올려야 하는데, 그러면 호출부 셋이 전부 두 줄이 되고 순서 실수를 열어주므로
**과한 분해로 보인다.**
- **비대칭 하나**: `mountSlotTree``unmountSlotTree`는 정확한 거울상인데,
`materializeSlotTree`의 거울상(부기 철거)은 별도 함수가 아니라 호출부의
`setOffsetSource(None)``setLength(0)` 관용구다. 이름만 보고 짝을 찾으면
헷갈릴 수 있어 문서화 시 짚을 것.
### 6-5. 그래서 쪼개야 하나 — 내 판단
**쪼개는 쪽을 약하게 추천한다.** 근거는 셋인데 어느 하나도 결정적이진 않다:
1. **C6/C7을 둘 다 만족시키는 유일한 방법**이고, 지금은 둘 중 하나를 포기하고
있다(자기 교정 1회). 값이 틀려지진 않지만 "일반 계약을 세워놓고 자기가
예외"인 상태가 남는다.
2. **`_mounted`의 의미가 정직해진다** — 지금은 "`activateList`는 지났고 flush는
아직"이라는 중간 시점이라 `RC-3`/`RC-4`가 그 미묘함에서 나왔다. 분해하면
문자 그대로 "mount 단계를 지났는가"가 되고, 그 버그 클래스가 구조적으로
사라진다.
3. `attachSlot` 몸통이 두 줄이 되어 **읽는 사람이 "무엇을 언제 하는가"를 두
이름만으로** 파악한다.
**반대로 안 쪼갤 이유**도 정당하다 — 동작이 지금도 맞고(자기 교정), 순회가
하나 늘며, 확정된 의사코드를 건드리는 변경이다. **M6 착수 시점에 실제로 짜보며
정해도 늦지 않다.**
---
## 7. "물리 마운트가 일괄로 밀린다"는 차이 — 손해가 아니라 이득 (2026-08-21)
**사용자 관찰**: *"얼마나 nested 든, 하나 마운트되고 하나 마운트되고… 각
객체들이 `Parent = inst` 되어가며 피지컬에 붙어가는 일자 진행인데, 이건 관측
이후 일괄 등록이라는 차이가 있는듯 … 그러나 이런 사소한, 문제를 야기하기 어려운
동작을 원래와 동치시키기 위해 디버깅이 어려운 함수를 만드는건 옳지 않아보임."*
**결론에 동의한다. 그리고 그 "차이"는 감수하는 비용이 아니라 개선이다.**
### 7-1. 마운트 **순서**는 안 바뀐다 — 바뀌는 건 부기와 섞이느냐뿐
`outer { A, inner { B, C }, D }` 기준:
| | 현행(인터리브) | 분해(일괄) |
|---|---|---|
| `Parent` 대입 순서 | A → B → C → D | A → B → C → D (**동일**) |
| 각 대입 시점의 부기 상태 | 부분적 — A가 붙을 때 `inner.Length == 0`, outer의 뒤 offset은 아직 stale | **전부 완결** |
두 방식 다 `_elements`를 깊이 우선으로 같은 순서로 훑으므로 **관측 가능한
마운트 순서는 동일**하다. 달라지는 건 "그 시점에 부기가 얼마나 완성돼
있는가"뿐이다.
### 7-2. 그 차이를 실제로 관측하는 주체가 있고, 분해 쪽이 더 정확하다
Roblox의 `Parent` 대입은 **동기적으로 `ChildAdded`/`DescendantAdded`/
`AncestryChanged`를 발화**시킨다. 즉 사용자 핸들러가 마운트 도중에 실제로
끼어들어 상태를 읽는다.
- **현행**: A의 `ChildAdded`가 뜰 때 `inner.Length`는 아직 `0`이고, `outer`
뒤 형제 offset도 아직 안 밀려 있다 — **완성 전 스냅샷**을 보게 된다.
- **분해**: 첫 `ChildAdded`가 뜰 때 **서브트리 전체의 `Length`/`Offset`이 이미
최종값**이다.
`slot.Length`를 구독하는 사용자 State/Observer("n개 검색됨" 라벨 등)도 같다 —
분해 쪽이 **한 번, 최종값으로** 발화한다(현행은 `0` → 최종으로 두 번).
`PostRef`가 "자기 아래는 전부 끝난 뒤"를 보장하는 것과 결이 같아진다.
**즉 원래 동작을 보존할 이유가 약한 정도가 아니라, 원래 동작 쪽이 덜 정확하다.**
### 7-3. "합치는 거대 함수"는 애초에 목적을 못 이룬다
*"액티베이션 재귀와 실측시 마운트를 합치는 거대한 함수를 만드는게 아닌이상
해결하지 못할 문제"* — 맞는데, **그 거대 함수를 만들어도 안 풀린다.**
변형을 다 따져보면:
- **완전 병합(측정하면서 그 자리에서 마운트)** — 자식의 최종 길이는 그 자식의
재귀가 **끝나야** 알 수 있으므로, 자식을 마운트하는 시점엔 아직 자기 길이를
모른다. **C6를 못 지킨다** — 그게 정확히 지금 코드(= `Length=0`으로 등록 후
자기 교정)다. 새로 얻는 게 없다.
- **부분 병합(자식마다 `materialize` → 바로 `mount` → 다음 형제)** — 실제로
가능하고 인터리브도 유지된다. 하지만 **자기 길이를 부모에게 등록하는 건
여전히 루프가 다 끝난 뒤**라, 고치려던 부모 레벨 C7 위반이 그대로 남는다.
구조만 복잡해지고 목적은 못 이룬다.
**그래서 실질적 선택지는 둘뿐이다** — (A) 인터리브 + 자기 교정(현행),
(B) 분해 + 일괄. "인터리브하면서 최종값도 아는" 제3의 안은 구조적으로 없다.
### 7-4. 부수 이득 둘
1. **Blocker의 목적이 선명해진다**`mountSlotTree`는 부기를 전혀 안
건드리는 순수 walk라 Blocker가 필요 없다. 결과적으로 Blocker가
`materializeSlotTree` **하나만** 감싸게 되고, "이건 배치 *등록*을 게이팅하는
물건"이라는 정의와 코드 모양이 일치한다(지금은 물리 마운트까지 같이 감싸고
있어 이름과 범위가 어긋난다).
2. **백엔드가 갈아끼울 seam이 생긴다**`mountSlotTree`가 "부기는 끝났고
붙이기만 하면 되는" 순수 함수라, 일괄 삽입이 유리한 백엔드(웹의
`DocumentFragment` 등)가 **이 함수 하나만** 자기 방식으로 바꿔 끼울 수 있다.
인터리브 구조에서는 부기 호출 사이사이에 물리 조작이 박혀 있어 불가능하다.
### 7-5. 판단 갱신 — 약한 추천에서 **추천**으로
6-5절은 "약하게 추천"이었는데, 위 7-2(관측 정확도)와 사용자가 짚은 **구현
실수 위험**을 더하면 근거가 한 단계 올라간다:
- 안 쪼개면 C1~C7 일곱 제약이 **한 함수 안에서 줄 순서로만** 지켜진다 —
`RC-1`/`RC-3`/`RC-4`가 전부 그 줄 순서를 잘못 잡아서 난 버그였다. 분해하면
그중 C2/C3/C7이 **함수 경계로 강제**되어 줄 순서 실수로 깨질 수 없게 된다.
- 남는 반대 근거는 "확정된 의사코드를 건드린다" 하나인데, 위 위험과 견주면
약하다.
**단 여전히 M6 착수 전까지 시간이 있고, 실제로 짜보며 확정해도 늦지 않다** —
지금 확정할 필요는 없다는 판단은 안 바뀐다.

View file

@ -1609,3 +1609,183 @@ Luau 함정을 발견해 `typing-limits.md` §6으로 승격.
(`type function`은 outer local 참조 불가, cross-package엔 `export type
function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로
구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋.
## 2026-08-19 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
원문: `session/2026-08-19-04-qa-round4-questionnaire.md`
사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 …
서브에이전트는 쓰지 말아줘")으로 `base/` 확정 주장 전체를 한 맥락에서 읽으며
"예가 나와야 정상인 문항"으로 전수 문항화. **설계 결정도 정정도 하나도 안
내리고** 문항지(`qa-request/pre-implementation-qa-round4.md`)만 남긴 채 회신
대기로 끝난 세션.
## 2026-08-19 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소
원문: `session/2026-08-19-09-handover-prep-roadmap-status-sync.md`
`quad-roblox-types` 언급 확인 요청에서 시작했으나 훨씬 큰 공백 둘을 발견 —
이 색인에 당일 세션 5개(04~08)가 통째로 빠져 있었고,
`CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 시작 전"이라는
낡은 전제를 깔고 있었다(실제로는 M0/M1이 이미 완료·커밋됨). 둘 다 즉시
반영하고 감사 라운드로 재검증.
## 2026-08-20 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
원문: `session/2026-08-20-01-qa-round4-response-processing.md`
업스트림 12커밋(pesde 전환, mise/selene, RunInit 재설계, quad-types/
type-version-check 신설, M0 스파이크)을 먼저 rebase로 병합한 뒤, 사용자
회신을 (a) 바로 반영 / (b) 설명 보강 후 재질문 / (c) 사용자 판단 필요 /
(d) 조사해서 답이 나옴으로 갈라 (a)만 `base/`에 반영. 나머지는
`pre-implementation-qa-round4-followup.md`로 정리해 남김.
## 2026-08-21 — `Detach` 보존 주체/`KeyGone`/`Owned` 확정, `attachSlot` 분해
원문: `session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md`
QA 4라운드의 마지막 열린 항목이 닫힌 세션. gcconn 트릭 때문에 detach된
quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체를
`userdata`**`slot._detached` 필드**로 뒤집고, 키 소멸 처분을 **`KeyGone`
센티널**로 확정(재-`Detach`는 nop, `prev` 반환은 재마운트). `Detach`(사이클
단위)와 **`Owned`**(설치 단위)를 직교 축으로 분리해 `state<Frame>` 의미론
충돌도 해소. 같이 **`attachSlot``materializeSlotTree`+`mountSlotTree`로
분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수
안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를
건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정.
## 2026-08-21-02 — QA 5라운드 전량 처리 + `Gate` 표면·State 에포크 확정, `Epoch`/`Brand` 제안
원문: `session/2026-08-21-02-qa-round5-and-gate-epoch-research.md`
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설 —
**4라운드에 문항이 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`,
그리고 문서가 아닌 **실제 커밋된 M1 코드**), **그 이후 확정된 것**
(`Detach`/`KeyGone`/`Owned`/`attachSlot` 분해), **큰 문서의 심화**로 범위를
좁힌 205문항. 같은 세션에 회신까지 받아 14건 즉시 반영 — `slot._detached`
lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`
`_detached`에 안 들어감**, 조상 파괴 시 unowned도 같이 죽는다는 계약 신설,
`groupClaimKeys` 키 확정, `Tween<T>:Mapped` 확정, 4라운드가 빠뜨렸던 `E-10`
실반영, **"게이팅 먼저"(M2로 앞당김)**. 새로 열린 두 갈래는 `research/`로 —
공용 **`Gate`** 노드(emit을 가로채는 정책 노드, `Blocker`/`Debounce`가 그 위)와
**State 에포크 검증**(DFS 전파 중 `Get()`이 섞인 값을 캐시하는 glitch를
정확성 문제로 다룸). **2차 회신으로 되물은 6건도 같은 세션에 전부 확정**
`Slot:Replace` 신설(교체가 시프트 2회 → 0회), 물리 조작을 주입 op로
(`mountInst`/`unmountInst`, base는 `Parent`를 모른다), `rawAdd` 의사코드 신설,
`rawAdd``Length:Set` 제거, **래핑/언래핑 한 쌍**(`wrapElement`/`unwrapElement`),
**`setLength`에 `anchor` 인자**(4라운드 `D-56` 역전 → `isBoundAlive` 세 번째
분기 항목까지 닫힘), `Effect(fn, ...deps)`(`Ref`도 의존성). **3·4차에선 `mountInst`가 삽입 위치를 못 받는다는 지적에서
시작해 offset 부기 모델이 정리됨** — `None`의 뜻을 "발행 채널 없음"으로 좁히고
`Dispatch.getOffsetAt` 신설(pull), 그 과정에 **중첩 offset이 부모 베이스를 못
받던 결함**(depth ≥ 2에서 통째로 밀림)과 **재마운트가 `Offset` Source를 새로
만들던 결함**(포탈이 깨지는 자리)까지 발견·수정. **커밋 전 감사 2라운드가 또 실질적인 걸 잡았다** — 확정한 `Owned`
`Slot:List` 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, `effect-plan.md`
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. 마지막 라운드에선 **`native*` 물리 조작 계층**(Slot 스코프의 `raw*`와 갈리는,
확정된 offset/length 기반 여섯 op)이 확정되고 그 여파로 **4라운드 `C-7`("부기가
물리보다 먼저")이 역전**됐다 — base엔 자리를 비워둘 수단이 없고 미는 주체는
백엔드 삽입 연산 자신이라, 규칙이 "자기 자리 먼저 / 뒤를 미는 것 나중" 하나로
줄었다. **`Gate`만 사용자 지시로 다음 세션 — M2를 막는 유일한 항목.**
**같은 세션 후반 — 리서치로 신설했던 둘이 그 자리에서 확정됐다.**
`Gate`는 **탑레벨 프리미티브 없이 `state:Gate(setup)` 메소드 + `GateNode`**로,
State의 재계산/전파 판정은 **소스 에포크 비교 채택**으로 닫히며 두 문서가
`research/``base/`로 승격(`base/gate-plan.md`, `base/state-epoch-plan.md`).
그 여파로 `source-state-plan.md`의 확정 서술 둘("emit은 **항상** 전파 / quad가
접지 않는 것은 중복 *통지*뿐")이 역전돼
`archive/always-propagate-no-dedup-superseded.md`로 갔다 — **2026-08-14의
`invalid` 기반 dedup 금지를 되돌린 게 아니라는 것**을 세 곳에 못박았고,
스파이크 `05`는 핵심 assert가 정반대가 되어 `rewrite-required/`로 돌아갔다.
**에포크 기제는 네 라운드에 걸쳐 사용자 정정으로 다듬어졌다** — 순회 조건이
`rawInvalid == false`로 뒤집히고, emit은 count 없이 출처만 싣고, 순회가 값만
앞당기고 통지는 상류 emit을 기다리게 하려고 맵이
`sourceCountMap`/`sourceEmitMap` 둘로 갈렸다(에이전트가 한 번 철회했던 분리가
**다른 근거로** 되살아난 것). 새 노드의 두 맵은 **비대칭 초기화**
(emit 맵은 비우고, count 맵은 실제 값으로 채운 뒤 `rawInvalid = true`).
게이트는 흡수 집합을 들고 **flush 시 스왑**해 배치를 떼어 넘기며, 게이트가
게이트 emit을 받으면 **풀어서 자기 집합에 합치고**, **빈 배치면 통지 자체를
안 한다**(기존 `HasBlockedEmit` 계약의 일반화). 그 따름정리로
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠졌다.
**`/code-review high`를 두 번 돌려 19건이 나왔고 전부 유효했다** — 그중
재진입 시 빈 배치가 새던 것과 `OffWithoutEmit`이 흡수 집합을 안 비우던 것은
실제 유실 경로였다. 반대로 리뷰가 제기한 것 중 **에이전트 서술 자체가
틀렸던 것도 셋** — "Gate 재진입 계약"은 `Blocker`의 인스턴스 중첩 규칙을
잘못 옮긴 것이었고, "다중 소스 배치의 중복 계산"은 동기 전파를 놓친 것이었고,
"빈 배치 = 무조건 통지" 권고는 State 층에 `Source:Emit`을 추가하는 격이라
기각됐다.
**마지막으로 사용자가 `Epoch`/`EpochMap`/`Brand` 재구성을 제안**했다 — 에포크
부기를 State에서 떼어 컴포지션하고, emit 페이로드를 `Epoch` 인터페이스
(`{Revision: number}`, 그 자체로 키)로 일반화하고, 그러려면 `Brand`
**인스턴스 브랜드**(다중 태깅)로 바꾸는 안. 발단은 **다중 의존성 `Effect`에서
한 파동에 `fn`이 두 번 도는 갭**(공통 하류가 없어 에포크 dedup이 못 접는다)이고,
`Effect`가 자기 `EpochMap`을 들면 그게 공통 하류가 되어 닫힌다. **사실상 전량
확정됐으나 세션 길이 때문에 `base/` 승격은 다음 세션으로 미뤄졌다** —
`reference/epoch-brand-composition.md`가 소스, `todos.md` 000번이 진입점.
---
## 2026-08-21 (세 번째) — `Epoch`/`EpochMap`/`Brand` 전면 승격, 그리고 해소 기록 flatten
원문: `session/2026-08-21-03-epoch-brand-promotion-and-flatten.md`
앞 세션이 미뤄둔 승격을 실제로 수행했다. **`Epoch`**(`{Revision: number}`,
그 자체로 키가 되는 unique 테이블 — `Source`가 구조적으로 만족하고
`EpochBrand`에도 등록됨)와 **`EpochMap`**(`:Update(Epoch|{Epoch}) -> boolean`이
"뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`)이 `base/`에 들어갔고,
State는 그걸 **둘** 컴포지션한다(`sourceCountMap`/`sourceEmitMap` →
`valueEpochMap`/`emitEpochMap`). **`Brand`는 인스턴스 브랜드로 전면
재작성**됐다 — `Brand()` + `:register`/`:is`, **다중 태깅 허용**, 역조회
`Brand.get`은 제거(옛 표면은 `archive/brand-shared-registry-reversed.md`).
그 부수로 **`effect-plan.md`의 다중 의존성 중복 발화 미해결 항목이
닫혔다**(`EffectHandle`이 자기 `EpochMap`을 들어 첫 번째만 통과시킴).
**마지막 미정이던 리비전 갱신 방식도 같은 세션에 `bit32.bnot(-rev)`
확정**됐다 — 사용자 논거는 *"2^53 포화는 어차피 도달 불가능한데 그걸
피하겠다고 값을 double 영역까지 키울 이유가 없다, 매번 도는 코드라 값싸게
가고 싶다"*. **에이전트가 이 형태를 `band(rev + 1, mask)`로 잘못 옮기고
"그러니 `bit32`가 더 싼 건 아니다"라는 단서까지 붙였다가 사용자에게
정정당했다**(*"제가 말한건, bit32.bnot(-a) 입니다"*) — 사용자가 근거로 든
REPL 출력 셋이 예시가 아니라 **연산 자체**였다. `luau` 실측 결과
`bit32.bnot(-a)``a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드
감소**이고 갱신과 랩이 **FASTCALL 하나**로 끝난다. 즉 사용자 서술이 맞았고
에이전트 단서가 틀렸다. 따름정리로 리비전은 **증가가 아니라 감소**하며,
`==`/`~=`만 쓰는 지금 규칙에서만 무해하다는 경고를 `base/`에 남겼다.
**`Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 없다.**
커밋 전 `/code-review high`가 **9건을 더 냈고 전부 유효**했다(감사자 3라운드가
수렴한 뒤였다 — 둘이 보는 축이 다르다는 `conventions.md` 서술의 재확인).
치명적인 둘은 **표기가 실제 메커니즘과 안 맞던 것**이다: (1) `{Epoch}`
Luau에선 **배열**인데 실제 게이트 배치는 `{[Epoch]: true}` **집합**이라, 그대로
`ipairs`로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다 → `EpochSet`으로 확정,
(2) 새 노드 시딩("상류의 `Epoch`를 전부 끌어와")이 **확정된 `EpochMap` 표면으로
표현 불가능**했다(`:With`의 상류는 State이지 `Epoch`가 아니고, 키 열거/병합
연산이 없었음) → `:TrackFrom` 신설. 그 외 `GateNode` 예외 미기록, 설치 발화의
`from`이 non-optional, `2^32` 랩을 "똑같이 도달 불가능"이라 한 **틀린 수치
근거**(같은 척도로 285년 vs 72분 — 실제 안전 근거는 도달 시간이 아니라 충돌
조건이 한 점이라는 것), 옛 이름 잔재(`TweenTag` 3곳/`Effect(fn, state?)` 4곳),
`§` 참조 3곳.
**에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정**됐다 — `:Refresh`는 그대로
(*"Update 에 인자 없는건 좀 아니야 … 리프레시는 내가 받았던걸 처리하겠다는거라
표면적 의미 자체가 다르지"*), `:Absorb`**`:TrackFrom`으로 개명**
(*"absorb 는 … 상위 요소에서 제거할것만 같은 이름"* + `gate-plan.md`가 이미
"흡수 집합"을 다른 뜻으로 씀). 이 세션의 반복 교훈은 하나 — **승격은 문장을
옮기는 작업이 아니라 표기가 가리키는 것이 실제로 성립하는지 확인하는
작업**이다(`bit32` 형태, `{Epoch}` 타입, 시딩 표현 셋 다 같은 유형이었다).
**확정된 결정의 근거 기록이 갈 자리를 정했다 — `reference/`.**
`slot-attach-decomposition.md``epoch-brand-composition.md` 둘 다
`research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니라
"`base/`가 근거로 인용하는 온디맨드 자료"이므로. 폴더 기준 자체에 이 용도를
명문화했다.
**flatten** — 사용자 지적("재정정 기록이 쌓인 부분")대로 세 군데를 걷어냈다.
`question.md`가 스스로 정한 규칙(해소되면 archive로 옮김)을 어기고 다시
절반이 `[해소]`로 차 있어 **16건을 일괄 이관**(421→208줄), `todos.md`
"M3 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다.
이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다"
경고만** 달았다.

View file

@ -0,0 +1,71 @@
# 2026-08-19-04 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
**한 줄**: 사용자 요청으로 `base/` 확정 전체를 "예가 나와야 정상인 문항"으로
전수 문항화해 `qa-request/pre-implementation-qa-round4.md`를 만들었다.
**설계 결정은 하나도 안 내렸고, 정정도 하나도 안 했다** — 회신 대기 상태.
## 사용자 요청 원문
> 요즘 변경이 엄청 많고, 틀려서 정정한게 엄청 많아서, 모든 부분에 있어서 내
> 심사를 좀 받아야할듯. 예 가 되어야하는 질문들을 계속, 모든 확정 부분에
> 있어서 해줘. 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등, 모든
> 부분에 있어서 내가 검토해줄게. 우선 틀린게 있으면, 기록만 해둬. 정정은
> 나중에 틀린 부분을 모아서 한번에 내가 어디가, 어떻게 왜 틀렸는지, 원래 뭐가
> 맞는지 기술해서 Markdown 서식으로 보고해줄게. 따라서, 아니요가 된 부분만
> 모아서 하나의 문서로 만들어줘. 일종의 pre implementation QA 라고 봐도 될듯.
> 파일은 원하는 위치에 둬. 이전 라운드 처럼, qa-request 안에 정리해도 될거야.
> 서브에이전트는 쓰지 말아줘. 한 맥락에서 자연스럽게 흐르는 전체 구조를 보는게
> 필요해보임.
## 작업 방식
- **서브에이전트 금지 지시를 그대로 따름**`.claude/base/` 26개 문서
(약 14,900줄)를 메인 컨텍스트에서 의존성 순서로 직접 읽었다. `conventions.md`
"소스코드를 많이 읽어야 하는 리서치는 Agent로 위임" 관례에 대한 **이번
요청 한정 명시적 예외**(사용자가 "한 맥락에서 자연스럽게 흐르는 전체 구조"를
이유로 지정).
- 읽는 순서는 1라운드가 쓴 의존성 순서를 재사용: architecture → source-state →
store → bind-system → dispatch-core → lifecycle-pattern → relate → brand →
modifier → blocker/effect/event/onchange/purity → ref → slot → tag →
attribute → tween → ui-shorthand → component-composition → module-lifecycle →
lifecycle-hooks → fallback → typing-limits → debounce-throttle.
- **컨텍스트 유실 대비로 섹션별 증분 기록** — 문서 몇 개를 읽을 때마다 그
구간의 문항을 파일에 append했다(전부 읽고 나서 한 번에 쓰면 중간에 컨텍스트가
잘릴 때 통째로 날아감).
## 산출물
`.claude/qa-request/pre-implementation-qa-round4.md` — 문서 코드 26개
(`A`/`S`/`ST`/`BS`/`D`/`LP`/`RE`/`BR`/`M`/`SL`/`BK`/`E`/`EV`/`OC`/`R`/`T`/`AT`/
`TW`/`CC`/`UI`/`ML`/`DT`/`LH`/`FB`/`PE`/`TL`)로 나눈 전수 문항지. 문항 수와
문서별 분포는 그 파일 자신이 소스(여기서 반복 안 함 — `conventions.md`
"개수·목록·상태는 소스를 하나만 둘 것").
**문항을 쓸 때 지킨 것**:
- 각 문항은 `예/아니오`로 답할 수 있는 **단정문** — "무엇을 확정했나"가 아니라
"이렇게 확정한 게 맞나"를 묻는 모양.
- **근거까지 같이 넣음.** 결론만 물으면 사용자가 "왜 그렇게 정했더라"를 다시
찾아야 하고, 근거가 틀렸는데 결론만 맞는 경우를 못 잡는다. 실제로 코퍼스엔
"결론은 그대로인데 근거가 뒤집힌" 사례가 여럿 있다(`Tag`의 holders 키잉,
`Blocker` 게이팅의 필요 이유가 크래시→비용으로 바뀐 것 등).
- **⚠️로 열려 있다고 적힌 항목은 "확정이 맞나"가 아니라 "아직 열려 있다는 인식이
맞나"를 묻는 문항으로 별도 표기**(`S-38`/`ST-3`/`ST-8`/`E-10`/`SL-45`/`SL-74`/
`AT-3`/`AT-11`/`UI-5`/`ML-9`).
- 문항 자체가 문서를 오독했을 가능성도 회신 대상으로 명시(회신 안내 절).
## 이번 세션이 **안** 한 것 (중요)
- **정정 0건** — 사용자 지시("우선 틀린게 있으면 기록만 해둬")대로 `base/`
하나도 안 고쳤다. 읽는 도중 "이건 좀 이상한데" 싶은 게 있어도 문항으로만
만들었고 임의 판단을 넣지 않았다.
- **감사 루프 안 돌림**`quad-doc-auditor`는 "중대 변경 후"에 도는 것인데
이번 변경은 신규 파일 1개 + 인덱스 2줄이라 대상이 아니다. `doc-check.py`
돌려 ERROR 0 확인(신규 파일이 처음 낸 절 참조 불일치 1건은 그 자리에서 고침).
## 인덱스 레이어 반영
- `.claude/README.md``qa-request/` 행에 4라운드 항목 추가(**회신 대기** 상태
명시 — 1~3라운드는 "반영 완료"라 성격이 다름).
- `.claude/todos.md` 00번을 "1·2·3라운드 완료 + 4라운드 회신 대기"로 갱신.
- `question.md`**안 건드림** — 아직 사용자 판단이 나온 게 없어서 추가할
항목이 없다.

View file

@ -0,0 +1,104 @@
# 2026-08-20-01 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
**한 줄**: 사용자가 남긴 `pre-implementation-qa-round4-response.md`를 받아
판단이 명확한 것 20여 건을 `base/`에 반영하고, 나머지를
`pre-implementation-qa-round4-followup.md`로 정리했다. 같은 세션에 업스트림
12커밋(M0 검증 + M1 스캐폴딩)을 pull해 문항지 커밋을 그 위로 rebase했다.
## 순서
1. **업스트림 병합 먼저**`git fetch` 결과 `origin/main`이 12커밋 앞서
있었음(pesde 전환, mise/selene, RunInit 재설계, quad-types/type-version-check
신설, M0 스파이크 21~23 추가, ROADMAP/CLAUDE.md stale 정정). 전 세션
산출물(QA 4라운드 문항지)을 먼저 커밋한 뒤 `git rebase origin/main`
**충돌 없이 통과**(문항지가 신규 파일이고, 인덱스 2줄만 수정했기 때문).
2. **회신 읽고 분류** — 회신은 문항 번호별로 자유서술이라, (a) 바로 반영,
(b) 설명 보강 후 재질문, (c) 사용자 판단 필요, (d) 조사해서 답이 나옴으로
갈랐다.
3. **(a)만 문서에 반영** — (b)/(c)는 손대지 않고 followup에만 적었다.
## 반영한 것 중 설계가 실제로 바뀐 것
목록 전체는 followup A절이 소스(여기서 반복 안 함). 그 중 "표현 정정"이 아니라
**동작이 달라지는** 것만:
- **`SL-75`/`D-60` — 언마운트 시 `slot.Offset = nil` 금지.** 옛 서술은
"마운트 전 상태로 복원"이었는데, 사용자가 **포탈이 깨진다**고 지적. 이유가
깔끔했다 — `updateFn``layoutOrder:With(offset):Compute(...)`처럼 그
Source를 파생 그래프에 엮어둔 상태에서 필드를 갈아치우면, 그 구독은 옛
Source를 계속 보는데 Slot은 새 Source를 등록하게 되어 **영영 갈라진다.**
재마운트가 "값이 다시 채워지는 일"이 아니라 "연결이 끊긴 채 조용히 멈추는
일"이 되는 것. 부수로 `Slot.Offset`의 마운트 전 기본값도 `nil``0`.
- **`E-11` — leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹음.**
옛 서술의 "(3) 이후 leaf가 실제로 죽어도 중복 호출되지 않는다"가 사용자
눈에 이상해 보였는데, 따져보니 **성립할 수 없는 문장**이었다 — 이중 바인딩
게이트가 `:Subscribe()`(전역)와 `bindLifetime`(leaf)을 상호 배타로 막아두므로
`:Subscribe()`된 핸들은 애초에 leaf에 붙어 있을 수가 없고, "그 leaf가 죽는"
사건 자체가 없다. Observer와 같은 규칙으로 통일하는 것으로 정리.
- **`AT-20` — 생존 이름 최적화는 "안 하는" 게 아니라 "못 하는" 것.**
옛 서술은 "이론상 가능하지만 부품이 늘어나서 기본은 균일 철거"였는데,
사용자 지적대로 **이름이 같아도 값이 바뀌었을 수 있어** 이름 목록 비교만으로는
아무것도 못 건너뛴다. 값을 비교하려면 `:Get()`이 필요하고 그건 State 계약
위반 — 즉 설계 원칙상 애초에 막힌 길이다. `Tag`가 되는 이유(자원이 이름
집합이라 겹쳐도 합집합)와의 대조까지 같이 적었다.
- **`D-56``bindLifetime`의 첫 인자가 Instance가 아닐 수 있다.**
`Dispatch.setLength(ownerKey, ...)``ownerKey`가 Slot-in-Slot에선 Slot
자신인데, 구현 스케치는 `InstData`에서 gcconn을 찾는 걸 전제하고 있었다.
gcconn 트릭은 엔진 객체에만 걸 수 있으므로 **백엔드가 별도 경로를 제공해야
한다**는 요구사항으로 신설. `isBoundAlive`에 세 번째 분기가 필요하다는 것
(지금은 gcconn 없으면 `.Subscribed` 폴백으로 떨어져 **살아있는데 `canBound`
참으로 잘못 나옴**)까지 같이 짚었다.
## 사용자가 새로 찾아낸 갭 — 문서에 아예 없던 것 둘
1. **`Attribute(a, b, ...)` 생성자의 이름 겹침 정책이 어디에도 없었다.**
`Merged`(error)/`Overridden`(뒤가 이김)만 적혀 있었고, 생성자가 인자들을
하나의 이름 맵으로 평탄화하는데 겹치면 어떻게 되는지가 공백. 배열 순서
규칙과 결을 맞춰 **뒤가 이김**으로 명시했다.
2. **flatten이 소진한 Modifier 자리를 어떻게 하는지가 없었다**(`M-2`).
"`isModifier(v)`인 항목만 필드를 뽑아 merge"만 적혀 있고 그 자리를 지우는지
센티널로 채우는지가 공백 — 그냥 지우면 `PreRef` pre-pass가 `ProcessedPreRef`
소진하는 바로 그 이유(sparse 테이블)에 걸린다. 사용자는 `ProcessedModifier`
센티널을 제안했고, 나는 **flatten이 어차피 새 테이블을 만드는 순수 변환이라
압축(compact)이 더 싸다**고 봐서 두 안을 followup C-3에 나란히 올렸다.
## followup으로 넘긴 것 중 가장 큰 둘
- **`C-1` `KeyGone` 센티널** — 사용자 제안(키 소멸 시 `updateFn`을 한 번 더
불러 처분을 묻기)이 `SL-45`의 어정쩡한 상태를 정확히 메운다. 다만 반환값
의미/`userdata` 수명/소멸 루프 순회 대상/`index` 인자 넷이 안 정해지면 구현이
못 나가서 그대로 물었다.
- **`C-2` "밀려난 `prev`는 dispose" vs `state<Frame>` 의미론** — 회신의 두
답변(`SL-43`, `SL-51`)이 서로 반대를 가리켰다. 따져보니 **둘 다 맞고 갈리는
축이 "누가 그 요소를 만들었는가"**였다 — `:List``updateFn`이 만든 것은
reconcile이 지워줘야 하고(만든 쪽이 자기 손으로 못 지움), `Slot:Add(state)`
sugar로 들어온 것은 사용자 소유라 죽이면 안 된다. 지금 설계엔 후자를 표현할
방법이 없어서(`Detach`는 교체와 같이 못 씀) 선택지 셋을 제시했고,
**per-installation 소유권 옵션**을 추천했다 — 갈리는 기준이 per-cycle이
아니라 설치 단위 속성이기 때문.
## 조사만으로 닫은 것
- **`E-10`(dedup 대칭)** — 의사코드를 다시 따라가니 `bindLifetime`(내부
Observer cascade 포함)이 `old ~= v` 분기 안에, `unbindLifetime`
`nextValue ~= v` 분기 안에 있어 이미 대칭이었다. 사용자가 말한
"relate로 old를 들고 있다가 process에서 조회해 같으면 dedup"이 정확히 지금
코드다. 설계 갭 없음, 구현 시 회귀 확인만.
- **`D-10`** — 사용자 지적("Luau 일반화 for가 그냥 지원함")이 맞고, 문항이
"M0에서 순회 동작을 검증"이라 적어 언어 동작을 의심하는 것처럼 읽힌 게
문제였다. 스파이크 `01`이 검증한 건 언어가 아니라 **우리가 짠 두 패스
드라이버**이고, 두 패스를 명시하는 진짜 이유는 이식성이다. 업스트림에서
이미 통과했으므로 base의 "M0에서 검증할 것"도 닫았다.
- **`SL-76`** — 사용자 의문("해제에서 nil이 관측될 일이 없다")대로 지금
설계에서 도달 경로를 못 찾았다. `bk.N` 정의 확정 + Blocker 게이팅 +
`spliceArraysDown`의 압축이 각각 그 경로를 막는다. 그래서 "관대한 skip"을
유지/삭제/error 승격 중 고르는 문제로 바꿔 올렸고 **error 승격을 추천**했다.
## 안 한 것
- **감사 루프 안 돌림** — 이번 변경이 크긴 하지만 아직 **회신 처리가 절반**
(B/C절이 남음)이라, 감사는 그 라운드까지 끝난 뒤 한 번에 도는 게 맞다고 봄.
`doc-check.py`는 매 편집 후 돌려 ERROR 0 유지.
- **5라운드 문항지** — 업스트림이 들여온 신규 문서 둘(`project-setup-plan.md`,
`quad-types-plan.md`)과 이번에 내용이 늘어난 `architecture.md`/`typing-limits.md`는
문항이 아예 없다. followup D절에 적어두고 **사용자 지시 대기**.

View file

@ -0,0 +1,232 @@
# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해
**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로
전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` +
`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가
확정돼 `base/slot-plan.md`에 반영됐다.
**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`
H절, 분해 근거는 `reference/slot-attach-decomposition.md`. 이 파일은 그
결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다.
---
## 1. 발단 — `F-3`을 제안하게 된 관측
3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이
남아 있었다.
1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`
`Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가
담당"한다고 확정해뒀다.
2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번,
`ROADMAP.md` M6에 ⚠️로 박혀 있었음).
2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 —
`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는
`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과
`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.**
detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면
**GC 폴백이 아예 없이 영구 누수**다. `userdata``:List`에게 opaque라
"이 안에 뭐가 들었으니 죽여라"를 알 수 없다.
그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가
죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번
더 묻는다.
## 2. 사용자 회신 — 두 가지를 더 얹었다
> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud
> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를
> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게
> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."
여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 →
재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가
`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는
따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로
돌려주니까 사용자 코드가 `old``ud`에 담아 다니지 않아도 된다.
## 3. 반영 — `settle()`이 두 경로를 강제로 합친다
구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를
공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가
`wasMounted`/`wasDetached`를 함께 보고 분기한다:
- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동.
이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop.
- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트.
- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`.
두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로
났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"**
계열이었다.
`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) /
**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 —
"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`
정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이
안 됐다.
## 4. `Owned``Detach`와 직교하는 두 번째 축
`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state<Frame>` 의미론과 충돌)를
닫은 것이 이 축이다. 정리하면:
- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠."
- **`Owned`** = 설치 단위. "애초에 내 게 아님."
이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는
`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`
안 지우면 아무도 못 지운다). 사용자가 `state<Frame>`에 담아 넘긴 요소는
`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다.
## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정
3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게
재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."
핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다:
- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는
flush가 끝나야 정해지는 **최종값**이어야 한다.
- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.
한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회
헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다.
사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:
> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가
> 많아질 위험이 보임."
> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던
> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한
> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후
> 실수가 누적되는 비용보다 싸다고 생각함."
이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지
방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다**
이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그
클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다.
분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다.
### 사용자가 스스로 답한 우려
같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:
> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고...
> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건
> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이
> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한,
> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운
> 함수를 만드는건 옳지 않아보임."
확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건
`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
`reference/slot-attach-decomposition.md` 7절.
## 6. 이 세션이 안 한 것
- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로
끝낼 수 있어보임"에 따름.
- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`,
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
---
## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다
H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`**6라운드** 돌렸다
(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:
| 라운드 | 각도 | 확실 발견 |
|---|---|---|
| 1 | `base/slot-plan.md` 내부 문장 정합성 | 4 |
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS) | 6 |
| 3 | `archive/` + 바깥 `base/` + `reference/` | 2 |
| 4 | **의사코드 시나리오 손 트레이싱** | **3 (실제 크래시)** |
| 5 | 그 수정분의 회귀 트레이싱 | 3 |
| 6 | 수렴 확인 | **0** |
**이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다.** 1~3라운드는 전부
"고친 결정이 다른 자리에 안 옮겨졌다"는 **문서 정합성** 유형이었고, 각도를
트레이싱으로 바꾼 4라운드에서야 **실행이 깨지는 결함**이 나왔다. 문서 대조를
몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고,
**서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는** 종류였기
때문이다.
### 4라운드가 잡은 것의 공통 구조
셋 다 **`Detach`가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안
고쳐진 것**이다. 특히 `I-1`이 뼈아프다:
- `rawDetach`**일부러** `releaseOwner`를 안 부른다 — 소유권 유지가 이번
세션 설계의 핵심이었다.
- `claimOwner`**일부러** 같은 owner의 재클레임도 error다 — 2026-08-13
감사가 `Slot { a, a }`를 막으려고 엄격하게 만든 것이다.
- 둘 다 각자 맞는데, **"`prev`를 그대로 돌려주면 재마운트"라는 이 세션이
확정한 권장 패턴이 정확히 그 교차점을 지난다.** 그대로 구현했으면
문서가 권장하는 사용법이 첫 실행에서 죽었다.
`claimOwner`의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시
클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고
단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그
문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.
### 내가 감사 결론을 한 번 넘어선 곳
`I-2`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다.
`:List``data:Observer``bindLifetime(inst, observer)`로 **물리 target에
앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만
넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`
조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로
마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라
새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**(
`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나
추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.
### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)
1. **재마운트 경로**`claimOwner``fromDetached` 예외(대안: 전용
`rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경).
2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고
`pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래
요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는
`base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라
옛 단일 `attachSlot`에도 있었을 구조적 갭이다.
### 부수 정리
2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧
상태"라면서 재작성 대상 스파이크 `01``done/`에 둔 채 ⚠️ 마커만 달아둔
것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크`
절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼
구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를
지목했는데 정작 항목이 없었다.
### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건
`_detachCleanup` Effect 설치를 `mountSlotTree``activateList`로 이관,
그리고 `activateList(self, inst)`의 2번째 인자를 `physicalTarget`으로
리네이밍. 상세는 followup `I-7`.
**여기서 짚어둘 것은 상호작용 하나다.** 이관 제안의 근거가
*"`materializeSlotTree`가 `_listed`면 재마운트 때도 `activateList`를 다시
부르니 lifecycle이 보존된다"*였는데, **그건 오늘 오후에 내가 멱등 가드를
넣기 전의 동작**이고 사실 그게 `I-2`의 버그였다. 가드가 있는 지금은 두 번째
호출이 early return하므로, 가드 분기가 `_detachCleanup`도 같이 재앵커하지
않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만
근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.
부수적으로 `_listObserver``_detachCleanup`이 **같은 범주로 통일**됐다 —
"`activateList`가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는
앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 `nil`).
오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이
훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.

View file

@ -0,0 +1,403 @@
# 2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), `Gate`/에포크 리서치 신설
**요약**: 사용자 요청으로 **QA 5라운드 문항지(205문항)** 를 만들고, 같은
세션에 회신을 받아 **1차 처리까지** 끝냈다. 즉시 반영 14건, 되물은 것 7건,
그리고 **새 research 문서 둘**(`gate-primitive.md`,
`state-epoch-validation.md`)이 나왔다. 처리 결과의 소스는
`qa-request/pre-implementation-qa-round5-followup.md`.
## 1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음
4라운드 종결 시점엔 사용자 지시(*"이후 stale 만 잡는것으로 끝낼 수
있어보임"*)로 **5라운드를 안 만들기로** 했고 그 문장이 세 곳
(`todos.md` 00번, `README.md` qa-request 행, 4라운드 followup H-7)에
적혀 있었다. 같은 날 사용자가 5라운드를 요청 — *"4차에서 예로 넘어갔던건
스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을
쌓아보자."* → 세 곳 전부 정정하고 문항지를 신설했다.
**범위를 셋으로 좁힌 게 이번 라운드의 설계**: (1) 4라운드에 문항이 아예
없던 영역(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가
아니라 실제 커밋된 M1 코드**), (2) 4라운드 회신 **이후** 확정된 것
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의
심화(예: `debounce-throttle-plan.md`는 1100줄인데 4라운드 문항이 9개뿐이었음).
## 2. 문항지 작성 중 이미 잡힌 것
문항을 쓰는 과정 자체가 감사가 됐다:
- **`EF-3`** — 4라운드 followup이 "반영 완료"로 적은 `E-10`(dedup 대칭)이
`effect-plan.md`**실제로는 안 들어가 있었다**. → 일반 교훈으로
`CR-1`("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는 `base/` 본문")을
문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다.
- **`DE-9`** — `KeyGone` 소멸 루프가 새 값을 반환받으면 `settle`의 교체
분기로 들어가 `rawAdd(self, result, 0)`(범위 밖 인덱스)로 터진다.
- **`IM-1`** — `architecture.md`는 "지금은 `New()` 미노출 싱글톤"이라는데
실제 코드는 `module.New = New` + `return New()`.
- **`CR-2`/`CR-9`** — `Tween:Map` vs `Mapped` 이름과 `isBoundAlive`의 세 번째
분기가 **결정이 필요한데 어느 추적 목록에도 없었다.**
- **`DC-9`** — Blocker 게이팅이 `recompute`를 O(N²)→O(N)으로 줄였는데
`setOffsetSource`의 즉시 계산이 그 자체로 O(N²)라 상쇄된다.
## 3. 회신으로 확정된 것 (즉시 반영)
- **`slot._detached`는 lazy** — 모든 Slot이 빈 테이블을 미리 갖지 않는다
(사용자: *"테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로
nil 이면 스킵이 훨씬 싸게 먹히지 않는가?"*). `getDetached(slot)` getOrCreate.
- **`prev` 없이 `Detach`를 반환해도 nop** — 사용자가 prev 유무를 추적할
의무 없음.
- **`KeyGone``nil`/`None`/`Detach`만** — prev도 새 값도 error(*"KeyGone 을
받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다"*).
- **`Owned = false`에서 `Detach``_detached`에 안 들어간다** — 남의 것에
소유권을 유지하는 건 모순이라 `rawUnmount`로 처리하고 다음 `prev``nil`.
부수로 unowned에선 `Detach``nil`이 같은 동작이 되고 `_detachCleanup`
`_owned` 분기가 도달 불가가 되어 삭제됐다.
- **조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다**`Owned=false`
약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설.
- **`groupClaimKeys` 키 = `(inst, groupValue) → k`**, `nameClaims`보다 위치
claim이 먼저. `Frame { a, a }` 갭이 이걸로 닫힘.
- **`Tween<T>:Mapped`** 확정(`-ed` 관례).
- **⭐ "게이팅 먼저"** — M2가 M3의 `Blocker`에 의존하던 순서 문제를 로드맵
순서 유지가 아니라 **앞당기는 쪽**으로 결정. 단 앞당기는 대상이 `Blocker`
아니라 그 아래 공용 `Gate` 노드로 바뀌었다(아래 4절).
## 4. 새로 열린 것 — research 문서 둘
- **`research/gate-primitive.md`** — `DT-4`에서 사용자가 정리: 시간 기반
게이트를 공개 `Blocker` API 위에 못 얹는 이유가 **순서 보존**이다
(*"이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이
먼저이냐가 문제"*). 그래서 emit을 **가로채는** 공용 노드가 필요하고,
공개 API로 낸다. 사용자 스케치는 `Gate(function(emit) return function()
... end end)`. 남은 건 이름(`Gater`가 어색하다는 지적 — 에이전트 권고는
`Gate` 그대로), `:Apply` 팩토리 여부, `Blocker`의 얹힘 방식, M2 범위.
- **`research/state-epoch-validation.md`** — `SS-2`/`SS-3`에서 사용자가
제기한 **glitch**: DFS 전파 중 Observer가 `Get()`을 부르면 아직 신호를 못
받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트
Source들의 **에포크(count)** 를 들고 `Get()` 때 비교하는 것. 에이전트
분석: 진단·방향 타당(MobX/Adapton류 선례), **정확성 결정이지 최적화가
아님**, 다만 중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화
해야 함. **M3(State 구현) 전에 결론 필요.**
## 5. 2차 회신 — C절 전량 확정 (같은 세션)
되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup **F절**):
- **`Slot:Replace` 신설**(`B-5`) — 사용자가 "교체는 제거+삽입이 아니라 replace가
나아 보인다"고 제시. `:List`의 교체가 `spliceArraysDown`+`spliceArraysUp`
쌍(시프트 2회·`recompute` 2회)에서 **`rawReplace` 한 번(시프트 0)** 으로
바뀌었고, 그 부수로 `C-7`(`:Single` 교체 시 Length가 잠깐 줄었다 느는 것)이
같이 사라졌다.
- **에포크 안에서 중복 통지도 접기로**(`B-7`) — emit이 `(source, count)`
실어오므로 판정이 O(1). **2026-08-14에 폐기된 옛 dedup과 다른 장치**임을
문서에 못 박았다(그건 `invalid` 플래그 기반이라 Observer 영구 침묵 모드가
있었고, 에포크는 매 `Set`마다 새 값이라 그 모드가 없다). 구현 요구로
**`seen`/`computedAt` 두 카운트 분리**가 추가됐다. 에이전트가 붙였던 "선언
안 된 의존성을 UB로 명문화" 조건은 **사용자 기각**(*"이전과 다른게 없다"*).
- **물리 조작은 주입 op**(`C-1`) — *"slot 의 해당 동작은 base 이므로 parent 를
모른다"*. 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를
`mountInst`/`unmountInst`/`disposeInst`로 정정(9곳)하고 경계 절을 신설.
**`rawAdd` 의사코드도 이때 처음으로 문서에 들어갔다.**
- **`rawAdd``Length:Set` 제거**(`C-2`) — `Length``recompute`만 쓴다.
- **래핑/언래핑을 Slot 전체 연산으로**(`C-3`) — `wrapElement`/`unwrapElement`
한 쌍 + 래퍼의 `_wrapped` 역참조. 사용자가 예상한 대로 `wrappers[key]` /
`mounted[key]` 분리가 **필요 없어졌다**.
- **`setLength``anchor` 인자 신설**(`C-4`) — 부기 키와 생명주기 앵커 분리.
4라운드 `D-56`이 역전돼 `archive/bindlifetime-slot-owner-reversed.md`로 갔고,
형태 미정으로 열려 있던 **`isBoundAlive` 세 번째 분기 항목이 같이 닫혔다.**
- **`Effect(fn, ...deps)` 확정**(`C-6`) — `Ref`도 의존성이 될 수 있고, 최소
1회 실행(useEffect 동일), trailing lazy 위치 인자, `Ref``Set`될 때만 발화.
- **`Gate`만 다음 세션으로** — *"고칠것이 많으므로 … 지금 세션 상 지식만
이전될 수 있게 두세요."* 그래서 `research/gate-primitive.md`는 재료만 모아둔
상태로 두고 `question.md`/`todos.md`에 **M2를 막는 유일한 항목**으로 남겼다.
## 6. (1차 시점 기록) 되물었던 것
파급 큰 순서로: **`rawAdd``Length:Set` 제거**(지금 서술이 "기여도 합"
정의와 충돌하고 `recompute`와 이중 기록), **`updateFn`이 State를 반환할 때의
래핑/`prev` identity**(래핑이 공개 `Slot:Add`에만 있어 reconcile 경로엔
없다는 실제 갭), **`setLength`의 Observer 앵커를 물리 target으로 되돌리기**
(사용자 문제 제기 — *"우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지"*,
되돌리면 4라운드 `D-56`의 백엔드 요구사항과 `isBoundAlive` 세 번째 분기가
통째로 불필요해짐), `rawAdd` 의사코드 초안 승인(문서에 정의가 아예 없었음),
`Gate` 이름·표면, `Effect`의 다중 의존성(`Ref` 포함) 안.
## 6-1. 3·4차 — `mountInst`의 삽입 위치에서 시작해 offset 모델이 정리됨
2차까지 끝난 뒤 사용자가 `mountInst`/`unmountInst`가 index를 안 받는 걸 문제
삼았다(*"웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"*). 이
질문이 결국 offset 부기 모델 전체를 정리하게 만들었다:
- **`None`이 두 뜻을 겸하고 있었다** — 정의는 "실제 마운트를 하지 않는 위치"인데
**plain 요소가 `None` + `setLength(1)`로 등록**되고 있었다. 그래서 그 자리의
offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다.
`None`의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는 `lengthList`가 답),
숫자가 필요한 쪽은 **`Dispatch.getOffsetAt(ownerKey, i)`로 pull**한다.
- **⭐⭐ 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다.**
`recompute``sum = 0`으로 시작하고 `ownerKey.Offset`을 읽는 자리가 없어서,
**depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다**(depth 1만
쓰던 동안 로컬==절대라 안 드러남). → `base`를 시드하고, 중첩 Slot이 자기
`Offset`을 관측해 자식 offset을 다시 미는 구독을 추가.
- **⭐ 반영하다 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다.**
언마운트가 `slot.Offset`을 일부러 보존하는 이유(`SL-75`/`DC-6`: 이미 렌더된
요소들이 그 Source를 **구독한 채 딸려 나감**)가 재마운트에서 무너지고 있었음
`slot.Offset or Source(0)`으로 identity 재사용.
- **에이전트 제안(`bk.offsetList` push)은 사용자안(pull + `getOffsetAt`)에
밀려 폐기**됐고, 이어서 **`bk.base` 필드도 사용자 지적으로 걷어냈다**
(*"이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데"* — 같은 값을 두 곳에 두는
중복 상태). `isSlot` 분기는 남는데, 그건 타입 분기가 아니라 **duck-typing이
금지돼 있어서**(Roblox userdata의 미정의 키 인덱싱 에러, `brand-plan.md`)
브랜드 검사가 유일한 안전 경로이기 때문.
**이 라운드의 패턴**: 세 결함 전부 *"사용자가 표면적인 질문 하나를 던졌더니 그
아래에서 나왔다"* — `mountInst`의 인자 하나가 offset 모델의 두 결함을 끌어냈다.
## 6-2. 커밋 전 감사 2라운드 — 실제 결함 다수
핸드오버 준비로 `quad-doc-auditor`를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸
인용하는 base / 2: 인덱스 레이어·히스토리). **둘 다 실질적인 걸 잡았다.**
- **`Owned`가 코드에 도달하지 못하고 있었다** — `settle`/`destroySlotTree`/
`releaseElement``self._owned`를 9곳 넘게 읽는데 `Slot:List`/`Slot:Single`
의사코드가 **`opts` 인자를 안 받고 있었다.** 확정한 옵션이 **문서 안에서
배선이 끊긴 채** 있었던 셈.
- **`effect-plan.md`가 자기 자신과 모순** — 최상단 시그니처와 "trailing args
sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채 `Effect(fn, ...deps)`
절이 추가돼 **역전 배너 없이 정반대 두 서술이 공존**하고 있었다.
- **⭐ 손대지 않은 문서의 사각지대** — `ROADMAP.md` 백로그 문단과
`debounce-throttle-plan.md`가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다
(이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건
`conventions.md`가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지
모른다"의 교과서적 사례.
- **⭐ 인덱스에 옛 결론이 남음** — State-에포크의 "중복 통지는 안 고쳐진다 /
UB 명문화 필요"가 같은 날 정반대로 확정됐는데 `question.md`/`README.md`만
안 따라왔다.
- 그 외: `indexOfRaw` 미정의, `mountSlotTree`의 전제 미명시, `Replace`
`destroyOld` 미서술, `Gate` 이름이 용어 대기열에 없음, 주입 op 목록 미갱신,
M2 체크박스 문장 깨짐 등.
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`
**접두합 캐시**(`invalidAfter` — 단일 함수가 필요한 만큼만 이어붙임). offset 물리 재배치 질문도 "DOM은 insert가
알아서 밀어낸다"로 닫혔다.
## 6-3. 마지막 라운드 — `native*` 계층 확정, `C-7` 역전
주입 op 셋(`mountInst`/`unmountInst`/`disposeInst`)으로는 **`Move`/`Swap`을 아예
표현할 수 없다**는 사용자 지적에서 시작해 물리 조작 계층이 재설계됐다.
- **층위 정의가 생겼다**`raw*` = Slot 스코프(평탄화 전), `native*` = 확정된
offset/length 기반 물리 연산(평탄화 후).
- 표면은 여섯(`nativeInsert`/`Extract`/`Remove`/`Move`/`Swap`/`Dispose`).
`Replace`는 별도 op이 아니라 **`newElements`가 있는 Remove/Extract**이고,
파괴/비파괴는 **불리언이 아니라 이름**으로 가른다(Roblox의 "그 자리에서 바로
Destroy" 융합을 열기 위해).
- **⭐ 대상 요소를 배열로 넘겨야 한다** — `(target, offset, count)`로 찾을 수
있는 건 DOM뿐이고 **Roblox는 자식이 순서 없는 집합**이라 offset 역조회가 안 된다.
- **`C-7`("부기가 물리보다 항상 먼저")이 역전됐다** — base엔 물리적으로 자리를
비워둘 수단이 없고 미는 주체는 백엔드의 삽입 연산 자신이다. 규칙이
**"자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나로 줄었다.
원문은 `archive/bookkeeping-before-physical-reversed.md`.
- 같은 라운드에서 `getOffsetAt`의 접두합 캐시도 사용자 의사코드로 정정 —
**단일 함수 + `invalidAfter`**, 무효화는 `min(invalidAfter, i)` 하나.
## 7. 남긴 파일
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)
- `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문
- `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스**
(절이 라운드마다 쌓임 — **마지막 절이 최신**)
- `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문
- `research/gate-primitive.md`, `research/state-epoch-validation.md`
## 8. 후속 — State 에포크 안 3차 정정 (같은 날)
사용자가 `research/state-epoch-validation.md`를 직접 읽고 기제 서술 세 건을
정정했다: (1) `sourceList` 순회는 `rawInvalid`가 **false**일 때만 돈다(문서는
반대로 적고 있었다, 목적은 "못 받은 emit 받기"), (2) `emit`은 count 없이
**발행 source만** 싣는다, (3) 에이전트가 요구했던 **`seen`/`computedAt` 두
카운트 분리는 철회** — count 갱신과 `rawInvalid = true`가 같은 스텝이라
캐시 오인 경로가 없다. 부수로 "순회가 발견한 변경을 뒤로 emit 할 것인가"가
열렸는데, 다이아몬드 쪽은 사용자가 스스로 안전으로 정정했고 게이트 쪽만
**해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에
쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와
`qa-request/pre-implementation-qa-round5-followup.md`의 M절.
## 9. 후속 — 순회의 count 갱신 문제 + `Gate``:Apply`가 아니다 (같은 날)
**(1) 순회가 count를 올리면 통지가 죽는다.** 사용자 지적 — 순회로 발견해
count를 최신으로 올려두면 뒤늦게 온 진짜 emit이 삼켜져 하류가 그 에포크를
영영 못 받는다(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열).
사용자 해법은 **(b) 순회도 emit 한다**이고, 게이트 경우엔 그 emit을 해제
시점까지 민다는 것. 에이전트는 **(c) 순회는 `rawInvalid`만 세우고 count는
안 올린다**를 대안으로 냈다 — 원인이 정확히 "순회가 count를 올리는 것"이라
그것만 안 하면 통지 죽음도 게이트 누출도 `nil` emit 규약도 안 생긴다.
대가는 emit이 올 때까지 `Get()`마다 재계산, 그리고 `OffWithoutEmit` 경로에서
count가 안 따라잡는 것. 또 (b)에서 "게이트까지 민다"를 실제로 구현하려면
순회하는 노드가 상류 게이트를 알아야 하는데 `sourceList`가 루트로 평탄화돼
있어 불가능하고, 유일하게 깔끔한 기제(게이트를 에포크 경계로)는
`blocker-plan.md`의 "`:Get()`엔 영향 없음" 확정 계약을 뒤집는다는 걸 짚었다.
**미결** — `research/state-epoch-validation.md` §5의 3번이 소스.
**(2) `Gate``:Apply`가 아니라 State 메소드.** 사용자 확정 — *"state 의
전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."* 경계를 정확히
적어두면, `Apply`가 노드를 못 만드는 게 아니라(확정 예시 `capAt(100)`
`:With` 노드를 만든다) **프리미티브는 메소드 / 유저랜드 조합 팩토리는
`:Apply`**라는 층위 구분이다. 부수 결론 셋 — `Debounce`/`Throttle`의
`state:Apply(...)` 관용구는 그대로 유효(팩토리가 내부에서 `:Gate`를 부르면
됨), `Blocker` 배선 문제는 이미 확정된 `state:Block(blocker)` 메소드로
자동 해소, `__call`은 안 씀(사용자 선호 + Luau 함수 타입 자리 통과 여부
불확실). `research/gate-primitive.md`의 2번이 해소로 갱신됨.
## 10. 후속 — 순회 처분을 테이블 둘로 확정 (같은 날)
9절에서 열어둔 (b)/(c)를 사용자가 **제3안으로 닫았다**: 판정 기준을 둘로
나눠 `sourceCountMap`(값 유효성, 순회가 앞당겨 올림)과 `sourceEmitMap`(전파
dedup, 상류의 진짜 emit을 기다림)을 따로 둔다. 순회는 emit을 안 하므로
게이트 누출이 아예 안 생기고, 뒤늦은 emit은 "count는 같은데 emit 기록이
다름"으로 걸려 정상 전파된다. (c)의 유일한 약점(`Get()`마다 재계산)도
사라진다 — 순회가 `sourceCountMap`을 실제로 올리기 때문. 같이 나온
`rawEmit`+`nil` 안은 구조 위생(상류 emit과 내부 emit의 진입점 통일)만
살리고 해법으로는 안 쓴다(막는 게이트는 보통 상류에 있어 자기 `rawEmit`
태워도 누출이 남고, `nil` emit은 하류마다 전체 순회를 강제해 연쇄한다).
M절에서 철회했던 두-카운트 분리가 **다른 근거로 되살아난** 셈이다.
이제 이 안은 기제가 다 정해졌고 **남은 건 채택 여부 자체**다.
## 11. 종결 — `Gate` 표면 확정 + 에포크 채택, 두 문서 `base/` 승격 (같은 날)
사용자가 `Gate`를 **탑레벨 프리미티브 없이 `state:Gate(setup)` 메소드 +
`GateNode`**로 확정하고(*"Blocker 는 해당 내부 배선을 따른다"*), 이어서
State 에포크도 **채택**했다(*"gate 와 epoch 가 제가 만족할만한 정도로
올라왔습니다"*). `research/gate-primitive.md``base/gate-plan.md`,
`research/state-epoch-validation.md``base/state-epoch-plan.md`로 승격.
에포크 채택으로 `source-state-plan.md`의 두 확정 서술("항상 전파" / "중복
통지는 안 접음")이 역전돼 `archive/always-propagate-no-dedup-superseded.md`
옮겼다. **2026-08-14의 `invalid` 기반 dedup 금지를 되돌린 게 아니라는 것**을
역전 문서·`source-state-plan.md`·`README.md` 세 곳에 모두 못박았다 — 이
구분이 흐려지면 "영구 침묵" 버그로 되돌아간다.
부수로 스파이크 `05-store-state-diamond-propagation`이 다시
`rewrite-required/`로 갔다(다이아몬드 Observer가 이제 변경당 1회만 울어야
해서 핵심 assert가 정반대). 처리 전량은
`qa-request/pre-implementation-qa-round5-followup.md`의 O절.
## 12. `/code-review high` — 12건 전부 유효, 그중 3건은 실제 설계 구멍
O절 커밋 직후 사용자가 돌린 리뷰에서 12건이 나왔고 전부 유효했다. 특히
**게이트가 유보했다 내보내는 emit이 어느 source를 싣는가**는 M절이 이미
짚어뒀는데(*"`nil` 규약만 Gate 설계와 같이 확정하면 된다"*) 표면 확정 때
같이 안 닫힌 것으로, 그대로 두면 `blocker:Off()`의 배치 통지가 하류에서
삼켜진다 — `setup` 시그니처에 영향이 있어 **M2 착수 전 항목**으로 되돌렸다
(같은 날 "M2를 막는 항목 없음"이라 적었던 `todos.md` 00번도 정정).
교훈이 하나 더 있다: 이번에도 **감사자 각도가 아니라 diff 각도에서만 보이는
것**이 다수였다(개수 하드코딩, 배너 vs 본문, 같은 파일 안의 모순 문장,
방금 옮긴 파일을 가리키는 새 텍스트). `conventions.md`의 "`/code-review`는
감사자를 대체하지 않는다" 항목이 다시 확인된 셈. 전량은
`qa-request/pre-implementation-qa-round5-followup.md`의 P절.
## 13. `/code-review` 3건에 대한 사용자 회신 — 2건 확정, 1건은 근거가 반증됨
게이트 emit의 출처는 **`emit(self)` + 흡수 집합**으로 닫혔다. 에이전트가
권고한 (c)를 사용자가 더 단순화한 형태 — 게이트에 자체 count를 주는 대신
`withheld : {[source]=true}`만 들고 있다가 풀 때 자기를 출처로 emit 하고,
**동기 전파**라 반환 뒤 `table.clear`하면 하류 전원이 집합을 본다. 검토
결과 **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라
노드이기 때문(P절이 "M2 표면에 영향"이라 적은 건 기우였다).
에포크 쪽은 "재계산 때 count 전부 갱신"만 확정되고, 그걸 제기한 에이전트
근거는 **사용자가 반증**했다 — 전파가 동기라 `A:Set()` 파동이 끝난 뒤에야
`Z:Set()`이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다.
그 과정에서 대원칙 하나가 명문화됐다: **무효화를 결정하는 건 언제나 count
비교지 emit의 도착이 아니다.** 전량은 followup의 Q절.
## 14. 마지막 단순화 두 건 — 게이트 통과/유보 미구분, 새 노드 맵 비대칭 초기화
사용자가 Q절 기제를 한 번 더 줄였다. 게이트는 **정책 실행 전에 무조건**
출처를 `withheld`에 넣고, 통과시킬 때조차 **자기를 출처로** 전파한다 —
"지연과 비지연을 구분할 이유가 없다". 그래서 Q절이 넣었던 "정책이 emit을
불렀는지 노드가 되짚는다"는 감지 로직이 통째로 사라졌다.
새 노드의 두 맵은 **비대칭**으로 초기화한다: `sourceEmitMap`은 비우고
(새 노드는 개념적으로 emit을 받아본 적이 없으므로 `nil`이 곧 "변경"),
`sourceCountMap`은 상류에서 전부 끌어와 실제 count로 채운 뒤
`rawInvalid = true`(순회가 훑을 목록이 곧 이 맵이라 비워두면 "유효"로
오판). 이걸로 `:With` 병합 규칙이 필요 없어지며 `/code-review` Med-3이
닫혔다. 전량은 followup의 R절.
## 15. 게이트가 게이트 emit을 받는 경우 — 미정의였고, 풀어 담는 것으로 확정
사용자가 마지막으로 잡은 구멍. "받은 출처를 그대로 넘긴다"는 규칙이 하류가
또 게이트일 때 깨진다 — 상류 게이트가 전파 직후 집합을 비우므로 하류 게이트가
나중에 풀면 빈 집합을 내보내 변경이 증발한다. 수신 시점에 **풀어서 자기
`withheld`에 합치는** 것으로 확정. 겹겹이 쌓여도 각 층이 자기 집합을 들고
있어 어느 층이 먼저 풀리든 정보가 안 샌다. 부수로 게이트의 `sourceEmitMap`
수신 때가 아니라 **실제 전파 때** 갱신하는 것으로 못박았다. 전량은 S절.
## 16. 두 번째 `/code-review high` — 7건 전부 유효, 유실 경로 둘
`e2b85bc` 직후 돌린 리뷰. 특히 둘이 실제 유실이었다: (1) `withheld`
페이로드로 그대로 넘기면 **재진입**(전파 중 Observer가 `Set`) 시 중첩 전파의
`clear`가 바깥 전파를 빈 집합으로 만들어 변경이 증발한다 → 전파 직전에
새 테이블로 **스왑**해 배치를 떼어내는 것으로 수정. (2) `OffWithoutEmit()`
집합을 안 비워, 매 프레임 `On()`/`OffWithoutEmit()`을 도는 `Dispatch.drive`
배치 게이팅에서 집합이 단조 증가하고 나중에 폐기분이 같이 실려 나간다 →
그 경로도 비우도록 확정, `withheld`도 weak key로 명시.
부수로 "무조건 `withheld`에 넣는다"가 수신 규칙을 건너뛴다는 뜻으로 읽히던
모호함(다이아몬드에서 `Throttle` 유령 trailing emit)과 STATUS의 런타임 개수,
followup D절의 옛 `research/` 현재형 서술을 고쳤다. 재진입 계약과 "소스 없는
emit(빈 배치)"은 사용자 판단 항목으로 승격 — 후자는 `Effect(fn, ...deps)`
설치 구간 억제 용례에 직접 걸린다. 전량은 T절.
## 17. 사용자 반문 — "재진입 계약"은 없던 항목, `emit`은 flush
사용자가 T절을 되짚어 에이전트 서술 오류 둘을 잡았다. (1) `gate-plan.md`
6번의 "정책 안에서 `emit()` 재귀호출"은 `blocker-plan.md`의 재진입 절을
**잘못 옮긴 것** — 그 절은 같은 인스턴스 중첩(`On()`/`Off()`)을 말하고,
정책이 flush를 부르는 건 평범한 통과 경로다. 무한 되먹임은 2026-08-04
확정 원칙대로 UB이고 유한 재진입은 이미 지원(`debounce-throttle-plan.md`의
`onWindowEnd` 주석)이라, 이 항목은 열린 게 아니라 **정리 대상**이었다.
(2) 정책이 받는 `emit`은 페이로드를 정하지 않는 **flush 트리거**라 배치
스왑이 그 핸들 안에서 일어난다 — T절의 H-1은 모델의 구멍이 아니라
에이전트가 적은 "전파 후 `table.clear`" 의사코드의 결함이었다. 수정은
그대로 유효하고 서술만 고쳤다.
그래서 `Gate`에 남은 **사용자 판단은 빈 배치 emit 하나뿐**이다. 전량은 U절.
## 18. 빈 배치 emit — 에이전트 권고 기각, 기존 Blocker 계약의 일반화로 확정
마지막 열린 항목. 에이전트는 "빈 배치 = 무조건 통지"를 권고했으나 사용자가
기각했다 — 빈 배치는 "더 던질 게 없다"는 뜻이고 그걸 흘리는 건 State 층에
`Source:Emit`을 추가하는 격이다. 확정은 **통지 자체를 안 함**이고, 확인해보니
새 규칙도 아니었다: `blocker-plan.md`의 "`HasBlockedEmit`이 false면 아무 것도
안 함(idempotent)"이 이미 같은 계약이고 `withheld`가 그 플래그의 일반화다.
따름정리로 `Effect(fn, ...deps)`의 설치 구간 억제가 **`Gate` 소비자에서
빠졌다** — 설치 구간엔 `Set`이 없어 쌓일 게 없으므로 게이트가 내보낼 것이
없고, `Effect` 내부 플래그면 충분하다. `effect-plan.md`의 "`Gate`보다 뒤"
순서 제약도 같이 사라졌다. 이로써 `Gate`에 사용자 판단 항목은 남지 않았다.
전량은 V절.
## 19. `Epoch`/`EpochMap`/`Brand` 제안, 그리고 핸드오버 (세션 종료)
에이전트가 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 돈다"는 갭을
제기하자(`A → b`, `A → c`일 때 접어줄 공통 하류가 없어 에포크 dedup이
못 접는다), 사용자가 그걸 계기로 더 큰 재구성을 제안했다 — 에포크 부기를
State에서 떼어 `EpochMap`으로 컴포지션하고, emit 페이로드를 `Epoch`
인터페이스로 일반화하고, 그러려면 `Brand`를 인스턴스 브랜드(다중 태깅)로
바꾸는 것. `Effect`가 자기 `EpochMap`을 들면 **그 지점이 공통 하류가 되어**
갭이 닫힌다.
두 차례 회신으로 사실상 전량 확정됐다. 그 과정에서 에이전트 주장 셋이
반증됐다 — (a) "포함 관계가 생성자로 흩어진다"는 자기 등록이 다중 등록을
강제하지 않으므로 틀렸고, (b) "재계산 후 sync 연산이 따로 필요하다"는
`Update``{Epoch}`를 받는다는 걸 놓친 것이었으며, (c) "오버플로해도
다르다"는 사용자 근거는 double이 랩이 아니라 **포화**한다는 점에서
정정됐다(도달 불가능이라 결론은 유지).
**승격은 사용자 지시로 다음 세션** — *"전면 승격을 하고 싶지만, 이 세션은
너무 길어요."* 감사도 승격 이후에 돌리기로 했다. 핸드오버로 `base/`
문서(`state-epoch-plan`/`source-state-plan`/`effect-plan`/`brand-plan`)
상단에 이 제안을 가리키는 ⚠️ 배너를 달았고, `todos.md`에 **000번**을 신설해
다음 세션의 첫 작업으로 지정했다. 다중 dep `Effect` 갭 자체도
`effect-plan.md`에 미해결로 기록했다 — 제안이 기각되더라도 갭은 남으므로.

View file

@ -0,0 +1,336 @@
# 2026-08-21-03 — `Epoch`/`EpochMap`/`Brand` 전면 승격 + 해소 기록 flatten
**요지**: 앞 세션(`2026-08-21-02-qa-round5-and-gate-epoch-research.md`)이
컨텍스트 피로로 미뤄둔 승격을 실제로 수행하고, 그 과정에서 코퍼스 곳곳에 쌓여
있던 `[해소]`/`[정정]` 층을 걷어냈다. 지금 유효한 설계는 전부 `base/`가 소스 —
이 파일은 "무엇을 어디로 옮겼나"의 기록이다.
## 1. 사용자 지시
두 갈래였다.
1. **질문**: *"slot-attach-decomposition.md 은 이제 확정 이야기이기에, 리서치
대상이 아니지 않나요? 또, 이것이 base에 적용되어 있습니까?"*
2. **작업 지시**: `state-epoch-plan.md`/`source-state-plan.md`/`effect-plan.md`/
`brand-plan.md` 넷을 전면 승격하고, *"이 승격을 하며 더이상 필요 없어진
요소들은 archive 하세요. 특히 재정정 기록이 쌓인 부분이 있는데, 풀어가며
archive 해서 flatten 해야할 필요가 보입니다."*
## 2. 1번 질문에 대한 답 — 둘 다 예
- **`base/slot-plan.md`에 이미 반영돼 있다** — `materializeSlotTree` /
`mountSlotTree` / 두 줄짜리 `attachSlot` 래퍼가 그 문서의 "재귀 메커니즘"
절에 의사코드로 들어가 있고, 그 문서가 근거로 이 파일을 네 곳에서 인용한다.
- **`research/`에 있을 이유는 없다** — `.claude/README.md`의 폴더 기준상
`research/`는 "아직 착수 전, 상의 필요"다.
**어디로 옮길지**가 실제 판단이었다. `archive/`는 기준이 "뒤집혔거나 기각됨"
이라 채택된 결정의 근거 기록엔 안 맞고, `reference/`의 기준("결정 자체가
아니라 다른 문서가 근거로 인용하는 온디맨드 자료")엔 정확히 맞는다. 그래서
**`reference/`로 옮기고 폴더 기준 자체에 그 용도를 명문화**했다
(`README.md` 폴더 기준표 + `project-context.md`). 선례도 있다 —
`quad-v1-architecture.md`/`comparison-fusion-vide.md`가 `base/`에서 같은
이유로 이동해온 것.
같은 처리를 `epoch-brand-composition.md`에도 적용했다(승격이 끝나면 성격이
똑같아지므로).
## 3. 승격 — 무엇이 어떻게 바뀌었나
### `base/state-epoch-plan.md` (재작성)
- **`Epoch` 인터페이스 신설** — `type Epoch = { Revision: number }`, 그 자체로
키가 되는 unique 테이블. `Source`가 구조적으로 만족하고 `EpochBrand`
등록된다. "루트 `Source`의 에포크"라는 좁은 서술이 전부 여기로 일반화됨.
- **`EpochMap` 신설** — `:Update(Epoch|{Epoch}) -> boolean`(반환값의 뜻은
"뒤로 전파가 필요한가"), `:Refresh()`, `:Sync()`. 부기가 State에서 떨어져
나와 재사용 가능한 객체가 됐다.
- **`:Refresh`는 이 세션이 이름만 붙인 것**이다 — "순회"라는 연산 자체는
이미 확정돼 있었고(`rawInvalid == false`일 때 자기 맵을 훑는다), 맵이
키를 소유하므로 인자 없는 형태가 자연스러워서 그렇게 적었다. 새 설계가
아니다.
- **State의 두 맵이 `EpochMap` 둘이 됐다**`sourceCountMap`
`valueEpochMap`, `sourceEmitMap``emitEpochMap`. 이름을 바꾼 이유는
"source"가 더 이상 정확하지 않아서다.
- **문서 구조를 §1~§8로 다시 짰다.** 옛 §3(에이전트 분석)과 §5(열린 질문)에
결론과 정정 경위가 섞여 있었는데, 결론은 규칙 본문(§2~§5)으로 올리고
경위는 걷어냈다(원문은 `qa-request/pre-implementation-qa-round5-followup.md`
M·N절과 `reference/epoch-brand-composition.md`에 있다).
### `base/brand-plan.md` (전면 재작성)
- **인스턴스 브랜드**`Brand()`가 브랜드마다 weak 집합 하나를 들고,
`SomeBrand:register(x)` / `SomeBrand:is(x)`. **다중 태깅**이 이 재작성의
존재 이유다(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
- **역조회 `Brand.get`은 없어졌다.**
- **유지된 것**: weak-key 레지스트리, 테이블 아이덴티티, duck-typing 기각
근거 둘, 포함 관계 predicate 합성, `Brand``None`에 의존하지 않는다는
결정, Luau narrowing 주의.
- **새로 명시한 것 하나** — 다중 태깅이 가능해져도 **포함 관계는 계속
predicate 합성으로 쓴다**(`Source`를 `StateBrand`에도 등록하지 않는다).
등록 지점이 흩어지면 조용한 버그가 되고 포함 관계가 코드에서 사라지기
때문. 정당한 다중 등록은 `Epoch`처럼 **다른 축의 계약**뿐이다.
- 옛 표면은 `archive/brand-shared-registry-reversed.md`.
### `base/source-state-plan.md`
- "Source가 State를 만족함" 절에 **`Epoch`도 같은 방식으로 만족**한다는 항목
추가(`Revision`이 공개여야 하는 이유 포함).
- `state:Observer(fn)` 절에 **`fn(self, from: Epoch | {Epoch})`** 시그니처
확정 추가. "값을 안 실어주는 구독" 계약이 안 깨지는 이유도 같이.
- 승격 대기 ⚠️ 배너 제거, 그 절들의 "에포크" 표현을 "리비전"으로 통일.
### `base/effect-plan.md`
- **⚠️ 미해결이던 다중 의존성 중복 발화가 닫혔다** — `EffectHandle`
`EpochMap`을 하나 들고 각 내부 Observer가 받은 `from`으로 `Update`,
`true`일 때만 `fn`을 돌린다. `Effect`가 곧 deps의 공통 하류가 된다.
- **`Ref` 의존성은 이 맵에 안 낀다**(이 세션이 명시) — `Ref``Epoch`
아니고 `:Callback`으로 발화해 `from`이 없다. 공통 상류 문제 자체가 없다.
### `base/gate-plan.md`
승격 대기 배너를 반영 완료로 바꾸고, 페이로드/`withheld`/`emitEpochMap`
표현을 `Epoch` 어휘로 통일. **기제는 하나도 안 바뀌었다.**
## 4. flatten — 걷어낸 것들
사용자가 지목한 "재정정 기록이 쌓인 부분"은 실제로 세 군데였다.
1. **`question.md`가 다시 해소 항목으로 절반이 찼다.** 그 문서 스스로
*"항목을 해소하면 여기서 지우고 `archive/question-resolved.md`에 근거와
함께 옮길 것 — 다시 쌓이면 같은 문제가 반복됨"*이라 규정해뒀는데,
2026-08-21 하루에 여러 건이 닫히면서 예고대로 재발했다. **16건을 일괄
이관**하고(`Gate` 이름 항목 포함), 파일이 421줄 → 208줄이 됐다.
- 이관분 중 `Epoch` 관련 서술은 **이관 시점 표현 그대로 뒀고**, 대신
이관 섹션 머리에 "필드 이름이 그 뒤 바뀌었다"는 경고를 붙였다. 히스토리
문서의 원문을 소급해 고치지 않는다는 관례를 지키면서 오독만 막는 처리.
2. **`todos.md`의 "M3 착수 전에 결론이 필요한 항목 목록"이 절반 넘게 `[해소]`
였다.** 실제로 열려 있는 건 둘뿐(중간 State GC 미검증, `store:GetDynamic`)
인데 해소 항목 일곱이 섞여 있어 "지금 할 일"로 안 읽혔다. 걷어냄.
3. **`todos.md` 000번**(다음 세션 첫 작업 = 이 승격)은 완료됐으므로 삭제하고,
00번 안에 승격 완료 사실만 남겼다.
## 5. 마지막 미정도 같은 세션에 닫힘 — 리비전 증가는 `bit32`
승격 보고 직후 사용자가 그 자리에서 정했다: *"리비전 증가는 bit32 로
두고싶어요. 그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의
연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운
라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를
모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게
가고 싶어요."*
논거의 핵심은 **"`2^53` 포화는 어차피 도달 불가능한 시나리오인데, 그걸
피하겠다고 값을 double 영역까지 키울 이유가 없다"**이고, 매 `Set`마다 도는
hot path라는 것.
**⚠️ 에이전트가 형태를 잘못 옮겼고, 사용자가 그 자리에서 정정했다.**
에이전트는 이걸 `bit32.band(rev + 1, 0xFFFFFFFF)`로 적고, 거기에 "그러니
`bit32`라서 증가가 더 싼 건 아니다 — `n + 1`은 어느 쪽이든 double 덧셈이고
`bit32`는 fastcall을 하나 더 얹는다"는 단서까지 달았다. 사용자 정정:
> *"잠시만요. 제가 말한건, bit32.bnot(-a) 입니다"* — 그리고 REPL 출력 셋을
> 그대로 제시했다(`bit32.bnot(-1)` → `0`, `bit32.bnot(-0)``4294967295`,
> `bit32.bnot(-4294967295)``4294967294`).
**즉 그 세 줄은 예시가 아니라 연산 자체였다.** `luau`로 재확인한 결과:
```
bump(a) = bit32.bnot(-a)
0 → 4294967295 | 1 → 0 | 2 → 1 | 4294967295 → 4294967294
```
`a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드 감소**이고, **갱신과
랩이 FASTCALL 하나로** 끝난다. 그래서:
- **에이전트가 붙였던 단서는 틀렸다**`band(rev + 1, mask)`라는 **다른
형태**를 놓고 한 비교였다. `bnot(-a)`는 덧셈 위에 얹히는 게 아니라 갱신
자체를 대체하므로, *"native call 이라 아주 빨라요"*라는 사용자 서술이 맞다.
- **따름정리는 방향만 바뀌어 유효하다** — 단조 증가가 아닌 게 아니라 아예
**감소**한다. 지금 규칙이 `==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를
넣고 싶어지면 이 결정부터 되짚어야 한다. `Revision`이라는 이름이 순서를
뜻하지 않는다는 것도 같이 적어뒀다.
**교훈**: 사용자가 근거로 든 REPL 출력을 "그 함수가 랩한다는 예시"로만 읽고
연산 형태를 에이전트가 임의로 재구성했다. `conventions.md`의 "사용자 발언을
근거로 인용할 때는 결론만 적지 말고 논거까지 남길 것" 항목이 겨냥하는 실패에
가깝다 — 이 경우엔 논거를 남기긴 했는데 **형태를 바꿔 남겼다.**
**그래서 `Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 하나도 남지 않았다.**
`doc-check.py` ERROR 0 유지(WARN 30건은 전부 이 세션 이전부터 있던 것 —
축약 파일명 인용과 날짜 없는 시한부 주장).
## 6. 감사 루프 기록
`conventions.md`의 "핸드오버 준비하고 커밋해" 절차대로 **한 턴에 하나씩**,
라운드마다 각도를 바꿔 돌렸다.
### 1라운드 — `base/` 정합성 + diff 범위
발견 넷 중 **하나는 stale**(감사자가 `bit32` 편집 이전 스냅샷을 읽어
"리비전 증가 방식이 아직 미정"이라 보고했으나 다섯 곳 전부 이미 갱신돼
있었음 — 감사자가 도는 도중 메인 세션이 같은 파일을 고치면 생기는 일이라,
**감사 리포트를 받으면 항상 현재 파일로 재확인할 것**). 나머지 셋은 유효:
1. **`state-epoch-plan.md`가 "§8에서 기각됨"으로 실재하지 않는 내용을
가리켰다** — 재작성하면서 "게이트를 에포크 경계로" 기각 논거를 떨어뜨렸고,
`gate-plan.md``reference/`는 또 **다른** 번호(§5-3)를 대고 있었다.
→ §8에 실제 논거를 쓰고 세 포인터를 절 제목으로 통일. **`doc-check.py`
산문 속 `§번호`를 검증하지 못한다** — 문서를 재작성할 때 §번호 포인터는
손으로 훑어야 한다.
2. `ROADMAP.md` M7이 `isState`/`isSource`를 아직 "공유 레지스트리 기반"으로
서술 → 멤버십 기반으로.
3. **스파이크 `22`가 역전된 `Brand.set`/`Brand.get`을 직접 구현한 채
`done/`에 있었다** → `rewrite-required/`로 이동. 검증 대상(포함 관계)은
유효하므로 결론이 틀려서가 아니라 **구현자가 그 파일의 `Brand` 구현을
참고 모델로 오독**하는 걸 막기 위함. `05`와 같은 처리.
### 2라운드 — 인덱스 레이어 + `luau-test` + 1라운드 이후 diff
여섯 건, 전부 유효:
1. **`state-epoch-plan.md`가 자기 문서 안에서 모순**했다 — §2가 "`Revision`을
**증가**시킨다"라고 적어놓고 20줄 뒤에 "확정된 방식은 증가가 아니라
**감소**한다"라고 반박. `bit32.bnot(-a)` 정정을 반영하면서 앞쪽 문장을
안 고친 것. → 방향을 안 담는 "갱신한다"로.
2. `source-state-plan.md`**같은 문장이 복붙돼 있었다** — 같이 정정.
3. `README.md``effect-plan.md` 행이 dedup 대칭을 아직 "미확인"으로 서술
(본문은 `EF-3`에서 이미 "성립함"으로 확정). 세션 전부터 있던 stale.
4. `STATUS.md``✅ done/` 절이 **같은 파일 위쪽과 어긋났다** — 배너와 표는
`22` 이동을 반영했는데 절 제목("17건")과 본문 나열("런타임 9건 …/`22`")은
옛 상태. → 하드코딩 개수를 빼고 폴더/표를 소스로.
5. `luau-test/README.md`의 "실행 환경 세 갈래" 표가 `21`/`22`/`23`을
빠뜨리고 `13`을 아직 "런타임/타입 부분"으로 쪼개 적고 있었다(런타임
절반은 2026-08-19에 `22`로 분리돼 나갔다). → 실제와 맞추고, 파일 헤더가
소스임을 명시.
6. `reference/slot-attach-decomposition.md`의 제약 `C7`이 그 뒤 `native*`
계층 확정으로 **일반 계약으로서는 폐기**됐는데 문서에 아무 표시가
없었다. 결론 자체는 유효하지만(배치 경로가 부기를 먼저 끝내는 건 `C6`
요구사항이라 별개) 표만 떼어 읽으면 오독하므로 상단에 캐비엇 배너.
`reference/comparison-fusion-vide.md`가 이미 같은 형태의 캐비엇을 달고
있어 선례를 따랐다.
**교훈 하나** — 4·5번은 둘 다 **"같은 문서 안에서 위쪽만 갱신되고 아래쪽이
남은"** 형태다. 개수를 제목에 박아두면 정확히 이렇게 갈라지므로, 이번에
`STATUS.md`의 절 제목 두 곳에서 하드코딩 개수를 뺐다.
### 3라운드 — `archive/` 배너 정합성 + `qa-request/`의 "반영 완료" 주장
**확실한 모순 발견 0건 — 수렴.** 감사자가 대조한 것: `archive/`의 배너
포인터 넷(`brand-shared-registry-reversed` / `question-resolved`의 이관 섹션 /
`always-propagate-no-dedup-superseded` / `invalidate-dedup-propagation-reversed` /
`bookkeeping-before-physical-reversed`), 2라운드가 고친 여섯 자리, 그리고
`round4/5-followup.md`가 소스라고 선언한 항목들.
특히 **2라운드에서 내가 새로 쓴 `C7` 캐비엇**(*"배치 경로가 부기를 먼저
끝내는 건 `C7`이 아니라 `C6`가 요구하는 별개 사안"*)이
`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절 4번과 정확히 대응함이
확인됐다 — 새로 쓴 서술이 기존 확정과 어긋나지 않는지가 이 라운드의 핵심
질문이었다.
유일한 지적은 문체였다 — §8에 "`Revision`만 **올리면**"이라는 방향 어감이
남아 있던 것(§2가 이미 "방향은 계약이 아니다"로 커버하고 있어 모순은 아님).
같이 정리했다.
**수렴 판정**: 3라운드에서 새 발견 0건이므로 `conventions.md`의 루프 종료
조건을 만족한다. 총 소비는 서브에이전트 3패스(각도: `base/` 정합성 →
인덱스 레이어+`luau-test` → `archive/`+`qa-request/`).
## 7. 커밋 전 `/code-review high` — 감사 3라운드가 못 본 축에서 9건
`conventions.md`가 명문화한 그대로였다 — **감사자와 code-review는 보는 축이
다르다.** 감사자 3라운드가 수렴(0건)한 뒤 사용자가 `/code-review high`
돌리자 **9건이 더 나왔고 전부 유효**했다. 감사자는 코퍼스 전체의 **의미론적
정합성**(A 문서 결정 ↔ B 문서 서술)을 보고, code-review는 **diff 자체의
결함**(이번에 새로 쓴 서술 안의 모순, 새 표면이 기존 계약과 충돌하는가)을
본다.
### 구현을 실제로 막았을 것들
1. **`{Epoch}` 표기가 실제 배치 모양과 달랐다.** Luau에서 `{Epoch}`
**배열**(`{[number]: Epoch}`)인데, 실제로 넘어오는 게이트 배치는
`gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }` — **집합**이다.
이 표기를 믿고 `ipairs`로 구현하면 배치 순회에서 **원소가 0개**가 되어
유보됐다 풀린 emit이 전부 조용히 삼켜진다. `gate-plan.md` 4번이 애초에
고치려던 바로 그 버그였다. → `type EpochSet = { [Epoch]: true }`로 확정.
**집합이어야 하는 이유는 게이트 쪽 요구**다(흡수·unfold가 저절로 접혀야
함). 이건 사용자 원 제안의 `Epoch|{Epoch}` 표기를 에이전트가 그대로
옮기면서 **Luau 타입으로서 뭘 뜻하는지 확인 안 한** 결과다.
2. **⭐ 새 노드 시딩이 확정된 `EpochMap` 표면으로 표현 불가능했다.**
"상류의 `Epoch`를 전부 끌어와 채운다"인데 `:With(a, b)`의 상류는
**State이지 `Epoch`가 아니다.** 상류가 추적 중인 루트 집합은 그 State의
`valueEpochMap` 안에만 있는데 §3 표면엔 **키 열거도 병합도 없었다.**
`:TrackFrom(other)` 신설(가칭 `Absorb`, 같은 날 개명 — §8 참고).
`:Refresh`와 같은 성격으로 **이미 확정된 동작에 이름을 붙인 것**이지
새 설계가 아니다(사용자 확정 문구가 이미 *"전부 가져와서, 실제 count 로
둡니다"*였다). dep이 `Epoch``:Sync`, State면 `:TrackFrom`으로 갈리는
것도 같이 적었다.
3. **`GateNode` 예외가 §4에 기록돼 있지 않았다.** §4 의사코드는
`emitEpochMap:Update(from)`**수신 시점에 무조건** 부르는데,
`gate-plan.md` 4번은 게이트가 **전파할 때** `:Sync(batch)`로 갱신하는
것으로 확정돼 있다. §4대로 구현하면 그 계약이 조용히 깨진다.
4. **설치 시 즉시 1회 발화에 `from`이 없다.** `fn(self, from)`
non-optional로 선언해뒀는데 등록 시점 발화엔 출처가 없다 —
`Effect`의 확정 클로저가 `Update(nil)`을 부르게 된다. → 옵셔널로 바꾸고,
`Effect`의 억제 플래그가 `Update`보다 **먼저** 와야 함을 명시(순서를
뒤집으면 설치 발화가 맵을 건드려 그 파동의 첫 진짜 emit이 접힌다).
### 근거·표기 정확성
5. **`2^32` 랩이 "똑같이 도달 불가능"이라는 근거가 틀렸다.** 같은 척도(초당
100만 `Set`)로 `2^53`은 285년인데 **`2^32`는 약 72분**이다(20만 배 차이).
실제로 안전한 이유는 **도달 시간이 아니라 충돌 조건이 한 점**이라는 것
("정확히 `2^32`만큼 뒤처진 항목"이라야 하고, 한 바퀴 중 한 번이라도
건드려지면 갱신됨). 이 문서가 바로 위 항목에서 *"근거를 정확히 적을 것"*
이라 스스로 규정해놓고 20줄 뒤에 어긴 셈이라 그대로 정정했다.
6. `:Sync`를 "초기화에만 쓴다"고 적었으나 `gate-plan.md`가 flush 경로에서도
쓴다 → "반환값이 필요 없다고 이미 아는 곳 둘"로 정정.
7. `ROADMAP.md``Brand.luau` predicate 목록에 `isEpoch` 누락.
8. **역전된 옛 이름 잔재**`TweenTag`(3곳)와 `Effect(fn, state?)`(4곳).
후자는 code-review가 짚은 것보다 실제로 더 많았다
(`architecture.md`/`lifecycle-hooks-plan.md`/`ROADMAP.md`/`README.md`).
9. **절 재편에 안 따라온 `§` 참조 3곳** — 1라운드가 같은 유형을 잡았는데도
남아 있었다. `doc-check.py``§번호`를 검증 못 하는 사각지대다.
**교훈**: 1·2번은 둘 다 **"사용자 제안의 표기를 그대로 옮겼는데 그게 실제
메커니즘과 안 맞는" 유형**이다. 승격은 문장을 옮기는 작업이 아니라 **표기가
가리키는 것이 실제로 성립하는지 확인하는 작업**이라는 게 이번의 교훈.
## 8. `:Refresh`/`:TrackFrom` — 에이전트가 이름 붙인 둘, 사용자 검토로 확정
`/code-review`까지 처리한 뒤, **에이전트가 임의로 이름 붙인 연산 둘을
사용자에게 명시적으로 올렸다.** 동작 자체는 회신에 이미 확정돼 있었지만 표면
이름은 에이전트 판단이었기 때문 — `conventions.md`의 "애매하면 임의로 정하지
말고 그 자리에서 사용자에게 보고할 것"에 해당한다.
**`:Refresh` — 그대로 확정.** 사용자가 `:Update`와 합치지 않는 이유를 직접
정리했다: *"Update 에 인자 없는건 좀 아니야. 뭔가 받아서 받은것들에 대해서
처리하겠다는건데, 리프레시는 아무래도 내가 받았던걸 처리하겠다는거라
표면적 의미 자체가 다르지."* — **인자를 받아 그것을 처리하는 연산**과
**자기가 이미 들고 있는 것을 처리하는 연산**은 표면적 의미가 달라서 오버로드로
합치면 안 된다는 것. 표면이 하나 줄어드는 것보다 이 구분이 값이 크다.
**`:Absorb` → `:TrackFrom`으로 개명.** 사용자 지적: *"absorb 는 조금 상위
요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름이긴 하네."* 맞다 — 이
연산은 `other`**전혀 안 건드린다.** 덧붙여 `base/gate-plan.md`가 이미
"흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어, 한 코퍼스 안에
같은 단어가 두 의미로 놓이는 문제도 있었다.
후보를 넷 올렸고(`TrackFrom`/`SeedFrom`/`Include`/`Adopt`) 권장안이 채택됐다.
`TrackFrom`을 고른 근거 셋:
1. **이 맵의 존재 이유를 사용자가 표현한 말이 "추적"이었다**
*"'내가 뭘 추적하고 있나' 가 필요하죠"*(§4의 시딩 규칙 근거). 코퍼스가 이
맵을 설명하는 말과 메소드 이름이 일치한다.
2. **`From`이 방향을 못박아 비파괴가 드러난다** — `Absorb`가 실패한 지점.
3. **`SeedFrom`보다 오래 간다** — 동적 의존성으로 "생성 이후에 키를 더하는"
자리가 생겨도 이름이 그대로 맞다. `Seed`는 그때 거짓말이 된다.
배제한 것도 근거가 있다 — `Extend`/`Inherit`은 `quad2-try``Base:Extends`
OOP 상속이 **확인된 죽은 접근**이라 그 어휘를 되살리면 오독을 부르고,
`Extract`는 코퍼스에서 이미 "소유권을 통째로 넘긴다"는 뜻이다.
**두 이름 다 `question.md`에 안 올린다** — 같은 자리에서 확정됐으므로 열린
항목이 아니다.

View file

@ -0,0 +1,234 @@
# 2026-08-22-01 — `ROADMAP.md` 전반 점검: 마일스톤 경계 재편과 stale 정리
사용자 요청: *"마일스톤 전체가 이제 손봐야할 부분이 많다고 생각해. 전반을
확인해볼래?"* — 직전 세션(`2026-08-21-03`)이 `Epoch`/`EpochMap`/`Brand`를
승격하며 커밋 메시지에 마일스톤 관련 항목 셋을 남긴 게 발단.
**이 세션은 커밋하지 않았다** — 사용자 지시(*"이거 다 하고 내가 직접
코드리뷰 트리거해줄게 커밋 하지 말아줘"*). 작업 트리에 17개 파일이 그대로
남아 있고, 사용자가 `/code-review`를 돌린 뒤 그 결과까지 반영해서 커밋하는
게 다음 단계다.
## 1. 무엇을 했나
`ROADMAP.md` 전문(1074줄)을 읽고 `base/`의 현재 확정과 대조했다. 발견을
셋으로 갈라 보고했다 — A(내용 모순), B(순서/소속), C(상태 표시 신뢰성).
사용자가 B와 C-3에 대해 각각 "실제로 옮긴다", "설계 확정은 별도 표기로
분리"를 선택했고 그대로 실행했다.
### A. 모순 — 그대로 읽으면 반대로 구현되는 것
- **M2 첫 체크박스가 자기 자신을 부정**했다. 불릿 머리는 하강 diff인데
꼬리가 *"`Dispatch.process`는 diff를 하지 않음"*(2026-08-13 다섯 번째
세션 모델, **같은 날 열네 번째 세션에 폐기**)으로 끝났다.
- **`Dispatch.drive`의 순회 계약이 옛 버전**이었다. QA 4라운드 `F-4-1`
"두 패스 구현"이 폐기되고 단일 일반화 `for`로 정정됐는데 ROADMAP 두 곳이
안 따라왔다. 더 나쁜 건 **`dispatch-core-plan.md` 자신이 모순**이었다는
것 — `F-4-1` 정정(§390)이 "이식성 논거는 과했다"고 명시적으로 기각한
근거를 34줄 뒤 `D-10` 문단(§440)이 여전히 현재형으로 주장했다.
- **물리 조작 주입 op 이름이 두 벌** 돌았다. `slot-plan.md`/`architecture.md`는
`native*`로 넘어갔는데 ROADMAP M6와 `dispatch-core-plan.md`는 가칭
`mountInst`/`unmountInst`/`disposeInst`를 썼다. 뿌리는
`dispatch-core-plan.md`가 *"이름은 아직 가칭이라 정식 등재하는 건 그
확정 시점에 한다"*로 멈춰 있던 것 — 확정이 났는데 그 문단이 안 열렸다.
- **"주입되는 엔진 op 3개"** 하드코딩. `architecture.md``EngineOps.luau`
줄을 단일 소스로 지정하고 개수를 지웠다.
### B. 순서/소속 — 각주로 덮어온 자리 (사용자 선택: "실제로 옮긴다")
M2가 실제로 필요로 하는 모듈 넷이 전부 뒤 마일스톤에 체크박스로 있었고,
**옮기는 대신 각주가 붙어 있었다**:
| 모듈 | 있던 곳 | 옮긴 곳 |
|---|---|---|
| `GateNode` / `state:Gate` | **체크박스 자체가 없음**(각주 안에만) | M2 |
| `EpochMap.luau` | M3 | M2 |
| `Blocker.luau` | M3 | M2 |
| `None` + `Dispatch/None.luau` | M7 | M2 |
`GateNode`가 특히 나빴다 — M2 체크박스를 훑는 구현자에게 **항목으로 보이지
않았다**. 전례가 있다: `LifetimeHandle` 인터페이스도 같은 이유로 M8→M2
실제 이동을 했었다(각주가 아니라).
### C. 상태 표시
- **M0이 전부 `[x]`인데 검증 스파이크 둘이 무효화**돼 있었고(`01`: 두 패스→
단일 for, `05`: Epoch 리비전 채택), 재작성 작업이 어느 체크박스에도
없었다. `### 재검증 대기` 절을 신설해 7건 + 파일 없는 실측 2건을 모았다.
- **머리 blockquote가 `[2026-08-19 기준]`**에서 멈춰 있었다.
- **`[x]`의 의미가 마일스톤마다 달랐다** — M0/M1은 "실제로 짜서 통과",
M6/M11은 "설계 확정"(코드 0줄). `initValue` 항목은 아예 스코프 배제가
`[x]`였다. 사용자 선택대로 M6/M11에 `### 확정된 것 — 코드 아님, 구현 전
필독` 절을 만들어 10개 항목을 옮기고, 체크박스는 "짜야 할 코드"만 남겼다.
## 2. ⭐ 이동하다 드러난 것 — M2와 M3의 의존이 양방향이다
**이게 이 세션의 실질적 산출물이다.** `EpochMap`/`Gate`/`Blocker`를 M2로
옮기려다 확인한 것:
- **M2 → M3 (본체 의존)**: `Dispatch.setLength``len: number | State<number>`를,
`setOffsetSource``Source<number>`를 받고 `recompute``offset:Set(abs)`/
`ownerKey.Length:Set(sum)`을 부른다(`dispatch-core-plan.md:1279-1280`,
`:1621`). 즉 **M2는 `Source.luau`/`State.luau` 없이는 구현이 안 된다.**
M2로 옮긴 `GateNode`/`Blocker`도 State 위에 얹히므로 마찬가지.
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
`Dispatch.addHandler` + `Handler.luau` 계약을 쓴다 — M2 앞머리 두 항목이면
충족.
선택지 셋((a) M2/M3 순서 교체 / (b) M2를 M2a·M2b로 분할 / (c) 유지)을
`question.md` 2번으로 신설하고 **유일한 소스**로 지정했다. `ROADMAP.md` M2
배너 / `todos.md` 00번 / `CLAUDE.md` / `project-context.md` /
`HUMAN_TODO.md` 11번은 전부 가리키기만 한다.
**중요한 구분**: `todos.md`가 오래 유지해온 "M2 착수를 막는 **설계** 항목은
없다"는 여전히 참이다 — 이건 설계가 아니라 **순서** 문제다. 그 구분을
안 하면 두 서술이 모순으로 읽히므로 다섯 곳 전부에 명시했다.
## 3. 감사 루프 — 8라운드, 30건
`conventions.md`의 절차대로 `quad-doc-auditor`**한 턴에 하나씩**, 라운드마다
각도를 바꿔가며 돌렸다. 발견 추이: **9 → 5 → 7 → 3 → 3 → 1 → 1 → 0**.
라운드별 각도: (1) diff 범위, (2) 인덱스 레이어 + `base/` 프리미티브,
(3) `archive/`·`reference/`·`qa-request/`, (4) **내 수정 자체를 의심**,
(5) 통독 시점 + `architecture.md`/`source-state-plan.md`/`HUMAN_TODO.md`,
(6) 아직 안 본 `base/` 17개 + `session-summary.md`, (7) "이름만 고치고 의미는
안 고친" 각도, (8) 마일스톤 소속 주장 전수.
### ⭐ 이 루프에서 배운 것 — 내가 고치면서 새 결함을 3건 만들었다
1. **하드코딩 "넷"** — 배너에 *"검증 스파이크 **넷**이 재작성 대기"*라고
쓰면서 **같은 문장에서** "개수·현황의 소스는 `STATUS.md`, 여기서 세지
않음"이라 선언했다. 실제로는 7개.
2. **`debounce-throttle-plan.md`의 M3 누락** — "전부 M3/M8"에서 `Blocker`
M2로 옮기다 **State 코어(M3)가 통째로 빠졌다.**
3. **`nativeInsert` 치환 시 순서 주장 미검토** — 3라운드에서
`dispatch-core-plan.md``mountInst``nativeInsert`로 바꾸면서
**이름만 갈고 그 이름이 든 문장이 무슨 주장을 하는지 안 읽었다.**
문단은 역전된 옛 C-7(*"부기를 먼저 완결하고 그 다음 물리 마운트"*)을
현재형으로 주장하고 있었고, 지금 단건 경로는 정반대다
(`setOffsetSource` → `nativeInsert``setLength``recompute`,
`slot-plan.md``rawAdd` 의사코드가 소스). **역전 배너를 스스로 달아둔
바로 그 절 안에** 옛 주장이 살아 있었다.
셋 다 `conventions.md` 핸드오버 체크리스트 2번(*"배너를 달았으면 그 배너가
부정하는 문장을 같은 커밋에서 고쳤는지 확인"*)이 경고하는 패턴이다.
**일괄 치환은 그 자체가 이 실패 모드를 만든다** — 치환한 줄이 속한 문단을
안 읽으면, 옛 주장이 새 어휘를 입고 살아남는다. 앞으로 용어/이름 치환을
할 땐 치환 지점마다 **문단 단위로** 읽을 것.
### 감사자가 잡은 것 중 성격이 두드러진 것
- **`ROADMAP.md` 맨 위 헤드라인 배너에만 M2 블로커 경고가 빠져 있었다** —
`CLAUDE.md`/`project-context.md`/`todos.md`/`question.md` 넷은 다 넣어놓고,
정작 그 넷이 "진행 상황의 소스"로 지목하는 문서의 첫 배너를 빠뜨렸다.
ROADMAP만 읽는 사람이 M2를 바로 시작할 수 있는 상태였다.
- **`architecture.md` 소스 트리에 `EpochMap.luau`가 아예 없었다** — ROADMAP
M2가 "별도 모듈로 낼 것"이라 명시하는데, "다음 세션이 실제로 만들 구조"라
자임하는 정본에서 빠져 있었다.
- **`source-state-plan.md``Blocker`를 "M3에서 함께 구현"이라 서술** —
`blocker-plan.md`엔 정정 배너를 달았는데 정작 **그 사실을 처음 서술한
본문**은 안 고쳤던, 체크리스트 2번 그대로의 사례.
- **`HUMAN_TODO.md`만 인덱스 레이어 동기화에서 빠졌다** — *"`question.md`엔
이제 '결정 대기' 절 자체가 없음"*이라 단정 중이었다.
- **`두 패스`가 코퍼스 전반에 남아 있었다** — `ref-plan.md`가 "본체 루프"로
고친 **같은 문장 안에서** 다시 "두 패스로 처리"라고 썼다. 구현 단정
용법은 전부 "본체 루프"로 바꾸고, `ref-plan.md`/`lifecycle-hooks-plan.md`에
용어 각주를 신설했다. **"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은
*시점* 표기는 의도적으로 남겼다** — `PostRef` 절 제목이기도 하고 인용
대상이라, 바꾸면 절 참조가 깨진다.
## 4. `/code-review high` — 감사 0건 뒤에 9건 더
감사 루프가 0건으로 수렴한 **뒤에** 사용자가 `/code-review high`를 돌렸고
**9건이 더 나왔다. 전부 유효했다.** `conventions.md`가 2026-08-18에 실측으로
기록해둔 것("code-review는 감사자를 대체하지 않는다 — 둘이 보는 축이 다르다")이
이번에도 그대로 재현됐다.
**⭐ 가장 나빴던 것 — 내가 새로 쓴 체크박스가 역전 전 표기를 구현 지시로
복제했다.** M2의 `GateNode` 항목에 *"내보내는 emit은 `emit(self)` + 흡수
집합"*이라 적었는데, 확정 계약은 정반대다 — `state-epoch-plan.md` §5는
*"게이트 노드 자체는 페이로드에 안 싣는다 … 하류는 게이트 identity를 한 번도
안 쓴다"*이고, 넘기는 건 스왑해 떼어낸 `EpochSet` 스냅샷뿐이다. `emit(self)`
`Epoch` 일반화 **이전** 표기인데 `gate-plan.md`의 배너와 4번 제목에 잔존해
있었고, 내가 그걸 그대로 베껴 **M2를 짜는 사람이 게이트 노드를 출처로 싣게
만드는 지시**로 승격시켰다. 뿌리인 `gate-plan.md` 두 곳까지 같이 고쳤다.
나머지 여덟 건의 성격:
- **`F-4-1` 정정이 자기 절 안의 다음 문장을 거짓으로 만들었다.** 구현을 단일
일반화 `for`로 확정해놓고, 아래 문단은 여전히 *"base는 이 우연한 동작에
기대지 않고 명시적으로 강제한다"*고 주장했다. **단일 `for`는 정확히 그
언어 동작에 기대는 구현이다.** 따라서 재작성될 `01`이 검증할 것도 "우리
드라이버가 계약대로 도는가"가 아니라 **언어 동작 그 자체**로 바뀐다. 그리고
`nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안 된다는 따름정리도
같이 적었다.
- **`gate-plan.md`가 마일스톤 순환을 "해소"로 서술**했다. `Blocker`를 M2로
옮겼다고 순환이 닫히지 않는다 — `state:Gate`는 State 메소드라 M2→M3 참조가
그대로 남는다. `question.md` 2번이 인정한 바로 그 사실인데 gate-plan만
읽으면 닫힌 것으로 읽혔다.
- **"M2 앞머리 두 항목이면 충족"이 부정확**했다. `Dispatch/init.luau`
`Dispatch.drive`가 들어 있고 그 자신이 배치 등록을 Blocker로 게이팅한다
(`dispatch-core-plan.md`의 "적용 지점" 확정). 선택지 (b)로 쪼갠다면
`drive`를 어느 쪽에 두느냐가 경계선이 된다 — 사용자가 (a)/(c)를 고를 때
결합도를 실제보다 얕게 볼 뻔했다.
- **"M0 체크박스는 전부 닫혔다"가 같은 편집이 M0에 넣은 미체크 8개와 충돌.**
새로 선언한 `[x]` 규약상 그것들은 실제 열린 작업이다.
- **단일 소스로 지정한 자리가 정작 불완전**했다 — `architecture.md`
`EngineOps.luau` 줄에 `setTimeout`/`clearTimeout`이 없었고, 인접
`RobloxFactory.luau` 줄의 주입 대상 목록도 `native*` 없이 멈춰 있었다.
"개수 하드코딩을 지우고 소스를 하나로"라는 의도가 그 소스가 불완전해서
성립하지 않았다. 소스 쪽을 실제로 채웠다.
- **근거로 제시한 문서를 열면 확인이 안 되는 주장**`None.luau`
소스 트리에 "같은 위치로 있다"고 적었으나 트리엔 `Dispatch/None.luau`
있고 탑레벨 센티널도 `Brand.luau`도 줄이 없었다.
- **새로 세운 `[x]` 규약이 같은 문서에서 이미 깨져 있었다** — M3의
`store.key` 타입 추론 확인이 코드가 아닌데 `[x]`였다. 배너가 위반 사례를
"M6/M11"로 한정 열거해서 감사에서 빠졌다. 규약 문구를 일반화하고 그
항목도 뺐다.
- **재작성 지시가 없는 경로를 가리켰다**`luau-test/done/01-...`인데 실제
파일은 `rewrite-required/`에 있다. 하필 *"⚠️ 스파이크 `01` 재작성 필요"*
지시문 자체가 그랬다.
**이 세션의 교훈을 한 줄로**: 감사 0건은 "코퍼스가 스스로와 정합한다"는
뜻이지 "이번에 새로 쓴 문장이 옳다"는 뜻이 아니다. 특히 **다른 문서에서
문장을 베껴 새 체크박스를 만들 때, 그 문장이 이미 역전된 표기인지를
확인해야 한다** — 감사자는 "베껴온 곳과 원본이 같다"를 보고 정합으로
판정한다(실제로 `emit(self)`가 gate-plan에도 남아 있었으므로 둘은 "일치"했다).
## 5. code-review 뒤 감사 2라운드 — 그리고 실측된 패턴 하나
사용자 요청으로 code-review 수정분에 감사를 두 라운드 더 돌렸다.
- **1라운드 3건, 전부 "직전 수정이 만든 것"**`todos.md``emit(self)`
"사용자 확정"으로 인용하면서 **내가 방금 그 표기를 부정하는 배너를 단
문서**를 근거로 가리키던 것, "주입 op 전체 목록의 소스는 `architecture.md`"라고
선언한 **6줄 아래에 그 목록이 그대로 있던** 것(게다가 `native*`도 시간
op도 없는 불완전한 셋이라, 단일 소스를 세우려던 편집이 오히려 "목록이 두
곳"을 고착시킬 뻔했다), `nil`-hole 문단 중복.
- **2라운드 1건 — 이번엔 내 수정이 만든 게 아니라 앞 라운드들이 놓친 기존
자리였다.** `module-lifecycle-plan.md`가 *"주입 대상 목록에 엔진 op 3개가
추가됨"* + *"`addTag`/`removeTag`/`setAttribute`**만** 채우는 타입 계약"*으로
같은 하드코딩을 들고 있었다. 스윕해보니 `debounce-throttle-plan.md`도 두
곳에서 "엔진 op 3개"를 관례 비유로 쓰고 있었는데, **그 문서 자신이
`setTimeout`/`clearTimeout`을 추가해 그 개수를 늘리는 주체**라 자기모순이었다.
### ⭐ 실측된 패턴 — 개수 하드코딩은 "한 곳을 고치면 끝"이 아니다
"주입 op 3개"라는 같은 사실이 **네 문서**(`ROADMAP.md`, `dispatch-core-plan.md`,
`module-lifecycle-plan.md`, `debounce-throttle-plan.md`)에 흩어져 있었고,
`conventions.md` 체크리스트 4번이 예고한 그대로 전부 갈라져 있었다. 처음
둘만 고치고 "단일 소스로 정리했다"고 선언한 게 이 세션인데, **선언 자체가
나머지 둘을 못 찾았다.** 개수를 지울 땐 그 개수의 **모든 사본**을 grep으로
전수 찾아야 하고, "소스는 X다"라고 선언한 뒤엔 **X가 실제로 완전한지**와
**다른 사본이 남았는지**를 같은 자리에서 확인해야 한다.
## 6. 남은 것
- **`question.md` 2번(M2/M3 마일스톤 경계) 사용자 회신** — M2 착수 전 필요.
`HUMAN_TODO.md` 11번이 사용자용 진입점.
- **사용자가 직접 `/code-review`를 돌린 뒤 그 결과 반영 + 커밋.**
`conventions.md`가 이미 실측으로 기록해둔 대로(2026-08-18) code-review는
감사자가 못 보는 축을 본다 — 이번 변경은 (a) 마일스톤 경계 이동,
(b) 여러 문서에 걸친 용어/이름 일괄 치환, (c) 확정 문서의 순서 계약 정정이라
정확히 그 축에 걸린다.

View file

@ -5,8 +5,151 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등).
00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드
전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`
**[2026-08-22 정정]** 구현 마일스톤은 갈린다: `EpochMap`/`Epoch`
인터페이스는 **M2**, State 본체 통합은 **M3**) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다.
**[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** —
부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가
`Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다
(근거 기록은 `reference/epoch-brand-composition.md`, 옛 `Brand` 표면은
`archive/brand-shared-registry-reversed.md`). 그 부수로 `effect-plan.md`
다중 의존성 중복 발화 미해결 항목도 닫혔다. **[같은 날] 마지막 미정이던
리비전 증가 방식도 `bit32` 랩으로 확정** — `Epoch`/`EpochMap`/`Brand`에
열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2).
**[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로
되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번.
**[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀
있었으나 그건 `Epoch` 일반화 **이전** 표기다 — 게이트 노드 자신은
페이로드에 안 싣는다). 같이
제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 /
새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤
`rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) —
`base/state-epoch-plan.md` §4. **[같은 날 두 번째 `/code-review high`]**
7건이 더 나왔고 전부 유효했는데(재진입 시 빈 배치가 새어 변경이 증발하던
것, `OffWithoutEmit`이 흡수 집합을 안 비우던 것 등), 그중 사용자 판단으로
올라갔던 둘도 같은 날 닫혔다 — "재진입 계약"은 애초에 **잘못 옮긴
서술**이었고, "빈 배치 emit"은 **아무것도 안 하는 것**으로 확정(그 따름정리로
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md`
순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.**
**⚠️ [2026-08-22 신설] 다만 설계가 아니라 *순서*가 막고 있다.**
`ROADMAP.md` 전반 점검에서 **M2와 M3의 의존이 양방향**인 게 드러났다 —
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 `State<number>`/
`Source<number>`를 쓰므로 M2는 `Source.luau`/`State.luau` 없이 구현이
안 되고, 반대로 M3의 `Observer`/`Effect` 동적 경로 가드는 M2의
`Dispatch.addHandler`를 쓴다. 선택지 셋(M2/M3 순서 교체 / M2를 둘로
분할 / 지금 구조 유지)은 **`question.md` 2번이 소스** — 여기서 반복하지
않는다. 같은 점검에서 `EpochMap`/`GateNode`/`Blocker`/`None`이 M3·M7에서
**M2로 실제 이동**했고(각주로만 예고돼 있던 것), `Dispatch.drive`
두 패스가 아니라 단일 일반화 `for`라는 `F-4-1` 정정과 물리 조작 주입 op
이름 `native*` 확정이 `ROADMAP.md`/`dispatch-core-plan.md`에 반영됐다.
M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도
`ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히
`luau-test/STATUS.md`).
그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`
생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`).
**[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날
`ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다.
**⭐⭐ [2026-08-22 신설] 6라운드 — 손 트레이싱, 사용자 회신 대기.**
사용자 요청으로 최근 확정 5개 영역(`Effect(fn, ...deps)` / `Gate`·`Blocker` /
State 전파(`rawInvalid`·emit 지연) / Slot의 `native*`·offset·length·mount /
`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려봤고, **의사코드 그대로
구현하면 크래시하거나 조용히 어긋나는 것들이 나왔다.** 문항지가 아니라
발견 보고이고 **`base/`는 아직 한 줄도 안 고쳤다** — 소스는
`qa-request/pre-implementation-handtrace-round6.md`(발견 수·심각도·개별
항목은 여기서 세지 않는다). **M2/M3 구현에 직접 걸리므로 다음 세션의 첫
작업으로 볼 것.**
**⭐ [2026-08-23] 같은 파일에 2차 패스가 이어붙었다** — 사용자 요청으로
1차가 안 본 영역(디스패치 코어 전체 / 라이프타임 유틸 / `Ref`·`Tag`·
`Attribute`·UI 숏핸드 핸들러 / Slot의 `raw*` 계층)까지 넓혀 다시 돌린
것. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 갈래 선택이 필요한 항목이
1차보다 늘었고(어느 것인지는 그 문서의 두 "회신 방법" 절이 소스),
그중 하나는 **`Effect`의 leaf 사망 cleanup을 발화시키는 배선이 어느
의사코드에도 없다**는 것이라 `Effect`/`OnDestroyed`/`slot._detached`
정리가 통째로 걸린다 — M3 `Effect` 구현 전에 반드시 닫을 것.
**⭐⭐ [2026-08-23] 같은 파일에 3차 패스도 이어붙었다 — 발견 번호는
`H-21`~`H-38`.** 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어,
Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트,
타입 계약과 **실제 커밋된 M1 코드**)와 문서 경계를 가로지르는 통합
시나리오를 돌렸다. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 1·2차와
달리 **추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`로 직접 재현**했고,
그 부수로 **기존 `H-2`의 크래시 주장이 틀렸다는 것도 드러났다**(결론은
유효하지만 결과가 크래시가 아니라 조용한 영구 고아 — 그 문서 3차 패스
머리의 정정 절이 소스). **M2 착수 전에 정해야 하는 것이 하나 늘었다**
`New(): Quad`가 닫힌 타입이라 M2가 붙일 `quad.Dispatch` 접근이 그대로는
타입에러라는 것(`H-25`, `luau-analyze`로 재현). 개별 항목·심각도·갈래는
여기서 세지 않는다 — 그 문서의 세 "회신 방법" 절이 소스.
**⭐⭐ [2026-08-24] 4차 패스도 이어붙었다 — 발견 번호는 `H-39`~`H-54`.**
이번엔 문서 단위가 아니라 **축을 바꿔서** 훑었다(핸들러 레지스트리 전수 /
`ref-plan.md`·`attribute-plan.md` 심층 / **`luau-test` 스파이크 실제 재실행** /
프리미티브 조합 매트릭스 / `reference`·`archive`·로드맵 M3~M9 / **엔진·언어
사실 주장 전수 검증**). 여기서도 **`base/`는 한 줄도 안 고쳤다.**
**⭐ M2에 직접 걸리는 게 하나 나왔다** — 배열 자리를 차지하는 말단 핸들러
4종(`TagHandler`/`AttributeGroupHandler`/`RefLeafHandler`/
`ObserverEffectLeafHandler`)이 `setLength`/`setOffsetSource`를 **아예 등록하지
않아** `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 마운트에
`recompute`의 명시적 error로 죽는다(`H-39`, **세 축에서 독립 발견**
전수 grep으로 재확인). 부수로 **`H-21`의 전제가 공식 문서로 확인**됐고,
반대로 **`PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부**임이
드러났다(`H-42` — 신규 템플릿 place는 이미 `Deferred`가 기본).
**스파이크 쪽은 깨끗하다**`done/` 16개 전원이 `STATUS.md` 주장과 실행
결과가 일치했고 GC 스파이크도 반복 실행에서 안 흔들렸으며 설계 드리프트도
0건이었다. 개별 항목·심각도·갈래는 여기서 세지 않는다 — 그 문서의 네
"회신 방법" 절이 소스.
**4라운드 — [2026-08-21] 종결.** 문항지는
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
`-response.md`, 처리 결과 전량은 **`-followup.md`가 소스**(여기서 목록을
세지 않음 — 마지막 H절이 최신). 4차 처리로 `F-3`이 전량 확인되며
**`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**,
**`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
(`materializeSlotTree` + `mountSlotTree`, 근거는
`reference/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
**[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자
지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은
날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운
부분들이나 다른 깊은 부분")해 `qa-request/pre-implementation-qa-round5.md`
신설했다(**회신 대기**). 범위는 셋 — (1) 4라운드에 문항이 아예 없던 영역
(`base/project-setup-plan.md`/`base/quad-types-plan.md`, 그리고 문서가 아니라
**실제 커밋된 M1 코드**), (2) 4라운드 회신 이후 새로 확정된 것
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의 심화.
문항 수와 분포는 그 문서 자신이 소스.
**[2026-08-21 후속] 5라운드 회신 도착·처리 완료**(라운드 수는 여기서 안 셈) — 회신 원문은
`-round5-response.md`, 처리 결과의 소스는 **`-round5-followup.md`**.
즉시 반영된 것은 `slot._detached` lazy화, `KeyGone`에 새 값 반환도 error,
**`Owned = false`에서 `Detach``_detached`에 안 들어감**, 조상 파괴 시
unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정
(`(inst, groupValue) → k`), `Tween<T>:Mapped` 이름 확정, 4라운드가 반영을
빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"(M2로 앞당김)**.
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
`rawAdd``Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
다중 의존성) — **[2026-08-21 전량 처리 완료]**, 소스는 그 파일의 **마지막
절**(A~L). 대화 마지막 라운드에서 **`native*` 물리 조작 계층**이 확정되며
4라운드 `C-7`("부기가 물리보다 먼저")이 역전됐다. `Gate` 설계는 한때
다음 세션으로 미뤄졌으나(*"고칠것이 많으므로 Gate 는 다음 세션에
다루겠음"*) **[2026-08-21 같은 날 확정]** 사용자가 이어서 표면을 정해
`base/gate-plan.md``research/`를 떠났다 — 위 00번 머리말이 소스.
아래는 4라운드 회신 전 서술:
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
`base/` 전 문서를 한 맥락에서 읽으며 확정 주장을 전수 문항화한 것.
**아직 아무것도 정정하지 않았음** — 사용자가 "아니오"인 항목을 회신하면
그때 `base/`에 반영하고 그 문서를 1라운드처럼 근거 기록으로 재편한다.
문항 수/분포/읽는 순서는 그 문서 자신이 소스(여기서 세지 않음).
**1~3라운드(완료)**: 1라운드는 사용자가 `base/` 확정 문서 전체를
문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
`.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
@ -38,44 +181,21 @@
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
마일스톤 정합성" 절 참고).
**아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀
있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
3번에도 올라가
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시
검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/`
문서에도 ⚠️로 표시돼 있다:
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
전 필요**. `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
**아래는 M3 착수 전에 결론이 필요한 항목 목록****설계** 게이트
얘기다. M0은 아예 막혀 있지 않고, M2도 설계로는 안 막혀 있으나
**[2026-08-22] 마일스톤 순서 문제 하나가 M2를 막는다**(위 00번의
⚠️ 문단 + `question.md` 2번). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도
⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들
(`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키,
`SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키
타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md`
`base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어
"지금 할 일"로 읽히지 않던 것을 걷어낸 것.
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
방향은 확정, 키 설계가 미정. M10 착수 전 필요.
- **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply`
시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도.
- **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md`
`:Unsubscribe()` 절) — M3 착수 전 확인.
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
위치도 `None`과 같은 최상위 export로 같이 확정). 원문은
`archive/question-resolved.md`.
- **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분**
(`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고
참조만 끊김. **[정정, 2026-08-18 `/code-review high``ROADMAP.md`
M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수
전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥
지나칠 위험이 있었음.
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
- **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가
나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`,
`ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키
접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store =
Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요.
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
@ -147,12 +267,11 @@
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영
완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영
완료로 목록에서 빠짐**): `Slot`(2순위),
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
**[2026-08-21] 여기 있던 이름 나열은 지웠다.** 바로 위 문장이 이미
"`question.md` 1번이 최신 소스"라고 선언해놓고 다음 줄에서 목록을 다시
나열하고 있었고, 예고대로 실제로 갈라졌다(2026-08-21에 추가된 `Owned`
그 전부터 있던 `hintValue`가 둘 다 빠져 있었음 — 감사가 발견).
**열린 항목이 뭔지는 `question.md` 1번을 열어볼 것.**
3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스
재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`
이전됨 — 더 이상 상의할 스코프 항목이 아님.
@ -187,8 +306,9 @@
기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니
실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로
확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지
않고, **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용 `Gate`
빼두는 것만은 그 시점에 해야 함**(따로 하면 같은 설계를 두 번 함).
않고, **그 게이티드 노드는 [2026-08-21] `state:Gate`로 확정돼 M2에서
만들어진다**(`base/gate-plan.md`) — `Debounce`/`Throttle`은 그 위의
정책으로 얹으면 되고, 같은 설계를 두 번 할 일은 없어졌다.
주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에
추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구
`question.md` 3번, 전량 해소로 항목 자체가 빠짐).

View file

@ -1,8 +1,12 @@
# CLAUDE.md
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치
엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은
M2(디스패치 엔진)** — 다만 **⚠️ M2 착수를 막는 항목이 하나 있음**:
M2와 M3의 의존이 양방향이라(M2의 Length/Offset 배관이 `State`/`Source`를
씀) 지금 순서로는 M2를 끝까지 짤 수 없다. 설계 결정이 아니라 **마일스톤
순서** 문제이고, 선택지와 근거의 소스는 `.claude/question.md` 2번(사용자
회신 대기). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`.

View file

@ -6,6 +6,20 @@
올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은
사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소).
## ⭐ 11. **[2026-08-22 신설] M2 착수를 막는 마일스톤 순서 결정** — 사용자 답변 필요
**설계 결정이 아니라 순서 문제**라서 에이전트가 임의로 정하면 안 되는 자리다.
`ROADMAP.md` 전반 점검에서 **M2(디스패치 엔진)와 M3(Store/State/Source)의
의존이 양방향**인 게 드러났다 — M2의 Length/Offset 배관이 `State<number>`/
`Source<number>`를 쓰므로 **M2는 State/Source 없이 구현이 안 되고**, 반대로
M3의 동적 경로 가드는 M2의 `Dispatch.addHandler`를 쓴다(그쪽은 얕음).
**선택지 (a) M2/M3 순서 교체 / (b) M2를 둘로 분할 / (c) 지금 구조 유지**의
근거와 상세는 **`.claude/question.md` 2번이 소스** — 여기서 반복하지 않는다.
답이 나오면 `ROADMAP.md`의 마일스톤 경계와 `question.md` 2번을 같이 닫으면 된다.
---
## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용)
Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접
@ -172,11 +186,37 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
설계/구현 상세는 `.claude/base/quad-types-plan.md`
"`type-version-check`" 절.
## 10. **[2026-08-20 신설, 안 막음]** Tween 초기 진입 애니메이션(`initValue`) — 에이전트 작업 범위 밖
`base/tween-plan.md`**"필요해지면 사용자가 직접 코드베이스+문서를 만진다,
에이전트는 임의로 착수하지 말 것"**으로 확정해둔 항목인데, **여기 HUMAN_TODO에는
그 언급이 없어서 사람 쪽 할 일 목록에서 빠져 있었다**(2026-08-20 구현 전 QA
4라운드 `TW-16`에서 사용자가 지적 — *"틀리진 않았는데, Human todo 에 언급이
없음"*). 지금 보강.
- **무엇인가**: 다이얼로그가 아래에서 위로 슬라이드-인하는 것처럼 **첫 마운트에도
애니메이션을 원하는 경우**. 지금은 "첫 세팅은 무조건 애니메이션 없이 즉시
스냅"(3-상태 릴레이션 슬롯의 `prev == nil` 분기)이 기본이라 이게 안 된다 —
그 기본값은 "엔진 기본값에서 목표값으로 날아오는 진입 애니메이션 버그"를
막으려고 일부러 넣은 것이라, 우회하려면 그 억제 동작과의 상충을 같이 설계해야
한다.
- **왜 에이전트가 안 하는가**(그 문서의 근거 그대로): Tween 정보가 부족한
에이전트가 다루기엔 `hasBeenSet` 억제 동작과의 상충 판단이 미묘하고, 반대로
Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부
`Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데
범위상 문제가 없다.
- **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2를
막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에
사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입
애니메이션(`initValue`)" 절.
## 3. `.claude/question.md`**나머지** 항목 검토 (급하지 않음)
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준]
마음에 안 드는 것만 답해주면 됨 — **[2026-08-22 갱신] 설계 결정 대기는
여전히 0건**이지만, **마일스톤 순서 결정 하나가 새로 열렸다**(`question.md`
2번, 위 11번 항목이 소스). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준]
`question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 —
마지막 남았던 0-W
`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지"

View file

@ -7,10 +7,35 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
> **✅ [2026-08-19 기준] M0 스파이크 4개 전부 통과, M1 스캐폴딩도 대부분
> 완료(quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/
> .luaurc, mock 테스트 하네스, `New()`/`RunInit`/`AddPlugin` 골격 — 아래
> M0/M1 체크박스 참고). 다음은 M2(디스패치 엔진) 착수.** M1 착수 도중
> **✅ [2026-08-22 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은
> M2(디스패치 엔진)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크
> 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그
> 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. — **⚠️ 다만 M2를 바로 착수하면 안 됩니다: M2와 M3의 의존이
> 양방향이라 이 순서로는 M2를 끝까지 짤 수 없습니다**(설계가 아니라
> 마일스톤 **순서** 문제 — 아래 M2 배너와 `.claude/question.md` 2번,
> 사용자 회신 대기). M1까지의 산출물은
> quad-base/quad-roblox 폴더+pesde.toml, 루트
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
> 재작성 대기 상태입니다** — 아래 "재검증 대기" 절 참고(개수·현황의
> 소스는 `.claude/luau-test/STATUS.md`, 여기서도 그 절에서도 세지 않음).
>
> **[2026-08-22] 이 문서에 이번에 반영된 것**: `Dispatch.drive`가 두
> 패스가 아니라 단일 일반화 `for`(`F-4-1`) / 물리 조작 주입 op 이름이
> `native*`로 확정 / `EpochMap`·`GateNode`·`Blocker`·`None`이 M2로 이동 /
> M2↔M3 의존이 양방향이라는 것(M2 헤더 배너) / `[x]` 표기 의미 분리
> (바로 아래).
>
> **⚠️ `[x]`의 의미** — 체크박스는 **"짜야 할 코드"만** 담습니다.
> "설계가 확정됐다"/"타입을 실측해봤다"는 사실은 체크박스가 아니라 각
> 마일스톤의 **`### 확정된 것`** 절(항목이 여럿일 때) 또는
> **`- **[확정된 것 — 코드 아님]**`** 불릿(하나뿐일 때)에 둡니다. 예전엔
> 둘이 같은 목록에 섞여 있어 `[x]`만 보고는 코드가 있는지 알 수
> 없었습니다 — M0/M1의 `[x]`는 실제 구현이고, M3/M6/M11에 섞여 있던
> `[x]`는 설계 확정·실측이었습니다. **새 항목을 추가할 때도 이 구분을
> 지킬 것.**
> M1 착수 도중
> wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의
> `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는
> `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서
@ -40,13 +65,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [x] Store/State push-invalidate → pull-recompute propagation을 실제로
짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
**emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은
`:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는
**emit은 자기 invalid 상태와 무관하게 전파되고**, 중복 재계산은
`:Get()` 시점 캐시로만 막히는지(**[2026-08-21]** 그 뒤 "항상"에서
"같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
스파이크 `05-store-state-diamond-propagation.luau`**[2026-08-19
재작성 완료, `done/`]** 현행 모델("emit은 항상 전파 + `:Get()`
시점 캐시로만 dedup")로 재검증 통과)
재작성 완료]** 그 모델("emit은 항상 전파 + `:Get()` 시점 캐시로만
dedup")로 재검증 통과 — **[2026-08-21] 그 모델이 다시 바뀌어
`rewrite-required/`로 되돌아갔다**(`Epoch` 리비전 비교 채택으로 다이아몬드
Observer가 이제 변경당 **1회**만 울어야 함, `base/state-epoch-plan.md`))
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
@ -59,9 +87,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 —
`luau-test/done/03-recursive-store-bind-dispatch.luau` 통과)
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" **계약**이 실제
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
(**[2026-08-21 정정]** 이 계약을 **두 패스로 구현**한다는 서술은
`F-4-1`로 폐기 — 구현은 단일 일반화 `for`, 계약은 그대로. 그래서
두 루프로 짜인 스파이크 `01``rewrite-required/`로 갔다 — 아래
"재검증 대기" 참고)
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은
`nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가
@ -70,7 +102,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜
`nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션]
소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** —
정상 두 패스가 그 자리를 `ProcessedPreRefHandler`로 매치해
정상 본체 루프가 그 자리를 `ProcessedPreRefHandler`로 매치해
`Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
@ -94,6 +126,42 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`
소스, 여기서 세지 않음).
### 재검증 대기 — 위 `[x]`가 검증했던 스파이크 일부가 무효화됨
**⚠️ [2026-08-22 신설]** M0의 *원래* 체크박스는 전부 닫혔지만, 그 뒤 설계가 바뀌면서
**당시 통과했던 스파이크 몇 개가 지금 계약을 검증하지 않는 상태**가 됐다.
`rewrite-required/`로 되돌아간 것들이고, **어느 마일스톤 체크박스에도
없어서 그냥 잊히기 쉬운 자리**라 여기 모은다. **무엇이 지금 어느 폴더에
있는지의 소스는 항상 `.claude/luau-test/STATUS.md`** — 여기서 세지 않는다.
- [ ] **`01`(props 순회 순서)** — 두 루프로 짜여 있어 지금 계약의 구현
(단일 일반화 `for`, `F-4-1`)과 안 맞음. 재작성하면서 "배열 파트 전체가
해시보다 먼저 + 배열 안에서는 index 순서"를 그대로 확인할 것
- [ ] **`05`(다이아몬드 전파)** — `Epoch` 리비전 비교 채택으로 다이아몬드
Observer가 이제 변경당 **1회**만 울어야 함(옛 "emit은 항상 전파" 모델을
검증 중). `base/state-epoch-plan.md` 기준으로 재작성
- [ ] **`04`(Dispatch 체인 retractFrom)** — 하강 diff 확정으로 무효화.
**M2 착수 시 같이 처리**하는 게 자연스러움
- [ ] **`19`(소유권/참조카운트 Relate 패턴)** — **B 섹션만** 낡음
(공개 `AttributeKey(name)` + 인덱스 1 점유 체크가 폐기되고 그룹 전용
키 + `AttributeKeyHandler`의 이름 claim으로 바뀜, `0-Z` 확정).
A/C 섹션은 손댈 것 없음 — **Attribute 소관이므로 M10 착수 시 같이
처리**(**[2026-08-22 정정]** 여기 "M6"라고 적었으나 B 섹션이 검증하는
건 Slot이 아니라 Attribute 이름 소유권이다)
- [ ] **`22`(Ref/PreRef/PostRef 브랜드)** — `Brand`가 인스턴스 브랜드로
전면 재작성되며 옛 `Brand.set`/`Brand.get` 구현에 의존하던 부분이
깨짐(검증 대상인 `isRef`/`isPreRef` 포함 관계 자체는 그대로).
**M8 착수 시 같이 처리**
- [ ] **`15`(`:Compute` trailing deps 타입팩)** — 이형 다중 deps를 제네릭
타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정).
**M3 착수 시 같이 처리**
- [ ] **`10`(Roblox Studio 확인)** — `bindLifetime`/`canExecute`/
`unbindLifetime` 재정정으로 무효화. **Studio 작업이라
`HUMAN_TODO.md` 1번(계정 분리)이 선행**
- [ ] **아직 파일이 없는 실측 항목**`R-11``table.insert` 구멍 재사용,
**중간 State GC**(`base/source-state-plan.md`, 상류 strong / 하류 weak
불변식 — `.claude/todos.md`가 "M3 착수 전 필요"로 지정)
## M1 — 실제 스캐폴딩
- [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19
@ -128,6 +196,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> 분리 신설), 뒤집힌 옛 모델은
> `.claude/archive/dispatch-hintvalue-model-reversed.md`.
> **⚠️ [2026-08-22] M2는 이름과 달리 "디스패치만"이 아닙니다 — 반응형
> 하부구조 일부가 여기 있습니다.** `EpochMap`/`GateNode`/`Blocker`/`None`
> 체크박스가 M3·M7에서 옮겨왔습니다(각 항목의 이동 표시 참고). 그동안
> 각주로만 예고돼 있어 M2 체크리스트를 훑는 구현자에게 **항목으로 보이지
> 않던** 것을 고친 것이고, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
> 것과 같은 처리입니다.
>
> **⚠️ 다만 이동으로 드러난 더 큰 문제가 하나 남아 있습니다 — M2와 M3의
> 의존이 양방향이라, 이 순서대로면 M2를 끝까지 짤 수 없습니다.**
> 요지만: **M2는 `Source.luau`/`State.luau` 없이는 구현할 수 없고**
> (Length/Offset 배관이 `State<number>`/`Source<number>`를 쓰고,
> `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅합니다),
> 반대 방향은 핸들러 등록 표면만 요구하는 얕은 의존입니다.
> **어느 쪽으로 가를지(순서 교체 / M2 분할 / 유지)는 사용자 결정
> 대기이고, 근거와 선택지의 소스는 `.claude/question.md` 2번입니다** —
> 여기서 반복하지 않습니다.
- [ ] `Dispatch/init.luau``Dispatch.getHandler(inst,k,v): Handler?`(순수
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
@ -137,20 +222,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`
flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None`
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
**[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면
`retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로
재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에
스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process`
diff를 하지 않음
**[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`)
- [ ] `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`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/
`Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
옛 공유 레지스트리 + `Brand.get(x) -> tag`
`archive/brand-shared-registry-reversed.md`. **[2026-08-22] `isEpoch`
여기 포함** — `Epoch` 인터페이스 확정으로 `EpochBrand`가 생겼고
`base/state-epoch-plan.md`/`base/source-state-plan.md`가 "런타임 분기는
`isEpoch`로"라고 확정했다 — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
`isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/
`isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째
세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09
@ -170,6 +264,46 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None`
항등 비교 — `brand-plan.md``Brand` 절, 2026-08-07 여덟
번째 세션 신설)
- [ ] **[2026-08-22 M3에서 이동] `EpochMap.luau`** — 재사용 가능한 에포크
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
{[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()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
`inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy
@ -181,8 +315,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
`canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9,
`question.md` 2번 — 2026-08-07 네 번째 세션에 반영).
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9 —
2026-08-07 네 번째 세션에 반영. **[2026-08-22 정정]** 여기 있던
`question.md` 번호 참조는 그 항목이 해소되며 이미 깨져 있었고,
지금 그 번호는 다른 항목이 쓰고 있어서 지웠다).
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는
`inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.
`bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value`
@ -207,9 +343,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)`
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
**`Dispatch.getOffsetAt(ownerKey,i)`** —
**[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자
`anchor`(생명주기 앵커, 항상 물리 Instance — 부기 키와 분리, **생략 시
`ownerKey`로 폴백**이라 최상위 호출부는 3-인자 그대로),
`setOffsetSource``None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지
"참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답),
숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull.
`recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서
시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다.
**이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→
`LayoutOrder` 리액티브
바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
@ -223,15 +369,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는
`bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가
바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker`
(`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는
`Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면
(`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행)
만들 필요가 있는지 사용자 판단 필요 —
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
정합성" 절.
(`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를
lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감.
**[2026-08-22] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은 이제
이 마일스톤에 체크박스로 있다**(위 세 항목) — 예전엔 M3에 있는 걸
각주로 가리키기만 했다. 결정 경위("게이팅 먼저", 그리고 앞당기는
대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
`qa-request/pre-implementation-qa-round5-followup.md`와
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
@ -300,7 +446,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
- [x] `store.key` dot-access 타입 추론 확인 — Luau `type function`
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>``T`의 각 필드를
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
@ -321,9 +467,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정)
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive,
State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
> **[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도
@ -334,10 +495,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치
1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로
`state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React
`useEffect` 동형). Observer 구현 이후에 착수(의존 관계).
- [ ] `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 일곱 번째 세션).
@ -387,6 +552,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M5 — quad-roblox 최소 프로바이더
> **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.**
> `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/
> `nativeDispose`. base가 `Parent`를 모른다는 원칙을 실제로 지키기
> 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은
> `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만
> 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게
> 실익). 상세는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절.
> **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
> 백로그 `quad-roblox-types`(가칭, `quad-types`와 같은 패턴)로 쉽게
@ -423,13 +596,152 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나
> `nil`임이 계약으로 보장된다는 점만 새로 추가됨.
### 확정된 것 — 코드 아님, 구현 전 필독
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
(**[2026-08-22]**).
- **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
— 에러 조건까지 전부 확정
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)`
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
`IndexOf`는 순수 읽기라 가드 대상 아님).
**[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체
+ 이전 것 **파괴**`Extract`의 파괴 짝, `Remove``Extract`와 같은 축)와
`rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍**
(`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데,
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List``prev`는 전부
**언래핑된 원래 값**이다). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
`question.md` 1번 용어정리 항목은 해소되어
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
Slot 타입.
- **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
raw 요소 항목 참고.
- **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
자신을 owner 키**로 재사용하는 재귀 `attachSlot`으로 최상위/중첩
마운트 통합(새 프리미티브 없음). `Slot.Length`가 raw 개수에서
"요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 `Clear()`
아니라 flat `destroySlotTree`(파괴 walk + `unbindLifetime` walk,
outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리
서브트리만 죽는 경우 명시적 `unbindLifetime` 필요(GC-native 정리의
예외 케이스). DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) —
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록
물리 조작이 **`native*` 주입 op 계층**으로 정리됐고(M5 배너가 목록,
시그니처는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절 —
**[2026-08-22 정정]** 여기 확정 전 가칭 `mountInst`/`unmountInst`/
`disposeInst`가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
`reference/slot-attach-decomposition.md`.
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
- **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
`Add→Remove`와 동일하므로. **`_crudUsed``_listed` 상호 배타
가드 신설** — 기존엔 `:List` 설치 후 수동 CRUD만 막았지 반대(수동
CRUD 후 `:List` 설치)는 안 막아서 `:List`의 reconcile이 기존
요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/
slot-plan.md` "CRUD API 확정" 절).
- **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
dispatch-core-plan.md` "Length/Offset" 절) — `sum` 누적과
`offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던
버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입
방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가져서 nesting만으로는 같은 `bk`가 재진입되는 경로
자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute
도중 같은 Slot의 length에 다시 쓰기)은 `Source⊇State`의 "단방향"
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
- **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
`_elements``None`이 절대 안 들어감(비어있는 nested Slot이 자연히
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
`:Single``updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
### 짜야 할 것
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
비파괴 경로 `unmountSlotTree``destroySlotTree`와 별도로 구현 —
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
`:List``reconcile`**값 교체와 `Detach` 경로만**.
`:List``reconcile`**`Owned = false` 설치와 `Detach` 경로**
(**[재정정, 2026-08-21]** 값 교체는 `Owned = true`면 파괴가 맞다 —
`updateFn`이 만든 걸 자기 손으로 못 지우기 때문. `state<Frame>`
의미론은 `Owned = false`가 담당).
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
@ -459,51 +771,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게
지우는 유일한 경로. 마운트 위치는 `elementOwner`가 이미 알고 있어
새 부기 불필요. `isSlot(value)`면 그 경로, 아니면 백엔드가 주입하는
`disposeInst(inst): ()`(`addTag`/`removeTag`/`setAttribute`와 같은
"base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임.
`nativeDispose(element): ()`(`addTag`/`removeTag`/`setAttribute`와 같은
"base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임
(**[2026-08-22 정정]** 여기 `disposeInst`라 적혀 있었으나 이름은
2026-08-21에 `native*` 계층으로 확정됨 — M5 배너 참고).
**`Observer`/`Effect`는 범위 밖**(GC-native `bindLifetime`/
`unbindLifetime`만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째
세션에 `question.md` 0-B 해소, 정본은 `base/slot-plan.md`
"`dispose(value)`" 절
- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨**, 위 M2 항목 참고.
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
— 에러 조건까지 전부 확정
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)`
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
`IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
`question.md` 1번 용어정리 항목은 해소되어
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를
그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드
@ -513,16 +789,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
저비용 재사용 경로.
**[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** —
`updateFn``Detach, { old = ..., source = ... }`를 반환하면 그
자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고,
보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내
반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용
경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/
slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절).
**⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를
어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론
필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
**[2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정]
`Detach` 반환 경로** — `updateFn``Detach`를 반환하면 그 자리는
**파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠진다.
**보존 주체는 `userdata`가 아니라 `slot._detached` 필드**(Slot 필드여야
`destroySlotTree` walk가 닿고 소유권도 유지됨 — `ud`로는 최종 처분이
불가능). reconcile이 `prev`로 그대로 돌려주므로 `ud`에 담을 필요 없이
**그대로 반환하면 재마운트**되고, 이미 detach 상태에서 또 `Detach`
**nop**. 언마운트는 소유권을 유지하는 `rawDetach`를 씀(`rawUnmount`
아님). `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. 공개
표면은 `None`과 같이 패키지 최상위 export(`base/slot-plan.md`의
"Detach된 요소는 `slot._detached`가 보유한다" 절).
**[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`
한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건
`Effect``_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
`state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저,
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`
`prev`보다 앞이었음).
**`updateFn``index``keyFn`의 raw `index`(원본 `data` 배열
@ -567,72 +851,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
- [x] **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
raw 요소 항목 참고.
- [x] **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
자신을 owner 키**로 재사용하는 재귀 `attachSlot`으로 최상위/중첩
마운트 통합(새 프리미티브 없음). `Slot.Length`가 raw 개수에서
"요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 `Clear()`
아니라 flat `destroySlotTree`(파괴 walk + `unbindLifetime` walk,
outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리
서브트리만 죽는 경우 명시적 `unbindLifetime` 필요(GC-native 정리의
예외 케이스). DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) —
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
`Add→Remove`와 동일하므로. **`_crudUsed``_listed` 상호 배타
가드 신설** — 기존엔 `:List` 설치 후 수동 CRUD만 막았지 반대(수동
CRUD 후 `:List` 설치)는 안 막아서 `:List`의 reconcile이 기존
요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/
slot-plan.md` "CRUD API 확정" 절).
- [x] **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
dispatch-core-plan.md` "Length/Offset" 절) — `sum` 누적과
`offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던
버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입
방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가져서 nesting만으로는 같은 `bk`가 재진입되는 경로
자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute
도중 같은 Slot의 length에 다시 쓰기)은 `Source⊇State`의 "단방향"
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
- [x] **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
`_elements``None`이 절대 안 들어감(비어있는 nested Slot이 자연히
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
`:Single``updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
## M7 — Modifier
- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션
@ -659,29 +877,21 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키
`Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인)
- [ ] `:Peek<<T>>(key): T|State<T>|nil` 필드 읽기 접근자 +
`isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 —
`modifier-plan.md` 9번, `brand-plan.md``Brand` 절, M2의
`Brand.luau`에 이미 구현돼 있어야 함)
- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널
(이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) +
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler`
(`dispatch-core-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의
"이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널
자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`
쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨**
그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄
(`base/dispatch-core-plan.md`).
**[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive``None` 스킵
분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`
도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고,
실제 정리는 아래 `NilHandler`가 맡는다
- [ ] **[2026-08-18 신설]** `NilHandler``isHandlable`
`type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러.
`Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)`
등록이 이 핸들러의 일이고 재귀는 안 함(`State<Slot|nil>`도 정상
동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`
"`NilHandler`" 절)
`isState(x)`/`isSource(x): boolean`(**[2026-08-21 갱신]** 인스턴스
브랜드 멤버십 기반 — `isSource(x)``SourceBrand:is(x)`, `isState`
그 위에 `StateBrand:is(x)`를 OR로 얹은 상위 개념. 옛 "공유 레지스트리 +
`Brand.get(x) == SourceTag`" 서술은 역전됨,
`archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번,
`brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미
구현돼 있어야 함)
> **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/
> `NilHandler`)는 M2로 옮겼습니다** — `None`은 modifier 전용 값이 아니라
> quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도
> `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M2의
> `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다.
> M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를
> 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md`
> 2-1번).
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
@ -705,8 +915,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치
방지" 절
- [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이
`Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프
전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고
`Dispatch.drive(inst, flattened)` 자신이 **본체 루프 전에** 배열
파트를 **한 번** 훑어(**[2026-08-22 정정]** 여기 "두 패스(배열→해시)
루프 전에"라고 적혀 있었으나 본체는 단일 일반화 `for`다 — `F-4-1`), `PreRef`는 그 자리에서 fire하고
`PostRef`는 로컬 `postRefList`에 push만 함(Dispatch.process/getHandler
우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten
재호출 가능성과 충돌하므로 기각). **복수 `PreRef`/`PostRef`의 계열 안
@ -717,9 +928,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
PreRef`류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두
번째 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef`/
`ProcessedPostRef` 처리** — 아래 `Processed*Handler` 항목이 그 자리를
정상 두 패스로 마저 처리)
정상 본체 루프로 마저 처리)
`base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절
- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 두 패스
- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 본체 루프
`postRefList` 소비 루프 — `PreRef.luau`와 같은 방식(`Ref` 런타임
재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store
타입 차단, `_fired` 1회용 가드). `Dispatch.drive`가 해시 파트까지
@ -811,13 +1022,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
(`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`
주입되는 엔진 op 3개**: `addTag(inst,{string})`/`removeTag(inst,{string})`
- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`
Tag/Attribute 몫** — `addTag(inst,{string})`/`removeTag(inst,{string})`
(`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제).
`RobloxFactory``BaseModule`에 주입(`bindLifetime`/`canExecute`와
같은 패턴) — 아래 base 핸들러들이 이걸 호출함
(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는
엔진 op" 절)
엔진 op" 절). **[2026-08-22 정정] 여기 "주입되는 엔진 op 3개"라고
적혀 있었으나 이 파일이 담는 op은 그 셋이 전부가 아니다** — M5의
`native*` 계층이 같은 파일에 들어오고 백로그의 `setTimeout`/
`clearTimeout`도 예정돼 있다. **주입 op 전체 목록의 소스는
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
세지 않는다.
- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` +
이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리
`String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리
@ -892,24 +1108,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`Tween<T>` 래퍼 모델로 전환 — 상세는 `base/tween-plan.md`(전면
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`.
### 확정된 것 — 코드 아님, 구현 전 필독
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
(**[2026-08-22]**).
- **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
- **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
`Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
전부 plain만(State 불가)
- **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
### 짜야 할 것
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
`TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음)
`TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
- [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
(`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈
없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이
스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만
새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
- [x] **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
- [x] **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
`Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
전부 plain만(State 불가)
- [ ] `quad-roblox/Animate.luau`**시그니처도 이미 확정 완료**(2026-08-12
두 번째/세 번째 세션, `base/tween-plan.md`): `Tween` opts(`Value` 제외)를
`T|State<T>`로 받아 각 필드를 resolve한 뒤 `Tween{...}`을 반환하는
@ -917,9 +1144,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
아님, `research/operator-sugar-plan.md` "왜 `:Apply`인가"). `CanAnimate`
필드 포함(`false`면 `Tween`으로 안 감싸고 plain 값 그대로). M11은
**구현만** 하면 됨
- [x] **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
## 특정 마일스톤에 안 묶이고 병행 가능
- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`
@ -947,9 +1171,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
— 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`
구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할
것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-21 5라운드]
그 공용 `Gate` 추출은 M3가 아니라 M2로 앞당겨졌다** — "게이팅 먼저"
결정(위 M2 각주)으로 `Dispatch.drive`의 배치 등록이 쓰는 게이팅부터
만들기로 했고, 그때 `Blocker`/`Debounce`/`Throttle`이 공유할 노드를
같이 빼둔다(따로 하면 같은 설계를 두 번 함). **[2026-08-21] 표면 확정 —
`state:Gate(setup)` 메소드**, `base/gate-plan.md`.
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라