qa: 9라운드 Q4~Q10 + H-138/H-139/H-142 확정·반영, 감사 8라운드, code-review 반영 — H-143~H-146 판단 대기

Q4 EffectHandle 네 진입점 의사코드(Observer 것 재사용, Unsubscribe만 게이트 통과
뒤 cleanup) / Q5 M2 공통 기반에 Ref 최소형(+ Quad.Ref 필드) / Q6 WeakUnsubscribe
관대 명문화 / Q7 폐기 블록 archive/effect-internal-observer-cascade-reversed.md /
Q8 InstanceChildHandler 부기(Parent → setLength(1), retractor는 Parent=nil →
해제) / Q9 문항 전제 정정 — Tween 절 스케치 hint==nil 줄은 복사 오류, retractor는
function() end / Q10 :List reconcile 재실행도 배치 Blocker(ownsGate — 네스팅 불가) /
H-138 숏핸드 우선순위 > PropertyHandler, 충돌 방지는 UI 접두어 / H-139
New(name)(props)·Dispatch.drive 파이프라인 의사코드(bind-system-plan) — 쓰면서
빈 배열 파트 가드와 H-142 발견 / H-142 props에 Parent 금지(부모가 하는 일) —
순서 문제 소멸, D·Modifier 타입 제외 + PropertyHandler 거부(배선은 에이전트 선택으로
갈라 적음). H-129/H-131 정정.

감사 8라운드(5→3→4→5→3→1→3→1) — 1라운드가 drive 의사코드의 H-17 위반(post-pass
포함 전체 감쌈)을 잡음, 이후는 기록 문서 표기. /code-review high 10건 — 여섯
반영(그중 셋이 이 세션의 H-134 반영이 만든 것), 넷은 새 메커니즘이라 문항
H-143~H-146으로(question.md 최우선 절). doc-check ERROR 0.

Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-27 15:52:48 +09:00
parent 031495cc0b
commit 7f5868302e
Signed by: qwreey
GPG key ID: D28DB79297A214BD
20 changed files with 961 additions and 129 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,72 @@
# [역전됨] `EffectHandle`의 내부 Observer 바인딩 세부 — `_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade
> **역전일**: 2026-08-25(7라운드 `H-58`/`H-59`). **대체된 곳**:
> `base/effect-plan.md`의 "확정 구조 — 강한 주인은 항상 `Effect`" 절과
> "의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`" 절 —
> `bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나에만** 적용되고 내부
> Observer로 cascade하지 않는다. dep 등록은 생성자에서 한 번만
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> 전부 `_deps` 하나와 `_blocker`로 대체됐다. **이 문단대로 짜면 `H-58`
> 중복 `Rerun`이 되살아난다.**
>
> **왜 `archive/`로 왔나 (2026-08-27, 9라운드 `H-130`, 사용자 결정 Q7-(b))**:
> 이 블록은 `base/effect-plan.md` 안에 ⛔⛔ 폐기 배너를 단 채 남아 있었는데,
> 배너 아래 죽은 문단을 **두 번**이나 살아 있는 문장처럼 편집하는 사고가
> 났다(8라운드 2차 #1, 그리고 커밋 `9dd8213`이 아래 40번째 줄 근처의
> *"`.Subscribed`는 구독 경로 전용"*을 날짜 마커 없이 고친 것 — 옛 표기는
> *"전역 `:Subscribe()` 전용"*). `conventions.md`의 핸드오버 체크리스트 3번
> (*"뒤집힌 원문은 `archive/`로 옮기고 포인터만 남길 것"*)이 정확히 이
> 실패 모드를 규정하고 있어 그대로 따랐다. 아래는 **옮기는 시점의 원문
> 그대로**(그 편집 흔적 포함)이고, 더 이상 갱신하지 않는다.
## 옮겨온 원문 (`base/effect-plan.md`, 2026-08-27 시점)
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
세션, 재확인 후 명시화)**:
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.**
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 —
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고**
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`
> 되살아난다.**
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패
모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로
고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이
배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스.
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] =
observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라
(그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime`
cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.
- **`bindLifetime(inst, handle)``state`가 있는 경우 내부 Observer도
같은 `inst``handle._observers` **전부**에 대해
`bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
`bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에
복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면
그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨
(=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부
Observer까지 같이 풀어야 대칭이 맞음.
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든
"`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는
틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers`
전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든
"`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면
안 됨).

View file

@ -287,7 +287,7 @@ quad/
│ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`) │ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`)
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
│ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정) │ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정)
│ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음

View file

@ -200,6 +200,139 @@ D.Frame = New<<Frame>> "Frame" :: (({ ...타입명시 }) -> Frame)
커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만 커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만
정확하고 밖은 `any`다. 정확하고 밖은 `any`다.
**⭐ [2026-08-27 신설, 9라운드 `H-139`] `New(name)(props)` 파이프라인 의사코드 —
네 문서에 흩어져 있던 순서를 한 자리에.** 사용자 지시: *"실 구현 전에
의사코드를 써보자. 그걸로 인해서 감추어졌던 설계 결함이나 폭탄이 발견된 경우가
많아서, 커지기 전에 확인해볼 필요가 있음. 중요 계층이라서"*. 각 단계의 소스는
주석의 문서이고 **여기는 순서만 확정**한다 — 단계 안의 규칙은 그 문서가 정본.
(이름 주의: 여기 `New`는 quad-roblox `D/init.luau`의 **인스턴스 생성자**이고,
`module-lifecycle-plan.md``New(): Quad`는 quad-base의 **모듈 팩토리**다.
패키지가 달라 런타임 충돌은 없지만 산문에서 섞이니, 생성자는 항상
`New "Frame"` 꼴로, 팩토리는 `New()` 꼴로 쓸 것.)
```lua
-- quad-roblox/src/D/init.luau — 생성기가 찍는 커링 생성자. 아래 ①~④ 순서가 계약.
local function New(className: string)
return function(props)
-- ① 물리 생성 — 백엔드의 일. base는 `Instance`를 모른다
-- (`dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op").
local inst = Instance.new(className)
-- ② gcconn/gchold — 생성 **직후, 무조건**(핸들러/바인딩 유무와 무관).
-- `lifecycle-pattern.md` (0)의 코드 그대로: 클로저가 `gchold``inst`
-- 같이 캡처해 userdata 동일성을 고정하고 `InstData:SetWeak(inst, …)`.
-- ③④보다 앞인 이유 — 거기서부터 `inst`를 키로 쓰는 `Relate`
-- (`elementOwner`/`nameClaims`/`bk`/`chains`)가 생기는데, 키의 동일성
-- 고정이 그보다 먼저여야 한다.
-- (lifecycle-pattern.md (0)의 인라인 코드 — 헬퍼 이름은 구현 시)
-- ③ flatten — Modifier 항목을 제자리에서 `ProcessedModifier`로 소진하고
-- 필드를 해시 파트로 merge. 새 테이블 없음, `inst`를 안 받는 순수 변환
-- (`modifier-plan.md`의 "flatten의 정확한 형태"). `PreRef`/`PostRef`가
-- Modifier 필드에 오는 건 타입으로 차단돼 있어 여기선 안 다룬다.
local flattened = flatten(props)
-- ④ 디스패치 — pre-pass → 본체 → `postRefList`. 전부 `Dispatch.drive`가 소유.
Dispatch.drive(inst, flattened)
return inst -- `D.Frame`은 이 함수에 캐스트만 얹은 별칭(위 확정)
end
end
```
```lua
-- quad-base: Dispatch/init.luau
function Dispatch.drive(inst, flattened)
-- ⓪ 배치 Blocker — **진입 직후** 켜고 `drive`가 할 일을 전부 마친 뒤(post-pass
-- 포함) 끈다: `dispatch-core-plan.md``H-17` 계약(*"`drive` 전체를 `inst`
-- 전용 `Blocker`로 감싼다"* / *"`PostRef` 콜백은 게이트가 켜진 채로 실행된다"*).
-- ⚠️ 배열 파트가 비어 있으면 열지 않는다 — 안 그러면 `Frame { Size = … }`
-- 처럼 자식 없는 모든 Instance마다 Blocker + `bk`가 eager 생성된다
-- (9라운드 Q2/Q3가 Slot 쪽에서 막은 것과 같은 부류). pre-pass는 자리를
-- 센티널로 바꿀 뿐 비우지 않으므로 이 판정은 pre-pass 앞뒤가 같다.
local batching = flattened[1] ~= nil
local blocker = if batching then getBlocker(inst) else nil
if batching then blocker:On() end
-- (a) pre-pass — 배열 파트만, index 순서(`ref-plan.md`의 "메커니즘 — pre-pass 한 스윕" 절).
-- `PreRef`는 그 자리에서 fire(`v:Set(inst)`) + 소진,
-- `PostRef`는 수집 + 소진. `_fired` 1회용 가드는 둘 다 **여기서** 선다.
local postRefList = {} -- 이 호출에만 로컬 — Relate 아님
for i, v in ipairs(flattened) do
if isPreRef(v) then
if v._fired then error("PreRef instance reused", 2) end
v._fired = true
v:Set(inst) -- 콜백 fire — 아직 자식도 프로퍼티도 없다
flattened[i] = ProcessedPreRef
elseif isPostRef(v) then
if v._fired then error("PostRef instance reused", 2) end
v._fired = true
table.insert(postRefList, v)
flattened[i] = ProcessedPostRef
end
end
-- (b) 본체 — **단일 일반화 `for`**(`F-4-1`). 배열 → 해시 순서는 Luau 순회에
-- 기댄다. 배열 파트가 Length/Offset **배치 등록 구간**이고 해시 파트는
-- 부기를 안 만진다(`dispatch-core-plan.md`의 "해법의 핵심" 1번·4번).
for k, v in flattened do
Dispatch.process(inst, k, v, 1) -- 체인 index는 항상 1부터
end
-- (c) `postRefList` — push 순서 = index 순서. 이 시점에 끝나 있는 것/아닌 것은
-- `ref-plan.md`의 "`PostRef`" 절(자기 서브트리와 프로퍼티는 완성,
-- **자기 `.Parent`는 아직**일 수 있다). **게이트가 켜진 채 돈다** — 콜백이
-- `slot:Add(…)`로 이 `inst`에 emit을 올려도 `gatedRecompute`는 스킵되고
-- 정합성은 아래 마지막 `recompute` 한 번에 의존한다(`H-17`).
for _, v in ipairs(postRefList) do v:Set(inst) end
-- ⓪' 배치 닫기 — `drive`가 할 일을 전부 마친 뒤 딱 한 번.
if batching then
blocker:OffWithoutEmit()
local bk = getBookkeeping(inst) -- 배열 파트가 있었으니 이미 존재
if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end -- `H-119`
end
end
```
**이 의사코드를 쓰면서 드러난 것**(결정은 `qa-request/pre-implementation-handtrace-round9-followup.md`
`H-139` 절 — 여기선 목록만):
- **배치를 닫는 자리** — 처음엔 *"어디에도 안 적혀 있다"*고 보고 해시 파트
앞에서 닫는 모양으로 썼는데, **틀렸다**(감사 1라운드가 잡음):
`dispatch-core-plan.md``H-17` 절이 이미 *"`drive` 전체(post-pass 포함)를
감싼다, `PostRef` 콜백은 게이트가 켜진 채 실행된다"*로 정해뒀고 그 이유(단일
루프에선 배열 파트의 끝이 루프 밖에서 관측되지 않는다 / `postRefList`는 해시
파트보다 뒤다)까지 적혀 있었다. 위 의사코드는 그 계약대로 고쳤다. 실제로
드러난 건 그 절의 아래쪽 "해법의 핵심" 4번이 옛 문구(*"배열 파트 순회
전체"*)로 남아 있던 것 하나.
- **자식 없는 Instance에도 Blocker/`bk`가 생기는 경로**`getBlocker(inst)`
무조건 부르면 그렇게 된다. `flattened[1] ~= nil` 가드로 막았다.
- **⭐ [2026-08-27 확정, 9라운드 `H-142`] props에 `Parent`는 올 수 없다 — 그건
부모가 하는 일이다.** 의사코드를 쓰다 "해시 파트 안의 `Parent` 대입 순서가
미정"이 드러났는데, 사용자는 순서를 정하는 대신 **키 자체를 금지**했다:
*"Parent대입 자체가 오면 안 돼. 그건 부모에서 할 일이거든. 자신이 바로 하는
경우는 없어. 그걸 허용해준다는것 자체가, '외부에서 직접 Parent 설정해주지
말것' 을 해치는 요인이 되기도 해.(암묵적으고 가능하도록 둬버려서)"* —
`slot-plan.md`가 *"동적 자식은 반드시 `Slot` 또는 `state<Frame>`류 store-bind를
통해서만"* 이라고 세운 원칙(외부 코드가 `.Parent`로 자식을 끼우면 `Length`/
형제 순서가 조용히 어긋난다)의 **정적 리터럴 판**이다. `.Parent` 대입은
자식을 받는 쪽 — `InstanceChildHandler`(정적 자식, `H-134`)와 Slot의
`native*` 주입 op — 만 한다.
- **타입**: `D` 생성기가 각 클래스의 props 타입에서 `Parent`를 **제외**한다
(`ROADMAP.md` M5 `D/init.luau` 체크박스) — **그리고 `FrameModifier`
메소드 목록에서도**(`ROADMAP.md` M7; **[2026-08-27 `/code-review`]** 두
목록이 같은 API 덤프에서 따로 생성되는데 한쪽만 빼면
`Modifier():Parent(x)`가 타입을 통과하고 `flatten`이 해시 파트로 merge한다 —
`PreRef`/`PostRef`를 Modifier 타입으로 차단하는 것과 같은 자리). 범위 밖 클래스의 `New<<X>> "X"`
`any`라 타입으로 못 막고 아래 런타임 가드가 잡는다.
- **런타임**: 새 메커니즘 없이 기존 계약으로 — `PropertyHandler.isHandlable`
`"Parent"`를 거부하면 그 키에 매치되는 핸들러가 없어 `Dispatch.process`
*"매치 핸들러 없음 → 즉시 error"* 계약(`ROADMAP.md` M3)에 걸린다. (이
배선은 사용자 확정이 아니라 **규칙을 기존 계약에 얹은 제 선택**이다 —
`H-142` 처방 후보 (a)/(b)/(c)가 전부 새 메커니즘이라 정하지 않았던 것을
"키 금지"로 바꾸니 필요한 코드가 이 거부 한 줄뿐이다. 다른 모양이 낫다면
갈아끼울 것.) 순서 문제는 키가 없어지면서 소멸한다.
**이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 **이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열
키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91` 키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`
`assign(instance, key, value)``ReflectionService:GetPropertiesOfClass`/ `assign(instance, key, value)``ReflectionService:GetPropertiesOfClass`/

View file

@ -245,6 +245,12 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
`Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자 `Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자
문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면
조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.
**[2026-08-27 9라운드 `H-136`] 이 규칙의 예외가 아닌 것 하나** — 같은
Blocker를 소유한 호출부가 `IsOn()`으로 "이미 바깥이 켜뒀다"를 알아보고 자기
`On()`/`Off()`를 건너뛰는 것(`base/slot-plan.md`의 `reconcile` — 최초
population은 바깥 배치 안에서, 재실행은 밖에서 같은 클로저로 불린다).
Blocker 자신은 여전히 단순 불리언이고 두 번 켜지지 않는다 — 카운팅이
아니라 소유권 판정이다.
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset 위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset

View file

@ -581,7 +581,11 @@ end
`base/ref-plan.md`의 "`PostRef`" 절. `base/ref-plan.md`의 "`PostRef`" 절.
**[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결 / 범위 정정 **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결 / 범위 정정
2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** — 2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** —
진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고
(**[2026-08-27 9라운드 `H-139`, 사용자 확인]** 단, **배열 파트가 비어
있으면(`flattened[1] == nil`) 열지 않는다** — 안 그러면 자식 없는 모든
Instance마다 Blocker + `bk`가 eager 생성된다. pre-pass는 자리를 센티널로
바꿀 뿐 비우지 않아 이 판정은 진입 시점에 해도 같다),
**`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함) **`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함)
`:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 `:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출
(**⭐ [2026-08-26, `/code-review high` 4차] 이 호출도 `H-119`의 재진입 (**⭐ [2026-08-26, `/code-review high` 4차] 이 호출도 `H-119`의 재진입
@ -1068,6 +1072,7 @@ end
| `NoneHandler` | 중간 | 없음(재위임만) | | `NoneHandler` | 중간 | 없음(재위임만) |
| `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) | | `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) |
| `PropertyHandler` | 말단 | 프로퍼티 세팅 | | `PropertyHandler` | 말단 | 프로퍼티 세팅 |
| `InstanceChildHandler` | 말단 | `Parent` 대입 (+ 부기 — `H-134`) |
| `TagHandler` | 말단 | `addTag`/`removeTag` (+ 부기 — `H-39`) | | `TagHandler` | 말단 | `addTag`/`removeTag` (+ 부기 — `H-39`) |
| `AttributeKeyHandler` | 말단 | `setAttribute` | | `AttributeKeyHandler` | 말단 | `setAttribute` |
| `AttributeGroupHandler` | 자기 체인에선 말단 | 다른 키로 위임 (+ 부기 — `H-39`) | | `AttributeGroupHandler` | 자기 체인에선 말단 | 다른 키로 위임 (+ 부기 — `H-39`) |
@ -1472,6 +1477,36 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
"Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하면 `bk.N`의 의미가 "Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하면 `bk.N`의 의미가
바뀌어 파급이 크다(사용자 확정, 2026-08-24). 바뀌어 파급이 크다(사용자 확정, 2026-08-24).
**⭐⭐ [2026-08-27 9라운드 `H-134`] 다섯째 — `InstanceChildHandler`.** 위
전수 grep은 **의사코드가 있는 핸들러만** 잡을 수 있었는데, 정적 자식
Instance를 받는 이 핸들러(`quad-roblox/src/Handlers/InstanceChild.luau`,
`k:number, v:Instance``architecture.md`의 소스 트리)는 코퍼스 어디에도
의사코드가 없었고 위 표에도 행이 없었다. 아래 Length/Offset 절 머리가
*"정적 단일 자식은 상수 `1`"*이라고만 하고 **누가** 등록하는지는 안 적어서,
`Frame { Frame{}, Slot() }`처럼 **정적 자식 뒤에 Slot이 오는 가장 흔한
배치가 첫 마운트에서 죽는다** — Slot의 `setOffsetSource(inst, 2, …)`
`getOffsetAt(inst, 2)``lengthList[1] == nil`을 만나 `H-106` 가드로 error
(순서를 뒤집으면 `bk.N`이 1에서 멈춰 안 터진다 — 위와 같은 "가끔" 모양).
확정(사용자, 갈래 없음 — `H-39`의 "예외 없이"에 이 핸들러를 넣는 것뿐):
`process`에서 `setOffsetSource(inst, k, None)`**`v.Parent = inst`** →
**`setLength(inst, k, 1, inst)`**(상수 `1`). 반환 클로저는 (A)/(B) 분기·단순
철거에서 **`v.Parent = nil`** → `setOffsetSource(inst, k, None)`
`setLength(inst, k, 0)`(`SlotHandler`의 retractor가 `unmountSlotTree`를 먼저
부르는 것과 같은 모양, 부기 둘의 순서 근거는 아래 "해제" 문단).
**[2026-08-27 `/code-review high` 정정 셋]** — (1) 옛 자식은 **내린다**(파괴
아님): `store.child:Set(otherFrame)`이 이전 `Frame``Destroy`하지 않고
트리에서 내리기만 한다는 것이 `base/slot-plan.md`의 "`State<Slot>` 교체는
파괴가 아니라 언마운트" 절의 근거 1이라, retractor가 `Parent = nil`을 안
하면 옛 자식이 물리 자식으로 남은 채 `lengthList[k] == 1`이 된다. (2) 순서는
**물리 먼저, `setLength` 나중** — 아래 "일반 계약 — 물리와 부기의 순서" 3번
(`nativeInsert` → `setLength``recompute`)과 같게. 옛 서술(`setLength` →
`Parent`)은 단건 경로에서 `recompute`가 부착 전에 돌았다. (3) **5번째 인자
`element`는 안 넘긴다** — 상수 길이라 지속 클로저가 없어 Q3 계약상 생략
대상이고, 넘기면 `setLength(…, 0)` 해제가 `bk.indexOfElement`의 옛 키를
안 지워(해제 호출엔 요소가 없다) 교체마다 옛 자식이 강참조로 쌓인다. 대안 — `Dispatch.drive``type(k) == "number"` 분기에서 일괄 등록 —
은 아래 *"모든 핸들러가 `k=number`일 때 처리하도록 두는"*에서 **이미 기각된
안**이라 다시 열지 않는다. `ROADMAP.md` M5 체크박스에 같은 두 줄을 적었다.
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
`setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
@ -2208,7 +2243,8 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree` 1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`
자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그 자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작 owner 전용 `Blocker``Relate(ownerKey)`에 lazy 생성하고 배치 시작
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 전에 `:On()`한다**(`Dispatch.drive`는 배열 파트가 있을 때만 — 위 `H-17`
절의 2026-08-27 가드). 이 Blocker는 `state:Block()`을 거치지 않고
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
직접 쓰는 두 번째 용례" 절 참고. 직접 쓰는 두 번째 용례" 절 참고.
2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는
@ -2223,8 +2259,10 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List` 미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
배치 중이라도 항상 최신값을 보게 됨. 배치 중이라도 항상 최신값을 보게 됨.
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 4. 배치가 끝나면(`Dispatch.drive`가 할 일을 **전부** 마치면 — post-pass 포함,
`materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()` `H-17` 절 / 또는 `materializeSlotTree`의 등록 루프 전체가 끝나면;
**[2026-08-27 정정]** 여기 *"배열 파트 순회 전체"*라고 남아 있던 건 `H-17`
범위를 넓히기 전 문구다) `blocker:OffWithoutEmit()`
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
호출한다(**[2026-08-26]** 이 명시 호출도 `bk.recomputeBlocker:IsOn()`이면 호출한다(**[2026-08-26]** 이 명시 호출도 `bk.recomputeBlocker:IsOn()`이면
건너뛴다 — `H-119`). 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, 건너뛴다 — `H-119`). 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
@ -2378,7 +2416,17 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 **`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클
도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. 도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. **⭐ [2026-08-27 확정,
9라운드 `H-136`] 재실행 사이클도 포함한다** — 의사코드는 최초 population만
Blocker로 감싸고 `data:Set(…)`으로 `reconcile`이 다시 돌 땐 raw op마다
`recompute`가 완주해(O(n²) + 부모 캐스케이드 n회) 이 문장과 어긋나 있었다.
이제 `reconcile` 본문 자체가 게이트를 쥔다(`base/slot-plan.md`의
`reconcile` 머리·꼬리). 사용자 논거: *"이전에는 native* 가 없어 모두 dom 식
elem 입출력이 강제가 아니였는데, 이젠 그렇기 때문에 최초 방식으로 recompute
되는것 처럼 처리되어도 되는 지점 … 이미 우린 set upto 와 valid upto 가 나뉜
지점이고, 싱크라서 괜찮아보임"* — 배치 중엔 offset이 뒤늦게 한 번에 잡히지만
`native*` 계층이 물리 위치를 부기와 무관하게 정확히 넣고, 무효화 커서 둘이
분리돼 있어 마지막 `recompute` 한 번이 전부 따라잡는다.
**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ **웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: `recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:

View file

@ -242,8 +242,10 @@ function Effect(fn, ...)
end end
local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처 local function onRefFire(_, ref) fire(ref) end -- Ref: 2번째가 출처
local function onStateFire(_, _, from) fire(from) end -- Observer: 3번째가 출처 local function onStateFire(_, _, from) fire(from) end -- Observer: 3번째가 출처
for d in pairs(seen) do -- [2026-08-26 `/code-review high` 7차] `for d in seen` for d in pairs(seen) do -- 스타일 통일(`pairs` 명시). **[2026-08-27 9라운드
-- 테이블을 호출하려 들어 죽는다 -- `H-131`]** 옛 근거 *"`for d in seen`은 테이블을
-- 호출하려 들어 죽는다"*는 **거짓** — Luau의 일반화
-- 반복은 런타임·`--!strict` 둘 다 통과한다(실측).
if isRef(d) then if isRef(d) then
self._deps[d] = onRefFire -- ⭐ 강한 주인 = Effect self._deps[d] = onRefFire -- ⭐ 강한 주인 = Effect
d:WeakCallback(onRefFire) -- Ref 쪽은 약함 d:WeakCallback(onRefFire) -- Ref 쪽은 약함
@ -465,53 +467,12 @@ got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입
named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로 named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로
값싸게 override 가능한 자리로 열어둠. 값싸게 override 가능한 자리로 열어둠.
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 **보강 — `EffectHandle`의 내부 Observer 바인딩 세부** — **⛔ [2026-08-25
세션, 재확인 후 명시화)**: 폐기, 7라운드 `H-58`/`H-59`; 2026-08-27 `archive/`로 이전, 9라운드 `H-130`]**
옛 모델(`_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade)의 원문은
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.** `archive/effect-internal-observer-cascade-reversed.md`. 위 "확정 구조 — 강한
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 — 주인은 항상 `Effect`" 절이 정반대로 확정했고, 배너 아래 죽은 문단이 두 번
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고** 살아 있는 문장처럼 편집되는 사고가 나서(그 파일 머리) 본문에서 뺐다.
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`
> 되살아난다.**
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패
모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로
고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이
배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스.
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] =
observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라
(그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime`
cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.
- **`bindLifetime(inst, handle)``state`가 있는 경우 내부 Observer도
같은 `inst``handle._observers` **전부**에 대해
`bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
`bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에
복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면
그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨
(=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부
Observer까지 같이 풀어야 대칭이 맞음.
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든
"`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는
틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers`
전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든
"`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면
안 됨).
**Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React **Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React
`useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, `useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나,
@ -536,6 +497,40 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
**확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다 **확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다
`self` 반환(Observer와 동일한 fluent 대칭). `self` 반환(Observer와 동일한 fluent 대칭).
**⭐ [2026-08-27 확정, 9라운드 `H-127`] 의사코드 — 네 진입점은 Observer의
것을 재사용하고, `Unsubscribe`*통과한 뒤* cleanup을 덧붙인다.** 아래
산문(번호 목록)은 이 블록을 풀어 쓴 것이고, **순서는 이 블록이 정본**이다.
산문의 번호 순서(플래그 → cleanup → fail-fast)대로 짜면 leaf 바인딩된 핸들에
`:Unsubscribe()`를 불렀을 때 **cleanup을 소진한 뒤에야 error**가 나서 `E-11`
막으려던 피해(cleanup 앞당김, `_installed = false`)가 이미 일어난 뒤다 —
Observer 쪽 의사코드는 가드가 첫 줄이라 이 문제가 없었다.
```lua
-- 레지스트리(`Subscribed`/`WeakSubscribed`)·`canBound` 게이트·`.Subscribed`
-- 플래그 전부 `base/lifecycle-pattern.md` (2)의 Observer 네 진입점과 **같은
-- 구현**이다 — 메소드 테이블에 그대로 배정한다. 등록되는 건 **핸들 자신뿐**
-- (내부 Observer·`Ref` 콜백은 생성자에서 이미 `Weak*`로 걸려 있다, `H-59`).
EffectHandle.Subscribe = Observer.Subscribe -- canBound 게이트 + 강한 킵
EffectHandle.WeakSubscribe = Observer.WeakSubscribe -- 게이트 + 약한 등록 + 플래그
EffectHandle.WeakUnsubscribe = Observer.WeakUnsubscribe -- 관대(`H-133`) — cleanup 안 건드림
function EffectHandle:Unsubscribe()
Observer.Unsubscribe(self) -- ⭐ 게이트가 **먼저** — 강하게 구독된 적 없으면(leaf
-- 바인딩·약한 구독·미구독) 여기서 error, cleanup엔
-- 손도 안 댄다(`E-11`). 통과하면 강한 킵 해제 +
-- `.Subscribed = false`(향후 재실행 차단).
self:_consumeCleanup() -- 통과했을 때만: 직전 cleanup 정확히 1회, `_installed = false`
return self
end
```
- **`WeakUnsubscribe`는 cleanup을 소진하지 않는다** — 약한 구독은 "GC에
맡기는" 경로라 해제가 곧 종료 신호가 아니다. 종료 신호는 강한 구독의
`Unsubscribe`와 leaf 사망(`unbindLifetime`의 훅) 둘뿐.
- 실측(9라운드 `core9.luau`, `t12` 매트릭스): 이 배정으로 leaf 바인딩된
핸들의 `:Unsubscribe()`가 cleanup을 건드리지 않고 error, `:Subscribe()`
`:Unsubscribe()`는 cleanup 1회 — 전부 기대대로.
- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에 - **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에
**핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후 **핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후
로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구). 로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구).
@ -611,31 +606,33 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.** 이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.**
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린 - **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
항목이 아니다. 항목이 아니다.
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 - **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 위임한 뒤
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 cleanup 하나를 덧붙인다 — Effect 계층에서 의미가 확장됨.** Observer의
`:Unsubscribe()`는 "미래 재실행만 `:Unsubscribe()`는 "미래 재실행만
끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"
이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()` 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`
동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨: 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨. **순서는 위
1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가 의사코드가 정본이고 아래 번호는 그 순서 그대로다**(**[2026-08-27
있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라 `/code-review high` 재정렬]** 옛 목록은 플래그 → cleanup → fail-fast 순이라
적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고 leaf 바인딩된 핸들에서 cleanup을 소진한 뒤 error가 났다):
**해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위 1. **게이트 — fail-fast.** 강하게 구독된 적 없는 값(leaf 바인딩·약한
"확정 구조" 절). `:Unsubscribe()``handle.Subscribed = false` 구독·미구독)이면 여기서 error, cleanup엔 손도 안 댄다(`E-11`).
만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로 **[2026-08-26 정정, `/code-review high` 7차]** 옛 "idempotent"는 폐기됐다
끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다. (`base/lifecycle-pattern.md`의 "(2) 전역 경로" 절 — 6차가 같은 문장을 두
2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 문서에서 지웠는데 이 세 번째 사본을 놓쳤었다).
하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. 2. **강한 킵 해제 + `handle.Subscribed = false`.** 이것만으로 향후 재실행이
3. **⚠️ [2026-08-26 정정, `/code-review high` 7차] "idempotent"는 폐기됐다** — 끊긴다 — `canExecute(handle)` 게이트가 곧바로 거짓이 되므로. **[2026-08-25
`Unsubscribe`는 이제 **fail-fast**다(약하게만 구독된 값이면 error, 정정, 7라운드 `H-58`/`H-59`]** 옛 문장 *"`state`가 있으면 내부 Observer도
`base/lifecycle-pattern.md`의 "(2) 전역 경로" 절. 6차가 같은 문장을 `:Unsubscribe()`해서"*는 틀렸다 — 내부 Observer는 생성자에서
두 문서에서 지웠는데 **이 세 번째 사본을 놓쳤다**). 살아 있는 요구는 `:WeakSubscribe()`로 걸리고 **해제하지 않는다**(위 "확정 구조" 절), 단수
뒷부분뿐이다 — **이후 leaf가 실제로 죽어도 cleanup이 중복 `state` 전제도 `...deps`로 대체됐다.
호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔 3. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때
`canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. **이후 leaf가 실제로
(전역=`Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로 죽어도 cleanup이 중복 호출되면 안 됨** — 새 메커니즘 불필요,
여기 그대로 얹힘. `canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동(전역=
`Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로
얹힘.
- **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미 - **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미
`Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금 `Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금
leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치. leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치.
@ -708,7 +705,10 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문 인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문
자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다. 자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다.
- `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은
핸들 표면에 바로 닿는다. 핸들 표면에 바로 닿는다. **⚠️ [2026-08-27 `/code-review high`, 판단 대기
`H-143`]** `fn``Unsubscribe`는 그 실행이 돌려준 cleanup을 `Rerun`
그대로 저장해 **아무도 소진 못 하게** 만든다 — 처방(`Rerun` 꼬리 분기 /
금지 / 문서화)은 `question.md` 최우선 절.
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref` - **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.

View file

@ -442,8 +442,12 @@ end
**⭐ [2026-08-26 재작성, 8라운드 `H-111`] 프리미티브는 `WeakSubscribe` 쪽이다** — **⭐ [2026-08-26 재작성, 8라운드 `H-111`] 프리미티브는 `WeakSubscribe` 쪽이다** —
여기 한때 `Subscribe`/`Unsubscribe` 둘만 있는 블록이 있었는데, 그건 여기 한때 `Subscribe`/`Unsubscribe` 둘만 있는 블록이 있었는데, 그건
`:WeakSubscribe()`가 생기기 전(2026-08-25 이전) 서술이라 **약한 쪽이 어디서 `:WeakSubscribe()`가 생기기 전(2026-08-25 이전) 서술이라 **약한 쪽이 어디서
`.Subscribed`를 세우는지가 통째로 빠져 있었다.** 아래가 네 진입점 전량이고 `.Subscribed`를 세우는지가 통째로 빠져 있었다.** 아래가 **Observer의**
소스다(사용자 원문 *"구현이 한 벌"*): 진입점 전량이고 소스다(사용자 원문 *"구현이 한 벌"*). **[2026-08-27 9라운드
`H-127`]** `EffectHandle`도 같은 넷을 **그대로 재사용**한다(같은 레지스트리,
같은 게이트) — `Unsubscribe`만 이 게이트를 통과한 *뒤에* cleanup 소진을
덧붙이며, 그 의사코드는 `base/effect-plan.md`의 "`EffectHandle:Subscribe()`"
절이 소스:
```lua ```lua
local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적) local Subscribed = {} -- 강한 레지스트리(살려두는 게 목적)
@ -472,6 +476,9 @@ function Observer:WeakUnsubscribe()
if Subscribed[self] ~= nil then if Subscribed[self] ~= nil then
error("...: subscribed strongly; use :Unsubscribe()", 2) error("...: subscribed strongly; use :Unsubscribe()", 2)
end end
-- ⭐ [2026-08-27 확정, 9라운드 `H-133`] 여기까지가 가드 전부다 — 구독한 적
-- 없는 값·이미 약하게 풀린 값은 **조용히 통과**(아래 두 줄이 no-op).
-- 의도된 관대함이지 누락이 아니다(아래 산문).
WeakSubscribed[self] = nil WeakSubscribed[self] = nil
self.Subscribed = false self.Subscribed = false
return self return self
@ -510,6 +517,20 @@ end
State dep 전량이 침묵한다. **"둘 중 뭐든 풀어주는" 범용 해제는 없다** — State dep 전량이 침묵한다. **"둘 중 뭐든 풀어주는" 범용 해제는 없다** —
필요하면 호출부가 `.Subscribed`가 아니라 어느 경로로 걸었는지를 알고 있어야 필요하면 호출부가 `.Subscribed`가 아니라 어느 경로로 걸었는지를 알고 있어야
한다(핸들을 만든 쪽이 안다). 한다(핸들을 만든 쪽이 안다).
- **⭐ [2026-08-27 확정, 9라운드 `H-133`] 그 대칭은 *경로 교차*만 막는다 —
`WeakUnsubscribe`는 관대하고 `Unsubscribe`는 엄격하다.** 구독한 적 없는
값·이미 약하게 풀린 값에 `WeakUnsubscribe`를 부르면 error 없이 지나간다
(`WeakSubscribed[self] = nil; .Subscribed = false`가 그대로 no-op — 실측
`t12_subscribe_failfast.luau`). 같은 값에 `Unsubscribe`는 error다. 사용자
논거: *"WeakSubscribed 자체가 사라질 수 있는 요소라서, 그 사라지는걸 유저가
정하게 하는 요소라서 에러를 내야할지 말아야할지 애매한 부분 … 다만 weak 에
대한 홀드를 유저가 유지하는게 강제라면 b가 되긴 해야하나, 그럴 이유가 없어서
a가 되는게 맞는듯"* — 약한 등록의 생존은 사용자가 쥔 참조에 달린 것이고
quad는 그 홀드를 강제하지 않으므로, "없는 항목의 약한 해제"를 오류로 볼
근거가 없다. 강한 등록은 quad가 살려두는 것이라 "없음"은 반드시 호출부
실수다. **leaf 바인딩된 값에 `WeakUnsubscribe`를 불러도 같은 이유로
통과**한다 — `.Subscribed = false` 대입만 하고 gcconn 경로는 안 건드려
무해하다.
- **⚠️ `:Subscribe()`는 idempotent가 아니다.** 이미 구독됐거나 leaf에 - **⚠️ `:Subscribe()`는 idempotent가 아니다.** 이미 구독됐거나 leaf에
바인드된 값에 다시 부르면 `canBound` 게이트에 걸려 **error**다 바인드된 값에 다시 부르면 `canBound` 게이트에 걸려 **error**다
(**[2026-08-26 확정, `/code-review high`]** `base/source-state-plan.md` (**[2026-08-26 확정, `/code-review high`]** `base/source-state-plan.md`

View file

@ -1367,6 +1367,12 @@ function Slot:List(data, updateFn, keyFn, opts)
if self._physicalTarget then if self._physicalTarget then
-- 초기 population도 `materializeSlotTree`와 같은 이유로 게이팅한다 — -- 초기 population도 `materializeSlotTree`와 같은 이유로 게이팅한다 —
-- 안 그러면 아이템마다 `recompute`가 돌아 O(n²)(그 절의 `blocker:On()` 문단). -- 안 그러면 아이템마다 `recompute`가 돌아 O(n²)(그 절의 `blocker:On()` 문단).
-- [2026-08-27, `H-136` 후속] `reconcile`이 이제 자기 게이트를 쥐므로(`ownsGate`)
-- **이 `if` 블록 전체가 잉여**다 — `On()`/`OffWithoutEmit()`뿐 아니라 아래
-- `recomputeBlocker` 확인 + `recompute` 호출까지 `reconcile` 꼬리가 똑같이
-- 한다. 안쪽에선 `ownsGate = false`로 걸러져 동작은 같다.
-- `materializeSlotTree`와 모양을 맞추려 두지만, 구현 시 블록째 지우고
-- `activateList(self, self._physicalTarget)` 한 줄만 남겨도 무방하다.
local blocker = getBlocker(self) local blocker = getBlocker(self)
blocker:On() blocker:On()
activateList(self, self._physicalTarget) activateList(self, self._physicalTarget)
@ -1541,6 +1547,21 @@ function activateList(self, physicalTarget)
keys[i] = key keys[i] = key
end end
-- ⭐ [2026-08-27 확정, 9라운드 `H-136`] **한 사이클은 한 배치다** — 재실행
-- (`data:Set(…)` → 이 함수)도 최초 population과 똑같이 Blocker로 감싼다.
-- 안 그러면 raw op마다 `recompute`가 완주해 O(n²) + 부모 캐스케이드가
-- 아이템 수만큼 나고, `dispatch-core-plan.md`의 *"한 사이클 … 한 번만"*이
-- 거짓이 된다. `raw*`의 명시 호출은 이미 `getBlocker(self):IsOn()`
-- 보므로(`H-119`) 추가 배선은 없다.
-- **Blocker는 네스팅이 안 된다**(불리언, `base/blocker-plan.md`) — 최초
-- population은 `Slot:List`/`materializeSlotTree`가 이미 켜둔 **바깥 배치
-- 안에서** 여기 오므로, 이미 켜져 있으면 손대지 않고 바깥이 끄게 둔다.
-- `updateFn`이 도중에 던지면 켜진 채 남는 것은 `materializeSlotTree`
-- 이미 감수하는 것과 같은 부류(`pcall` 안 씀 — error 계약).
local blocker = getBlocker(self)
local ownsGate = not blocker:IsOn()
if ownsGate then blocker:On() end
local slotPos = 0 -- `_elements` 자리 카운터 — 생존 아이템마다 정확히 +1 local slotPos = 0 -- `_elements` 자리 카운터 — 생존 아이템마다 정확히 +1
for i, item in ipairs(items) do for i, item in ipairs(items) do
@ -1607,6 +1628,15 @@ function activateList(self, physicalTarget)
-- **다음 사이클엔 다시 안 묻는다** -- **다음 사이클엔 다시 안 묻는다**
end end
end end
-- ⭐ [2026-08-27, `H-136`] 배치 닫기 — `Slot:List``_physicalTarget` 분기와
-- 같은 꼬리. `recomputeBlocker`는 따로 본다(사용자 코드가 바깥 `recompute`
-- 안에서 이 사이클을 일으킨 재진입이면 바깥 루프가 되감아 따라잡는다).
if ownsGate then
blocker:OffWithoutEmit()
local bk = getBookkeeping(self)
if not bk.recomputeBlocker:IsOn() then recompute(self, bk) end
end
end end
-- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree` -- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`

View file

@ -1421,7 +1421,11 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
- **⚠️ [2026-08-26 재정정, `/code-review high` 6차] `:Unsubscribe()` - **⚠️ [2026-08-26 재정정, `/code-review high` 6차] `:Unsubscribe()`
idempotent가 아니다.** 여기 한때 *"게이트가 없어 … 비대칭이 의도된 것"* idempotent가 아니다.** 여기 한때 *"게이트가 없어 … 비대칭이 의도된 것"*
이라고 적혀 있었는데, 같은 날 **대칭 가드**가 들어오며 거짓이 됐다(위 항목). 이라고 적혀 있었는데, 같은 날 **대칭 가드**가 들어오며 거짓이 됐다(위 항목).
지금 계약은 **해제는 건 경로로 푼다** 하나다. 지금 계약은 **해제는 건 경로로 푼다** 하나다. **[2026-08-27 9라운드
`H-133`]** 그 대칭 가드는 *경로 교차*(강↔약)만 막는다 — 구독한 적 없는
값·이미 약하게 풀린 값에 `WeakUnsubscribe`**조용히 통과**(의도된 관대함,
사용자 논거와 함께 `base/lifecycle-pattern.md` (2)가 소스), 같은 값에
`Unsubscribe`는 error.
- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) - **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프)
케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기
해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()` 해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`

View file

@ -48,6 +48,10 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
비슷한 이름의 부가 Modifier 필드인지 구분이 안 됨(사용자 지적). 접두어 비슷한 이름의 부가 Modifier 필드인지 구분이 안 됨(사용자 지적). 접두어
`UI`를 붙이면 실제 대응하는 Roblox Instance 클래스 이름과 1:1로 읽혀서 `UI`를 붙이면 실제 대응하는 Roblox Instance 클래스 이름과 1:1로 읽혀서
이 모호함 자체가 사라짐 — `Frame { UICorner = 8 }`, `mod:UICorner(8)`. 이 모호함 자체가 사라짐 — `Frame { UICorner = 8 }`, `mod:UICorner(8)`.
**[2026-08-27 추가, 9라운드 `H-138`]** 접두어의 두 번째 근거 — Roblox 프로퍼티
이름엔 `UI` 접두어가 없어서 숏핸드 키가 실제 프로퍼티와 **우연히 겹치는 일을
구조적으로 막는다**(아래 "메커니즘" 절의 우선순위 문단 — 충돌 방지는 접두어,
선택은 우선순위로 역할이 갈린다).
## 메커니즘 — 새 아키텍처 개념 불필요 ## 메커니즘 — 새 아키텍처 개념 불필요
@ -67,6 +71,19 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍
(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). (`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용).
**⭐ [2026-08-27 확정, 9라운드 `H-138`] 매치 우선순위 — 숏핸드 핸들러가
`PropertyHandler`보다 높다.** `Frame { UICorner = 8 }`에서
`getHandler(inst, "UICorner", 8)``UICornerHandler`를 고르는 근거는
`PropertyHandler`가 리플렉션으로 그 키를 거부해서가 **아니라** `priority`
(구체 상수는 구현 시 — `PropertyHandler`보다 높은 밴드면 된다). 사용자 논거:
*"당연히 숏핸드 우선순위가 높음. 안 그러면 프로퍼티 핸들러가 숏핸드 계층을
인지하고 준비한다는 말이 돼"* — 거부에 기대면 하위 계층(프로퍼티)이 상위
계층(숏핸드)의 키 집합을 알아야 하는 역방향 의존이 생긴다. 이름 충돌 방지도
우선순위가 아니라 **`UI` 접두어**가 맡는다: *"프로퍼티에 UI를 붙인 이유도
우연히 겹치는걸 막기 위함임. UI 프리픽스는 로블록스 프로퍼티에 발견되진
않거든"*(위 "결론" 절의 접두어 확정에 이 근거가 하나 더 붙는다 — 그 절은
Modifier 메소드와의 모호함만 들었다).
**[보강, 2026-08-09 열한 번째 세션] `mod:UICorner(8)`류 체이닝이 실제로 **[보강, 2026-08-09 열한 번째 세션] `mod:UICorner(8)`류 체이닝이 실제로
타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에
`UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 `UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼
@ -165,12 +182,24 @@ function UICornerHandler.process(inst, k, v, index)
end end
local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용 local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용
Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1) Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1)
return function(hint) return function() end -- ⭐ [2026-08-27 정정, 9라운드 `H-135`] no-op — 아래 참고
if hint == nil then destroyManagedChild(inst, k) end
end
end end
``` ```
- **⭐ [2026-08-27 정정, 9라운드 `H-135`] 반환 클로저는 `function() end`다 —
여기 한때 `function(hint) if hint == nil then destroyManagedChild(inst, k) end
end`가 적혀 있었는데, 그건 위 "`v`가 `nil`인 경우" 절의 `v == nil`(값) 규칙을
`hint == nil`(retractor 인자)로 잘못 옮긴 **복사 오류**였다.** 이 절의 주제는
자식 프로퍼티 세팅을 `Dispatch.process`로 되돌려주는 것뿐이고 관리 자식의
생사는 그 절이 정한다. **사용자 확정**: *"v == nil 로 신규 들어오면 파괴가
맞고 retract 는 nop 맞아. 파괴 자체가 사실 inst 바꿔서 넣은 process 를 처리할
필요 없게 만들어버리고, 우린 파괴에 대해서 retract 안하던게 맞아서, Tween 과
무관한 것도 맞지."* — 파괴 경로는 `process(inst, k, nil)` **하나**이고,
자식이 파괴되면 그 위에 위임됐던 `(child, "CornerRadius")` 체인·트윈 문맥은
Instance와 함께 사라지므로 retractor가 되돌릴 것이 없다. 옛 줄대로 짜면 (A)
분기에서 `R(nil)` + `process(nil)`이 같은 자식을 두 번 파괴하고, (B) 분기
(`State<State<>>`의 안쪽이 값↔State로 바뀔 때)에서 자식이 파괴·재생성됐다.
- **`process` 도중에 대상 `inst`를 바꾸는 것은 UB가 아님(사용자 확정)** — - **`process` 도중에 대상 `inst`를 바꾸는 것은 UB가 아님(사용자 확정)** —
키가 바뀔 수 있는 것과 정확히 같음. `chains``(inst,k)` 쌍으로 키가 바뀔 수 있는 것과 정확히 같음. `chains``(inst,k)` 쌍으로
인덱싱되므로 `(inst, "UICorner")``(child, "CornerRadius")` 위임은 인덱싱되므로 `(inst, "UICorner")``(child, "CornerRadius")` 위임은

View file

@ -27,7 +27,7 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선
(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`), (소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`),
`question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋 `question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋
`9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/` `9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`
반영됐고 Q4~Q10 대기** — 소스는 `-round9-followup.md`. 게이트는 여전히 0. 저장소 루트에 반영됐고**, 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량 반영** — 소스는 `-round9-followup.md`. 그 반영분에 `/code-review high`가 낸 **새 메커니즘 넷(`H-143`~`H-146`)이 `question.md` 최우선 절에 판단 대기**(M2 `Effect` 둘·M3·M5 하나씩). 게이트는 여전히 0. 저장소 루트에
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md` 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`

View file

@ -8,21 +8,56 @@
**진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** 순서를 **진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** 순서를
따른다. 8라운드와 같다. 따른다. 8라운드와 같다.
**⚠️ [2026-08-27 기준] 진행 중이다.** 아래 표가 어디까지 왔는지의 소스다. **✅ [2026-08-27] Q1~Q10·`H-138`·`H-139`·`H-142`까지 전량 처리·반영됐다.** 아래
처음엔 "문항을 다 처리한 뒤 일괄 반영"으로 잡았으나, Q1~Q3가 같은 `slot-plan.md` 표가 항목별 상태의 소스다. (경위: 처음엔 "문항을 다 처리한 뒤 일괄 반영"으로
구간에 몰려 있고 서로 얽혀(Q2의 생성자 이동이 Q3의 `_elemIndex` 삭제와 같은 잡았으나, Q1~Q3가 같은 `slot-plan.md` 구간에 몰려 있고 서로 얽혀(Q2의 생성자
줄) 사용자 지시로 **Q3까지 먼저 반영**했다. Q4 이후는 결정 뒤 반영한다. 이동이 Q3의 `_elemIndex` 삭제와 같은 줄) 사용자 지시로 Q3까지 먼저 반영하고
체크포인트 커밋했고, Q4 이후는 같은 날 이어진 세션이 결정 뒤 반영했다.)
| 문항 | 발견 | 상태 | | 문항 | 발견 | 상태 |
|---|---|---| |---|---|---|
| Q1 | `H-124` `recompute` 루프 순서 | ✅ **확정** — (a), `continue` 형태 | | Q1 | `H-124` `recompute` 루프 순서 | ✅ **확정** — (a), `continue` 형태 |
| Q2 | `H-125` 재마운트 캐시 | ✅ **확정** — 생성자 이동 + (c) 순서 + `_destroyed` | | Q2 | `H-125` 재마운트 캐시 | ✅ **확정** — 생성자 이동 + (c) 순서 + `_destroyed` |
| Q3 | `H-126` splice 빈자리 (+ `H-137` 소멸, `H-141` 신설) | ✅ **확정**`element → index``bk`가 소유, 토큰 폐기 | | Q3 | `H-126` splice 빈자리 (+ `H-137` 소멸, `H-141` 신설) | ✅ **확정**`element → index``bk`가 소유, 토큰 폐기 |
| Q4~Q10 | `H-127`~`H-133` | ⏳ 대기 | | Q4 | `H-127` `EffectHandle:Unsubscribe` 순서 | ✅ **확정·반영** — (a) 의사코드, Observer 게이트 먼저 |
| — | `H-134`~`H-140` | ⏳ 대기(레인 B·부수 발견) | | Q5 | `H-128` M2×M8 `Ref` | ✅ **확정·반영** — (a) M2 공통 기반에 `Ref` 최소형 |
| Q6 | `H-133` `WeakUnsubscribe` 비대칭 | ✅ **확정·반영** — (a) 의도된 관대함 |
| Q7 | `H-130` 폐기 블록 편집 | ✅ **확정·반영** — (b) `archive/`로 이전 |
| Q8 | `H-134` `InstanceChildHandler` 부기 | ✅ **확정·반영** — (a) |
| Q9 | `H-135` 숏핸드 retractor | ✅ **확정·반영** — 문항 전제 정정, Tween 절 스케치 한 줄이 복사 오류(🟡→🟢) |
| Q10 | `H-136` reconcile 배치 Blocker | ✅ **확정·반영** — (a) |
| — | `H-129`/`H-131` | ✅ 정정 반영(판단 불필요) |
| — | `H-138` 숏핸드 우선순위 | ✅ **확정·반영** — 숏핸드가 높다, 충돌 방지는 `UI` 접두어 |
| — | `H-139` `New`/`D` 파이프라인 | ✅ **의사코드 신설** — 쓰면서 `H-142` 발견 |
| — | `H-132`/`H-137`/`H-140` | ✅ Q1~Q3 처리 때 닫힘 |
| — | `H-142` 해시 파트 `Parent` 순서 | ✅ **확정·반영** — props에 `Parent` 금지(순서 문제 소멸) |
| — | `H-143`~`H-146` (`/code-review high` 발견 중 새 메커니즘 넷) | ⏳ **판단 대기**`fn``Unsubscribe` 고아 cleanup / 재`Subscribe` 재설치 / `indexOfElement` 잔존 / 루트 마운트 경로 |
**[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과 **[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과
열어둔 확인은 아래 "반영 기록" 절. 열어둔 확인은 아래 "반영 기록" 절. **같은 날 이어서 Q4~Q8·Q10·`H-138`·`H-139`를
확정·반영했다**(아래 Q4 이하 절). **[같은 날 마지막] Q9 확인과 `H-142` 판단도
닫혀 9라운드 발견은 전량 처리됐다** — 그 뒤 감사 8라운드와 `/code-review high`
돌렸고, **리뷰가 낸 새 메커니즘 넷(`H-143`~`H-146`)이 판단 대기**(아래
"`/code-review high`" 절).
**사용자 회신 원문(Q4~Q10 일괄, 2026-08-27)**: *"Q5 까지는 권고에 전부 동의함.
WeakSubscribed 자체가 사라질 수 있는 요소라서, 그 사라지는걸 유저가 정하게 하는
요소라서 에러를 내야할지 말아야할지 애매한 부분이긴 한듯. 다만 weak 에 대한
홀드를 유저가 유지하는게 강제라면 b가 되긴 해야하나, 그럴 이유가 없어서 a가
되는게 맞는듯. 7번은 프로젝트 컨벤션대로, 권고 b적용. 8번도 권고대로. 9는 뭔가
이상한데? nil은 파괴가 맞고 파괴 자체가 트윈을 지움. 메니지드를 지우는 스케치가
왜 트윈에 있는지도 모르겠는 부분..? 트윈은 inst 를 바꾸어 재프로세싱 하는거라
연관이 없는 부분인데. 다시 생각하고 말해볼래? Q10 은 이제 그래도 되어보임.
이전에는 native* 가 없어 모두 dom 식 elem 입출력이 강제가 아니였는데, 이젠
그렇기 때문에 최초 방식으로 recompute 되는것 처럼 처리되어도 되는 지점.
부작용으론, offset 이 먼저 설정되어진 다음 후행 요소들이 밀리거나 당겨진 다음
후행으로 넘어가느냐가 차이가 나는데, 이미 우린 set upto 와 valid upto 가 나뉜
지점이고, 싱크라서 괜찮아보임. 권고대로 진입해도 된다 보는데 어떻게 생각하는지?
H138은 당연히 숏핸드 우선순위가 높음. 안 그러면 프로퍼티 핸들러가 숏핸드 계층을
인지하고 준비한다는 말이 돼. 그리고 프로퍼티에 UI를 붙인 이유도 우연히 겹치는걸
막기 위함임. UI 프리픽스는 로블록스 프로퍼티에 발견되진 않거든. H139 은 실 구현
전에 의사코드를 써보자. 그걸로 인해서 감추어졌던 설계 결함이나 폭탄이 발견된
경우가 많아서, 커지기 전에 확인해볼 필요가 있음. 중요 계층이라서"*
--- ---
@ -336,6 +371,165 @@ if slot._physicalTarget == nil then return end
--- ---
## Q4 — `EffectHandle:Unsubscribe()`는 Observer 게이트를 **먼저** 통과한 뒤 cleanup (`H-127`, 확정 (a))
- `effect-plan.md`의 "`EffectHandle:Subscribe()`" 절에 의사코드 한 블록 신설 —
`Subscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 **Observer의 함수를 메소드
테이블에 그대로 배정**(같은 레지스트리·같은 `canBound` 게이트·같은
`.Subscribed`), `Unsubscribe``Observer.Unsubscribe(self)` 통과 뒤
`self:_consumeCleanup()`. `WeakUnsubscribe`는 cleanup을 안 건드린다(약한
구독은 GC에 맡기는 경로라 해제가 종료 신호가 아니다).
- 산문의 번호 목록(플래그 → cleanup → fail-fast)은 **의미 목록이지 실행 순서가
아니라고** 그 자리에 적었다. `lifecycle-pattern.md`의 *"아래가 네 진입점
전량이고 소스다"*는 *"Observer의 네 진입점 … `EffectHandle`은 같은 넷을 그대로
재사용"*으로.
## Q5 — M2 공통 기반에 `Ref` 최소형 체크박스 (`H-128`, 확정 (a))
- `ROADMAP.md` M2 "공통 기반" 절에 체크박스 신설 — `.Value`/`.Revision`/`:Set`/
`:WeakCallback`/`:Callback`/`:Uncallback`/`isRef` + `EpochBrand:register`.
M8 `Ref.luau` 체크박스는 *"최소형은 M2로 앞당겨졌다 — 여기 남는 건 `:Wait`,
`PreRef`/`PostRef`, 디스패치 핸들러"*로. 절 머리의 *"셋 다 State-free"*는
개수 없이 *"여기 있는 것 전부"*로.
## Q6 — `WeakUnsubscribe`의 관대함은 의도된 것 (`H-133`, 확정 (a))
- `lifecycle-pattern.md` (2) 의사코드 주석 + 산문 bullet 신설. **사용자 논거**
(위 원문): 약한 등록의 생존은 사용자가 쥔 참조에 달린 것이고 quad가 그 홀드를
강제하지 않으므로 "없는 항목의 약한 해제"를 오류로 볼 근거가 없다 — 홀드
유지가 강제였다면 (b)여야 했다. 강한 등록은 quad가 살려두는 것이라 "없음"이
곧 호출부 실수라 엄격. leaf 바인딩된 값에 `WeakUnsubscribe`도 같은 이유로
통과(무해).
## Q7 — 폐기 블록을 `archive/`로 (`H-130`, 확정 (b))
- `effect-plan.md`의 ⛔⛔ 배너 아래 블록(`_observers` 배열 + cascade)을
`archive/effect-internal-observer-cascade-reversed.md`로 옮기고 포인터 한
문단만 남겼다. 옮긴 원문은 **`9dd8213`이 배너 아래를 편집한 흔적 그대로**이고
파일 머리에 그 경위를 적었다. 새 규칙은 안 만들었다 — `conventions.md`
핸드오버 체크리스트 3번이 이미 이 실패 모드를 규정한다(사용자: *"프로젝트
컨벤션대로"*).
## Q8 — `InstanceChildHandler`도 부기를 등록한다 (`H-134`, 확정 (a))
- `dispatch-core-plan.md` 말단 표에 행 신설 + `H-39` 블록에 "다섯째" 문단:
`process` 맨 앞에서 `setOffsetSource(inst, k, None)`
**`setLength(inst, k, 1, inst, v)`** → `v.Parent = inst`; 반환 클로저는
`None``0` 순서로 해제. `ROADMAP.md` M5 체크박스에 같은 두 줄. 5번째
인자(요소)는 Q3의 `setLength` 시그니처를 따른 것 — 상수 길이라 지속 클로저는
안 생기지만 등록 모양을 다른 말단과 맞췄다.
## Q9 — 재고: 두 "확정"이 아니라 Tween 절 스케치의 **복사 오류** (`H-135`, 확정)
사용자가 문항의 전제를 정정했다(*"nil은 파괴가 맞고 파괴 자체가 트윈을 지움.
메니지드를 지우는 스케치가 왜 트윈에 있는지도 모르겠는 부분..? 트윈은 inst 를
바꾸어 재프로세싱 하는거라 연관이 없는 부분인데"*). 다시 본 결과:
- `v == nil``process`가 자식을 파괴하는 규칙은 두 절이 **같다**. 이상한 건
Tween 절 스케치의 마지막 줄 `return function(hint) if hint == nil then
destroyManagedChild(inst,k) end end` 하나 — 그 절의 주제는 *자식 프로퍼티
세팅을 `Dispatch.process(child, "CornerRadius", …)`로 되돌려준다*뿐이라
관리 자식의 생사를 정할 자리가 아닌데, `v == nil`(값)과 `hint == nil`
(retractor 인자)을 겹쳐 적은 **복사 오류**로 보인다.
- 제가 (B) 분기에서 *"트윈이 끊긴다"*를 피해로 든 것은 틀렸다 — 자식이
파괴되면 그 위의 트윈이 사라지는 건 당연한 결과지 결함이 아니다. 그 줄을
지워야 하는 이유는 **파괴 경로가 하나여야 한다**(`process(inst,k,nil)`)는 것
하나이고, 이중 파괴는 그 중복의 증상이다.
- 트레이스: `(inst,"UICorner")` 체인은 어떤 값이든 `UICornerHandler.process`
`number|Tween|nil`로 닿고(`StoreBind`가 State를 풀고 `NoneHandler``nil`로),
키 자체가 사라지는 건 인스턴스 teardown뿐이라 그때는 자식이 부모와 같이
죽는다 — retractor가 할 일이 없다.
- **✅ [2026-08-27 확정]** 사용자: *"9 맞음. v == nil 로 신규 들어오면 파괴가 맞고
retract 는 nop 맞아. 파괴 자체가 사실 inst 바꿔서 넣은 process 를 처리할 필요
없게 만들어버리고, 우린 파괴에 대해서 retract 안하던게 맞아서, Tween 과 무관한
것도 맞지."* — `ui-shorthand-plan.md` Tween 절 스케치의 그 줄을
`return function() end`로 고치고 정정 bullet을 달았다. `H-135`는 🟡→🟢.
## Q10 — `reconcile` 재실행도 배치 Blocker로 (`H-136`, 확정 (a))
- `slot-plan.md``reconcile` 머리(중복 키 선행 패스 뒤)에 `getBlocker(self)`
획득, 꼬리에 `OffWithoutEmit()` + `recomputeBlocker` 확인 후 `recompute` 1회.
**Blocker가 네스팅이 안 되므로**(불리언, `blocker-plan.md`) 최초 population
처럼 바깥 배치 안에서 오면 `IsOn()`으로 알아보고 손대지 않는다(`ownsGate` —
이 판정은 사용자가 승인한 (a)의 문구 밖에 있는 **구현 세부**다. 같은
`reconcile` 클로저가 바깥 배치 안(최초 population)과 밖(재실행) 양쪽에서
불리는데 Blocker에 네스팅이 없어서 필요한 것이고, 함수 지역 변수라 표면엔
안 드러난다).
- **사용자 논거**(위 원문): `native*` 계층이 생기기 전엔 DOM식 요소 입출력이
강제가 아니라 raw op마다 `recompute`가 돌아야 했지만, 이제는 물리 위치를
`native*`가 부기와 무관하게 정확히 넣으므로 최초 population처럼 한 번에
따라잡아도 된다. 부작용으로 "offset이 먼저 잡힌 뒤 후행 요소가 밀리느냐,
밀린 뒤 넘어가느냐"가 갈리지만 `offsetSetUpTo`/`offsetCacheValidUpTo`가
분리돼 있고 동기라 괜찮다. `dispatch-core-plan.md`의 *"한 사이클 … 한 번만"*
문단에 같은 근거를 적었다.
- 제 판단: 동의 — `raw*`의 명시 호출이 이미 `getBlocker(self):IsOn()`을 보므로
(`H-119`) 추가 배선이 없고, `getOffsetAt`/`physIndex`는 Blocker와 무관하게
정확하다(최초 population과 같은 논증). `updateFn`이 던지면 Blocker가 켜진 채
남는 것도 `materializeSlotTree`와 같은 부류다.
## `H-138` — 숏핸드 핸들러가 `PropertyHandler`보다 우선순위가 높다 (확정)
- `ui-shorthand-plan.md` "메커니즘" 절에 문단 신설, "결론" 절의 접두어 확정에
두 번째 근거 추가. **사용자 논거**(위 원문): 리플렉션 거부에 기대면 하위
계층(프로퍼티)이 상위 계층(숏핸드)의 키 집합을 알아야 하는 역방향 의존이
생긴다; 충돌 방지는 우선순위가 아니라 `UI` 접두어의 몫(Roblox 프로퍼티
이름엔 `UI` 접두어가 없다). 구체 상수는 구현 시.
## `H-139``New(name)(props)` 파이프라인 의사코드 (신설) + 거기서 나온 것
- `bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절에
`New` ①~④(물리 생성 → gcconn/gchold → `flatten``Dispatch.drive`)와
`drive` (a)~(c)(pre-pass → 단일 일반화 `for` 본체 + 배치 Blocker →
`postRefList`) 의사코드 신설. 단계 안의 규칙은 각 소스 문서가 정본이고 여기는
**순서**만 확정. `ROADMAP.md` M3 `Dispatch.drive` 항목에서 가리킨다.
- 이름 충돌(모듈 팩토리 `New()` vs 생성자 `New "Frame"`)은 산문 표기 규칙
한 줄로 닫았다(패키지가 달라 런타임 충돌은 없다).
- **쓰면서 드러난 것 셋** — 사용자가 예상한 그대로:
1. ~~**배치를 닫는 자리가 어디에도 없었다.**~~ **⚠️ 이 주장은 틀렸다**(감사
1라운드) — `dispatch-core-plan.md``H-17` 절이 이미 *"`drive` 전체
(post-pass 포함)를 감싼다, `PostRef` 콜백은 게이트가 켜진 채 실행된다"*로
정해뒀는데 제가 그 절을 안 보고 "해시 파트 앞에서 닫는" 의사코드를 썼고,
사용자 확인 1번은 **그 틀린 전제 위에서** 받은 것이다. 의사코드를 `H-17`대로
(진입 직후 On, `postRefList` 뒤 Off + `recompute`) 고쳤다 — 확인 1번은
무효, 계약은 원래 것 그대로. 실제로 낡아 있던 건 같은 문서 "해법의 핵심"
4번의 옛 문구(*"배열 파트 순회 전체"*) 하나라 같이 정정.
2. **자식 없는 Instance에도 Blocker/`bk`가 생기는 경로.** `getBlocker(inst)`
무조건 부르면 `Frame { Size = … }`마다 Blocker + `bk` + `Relate` 항목이
eager 생성된다 — Q2/Q3가 Slot 쪽에서 막은 것과 같은 부류.
`flattened[1] ~= nil` 가드로 막았다. 확인 요청.
3. **`H-142` 🟡 신설 — 해시 파트 안의 `Parent` 순서가 미정.** `Frame { Parent
= x, Size = … }`에서 `Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다
(Luau 해시 순회 순서는 계약이 아니다). 처방 후보(순서 미루기/문서화/무시)가
전부 새 메커니즘이라 정하지 않고 올렸다.
**✅ [2026-08-27 확정 — 순서가 아니라 키 금지]** 사용자: *"Parent대입 자체가
오면 안 돼. 그건 부모에서 할 일이거든. 자신이 바로 하는 경우는 없어. 그걸
허용해준다는것 자체가, '외부에서 직접 Parent 설정해주지 말것' 을 해치는
요인이 되기도 해.(암묵적으고 가능하도록 둬버려서)"* — `slot-plan.md`
"동적 자식은 반드시 `Slot` 또는 `state<Frame>`류 store-bind를 통해서만" 원칙의
정적 리터럴 판. 반영: `bind-system-plan.md` 파이프라인 절에 규칙 + `ROADMAP.md` M5
(`D` 생성기가 props 타입에서 `Parent` 제외 / `PropertyHandler.isHandlable`
`"Parent"` 거부 → 기존 "매치 핸들러 없음 → 즉시 error"에 걸림). **런타임
배선은 제 선택**(새 메커니즘 없이 기존 계약 재사용)이라 문서에 그렇게
갈라 적었다.
- **1·2 확인 완료**(사용자: *"1과 2는 확인했어. 2는 특히 생성 이후 다시 process
를 주는걸 우린 안 하기로 해서 충분히 가능한 일"*). 2에 대해 사용자가 남긴
의심 — *"Frame{} 으로 나온 bk없는게 Slot 안에 멀쩡히 들어가는데 문제
없을까"* — 를 코퍼스의 호출부 전수로 확인했다: **문제 없다.**
- `getBookkeeping`/`getOffsetAt`/`setLength`/`setOffsetSource`의 첫 인자
(`ownerKey`)는 코퍼스 전체에서 **Slot 자신(`self`/`slot`) 또는 `drive`
도는 최상위 `inst`뿐**이다. Slot에 들어간 `Frame{}`은 언제나 **요소**
(`setLength`의 5번째)나 **앵커/물리 target**(4번째, `bindLifetime`·
`nativeInsert`의 첫 인자)으로만 나온다 — 앵커는 gchold(②에서 무조건
생성)를 쓰지 `bk`를 안 본다.
- 그 Frame이 owner가 되는 경우는 자기 props에 배열 파트가 있을 때뿐이고,
그땐 `flattened[1] ~= nil`이라 가드가 안 걸린다. `outerSlot:Add(innerSlot)`
처럼 나중에 중첩 Slot이 그 Frame을 물리 target으로 써도 owner는
`outerSlot`이다.
- "`bk.base`"는 이미 걷어낸 필드다(`dispatch-core-plan.md` 3번, 사용자
지적으로 — 최상위 `inst`의 베이스는 항상 0이라 저장할 게 없다). 설령
예상 못 한 경로가 `getBookkeeping(frame)`을 불러도 **lazy 생성**이라
크래시가 아니라 그때 만들어질 뿐이다.
## 반영 기록 — Q1~Q3 (2026-08-27) ## 반영 기록 — Q1~Q3 (2026-08-27)
**바뀐 파일**: `base/dispatch-core-plan.md`(`recompute` 루프 재배치 / `setLength` **바뀐 파일**: `base/dispatch-core-plan.md`(`recompute` 루프 재배치 / `setLength`
@ -379,7 +573,10 @@ elem->index 를 위치를 재설정 해준다면 괜찮아"* — `rawReplace`의
맞아. 그래야 인덱싱 매핑을 만드니까"*. (4) *"slot._baseObserver 는 unbind 만 했고, 맞아. 그래야 인덱싱 매핑을 만드니까"*. (4) *"slot._baseObserver 는 unbind 만 했고,
nil 로 지우는것만 안 한다면 맞아"*. nil 로 지우는것만 안 한다면 맞아"*.
**아직 안 한 것**: `/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README **아직 안 한 것**(Q1~Q3 체크포인트 시점 문장 — **[2026-08-27 후속] Q4~Q10도
전량 반영됐고**, 그 반영분의 감사 루프는 아래 "감사 루프 (2026-08-27, Q4~Q10
반영분)" 절; 남은 건 라운드 전체에 대한 `/code-review high`와 커밋뿐):
`/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README
색인과 `todos.md` 00번 갱신은 감사 1라운드 지적으로 그 자리에서 했다. 감사 색인과 `todos.md` 00번 갱신은 감사 1라운드 지적으로 그 자리에서 했다. 감사
루프는 Q1~Q3 반영분에 대해 돌았고 6라운드에서 수렴했다(아래 "감사 루프" 절). 루프는 Q1~Q3 반영분에 대해 돌았고 6라운드에서 수렴했다(아래 "감사 루프" 절).
@ -396,3 +593,54 @@ nil 로 지우는것만 안 한다면 맞아"*.
| 4 | 앞 라운드 수정분 자체 + followup/발견 문서 내부 정합 | 확실 1 | `CLAUDE.md`·`project-context.md`(둘 다 `@import`)가 결정 소스로 8라운드까지만 나열 — 9라운드 한 줄 추가. 그 외 앞 수정분·두 문서 내부 정합은 줄 단위 대조로 이상 없음 | | 4 | 앞 라운드 수정분 자체 + followup/발견 문서 내부 정합 | 확실 1 | `CLAUDE.md`·`project-context.md`(둘 다 `@import`)가 결정 소스로 8라운드까지만 나열 — 9라운드 한 줄 추가. 그 외 앞 수정분·두 문서 내부 정합은 줄 단위 대조로 이상 없음 |
| 5 | 4라운드 수정분 + 델타 밖 `base/` 문서 | 확실 1 · 판단 1 | `README.md``dispatch-core`/`slot-plan` 색인 행에 Q1/Q2 요약 추가 / `architecture.md``Slot.luau` 한 줄에 생성자 필드·`_destroyed` 표기(판단 항목 — 8라운드 행들이 같은 밀도라 넣음) | | 5 | 4라운드 수정분 + 델타 밖 `base/` 문서 | 확실 1 · 판단 1 | `README.md``dispatch-core`/`slot-plan` 색인 행에 Q1/Q2 요약 추가 / `architecture.md``Slot.luau` 한 줄에 생성자 필드·`_destroyed` 표기(판단 항목 — 8라운드 행들이 같은 밀도라 넣음) |
| 6 | 5라운드 수정분 + 신설 규칙 문단 | **확실 0** · 의심 1 | *"`Owned=false`는 `_destroyed`가 안 선다"*가 README 요약과 followup에만 있고 `slot-plan.md` 산문엔 없던 것 — 사용자 확정 인용과 함께 명문화. **수렴**(확실 0) | | 6 | 5라운드 수정분 + 신설 규칙 문단 | **확실 0** · 의심 1 | *"`Owned=false`는 `_destroyed`가 안 선다"*가 README 요약과 followup에만 있고 `slot-plan.md` 산문엔 없던 것 — 사용자 확정 인용과 함께 명문화. **수렴**(확실 0) |
## `/code-review high` (2026-08-27, Q4~Q10 반영분 + 감사 8라운드 뒤)
10건(검증 12 CONFIRMED · 5 PLAUSIBLE · 1 REFUTED). **여섯은 기존 규칙 적용이라
그 자리에서 반영**, **넷은 처방이 새 메커니즘·표면이라 문항으로**(`-round9.md`의
`H-143`~`H-146`, `question.md` 최우선 절).
반영한 여섯:
1. **`InstanceChildHandler` retractor가 옛 자식을 안 내렸다** — `Parent = nil`
추가(파괴 아님 — `slot-plan.md`의 "`State<Slot>` 교체는 파괴가 아니라
언마운트" 절 근거 1이 바로 `state<Frame>`의 이 동작). `state:Set(B)`에서 A가
물리 자식으로 남은 채 `lengthList[k] == 1`이 되던 것.
2. **같은 핸들러의 순서**`setLength``Parent`였던 것을 `Parent`
`setLength`로("일반 계약 — 물리와 부기의 순서" 3번과 같게). 단건 경로에서
`recompute`가 부착 전에 돌았다.
3. **같은 핸들러의 5번째 인자 `v`** — 제가 "모양을 맞추려" 넣은 것인데, 해제
`setLength(…, 0)`이 옛 키를 안 지워 교체마다 `bk.indexOfElement`에 옛 자식이
강참조로 쌓였다. Q3 계약대로 상수 길이는 **생략**. (Slot 쪽 같은 구멍은
`H-145`로.)
4. **`H-17`에 빈 배열 파트 가드가 없었다** — 사용자 확인 2번이 스케치에만 적혀
정본(`dispatch-core-plan.md`)은 여전히 "무조건 On"이었다. 정본과 "해법의
핵심" 1번에 반영.
5. **`quad-types``Quad``Ref` 필드** — `H-128`로 최소형이 M2에 왔는데 그
필드 체크박스는 M8에 남아 `H-25` 공백을 다시 열었다. M2 `H-80` 목록에 `Ref`
추가, M8 항목은 흡수 표기.
6. **Modifier 메소드 목록의 `Parent`** — props 타입에서만 빼면
`Modifier():Parent(x)`가 타입을 통과한다. `bind-system-plan.md` `H-142` 타입
항목 + ROADMAP M7 체크박스.
7. **`effect-plan.md` 번호 목록 재정렬** — "실행 순서가 아니다" 괄호만 달고 옛
순서를 남겨둔 것이 `H-130`과 같은 모양이라 게이트 → 플래그 → cleanup으로
다시 씀.
기각 1(REFUTED): "`drive` 의사코드가 `dispatch-core-plan.md`를 중복한다" — 사용자
요청으로 신설한 블록. 캡에 밀린 하위 발견(`Slot:List` 잉여 블록 잔존 등)은 이미
"잉여" 주석으로 처리된 것과 같은 항목.
## 감사 루프 (2026-08-27, Q4~Q10 반영분)
관례대로 `quad-doc-auditor` 한 턴에 하나, diff 범위, 라운드마다 각도 변경.
| 라운드 | 각도 | 새 발견 | 처분 |
|---|---|---|---|
| 1 | `base/` 정합성 | 확실 3 · 판단 1 · 의심 1 | ⭐ `drive` 의사코드가 `H-17`(*"`drive` 전체를 감싼다, `PostRef` 콜백은 게이트 안"*)을 어김 — 제 *"닫는 자리가 없다"* 주장이 틀렸고 사용자 확인 1번은 무효(`H-139` 절) / `question.md` 최우선 절 "Q4~Q10 대기" 잔재 / README `effect-plan``:Callback` 잔재 / ROADMAP `Property.luau`에 "거부 배선은 에이전트 선택" caveat / Blocker 시작점 "진입 직후"로 |
| 2 | 인덱스 레이어 + 새 문단 자기모순 | 확실 1 · 의심 2 | ROADMAP M8 `Ref.luau` 체크박스가 자기 `H-128` 노트와 모순 → "나머지(`:Wait`)"로 재작성 / Q9 헤더 "확인 대기" 잔재 / `Slot:List` wrap이 `reconcile` 게이트와 중복 → "잉여" 주석 |
| 3 | 주변 폴더 인용처 + 인용문 역방향 + 수정분 | 확실 2 · 의심 2 | ROADMAP 머리 배너 "Q4~Q10 대기" 잔재 / 이 파일 "아직 안 한 것" 문장 / (의심) `ownsGate`는 승인 문구 밖의 구현 세부 — 지역 변수라 그대로, `H-136` 절에 그렇게 표기 / (의심) M8 체크박스의 *"서술도 같이 갔다"*가 과장 → 문구 정정. 인용문 역방향은 전부 일치 |
| 4 | 1~3라운드 수정분 + 발견/결정 문서 내부 정합 | 확실 3 · 의심 2 | 발견 문서 요약 표에 `H-142` 행 없음 / `H-135` 심각도 표 vs 헤더 / §4 아래 Q9 "확인 대기" 잔재 / (의심) `Slot:List` "잉여" 주석 범위를 블록 전체로 / (의심) M8 체크박스의 표면 목록 반복 → M2 참조로 |
| 5 | 4라운드 수정분 + 델타 밖 `base/` | 확실 1 · 의심 2 | `source-state-plan.md`*둘 다 error · 계약은 하나* 문장에 `H-133` 캐비엇 / `blocker-plan.md` 재진입 절에 "`IsOn()` 소유권 판정은 네스팅이 아니다" 한 줄 / `architecture.md` `Ref.luau` 한 줄에 `Epoch` 표면·M2 최소형 |
| 6 | 5라운드 수정분 + 신설 규칙 vs 기존 규정 | 확실 1 · 의심 0 | 이 파일 머리 배너 *"진행 중이다"* 잔재(5라운드 동안 빠졌던 것) → "전량 처리"로. 신설 규칙 여섯 자리(`Parent` 금지 / gcconn 시점 / `New` 표기 / `WeakUnsubscribe` 비소진 / 숏핸드 우선순위 / 다섯째 말단) 전부 기존 규정과 충돌 없음 |
| 7 | 6라운드 수정분 + 세션 기록 사실성 | 확실 3 · 의심 0 | README `bind-system-plan` 행이 "배치 닫는 자리 … `Parent` 순서 미정"으로 1라운드 정정·`H-142` 확정 이전 상태 / `todos.md` 00번에서 "`/code-review high`·커밋 남음" 액션이 사라짐 → 복원 / 이 파일의 "48줄" 개수 주장(실제 47) → 개수 삭제. 세션 파일·summary·todos 본문 서술은 전부 사실과 일치 |
| 8 | 7라운드 수정분만(좁게) | 확실 1 | `project-context.md`의 볼드 마커가 홀수(이번 갱신이 닫는 `**`를 지움) → 짝 맞춤. 지정 세 자리는 회귀 없음. **여기서 멈춤** — 1라운드 이후 설계 내용 발견은 0이고 [2026-08-27 8라운드 시점] 남은 건 기록 문서의 표기뿐이라, 비용 대비 `/code-review high`로 넘어가는 게 낫다고 판단(추이 5→3→4→5→3→1→3→1, 단조 수렴은 아님) |

View file

@ -49,13 +49,18 @@ high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견
| `H-132` | 🟢 | `slot-plan.md` 2900행의 *"…해야 하는 일이 **셋** 늘었다"* 헤딩 아래 bullet이 넷(`H-102`가 넷째를 더함) | `slot-plan.md` 2900 | 사냥 #3 | — | | `H-132` | 🟢 | `slot-plan.md` 2900행의 *"…해야 하는 일이 **셋** 늘었다"* 헤딩 아래 bullet이 넷(`H-102`가 넷째를 더함) | `slot-plan.md` 2900 | 사냥 #3 | — |
| `H-133` | 🟢 | `WeakUnsubscribe`는 **구독한 적 없는 값**에 조용히 통과한다(가드가 "강한 킵 있음"만 본다) — *"해제는 건 경로로 푼다 … 양방향 fail-fast"* 산문과 비대칭. 의도라면 명문화 | `lifecycle-pattern.md` (2) | 계약 정합 (E) | ✅ 실측 | | `H-133` | 🟢 | `WeakUnsubscribe`는 **구독한 적 없는 값**에 조용히 통과한다(가드가 "강한 킵 있음"만 본다) — *"해제는 건 경로로 푼다 … 양방향 fail-fast"* 산문과 비대칭. 의도라면 명문화 | `lifecycle-pattern.md` (2) | 계약 정합 (E) | ✅ 실측 |
| `H-134` | 🟡 | `InstanceChildHandler`(`Frame { Frame {} }`의 정적 자식)의 배열 자리 부기(`setOffsetSource`/`setLength(…, 1)`)가 **어디에도 명세돼 있지 않다** — 말단 표에 행이 없고 의사코드도 없어 `H-39`의 "전수"가 못 잡았다. `Frame { Frame{}, Slot() }`이 첫 마운트에 `H-106` 가드로 즉사 | `dispatch-core-plan.md` 말단 표 × `architecture.md` × `ROADMAP.md` M5 | 레인 B (M5) | 값 트레이스 | | `H-134` | 🟡 | `InstanceChildHandler`(`Frame { Frame {} }`의 정적 자식)의 배열 자리 부기(`setOffsetSource`/`setLength(…, 1)`)가 **어디에도 명세돼 있지 않다** — 말단 표에 행이 없고 의사코드도 없어 `H-39`의 "전수"가 못 잡았다. `Frame { Frame{}, Slot() }`이 첫 마운트에 `H-106` 가드로 즉사 | `dispatch-core-plan.md` 말단 표 × `architecture.md` × `ROADMAP.md` M5 | 레인 B (M5) | 값 트레이스 |
| `H-135` | 🟡 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘***"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 | | `H-135` | 🟡→🟢 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘***"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 |
| `H-136` | 🟡 | `:List` **재실행** reconcile은 배치 Blocker 없이 돌아 raw op마다 `recompute` + `Length:Set`이 난다 — *"한 사이클 전체가 끝난 뒤 한 번만"*(`dispatch-core-plan.md` 2344) 서술과 모순, 최초 population만 감싼 O(n²) 논거가 재실행엔 빠져 있다 | `slot-plan.md` `activateList`/`reconcile` × `dispatch-core-plan.md` | 레인 B (M6) | 값 트레이스 | | `H-136` | 🟡 | `:List` **재실행** reconcile은 배치 Blocker 없이 돌아 raw op마다 `recompute` + `Length:Set`이 난다 — *"한 사이클 전체가 끝난 뒤 한 번만"*(`dispatch-core-plan.md` 2344) 서술과 모순, 최초 population만 감싼 O(n²) 논거가 재실행엔 빠져 있다 | `slot-plan.md` `activateList`/`reconcile` × `dispatch-core-plan.md` | 레인 B (M6) | 값 트레이스 |
| `H-137` | 🟢 | `rawMove`/`rawSwap` `H-29` 규약 1의 치환 목록에 `bk.tokens`/`bk.indexOfToken`이 없다(`H-102`가 splice에만 요구) — 규약 4(이동 구간 **전부** `setLength` 재등록)에 암묵 의존, 토큰은 자리 귀속이라 치환하면 안 된다는 것도 미명시 | `slot-plan.md` `H-29` 규약 | 레인 B | — | | `H-137` | 🟢 | `rawMove`/`rawSwap` `H-29` 규약 1의 치환 목록에 `bk.tokens`/`bk.indexOfToken`이 없다(`H-102`가 splice에만 요구) — 규약 4(이동 구간 **전부** `setLength` 재등록)에 암묵 의존, 토큰은 자리 귀속이라 치환하면 안 된다는 것도 미명시 | `slot-plan.md` `H-29` 규약 | 레인 B | — |
| `H-138` | 🟢 | `UICorner`류 숏핸드 핸들러와 `PropertyHandler`의 매치 구분(리플렉션 거부? 우선순위?)이 명세에 없다 — 이름이 우연히 프로퍼티와 겹치면 조용히 `PropertyHandler`가 먹는다 | `ui-shorthand-plan.md` × `bind-system-plan.md` | 레인 B | — | | `H-138` | 🟢 | `UICorner`류 숏핸드 핸들러와 `PropertyHandler`의 매치 구분(리플렉션 거부? 우선순위?)이 명세에 없다 — 이름이 우연히 프로퍼티와 겹치면 조용히 `PropertyHandler`가 먹는다 | `ui-shorthand-plan.md` × `bind-system-plan.md` | 레인 B | — |
| `H-141` | 🟡 | **확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을 승인한 것**`H-102`*"dispatch 로 격상"* 인용 옆에 사용자가 정한 적 없는 `bk.tokens`/`indexOfToken`(`token = {}`, 2026-08-25 `/code-review`가 발명)이 확정 메커니즘처럼 앉아 있었고, 같은 문단의 *"splice 요구 목록에 항목이 늘지 않는다"*는 구현이 스스로 깼다(둘 늘렸다). 같은 뜻의 맵(`slot._elemIndex`)이 Slot 층에 따로 살아 두 층 이원화 | `dispatch-core-plan.md` `H-102` 문단 × `slot-plan.md` | 사냥 #1(인용문 판) — Q3 처리 중 발견 | — | | `H-141` | 🟡 | **확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을 승인한 것**`H-102`*"dispatch 로 격상"* 인용 옆에 사용자가 정한 적 없는 `bk.tokens`/`indexOfToken`(`token = {}`, 2026-08-25 `/code-review`가 발명)이 확정 메커니즘처럼 앉아 있었고, 같은 문단의 *"splice 요구 목록에 항목이 늘지 않는다"*는 구현이 스스로 깼다(둘 늘렸다). 같은 뜻의 맵(`slot._elemIndex`)이 Slot 층에 따로 살아 두 층 이원화 | `dispatch-core-plan.md` `H-102` 문단 × `slot-plan.md` | 사냥 #1(인용문 판) — Q3 처리 중 발견 | — |
| `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"*`SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — | | `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"*`SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — |
| `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — | | `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — |
| `H-142` | 🟡→닫힘 | **[2026-08-27 `H-139` 의사코드에서 발견]** `Dispatch.drive`의 해시 파트 순서가 계약이 아니라 `Frame { Parent = x, Size = … }``Parent` 대입이 다른 프로퍼티보다 먼저 올 수 있다 — 사용자 확정은 순서가 아니라 **props에 `Parent` 금지** | `bind-system-plan.md` × `ROADMAP.md` M5 | 사냥 밖 — 의사코드 작성이 드러냄 | — |
| `H-143` | 🟡 | **[2026-08-27 `/code-review high`]** `fn` 안에서 `self:Unsubscribe()`를 부르면(문서가 허용하는 자리) `Rerun``fn`의 반환 cleanup을 그대로 `_cleanup`에 저장하고 `_installed = true`로 되돌려 **아무도 소진 못 하는 cleanup**이 남는다 — "마지막 cleanup 정확히 1회" 계약 위반 | `effect-plan.md` `Rerun` | 사냥 밖 — 리뷰 발견, 처방은 새 메커니즘 | — |
| `H-144` | 🟡 | **[2026-08-27 `/code-review high`]** `EffectHandle.Subscribe = Observer.Subscribe` 배정이라 `Unsubscribe`로 cleanup을 소진한 핸들을 다시 `Subscribe`하면 `.Subscribed = true`만 서고 **재설치(`Rerun`)가 없다** — leaf 재바인드 경로(`_bindDestroying`의 `not _installed → Rerun`, `H-65`)와 비대칭 | `effect-plan.md` | 리뷰 발견, 처방은 새 메커니즘 | — |
| `H-145` | 🟡 | **[2026-08-27 `/code-review high`]** 최상위 `SlotHandler` retractor가 `setLength(inst, k, 0)`으로 해제할 때 `bk(inst).indexOfElement[slot]`**안 지워진다**(해제 호출엔 요소가 없고 `setLength``element ~= nil`일 때만 쓴다) — 교체마다 옛 Slot이 부모 `bk`에 강참조로 쌓여 부모가 죽을 때까지 산다(`InstanceChildHandler` 쪽은 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`가 State라 요소가 필요하다) | `dispatch-core-plan.md` `setLength` × `slot-plan.md` 494 | 리뷰 발견, 처방은 새 메커니즘 | — |
| `H-146` | 🟡 | **[2026-08-27 `/code-review high`]** `H-142`(props에 `Parent` 금지, 대입은 자식을 받는 쪽만)를 닫고 나니 **루트**(`ScreenGui` → `PlayerGui`)를 붙이는 승인된 경로가 코퍼스 어디에도 없다 — 유일한 선례는 v1 `Mount(ScreenGui, …)`, `slot-plan.md` 233행의 *"v2는 mount 함수 자체가"*의 그 함수는 어느 표면에도 없다. 부수로 거부 배선의 에러가 일반 매치 실패 메시지(*"provider가 초기화됐는지 확인"*)라 오해를 부른다 | `bind-system-plan.md` `H-142` | 리뷰 발견, 처방은 새 표면 | — |
--- ---
@ -433,7 +438,7 @@ Length/Offset 절 머리(1365행)가 *"정적 단일 자식은 상수 `1`"*이
등록)은 이미 기각된 안(*"모든 핸들러가 `k=number`일 때 처리하도록 두는"*, 등록)은 이미 기각된 안(*"모든 핸들러가 `k=number`일 때 처리하도록 두는"*,
1387행)이라 비권고. 갈래는 사실상 없다 — 확인만. 1387행)이라 비권고. 갈래는 사실상 없다 — 확인만.
### `H-135` 🟡 — 숏핸드 retractor의 두 모양 ### `H-135` 🟢 — 숏핸드 retractor의 두 모양 (**[2026-08-27] 🟡→🟢 강등 — 문항의 전제가 틀렸다**, 아래 처방 갈래가 아니라 Tween 절 스케치의 복사 오류 한 줄; `-round9-followup.md` Q9)
**무엇이 문제인가.** `ui-shorthand-plan.md`*"`v`가 `nil`인 경우"* 절(136행)은 **무엇이 문제인가.** `ui-shorthand-plan.md`*"`v`가 `nil`인 경우"* 절(136행)은
*"반환 클로저가 할 일이 없어 `function() end`이면 충분"*이라 확정하는데, 같은 *"반환 클로저가 할 일이 없어 `function() end`이면 충분"*이라 확정하는데, 같은
@ -584,8 +589,92 @@ slotPos, S.Offset)`이 `S.Offset:Set(newAbs)`를 내는데, 그 시점 `S._baseO
맵을 `bk.indexOfElement` 하나로 통일하고 `token`을 폐기**하는 형태로 닫혔다 — 맵을 `bk.indexOfElement` 하나로 통일하고 `token`을 폐기**하는 형태로 닫혔다 —
그 결과 **`H-137`은 소멸**했다(토큰이 없어졌으므로). 그 결과 **`H-137`은 소멸**했다(토큰이 없어졌으므로).
**⚠️ [2026-08-27 같은 날 후속] Q4~Q8·Q10과 `H-138`·`H-139`도 확정·반영됐다** —
Q4/Q5/Q8/Q10은 권고 (a), Q6은 (a)(의도된 관대함), Q7은 (b)(`archive/`로).
**Q9는 사용자가 문항의 전제를 정정**했다 — 두 "확정"이 경쟁하는 게 아니라
Tween 절 스케치의 `hint == nil` 줄이 `v == nil` 규칙을 잘못 옮긴 복사 오류이고,
(B) 분기의 "트윈이 끊긴다"는 피해가 아니다(자식 파괴가 트윈을 지우는 건 당연).
**[같은 날 확정]** 사용자: *"9 맞음 … retract 는 nop 맞아"*`H-135`는 🟢로.
`H-139` 의사코드를 쓰면서 **`H-142`**가 나왔다(아래). 결정의 소스는
`-round9-followup.md`.
### `H-142` 🟡 — 해시 파트 안의 `Parent` 순서가 미정 (`H-139` 의사코드에서 발견)
`Dispatch.drive`의 본체가 단일 일반화 `for`(`F-4-1`)라 해시 파트 순서는 Luau의
내부 순회 순서다 — 계약이 아니다. `Frame { Parent = x, Size = …, Position = … }`
에서 `Parent` 대입이 다른 프로퍼티보다 **먼저** 올 수 있고, 그러면 부모에 붙은
뒤에 프로퍼티가 바뀐다. 프레임 경계가 안 끼므로 사용자에게 보이는 중간 상태는
없지만 엔진 쪽 레이아웃 재계산이 프로퍼티 수만큼 붙는다. 코퍼스 어디에도
`Parent`의 처리 순서 서술이 없다(`PostRef` 절은 *"자기 `.Parent`는 아직일 수
있다"*라고 부모 쪽 시점만 말한다). 처방 후보는 전부 새 메커니즘이라 정하지
않았다 — (a) `drive``Parent` 키를 루프 뒤로 미룸, (b) 문서화만(사용자가
`Parent`를 별도로 대입), (c) 무시. **사용자 판단.**
**[2026-08-27 확정 — 셋 다 아님, 키 금지]** 사용자: *"Parent대입 자체가 오면 안
돼. 그건 부모에서 할 일이거든."* props에 `Parent`는 올 수 없고(타입 제외 +
`PropertyHandler` 거부 → 기존 no-handler error), 순서 문제는 소멸. 소스는
`-round9-followup.md``H-142` 항목.
--- ---
### `H-143`~`H-146` — 2026-08-27 `/code-review high`가 Q4~Q10 반영분에서 잡은 것 (사용자 판단)
반영 뒤 돌린 `/code-review high`(10건, 검증 12 CONFIRMED)가 낸 것 중 **처방이 새
메커니즘·표면인 넷**. 나머지 여섯(`InstanceChildHandler` retractor의 `Parent = nil`
누락·순서·5번째 인자 / `H-17`에 빈 배열 파트 가드 / `Quad.Ref` 필드 마일스톤 /
Modifier 메소드 목록의 `Parent` 제외 / `EffectHandle` 산문 순서)은 기존 규칙
적용이라 그 자리에서 반영했다(`-round9-followup.md`의 code-review 절).
**`H-143` 🟡 — `fn``self:Unsubscribe()` 뒤 반환 cleanup이 고아.**
`effect-plan.md`가 *"`fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 핸들
표면에 바로 닿는다"*고 허용하는데, `Rerun``_consumeCleanup()``fn` → 반환값을
`_cleanup`에 저장 + `_installed = true`다. `fn` 안에서 `Unsubscribe`가 통과하면
(레지스트리 제거, `_consumeCleanup` no-op — 이미 nil) 그 뒤 `fn`이 돌려준 새
cleanup이 저장되는데, 핸들은 이제 어느 레지스트리에도 없고 leaf도 아니라 재
`Unsubscribe`는 error, `WeakUnsubscribe`는 소진 안 함, dep도 못 깨운다 → 영원히
안 불린다(예: 타이머 정지 cleanup). **갈래**: (a) `Rerun``fn` 반환 뒤
`canExecute(self)`가 거짓이면 반환 cleanup을 **즉시 소진**(저장 안 함) / (b) `fn`
`Unsubscribe`를 금지하고 그 문장을 지움 / (c) 허용하되 "그 실행의 cleanup은
사용자 책임"으로 문서화. **권고 (a)** — 계약(*"끝나는 시점에 마지막 cleanup 정확히
1회"*)을 코드가 지키는 유일한 갈래. 다만 `Rerun` 꼬리에 분기 하나가 는다.
**`H-144` 🟡 — `Unsubscribe` 후 재`Subscribe`에 재설치가 없다.**
`Observer.Subscribe`를 그대로 배정해서 `canBound` 게이트를 통과하면
`.Subscribed = true`만 서고 `fn`은 안 돈다. deps 없는 Effect는 아무도 `Rerun`
못 불러 레지스트리가 살려두는 죽은 핸들(누수), deps 있으면 다음 emit까지 죽어
있다가 조용히 재설치. leaf 재바인드는 `_bindDestroying``not _installed`
`Rerun`한다(`H-65`)라 비대칭. **갈래**: (a) `EffectHandle:Subscribe` 래퍼 —
`Observer.Subscribe(self)``if not self._installed then self:Rerun() end`
(leaf 경로와 대칭) / (b) 소진된 핸들의 재구독을 error / (c) 허용하되 재설치 없음을
문서화. **권고 (a)**`H-65`가 leaf 쪽에 이미 세운 규칙과 같은 모양.
**`H-145` 🟡 — 최상위 Slot 교체 시 `bk(inst).indexOfElement[oldSlot]` 잔존.**
`setLength(inst, k, 0)` 해제엔 요소가 없어 옛 키가 안 지워지고(`setLength`는
`element ~= nil`일 때만 쓴다), `indexOfElement`는 강한 키 맵이라 `Relate(inst)`
부기가 옛 Slot을 부모가 죽을 때까지 붙든다 — Q3가 세운 맵의 부작용이고
`rawReplace`(Slot 층)는 옛 키를 지우지만 inst 층 핸들러는 `bk`에 못 닿는다.
`InstanceChildHandler`는 5번째 인자를 안 넘기는 것으로 닫았지만 Slot은 `Length`
State라 요소가 필요하다. **갈래**: (a) `indexOfElement`를 **weak-key**로(마운트
중엔 `_elements`/`Relate`가 강하게 잡으므로 사라질 일 없고, 해제 뒤엔 저절로
빠진다) / (b) 해제 호출 `setLength(inst, k, 0, inst, oldSlot)`에도 요소를 넘기면
`setLength``len == 0`일 때 그 키를 지운다(시그니처 의미 확장) / (c) Slot 층처럼
inst 층에도 명시 삭제 API. **권고 (a)** — 코드 한 단어이고 "다른 곳에서 안전하게
유지되는 것은 weak로"(`lifecycle-pattern.md` (0)) 규칙 그대로.
**`H-146` 🟡 — 루트를 붙이는 승인된 경로가 없다.**
`H-142`대로 `.Parent` 대입은 자식을 받는 쪽(`InstanceChildHandler`/Slot `native*`)만
하는데, 부모가 quad 밖인 루트(`ScreenGui` → `PlayerGui`)는 그 어느 쪽도 아니다.
사용자가 `D.ScreenGui { … }`를 만든 뒤 props `Parent`는 error, `Mount`류 API는
없음, 밖에서 `gui.Parent = PlayerGui`는 인용문(*"외부에서 직접 Parent 설정해주지
말것"*)상 금지처럼 읽힌다. 부수로 거부 배선의 에러가 *"no handler … check
quad-roblox provider"*라 provider 설정을 의심하게 만든다. **갈래**: (a) **루트는
예외** — quad가 만든 트리의 최상위를 quad 밖 부모에 붙이는 건 사용자가 밖에서
`.Parent =`로 한다고 명문화(금지되는 건 *quad가 관리하는 자식 자리*에 끼우는 것) /
(b) `Mount(root, parent)`류 표면 신설 / (c) 루트 전용 props 키. 에러 메시지는 어느
갈래든 `PropertyHandler``"Parent"`를 거부할 때 전용 문구를 내는 게 낫다(그건
배선 세부라 갈래와 무관). **권고 (a)** — 새 표면 없이 인용문의 범위만 정확히
적는 것.
## §5 이상 없다고 확인한 것 (다음 라운드가 다시 파지 않도록) ## §5 이상 없다고 확인한 것 (다음 라운드가 다시 파지 않도록)
**A각도 — 겹쳐 읽어 정합했던 조합**: **A각도 — 겹쳐 읽어 정합했던 조합**:

View file

@ -12,13 +12,24 @@
--- ---
## ⭐ 최우선 — **비어 있음** (2026-08-27 갱신) ## ⭐ 최우선 — `/code-review`가 낸 새 메커니즘 넷 (2026-08-27 갱신)
**[2026-08-27] 9라운드 손 트레이싱의 결정 문항 Q4~Q10이 회신 대기입니다 — **[2026-08-27] 9라운드 손 트레이싱은 Q1~Q10·`H-138`·`H-139`·`H-142`까지 전량
M2 착수 게이트는 아닙니다**(🔴 둘은 Q1/Q2로 이미 닫혔고 남은 건 🟡/🟢). 문항과 처리·반영됐고**, 그 반영분에 `/code-review high`를 돌린 결과 **처방이 새
권고는 `qa-request/pre-implementation-handtrace-round9.md` §4 표, 진행 상태는 메커니즘·표면인 넷**이 나와 판단을 기다립니다 — 갈래·권고는
`-round9-followup.md`의 진행 표가 소스(여기서 문항을 반복하지 않는다). 다음 `qa-request/pre-implementation-handtrace-round9.md``H-143`~`H-146` 절이
세션이 위에서부터 처리한다. 소스(여기서 반복하지 않음). 한 줄씩:
- **`H-143`** `fn` 안에서 `self:Unsubscribe()`하면 그 실행이 돌려준 cleanup이
영원히 안 불린다 — 권고 (a) `Rerun``canExecute` 거짓이면 즉시 소진. **M2.**
- **`H-144`** `Unsubscribe`한 핸들을 다시 `Subscribe`해도 재설치가 없다 — 권고
(a) `Subscribe` 래퍼가 `not _installed``Rerun`(leaf 경로 `H-65`와 대칭). **M2.**
- **`H-145`** 최상위 Slot 교체 시 부모 `bk.indexOfElement`에 옛 Slot이 강참조로
남는다 — 권고 (a) 그 맵을 weak-key로. **M3.**
- **`H-146`** props `Parent` 금지 뒤 루트(`ScreenGui` → `PlayerGui`)를 붙이는
승인 경로가 없다 — 권고 (a) 루트는 예외로 명문화(금지는 quad가 관리하는 자식
자리에 한함). **M5.**
`H-143`/`H-144`는 M2 `Effect` 표면이라 **M2 착수 전 답이 있는 게 좋지만** 구현
중 정해도 되는 크기입니다(게이트로 보지 않음).
**[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어) **[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어)
착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정 착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정

View file

@ -1943,3 +1943,21 @@ ref 는 그 자체로 epoch임"*), `H-118`은 소유권 문제가 아니라 `gat
high`는 Q4~Q10 반영 뒤로. 결정의 소스는 high`는 Q4~Q10 반영 뒤로. 결정의 소스는
`qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는 `qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는
`session/2026-08-27-01-handtrace-round9-q1-q3.md`. **Q4~Q10은 다음 세션.** `session/2026-08-27-01-handtrace-round9-q1-q3.md`. **Q4~Q10은 다음 세션.**
## 2026-08-27-02 — 9라운드 Q4~Q10 결정·반영 + `H-138`/`H-139`/`H-142` (전량 처리)
Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubscribe`만 게이트
통과 뒤 cleanup) / Q5(M2 공통 기반에 `Ref` 최소형) / Q6(`WeakUnsubscribe` 관대 —
약한 홀드를 강제하지 않으므로) / Q7(폐기 블록 `archive/effect-internal-observer-cascade-reversed.md`)
/ Q8(`InstanceChildHandler` 부기) / Q10(`reconcile` 재실행도 배치 Blocker,
네스팅 불가라 `ownsGate`). **Q9는 문항의 전제가 틀렸다** — Tween 절 스케치의
`hint == nil` 줄이 `v == nil` 규칙의 복사 오류, 파괴 경로는 `process(nil)` 하나.
`H-138` 숏핸드 우선순위 > `PropertyHandler`(충돌 방지는 `UI` 접두어). **`H-139`
파이프라인 의사코드**(`New` ①~④ + `drive` (a)~(c), `bind-system-plan.md`)를
쓰면서 배치 닫는 자리·빈 배열 파트 가드·`Parent` 순서가 드러났고, 마지막은
**`H-142` — props에 `Parent` 금지**(*"그건 부모에서 할 일"*)로 순서 문제 자체가
소멸. 감사 8라운드 뒤 `/code-review high` 10건 — 여섯 반영(그중 셋이 이 세션의
`H-134` 반영분이 만든 것), **넷은 새 메커니즘이라 문항으로**(`H-143`~`H-146`,
`question.md` 최우선 절). 결정의 소스는
`qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는
`session/2026-08-27-02-handtrace-round9-q4-q10.md`.

View file

@ -0,0 +1,73 @@
# 2026-08-27 — 9라운드 Q4~Q10 결정·반영 + `H-138`/`H-139`/`H-142`
**무엇을 했나**: 앞 세션(`session/2026-08-27-01-handtrace-round9-q1-q3.md`)이
Q1~Q3까지 반영하고 남긴 Q4~Q10을 사용자와 두 턴으로 처리해 `base/`·`ROADMAP.md`에
반영했다. 결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`
(사용자 회신 원문 두 개도 거기 인용). 여기는 흐름과, 문서에 안 들어간 시행착오.
## 흐름
1. 판단 불필요한 `H-129`(ROADMAP `:Callback` 잔재)·`H-131`(*"`for d in seen`은
죽는다"*는 거짓)을 먼저 닫고 Q4~Q10 + 🟢 둘(`H-138`/`H-139`)을 갈래·권고와 함께
한 번에 올렸다.
2. 사용자 1차 회신 — Q4/Q5/Q8/Q10 권고대로, Q6 (a)(약한 홀드를 강제하지 않으므로),
Q7 (b)(컨벤션대로 `archive/`), **Q9는 "뭔가 이상한데?"** — 문항의 전제를
정정. `H-138`은 숏핸드 우선순위가 높다(역방향 의존 논거 + `UI` 접두어의 역할),
`H-139`는 *"실 구현 전에 의사코드를 써보자 … 감추어졌던 설계 결함이나 폭탄이
발견된 경우가 많아서"*.
3. Q9 재고: 두 "확정"이 경쟁하는 게 아니라 Tween 절 스케치의 `hint == nil` 줄이
`v == nil` 규칙의 **복사 오류**였고, 내가 (B) 분기의 "트윈이 끊긴다"를 피해로 든
것이 틀렸다(자식 파괴가 트윈을 지우는 건 당연). 파괴 경로는 `process(nil)`
하나. 사용자 확인(*"9 맞음 … 우린 파괴에 대해서 retract 안하던게 맞아서"*).
4. `H-139` 파이프라인 의사코드(`New` ①~④ + `drive` (a)~(c))를 쓰면서 셋이
드러났다 — 배치 닫는 자리 미정(루프 뒤로), 자식 없는 Instance의 eager
Blocker/`bk`(`flattened[1] ~= nil` 가드), 해시 파트 `Parent` 순서(`H-142`).
사용자 2차 회신: 1·2 확인, **`H-142`는 순서가 아니라 키 금지**(*"Parent대입
자체가 오면 안 돼. 그건 부모에서 할 일이거든 … '외부에서 직접 Parent 설정해주지
말것' 을 해치는 요인"*). 2에 대한 의심(*"bk없는게 Slot 안에 멀쩡히 들어가는데
문제 없을까"*)은 호출부 전수로 확인 — owner 키는 코퍼스 전체에서 Slot 자신 또는
최상위 `inst`뿐이고 요소 Frame은 앵커(gchold)/요소로만 나온다, `bk.base`
이미 걷어낸 필드, `getBookkeeping`은 lazy.
## 시행착오 (문서엔 결론만)
- **Q9의 문항 자체가 잘못 세워져 있었다.** `H-135`는 "같은 문서의 두 확정이
갈린다"로 썼는데 실제론 한쪽이 그 절의 주제 밖 스케치 한 줄이었다. 그 줄을
"확정"으로 격상해 갈래를 만든 건 나였다 — 스케치 안의 코드 한 줄을 산문의
확정과 같은 무게로 읽지 말 것.
- **`H-142`에서 제시한 세 갈래가 전부 틀린 축이었다.** 순서 (a)/(b)/(c)를 물었는데
답은 "그 키가 존재하면 안 된다"였다 — 증상(순서)에서 처방을 찾지 말고 그 키가
거기 있어야 하는지부터 물었어야 했다. 반영한 런타임 배선(`PropertyHandler`가
`"Parent"` 거부 → 기존 no-handler error)은 내 선택이라 문서에 갈라 적었다.
- **Blocker 네스팅.** Q10을 반영하며 `reconcile``blocker:On()`을 넣으려다
`blocker-plan.md`의 "네스팅 미지원(불리언)"에 걸렸다 — 최초 population은 바깥
배치 안에서 `reconcile`에 오므로 무조건 `On()`이면 안쪽이 먼저 꺼서 바깥 등록이
게이팅을 잃는다. `ownsGate = not blocker:IsOn()`으로 갈랐다.
- **`H-17`을 못 보고 "배치 닫는 자리가 미정"이라고 써서 사용자 확인까지 받았다.**
감사 1라운드가 잡음 — `dispatch-core-plan.md``drive` 전체(post-pass 포함)를
감싸고 `PostRef` 콜백이 게이트 안에서 돈다고 이미 계약해뒀다. 의사코드를
그대로 고쳤고 사용자 확인 1번은 무효로 표기. "어디에도 없다"는 주장은 grep
한 번이면 반증됐을 것 — 없다고 쓰기 전에 그 단어(`post-pass`)로 찾을 것.
- 절 인용이 Lua 주석 두 줄에 걸쳐 `--` 마커가 인용문 안으로 들어가 `doc-check`
ERROR — 한 줄로 합쳤다(`conventions.md`의 blockquote 주의와 같은 부류, 코드
주석도 마찬가지).
## 감사 루프와 code-review
- `quad-doc-auditor` 8라운드(5→3→4→5→3→1→3→1, 각도: `base/` 정합성 → 인덱스
레이어 → 주변 폴더·인용문 역방향 → 수정분·발견/결정 문서 내부 → 델타 밖
`base/` → 신설 규칙 vs 기존 규정 → 세션 기록 사실성 → 7라운드 수정분). 단조
수렴은 아니었지만 1라운드(`H-17`) 이후 설계 내용 발견은 0이고 나머지는 기록
문서 표기라 8라운드에서 멈추고 code-review로 넘어갔다(처분 표는 followup 끝).
- `/code-review high` 10건 — 여섯 반영(`InstanceChildHandler` retractor의
`Parent = nil`·순서·5번째 인자 / `H-17` 빈 배열 가드 / `Quad.Ref` 필드 M2 /
Modifier 메소드 목록 `Parent` 제외 / `effect-plan` 목록 재정렬), **넷은 새
메커니즘이라 문항으로**(`H-143`~`H-146` — `fn``Unsubscribe` 고아 cleanup /
재`Subscribe` 재설치 / `indexOfElement` 잔존 / 루트 마운트 경로). 이번에도
**반영분 자체가 새 결함을 만든 것**(`H-134`의 순서와 5번째 인자 둘 다 내가 넣은
것)이 반복됐다 — `conventions.md`의 2026-08-26 실측 그대로.
## 남은 것
커밋, 그리고 `question.md` 최우선 절의 `H-143`~`H-146` 결정. M2 착수 게이트는
여전히 0.

View file

@ -34,14 +34,21 @@
검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동. 검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동.
- **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가 - **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가
사본마다 분리된다는 사실(`research/documentation-content-map.md` §4). 사본마다 분리된다는 사실(`research/documentation-content-map.md` §4).
- **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q3까지 확정·반영했다 — Q4~Q10 대기.** - **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q10·`H-138`·`H-139`·`H-142` 전량 확정·반영했다.**
발견은 `qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`, 발견은 `qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`,
🔴 둘 다 실측 재현), **결정의 소스는 `-round9-followup.md`**(진행 표가 🔴 둘 다 실측 재현), **결정의 소스는 `-round9-followup.md`**(진행 표가
상태의 소스). 반영된 셋: `recompute` 되감기 판정을 `lengthList[i]` 읽기 상태의 소스). 반영된 셋: `recompute` 되감기 판정을 `lengthList[i]` 읽기
앞으로 / `Offset`·`_baseObserver`를 Slot 생성자로 + `materializeSlotTree` 앞으로 / `Offset`·`_baseObserver`를 Slot 생성자로 + `materializeSlotTree`
순서 + `_destroyed` / `element → index``bk.indexOfElement` 하나로(사용자가 순서 + `_destroyed` / `element → index``bk.indexOfElement` 하나로(사용자가
정한 적 없는 `token` 폐기). README 색인은 했고 `/code-review high`·커밋은 정한 적 없는 `token` 폐기). 같은 날 후속으로 Q4~Q8·Q10(`EffectHandle` 네
Q4~Q10 뒤. 아래는 돌리기 전(2026-08-26) 서술: 진입점 의사코드 / M2에 `Ref` 최소형 / `WeakUnsubscribe` 관대 / 폐기 블록
`archive/` / `InstanceChildHandler` 부기 / `reconcile` 배치 Blocker)과
`H-138`(숏핸드 우선순위)·`H-139`(`New`/`drive` 파이프라인 의사코드 — 거기서
`H-142` `Parent` 순서 발견 → props에 `Parent` **금지**로 확정)까지 반영.
Q9는 문항 전제가 틀린 것(Tween 절 스케치 한 줄 복사 오류)으로 닫힘.
**[2026-08-27 기준] 감사 8라운드·`/code-review high`까지 돌렸다 — 리뷰 10건 중
여섯은 반영, 넷(`H-143`~`H-146`, 새 메커니즘)은 `question.md` 최우선 절에
판단 대기. 남은 액션: 커밋, 그리고 그 넷의 결정.** 아래는 돌리기 전(2026-08-26) 서술:
지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는 지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는
**커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤 **커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤
`/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도 `/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도

View file

@ -13,8 +13,9 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
결정의 소스는 결정의 소스는
`.claude/qa-request/pre-implementation-handtrace-round8-followup.md` `.claude/qa-request/pre-implementation-handtrace-round8-followup.md`
(7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은 (7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은
`-round9-followup.md` — Q1~Q3 반영 완료, Q4~Q10 대기**)이고 `.claude/question.md` 최우선 `-round9-followup.md` — Q1~Q10·`H-138`·`H-139`·`H-142` 전량 반영 완료**)이고 `.claude/question.md` 최우선
절은 비어 있다. 같은 상태를 `.claude/project-context.md` 절엔 **그 반영분에 `/code-review`가 낸 새 메커니즘 넷(`H-143`~`H-146`)이 판단
대기**로 올라 있다(M2 착수 게이트는 아님). 같은 상태를 `.claude/project-context.md`
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`. 항상 루트 `ROADMAP.md`.

View file

@ -23,7 +23,9 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
> 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다. > 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다.
> **[2026-08-27] 9라운드 몫은 `-round9-followup.md`** — Q1~Q3(`recompute` 되감기 > **[2026-08-27] 9라운드 몫은 `-round9-followup.md`** — Q1~Q3(`recompute` 되감기
> 순서 / Slot 생성자의 `Offset`·`_baseObserver`·`_destroyed` / `bk.indexOfElement` > 순서 / Slot 생성자의 `Offset`·`_baseObserver`·`_destroyed` / `bk.indexOfElement`
> 토큰 폐기)까지 반영됐고 Q4~Q10은 대기 중입니다. > 토큰 폐기)에 이어 같은 날 Q4~Q10·`H-138`·`H-139`·`H-142`까지 **전량**
> 반영됐습니다(`EffectHandle` 진입점 / M2에 `Ref` 최소형 / `InstanceChildHandler`
> 부기 / `reconcile` 배치 Blocker / `New`·`drive` 파이프라인 / props `Parent` 금지).
> **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에 > **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에
> 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐 > 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐
> 있습니다). M1까지의 산출물은 > 있습니다). M1까지의 산출물은
@ -249,8 +251,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
### 공통 기반 — 반응형보다 먼저 (구 M2, 지금의 M3에서 이동) ### 공통 기반 — 반응형보다 먼저 (구 M2, 지금의 M3에서 이동)
> 셋 다 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. 이 절이 > 여기 있는 것 전부 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다.
> 끝나야 아래 반응형 본체를 짤 수 있습니다. > 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다.
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()` - [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`, 브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
@ -325,6 +327,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐 이 게이트를 그대로 탐
- [ ] **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래
`Effect(fn, ...deps)``Ref` dep 분기(`isRef(d)` →
`d:WeakCallback(onRefFire)``self._epochs:Sync(d)`)가 **M2 안에서
실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/
`:WeakCallback(fn)`/`:Callback(fn)`/`:Uncallback(fn)`/`isRef` +
`EpochBrand:register(self)`(`Epoch`를 만족하는 데 필요한 것 전부).
`PreRef`/`PostRef`/`:Wait`/디스패치 핸들러(`(v=Ref)` 매치, `Processed*`)는
**M8 그대로**. 근거: `Ref``Epoch`인 것 자체가 M2의 결정
(`H-58`/`H-64`/`H-70`)이고, `Effect``isEpoch` 분기를 M2의 mock
테스트가 한 번은 실제로 태워야 한다 — 그 전엔 M8 머리가 *"M2가 이미 이
표면을 전제한다"*고 **인정만** 하고 M2 쪽엔 앞으로 참조가 없어, 구현자가
`isRef` 스텁으로 비워두기와 M8 절반 앞당기기 중 임의로 고르게 돼 있었다.
소스는 `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절.
**[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이
마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types``Quad``Ref`
생성자 필드도 여기서** 추가한다 — M8의 `H-25` 체크박스는 이걸로 흡수.
### 반응형 본체 ### 반응형 본체
@ -465,7 +483,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드 뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
(State/Source는 `Observer`, `Ref``:Callback`)을 걸어 (State/Source는 `Observer`, `Ref``:WeakCallback`**[2026-08-27
9라운드 `H-129`]** 옛 `:Callback` 표기는 `H-58`이 정정한 것의 잔재로,
강한 셋에 걸면 `Ref``Effect`를 영원히 붙든다)을 걸어
재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle` 재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`
`EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간 `EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간
억제 플래그가 그 `Update`보다 먼저 와야 함 억제 플래그가 그 `Update`보다 먼저 와야 함
@ -572,7 +592,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** - [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Ref`**(최소형,
2026-08-27 `H-128`) /
`is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/ `is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/
`isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`· `isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`·
`canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** — `canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** —
@ -658,6 +679,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)` flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None` `Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md``None`
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
**[2026-08-27 9라운드 `H-139`]** `New` ①~④ + `drive` (a)~(c) 전체
파이프라인 의사코드는 `base/bind-system-plan.md`의 "`New(name)(props)`
파이프라인 의사코드" 절 — 배치 Blocker를 여닫는 자리와 빈 배열 파트
가드도 거기.
**[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라 **[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라
단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히 단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau
@ -907,8 +932,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) — 진입점 - [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) — 진입점
`QuadRoblox(Quad): QuadRoblox``QuadTypes.CheckedQuad<T, Pattern>`으로 `QuadRoblox(Quad): QuadRoblox``QuadTypes.CheckedQuad<T, Pattern>`으로
주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고) 주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고)
- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) - [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **[2026-08-27 9라운드 `H-142`] 생성되는 props 타입에서 `Parent`를 제외할 것** — props에 `Parent`는 올 수 없다(부모가 하는 일, 같은 문서의 파이프라인 절). `New` ①~④ 순서는 그 절의 의사코드가 소스
- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` - [ ] `Handlers/Property.luau`(**[2026-08-27 9라운드 `H-142`]** `isHandlable``"Parent"` 키를 **거부**한다 — 매치 핸들러가 없어지면 `Dispatch.process`의 "매치 핸들러 없음 → 즉시 error"에 걸리는 것으로 런타임 가드가 공짜로 생긴다, 새 메커니즘 없음. **사용자 확정은 "props에 `Parent` 금지"라는 규칙이고, 이 거부 배선은 에이전트 선택**`base/bind-system-plan.md``H-142` 항목이 그렇게 갈라 적음), `Handlers/InstanceChild.luau`
**⭐ [2026-08-27 9라운드 `H-134`] `InstanceChildHandler`도 말단이라
부기를 등록한다**: `process`에서 `setOffsetSource(inst, k, None)`
`v.Parent = inst``setLength(inst, k, 1, inst)`(정적 단일 자식은 상수
`1`, 5번째 인자 없음). 반환 클로저는 `v.Parent = nil`(내리기만, 파괴
아님) → `setOffsetSource(inst, k, None)``setLength(inst, k, 0)`.
**[2026-08-27 `/code-review` 정정]** 옛 순서(`setLength` → `Parent`)와
"5번째 인자 `v`"는 틀렸었다 — 근거는 `base/dispatch-core-plan.md`의 그
문단. 빠뜨리면
`Frame { Frame{}, Slot() }`이 첫 마운트에서 죽는다 —
`base/dispatch-core-plan.md``H-39` 블록(그 다섯째 항목)이 소스.
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션
확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/
lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에 lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에
@ -1262,6 +1297,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택) `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
## M7 — Modifier ## M7 — Modifier
- [ ] **[2026-08-27 9라운드 `H-142` 후속, `/code-review`]** 생성기가 찍는
`FrameModifier`류 메소드 목록에서도 **`Parent`를 제외**할 것 — props
타입에서만 빼면 `Modifier():Parent(x)`가 타입을 통과하고 `flatten`
`Parent`를 해시 파트로 merge해 런타임에서야 죽는다. `PreRef`/`PostRef`가
Modifier 타입으로 차단되는 것과 같은 자리(`base/bind-system-plan.md`의
`H-142` 항목).
- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 - [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션
명시 — `Source(default)`/`Ref(default)`/`Store({defaults})`와 같은 명시 — `Source(default)`/`Ref(default)`/`Store({defaults})`와 같은
`Type(args)` 팩토리 관습, `modifier-plan.md` 3번) `Type(args)` 팩토리 관습, `modifier-plan.md` 3번)
@ -1319,18 +1361,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M8 — Ref ## M8 — Ref
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가 - [x] ~~**[2026-08-24 `H-25` 파생]** `quad-types``Quad``Ref` 필드 추가~~
(위 M3 항목의 "마일스톤마다" 규칙) **[2026-08-27 `H-128` 후속]** `Ref` 최소형과 함께 M2 공통 기반으로
- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ 이동(그 체크박스). `PreRef`/`PostRef` 필드는 아래 항목이 얹는다
`:Wait(thread?)`, 전부 self 반환). - [ ] `Ref.luau`**나머지**`:Wait(thread?)`(self 반환). **[2026-08-27
**⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-64`/`H-70`] `Ref``Epoch` 9라운드 `H-128`] 최소형은 M2 "공통 기반" 절로 앞당겨졌다**(표면 목록은
만족하게 됐다** — 공개 필드 **`.Revision`**(`:Set()`이 `Source`와 같은 그 체크박스가 소스 — 여기 반복하지 않는다) — `Ref``Epoch`를 만족한다는 2026-08-25 확정
`bit32.bnot(-rev)`로 갱신) + **`EpochBrand:register(self)`**, 그리고 (7라운드 `H-58`/`H-64`/`H-70`: `.Revision``:Set()``Source`와 같은
약하게 등록하는 **`:WeakCallback(fn)`**(weak-키 별도 테이블, `Weak` 쪽이 `bit32.bnot(-rev)`로 갱신, `Weak` 쪽이 프리미티브이고 `:Callback`이 그
프리미티브이고 `:Callback`이 그 위에 "GC 킵"을 얹은 것). 위에 "GC 킵"을 얹은 것, `Effect` 생성자가 `self._epochs:Sync(d)`로 태움)은
**M2가 이미 이 표면을 전제한다**`Effect` 생성자가 `Ref` dep을 그 표면의 일부라 M2 몫이다 — 세부는 체크박스에 재서술하지 않고
`self._epochs:Sync(d)``EpochMap`에 태운다(`base/effect-plan.md`). `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스. + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
`base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스 + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단,
위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase` 위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase`
옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절)